258 lines
8.2 KiB
TypeScript
258 lines
8.2 KiB
TypeScript
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 { ItemEntity } from "./entity/item_entity.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 { PauseScreen } from "./gui/pause_screen.ts";
|
|
import { back_to_title } from "./gui/title_screen.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;
|
|
// called once when the connection drops, with why
|
|
#on_disconnect: (message: string) => void;
|
|
|
|
constructor(connection: Connection, on_disconnect: (message: string) => void) {
|
|
this.connection = connection;
|
|
this.#on_disconnect = on_disconnect;
|
|
|
|
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));
|
|
}
|
|
for (const info of connection.initial_entities) {
|
|
this.level.add_entity(new ItemEntity(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();
|
|
}
|
|
|
|
// leaving on purpose, from the pause screen
|
|
disconnect() {
|
|
this.#stopped = true;
|
|
this.connection.close();
|
|
back_to_title();
|
|
}
|
|
|
|
#stop() {
|
|
this.#stopped = true;
|
|
this.level.dispose();
|
|
InputManager.set_mouse_grabbed(false);
|
|
}
|
|
|
|
// one frame: input, as many ticks as are due, then drawing between the last tick and the next
|
|
run_frame(delta: number) {
|
|
if (this.#stopped) {
|
|
return;
|
|
}
|
|
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) {
|
|
this.#stop();
|
|
this.#on_disconnect("Lost connection to the server");
|
|
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_drop)) {
|
|
this.#handle_drop();
|
|
}
|
|
if (InputManager.is_key_pressed(options.key_chat) && !this.screen) {
|
|
this.push_screen(new GuiChat(this));
|
|
}
|
|
// browsers eat the escape that lets go of the mouse, so losing the mouse pauses too
|
|
const lost_mouse = InputManager.take_lost_pointer_lock();
|
|
if (InputManager.is_key_pressed("Escape")) {
|
|
if (this.screen) {
|
|
this.pop_screen();
|
|
} else {
|
|
this.push_screen(new PauseScreen(this));
|
|
}
|
|
} else if (lost_mouse && !this.screen) {
|
|
this.push_screen(new PauseScreen(this));
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
// the held item while playing, the slot under the mouse in an inventory (only with nothing on the cursor)
|
|
#handle_drop() {
|
|
const all = InputManager.is_key_down("ControlLeft") || InputManager.is_key_down("ControlRight");
|
|
const inventories = this.player.inventories;
|
|
const screen = this.screen;
|
|
if (!screen) {
|
|
this.game_mode.drop_item(inventories.inventory, "inventory", inventories.hotbar_selected, all);
|
|
return;
|
|
}
|
|
const slot = screen instanceof GuiInventoryScreen ? screen.hovering : undefined;
|
|
const container = slot && screen instanceof GuiInventoryScreen
|
|
? screen.get_container(slot.container)
|
|
: undefined;
|
|
if (slot && container && !slot.output && !inventories.cursor.item) {
|
|
this.game_mode.drop_item(container, slot.container, slot.index, all);
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
}
|
|
}
|