import { ClientLevel } from "./level/client_level.ts"; import type { BlockHitResult } from "./level/client_level.ts"; import { LocalPlayer } from "./entity/local_player.ts"; import { RemotePlayer } from "./entity/remote_player.ts"; import { Camera } from "./camera.ts"; import { Options } from "./options.ts"; import { MultiPlayerGameMode } from "./game_mode.ts"; import { ClientPacketListener } from "./packet_listener.ts"; import { GameRenderer } from "./rendering/game_renderer.ts"; import { ChatComponent } from "./gui/chat_component.ts"; import { GuiInventoryScreen, GuiScreen } from "./gui/gui_screen.ts"; import { GuiPlayerInventory } from "./gui/gui_player_inventory.ts"; import { GuiChat } from "./gui/gui_chat.ts"; import { InputManager } from "./input_manager.ts"; import { Connection } from "./network.ts"; import { canvas, resize_canvas } from "./renderer/mod.ts"; import { show_fatal_error } from "./fatal.ts"; import { TICK_DELTA } from "$/common/constants.ts"; // after a long stall (a hidden tab, a debugger) the game skips ahead instead of running every missed tick at once const MAX_TICKS_PER_FRAME = 10; // the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer. // the game runs in fixed ticks (TICKS_PER_SECOND), input and drawing happen every frame export class Client { connection: Connection; options = new Options(); level: ClientLevel; player: LocalPlayer; camera = new Camera(); game_mode: MultiPlayerGameMode; packet_listener: ClientPacketListener; game_renderer = new GameRenderer(); chat = new ChatComponent(); // open screens, the last one is on top and gets the input screens: GuiScreen[] = []; // what the player is looking at, updated every frame hit_result: BlockHitResult | undefined; debugging = false; // seconds of game time not ticked yet #pending_time = 0; // whether the attack button is held on a block, breaking it advances every tick #attacking = false; #stopped = false; constructor(connection: Connection) { this.connection = connection; self.addEventListener("resize", resize_canvas); resize_canvas(); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); this.level = new ClientLevel(connection.seed); for (const [x, y, z, id, state] of connection.initial_changes) { this.level.record_change(x, y, z, id, state); } const spawn = connection.spawn; this.player = new LocalPlayer(this, connection.id, connection.name); this.player.set_position(spawn.x, spawn.y, spawn.z); this.player.yaw = spawn.yaw; this.player.pitch = spawn.pitch; this.player.inventories.hotbar_selected = connection.selected_slot; this.level.add_entity(this.player); for (const info of connection.initial_players) { this.level.add_entity(new RemotePlayer(this.level, info)); } this.game_mode = new MultiPlayerGameMode(this); this.packet_listener = new ClientPacketListener(this); } get screen(): GuiScreen | undefined { return this.screens.at(-1); } push_screen(screen: GuiScreen) { this.screens.push(screen); } pop_screen() { this.screens.pop()?.on_close(); } // one frame: input, as many ticks as are due, then drawing between the last tick and the next run_frame(delta: number) { this.screen?.on_tick(delta); this.#handle_keybinds(); this.#pending_time += delta; let ticks = 0; while (this.#pending_time >= TICK_DELTA && ticks < MAX_TICKS_PER_FRAME) { this.tick(); if (this.#stopped) { return; } this.#pending_time -= TICK_DELTA; ticks += 1; } if (ticks === MAX_TICKS_PER_FRAME) { this.#pending_time = Math.min(this.#pending_time, TICK_DELTA); } this.game_renderer.render(this, this.#pending_time / TICK_DELTA); } // one step of the game: what the server sent, breaking, chunk loading, then every entity tick() { this.packet_listener.handle_packets(); if (this.connection.closed) { show_fatal_error("Lost connection to the server"); this.#stopped = true; return; } if (this.#attacking && this.hit_result) { this.game_mode.continue_destroy_block(this.hit_result); } this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance); this.level.tick(); } #handle_keybinds() { const options = this.options; const player = this.player; if (InputManager.is_key_pressed(options.key_inventory)) { if (!this.screen) { this.push_screen(new GuiPlayerInventory(player.inventories, (m) => this.connection.send(m))); } else if (this.screen instanceof GuiInventoryScreen) { this.pop_screen(); } } if (InputManager.is_key_pressed(options.key_chat) && !this.screen) { this.push_screen(new GuiChat(this)); } if (InputManager.is_key_pressed("Escape")) { this.pop_screen(); } if (InputManager.is_key_pressed(options.key_debug)) { this.debugging = !this.debugging; } if (InputManager.is_key_pressed(options.key_fullscreen)) { InputManager.toggle_fullscreen(); } if (InputManager.is_mouse_grabbed()) { const mouse = InputManager.get_mouse_delta(); player.turn(mouse.x, mouse.y); } InputManager.set_mouse_grabbed(!this.screen); this.hit_result = this.level.pick(player.x, player.y + player.eye_height, player.z, player.yaw, player.pitch); this.#handle_block_interaction(); if (!this.screen) { this.#handle_hotbar(); } } // clicks happen right away, holding to break advances in tick() #handle_block_interaction() { const hit = this.hit_result; const attacking = !this.screen && InputManager.is_mouse_down(0); this.#attacking = attacking; if (!attacking || !hit) { this.game_mode.stop_destroy_block(); } if (this.screen) { return; } if (hit) { if (InputManager.is_mouse_pressed(0)) { this.game_mode.start_destroy_block(hit); } if (!attacking && InputManager.is_mouse_pressed(2)) { this.game_mode.use_item_on(hit); } } else if (InputManager.is_mouse_pressed(2)) { this.game_mode.use_item(); } } #handle_hotbar() { const inventories = this.player.inventories; const previous = inventories.hotbar_selected; const scroll = InputManager.get_wheel_delta(); if (scroll > 0) { inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1); } else if (scroll < 0) { inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1); } const pressed = this.options.key_hotbar.findIndex((key) => InputManager.is_key_pressed(key)); if (pressed !== -1) { inventories.hotbar_selected = pressed; } if (inventories.hotbar_selected !== previous) { this.connection.send({ type: "select_slot", slot: inventories.hotbar_selected }); } } }