diff --git a/MODS.md b/MODS.md index 2c1c881..44ecd37 100644 --- a/MODS.md +++ b/MODS.md @@ -1113,8 +1113,8 @@ loading mods (the base game and the template), their scripts and worldgen, saves **Phase 4: engine cleanup.** -- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the - `replaceable` block field. +- Replace the hardcoded water checks in `server/game/game_server.ts` with the `replaceable` block field, like + `client/game_mode.ts` already does. - `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have the same name. - Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means @@ -1179,5 +1179,6 @@ Moving the base game into `mods/bworld` happens alongside steps 4–9. See components to an existing block (for example to `bworld:grass`) is a possible middle ground. - **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a UI scale the player can change, with screens laid out in scaled units. -- **Entities.** The game's only entity is the player, so mob mods are out of scope until the ECS has more entity types. +- **Entities.** The only entities are players (`client/entity/`), so mob mods are out of scope until there are more + entity types. - **Hot reload.** Restarting the server and reconnecting is the version 1 answer. diff --git a/client/camera.ts b/client/camera.ts new file mode 100644 index 0000000..aa5dc6c --- /dev/null +++ b/client/camera.ts @@ -0,0 +1,24 @@ +import type { Entity } from "./entity/entity.ts"; + +// where the world is drawn from. like minecraft's Camera it isn't an entity, it's moved to one's eyes every frame +export class Camera { + x = 0; + y = 0; + z = 3; + + pitch = 0; + yaw = 0; + roll = 0; + + fov = Math.PI / 3; + near = 0.1; + far = 1000; + + setup(entity: Entity) { + this.x = entity.x; + this.y = entity.y + entity.eye_height; + this.z = entity.z; + this.yaw = entity.yaw; + this.pitch = entity.pitch; + } +} diff --git a/client/client.ts b/client/client.ts new file mode 100644 index 0000000..f58912c --- /dev/null +++ b/client/client.ts @@ -0,0 +1,177 @@ +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"; + +// the game client, like minecraft's Minecraft class: owns the level, the player, the screens and the renderer, +// and runs one step of the game 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; + + 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: network, input, the level and its entities, then drawing + run_frame(delta: number) { + this.packet_listener.handle_packets(); + if (this.connection.closed) { + show_fatal_error("Lost connection to the server"); + return; + } + + this.screen?.on_tick(delta); + this.#handle_keybinds(delta); + + this.level.update_loaded_chunks(this.player.x, this.player.z, this.options.render_distance); + this.level.tick(delta); + + this.game_renderer.render(this); + } + + #handle_keybinds(delta: number) { + 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(delta); + + if (!this.screen) { + this.#handle_hotbar(); + } + } + + #handle_block_interaction(delta: number) { + const hit = this.hit_result; + const attacking = !this.screen && InputManager.is_mouse_down(0); + + 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) { + this.game_mode.continue_destroy_block(hit, delta); + } else if (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 }); + } + } +} diff --git a/client/client_world.ts b/client/client_world.ts deleted file mode 100644 index 7662ff5..0000000 --- a/client/client_world.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { World } from "$/common/ecs/mod.ts"; -import { MovementSystem } from "$/common/systems/movement_system.ts"; -import { RenderSystem } from "$/client/systems/render_system.ts"; -import { PlayerControlsSystem } from "$/client/systems/player_controls.ts"; -import { DebugSystem } from "$/client/systems/debug_system.ts"; -import { UIInteractionSystem } from "$/client/systems/ui_interaction_system.ts"; -import { UIRenderSystem } from "$/client/systems/ui_render_system.ts"; -import { create_main_menu } from "./main_menu.ts"; -import { start_game } from "./game.ts"; -import { canvas, resize_canvas } from "./renderer/mod.ts"; -import { Dimension } from "./components/dimension.ts"; -import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts"; -import { WorldGenerationSystem } from "./systems/world_generation_system.ts"; -import { CollisionSystem } from "./systems/collision_system.ts"; -import { NetworkSystem } from "./systems/network_system.ts"; -import { Connection } from "./network.ts"; - -export interface ChatLine { - text: string; - time: number; -} - -export class ClientWorld extends World { - paused = false; - debugging = false; - - dimension!: Dimension; - - connection: Connection; - chat_log: ChatLine[] = []; - - constructor(connection: Connection) { - super("game"); - this.connection = connection; - - this.add_state("main_menu"); - this.add_state("paused"); - this.add_state("game"); - - self.addEventListener("resize", resize_canvas); - resize_canvas(); - - canvas.addEventListener("contextmenu", function (event) { - event.preventDefault(); - }); - - start_game(this); - - // Logic systems - this.add_system(new UIInteractionSystem(), "main_menu"); - this.add_system(new UIInteractionSystem(), "paused"); - this.add_system(new NetworkSystem(), "game"); - this.add_system(new GuiTickSystem(), "game"); - this.add_system(new PlayerControlsSystem(), "game"); - this.add_system(new WorldGenerationSystem(), "game"); - this.add_system(new CollisionSystem(), "game"); - this.add_system(new MovementSystem(), "game"); - - // render systems - this.add_system(new RenderSystem(), "game"); - this.add_system(new GuiRenderSystem(), "game"); - this.add_system(new DebugSystem(), "game"); - this.add_system(new UIRenderSystem(), "main_menu"); - this.add_system(new UIRenderSystem(), "paused"); - } - - add_chat(text: string) { - this.chat_log.push({ text, time: performance.now() }); - if (this.chat_log.length > 100) { - this.chat_log.shift(); - } - } -} diff --git a/client/components/camera.ts b/client/components/camera.ts deleted file mode 100644 index ff43ae2..0000000 --- a/client/components/camera.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; - -export class Camera extends Component { - x = 0; - y = 0; - z = 3; - - pitch = 0; - yaw = 0; - roll = 0; - - fov = Math.PI / 3; - near = 0.1; - far = 1000; -} diff --git a/client/components/clickable.ts b/client/components/clickable.ts deleted file mode 100644 index dc54bae..0000000 --- a/client/components/clickable.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; - -export class ClickableSprite extends Component { - clicked = false; - button: number; - access_range: number; - - constructor(button: number = 0, access_range = 2) { - super(); - this.button = button; - this.access_range = access_range; - } -} diff --git a/client/components/collision.ts b/client/components/collision.ts deleted file mode 100644 index 1181cf1..0000000 --- a/client/components/collision.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; - -export class CollisionCuboid extends Component { - width: number; - height: number; - depth: number; - gravity: number; - - colliding_x: number = 0; - colliding_y: number = 0; - colliding_z: number = 0; - - constructor(width: number, height: number, depth: number, gravity = -15.8) { - super(); - this.width = width; - this.height = height; - this.depth = depth; - this.gravity = gravity; - } -} diff --git a/client/components/player_controls.ts b/client/components/player_controls.ts deleted file mode 100644 index 0e1d383..0000000 --- a/client/components/player_controls.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; -import { KeyCode } from "$/client/input_manager.ts"; - -export class PlayerControls extends Component { - move_speed = 4; - jump_force = 6.7; - - // Keys - move_forward: KeyCode = "KeyW"; - move_backwards: KeyCode = "KeyS"; - move_left: KeyCode = "KeyA"; - move_right: KeyCode = "KeyD"; - sprint_key: KeyCode = "ShiftLeft"; - - hotbar_1: KeyCode = "Digit1"; - hotbar_2: KeyCode = "Digit2"; - hotbar_3: KeyCode = "Digit3"; - hotbar_4: KeyCode = "Digit4"; - hotbar_5: KeyCode = "Digit5"; - hotbar_6: KeyCode = "Digit6"; - hotbar_7: KeyCode = "Digit7"; - hotbar_8: KeyCode = "Digit8"; - hotbar_9: KeyCode = "Digit9"; - - open_inventory: KeyCode = "KeyE"; - open_chat: KeyCode = "KeyT"; - - open_debug: KeyCode = "F3"; -} diff --git a/client/components/sprite.ts b/client/components/sprite.ts deleted file mode 100644 index a7f53ec..0000000 --- a/client/components/sprite.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; -import { AssetManager } from "$/client/assets.ts"; -import { Texture } from "../renderer/mod.ts"; - -export class Sprite extends Component { - image: Texture; - width: number; - height: number; - source_x: number; - source_y: number; - source_width: number; - source_height: number; - flip_x = false; - flip_y = false; - - constructor( - image: Texture | string, - width: number, - height: number, - source_x = 0, - source_y = 0, - source_width = width, - source_height = height, - ) { - super(); - if (typeof image === "string") { - this.image = AssetManager.instance.get(image); - } else { - this.image = image; - } - this.width = width; - this.height = height; - this.source_x = source_x; - this.source_y = source_y; - this.source_width = source_width; - this.source_height = source_height; - } -} - -interface AnimatedSpritePiece { - source_x: number[]; - source_y: number[]; - source_width: number; - source_height: number; - duration: number; -} - -export class AnimatedSprite extends Component { - image: Texture; - width: number; - height: number; - flip_x = false; - flip_y = false; - - current_state: string; - states: Record; - timer = 0; - animation_frame = 0; - - constructor( - image: Texture | string, - width: number, - height: number, - states: Record, - initial_state: string, - ) { - super(); - if (typeof image === "string") { - this.image = AssetManager.instance.get(image); - } else { - this.image = image; - } - this.width = width; - this.height = height; - - this.states = states; - this.current_state = initial_state; - } - - set_state(state: string) { - if (this.current_state !== state) { - this.current_state = state; - this.timer = 0; - this.animation_frame = 0; - } - } -} diff --git a/client/components/ui_components.ts b/client/components/ui_components.ts deleted file mode 100644 index 807b1e4..0000000 --- a/client/components/ui_components.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component } from "$/common/ecs/mod.ts"; - -export class UIButton extends Component { - text: string; - width: number; - height: number; - on_click: () => void; - - hovered = false; - - constructor(text: string, width: number, height: number, on_click: () => void) { - super(); - this.text = text; - this.width = width; - this.height = height; - this.on_click = on_click; - } -} diff --git a/client/entity/entity.ts b/client/entity/entity.ts new file mode 100644 index 0000000..3fe0b11 --- /dev/null +++ b/client/entity/entity.ts @@ -0,0 +1,100 @@ +import type { ClientLevel } from "../level/client_level.ts"; + +// anything that exists in the level and moves, like minecraft's Entity. position is the middle of its feet +export abstract class Entity { + id: string; + level: ClientLevel; + + x = 0; + y = 0; + z = 0; + // blocks per second + vx = 0; + vy = 0; + vz = 0; + yaw = 0; + pitch = 0; + + // the collision box, width is used for both x and z + width: number; + height: number; + eye_height: number; + gravity = -15.8; + + // which way it hit something on each axis in the last move: 1 or -1, 0 for nothing. + // 1 on y means it's standing on something + colliding_x = 0; + colliding_y = 0; + colliding_z = 0; + + constructor(level: ClientLevel, id: string, width: number, height: number, eye_height: number) { + this.level = level; + this.id = id; + this.width = width; + this.height = height; + this.eye_height = eye_height; + } + + get on_ground() { + return this.colliding_y === 1; + } + + set_position(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } + + abstract tick(delta: number): void; + + // falls and moves by its velocity, stopping on each axis it would run into a block on + move(delta: number) { + this.vy += this.gravity * delta; + + this.colliding_x = this.vx !== 0 && this.#collides(this.x + this.vx * delta, this.y, this.z) + ? -Math.sign(this.vx) + : 0; + if (this.colliding_x !== 0) { + this.vx = 0; + } + + this.colliding_y = this.vy !== 0 && this.#collides(this.x, this.y + this.vy * delta, this.z) + ? -Math.sign(this.vy) + : 0; + if (this.colliding_y !== 0) { + this.vy = 0; + } + + this.colliding_z = this.vz !== 0 && this.#collides(this.x, this.y, this.z + this.vz * delta) + ? -Math.sign(this.vz) + : 0; + if (this.colliding_z !== 0) { + this.vz = 0; + } + + this.x += this.vx * delta; + this.y += this.vy * delta; + this.z += this.vz * delta; + } + + // whether the collision box at x/y/z overlaps any block, unloaded chunks included + #collides(x: number, y: number, z: number) { + const min_x = Math.floor(x - this.width / 2); + const max_x = Math.floor(x + this.width / 2); + const min_y = Math.floor(y); + const max_y = Math.floor(y + this.height); + const min_z = Math.floor(z - this.width / 2); + const max_z = Math.floor(z + this.width / 2); + + for (let bx = min_x; bx <= max_x; bx++) { + for (let by = min_y; by <= max_y; by++) { + for (let bz = min_z; bz <= max_z; bz++) { + if (this.level.get_block(bx, by, bz)) { + return true; + } + } + } + } + return false; + } +} diff --git a/client/entity/local_player.ts b/client/entity/local_player.ts new file mode 100644 index 0000000..1dba573 --- /dev/null +++ b/client/entity/local_player.ts @@ -0,0 +1,93 @@ +import type { Client } from "../client.ts"; +import { InputManager } from "../input_manager.ts"; +import { ClientInventories } from "../inventory.ts"; +import { Player } from "./player.ts"; + +// how often the position goes to the server +const SEND_POSITION_INTERVAL = 1 / 10; + +// the player this client controls, like minecraft's LocalPlayer +export class LocalPlayer extends Player { + client: Client; + inventories = new ClientInventories(); + + move_speed = 4; + jump_force = 6.7; + + #send_timer = 0; + + constructor(client: Client, id: string, name: string) { + super(client.level, id, name); + this.client = client; + } + + tick(delta: number) { + if (!this.client.screen) { + this.#apply_input(); + } + this.move(delta); + this.#send_position(delta); + } + + // turns with the mouse, like minecraft's MouseHandler.turnPlayer + turn(mouse_dx: number, mouse_dy: number) { + this.yaw += -mouse_dx * 0.001; + this.pitch += -mouse_dy * 0.001; + + const limit = Math.PI / 2 - 0.01; + this.pitch = Math.max(-limit, Math.min(limit, this.pitch)); + } + + // walking relative to where it's looking + #apply_input() { + const options = this.client.options; + let input_x = 0; + let input_z = 0; + + if (InputManager.is_key_down(options.key_left)) { + input_x -= 1; + } + if (InputManager.is_key_down(options.key_right)) { + input_x += 1; + } + if (InputManager.is_key_down(options.key_forward)) { + input_z -= 1; + } + if (InputManager.is_key_down(options.key_back)) { + input_z += 1; + } + + const size = Math.hypot(input_x, input_z); + if (size > 0) { + input_x /= size; + input_z /= size; + } + + const sin = Math.sin(this.yaw); + const cos = Math.cos(this.yaw); + const speed = this.move_speed * (InputManager.is_key_down(options.key_sprint) ? 1.75 : 1); + + this.vx = (sin * input_z + cos * input_x) * speed; + this.vz = (cos * input_z - sin * input_x) * speed; + + if (InputManager.is_key_down(options.key_jump) && this.on_ground) { + this.vy += this.jump_force; + } + } + + #send_position(delta: number) { + this.#send_timer += delta; + if (this.#send_timer < SEND_POSITION_INTERVAL) { + return; + } + this.#send_timer = 0; + this.client.connection.send({ + type: "move", + x: this.x, + y: this.y, + z: this.z, + yaw: this.yaw, + pitch: this.pitch, + }); + } +} diff --git a/client/entity/player.ts b/client/entity/player.ts new file mode 100644 index 0000000..920159d --- /dev/null +++ b/client/entity/player.ts @@ -0,0 +1,11 @@ +import type { ClientLevel } from "../level/client_level.ts"; +import { Entity } from "./entity.ts"; + +export abstract class Player extends Entity { + name: string; + + constructor(level: ClientLevel, id: string, name: string) { + super(level, id, 0.55, 1.79, 1.69); + this.name = name; + } +} diff --git a/client/entity/remote_player.ts b/client/entity/remote_player.ts new file mode 100644 index 0000000..ca48537 --- /dev/null +++ b/client/entity/remote_player.ts @@ -0,0 +1,65 @@ +import type { PlayerInfo } from "$/common/protocol.ts"; +import type { ClientLevel } from "../level/client_level.ts"; +import { Player } from "./player.ts"; + +const SMOOTHING = 12; + +// another player on the server, it moves where the server says instead of simulating anything +export class RemotePlayer extends Player { + color: [number, number, number]; + + // where the server last said it is, the drawn position eases towards it so movement isn't choppy + target_x: number; + target_y: number; + target_z: number; + + constructor(level: ClientLevel, info: PlayerInfo) { + super(level, info.id, info.name); + this.set_position(info.x, info.y, info.z); + this.target_x = info.x; + this.target_y = info.y; + this.target_z = info.z; + this.yaw = info.yaw; + this.pitch = info.pitch; + this.color = color_from_name(info.name); + } + + lerp_to(x: number, y: number, z: number, yaw: number, pitch: number) { + this.target_x = x; + this.target_y = y; + this.target_z = z; + this.yaw = yaw; + this.pitch = pitch; + } + + tick(delta: number) { + const t = Math.min(1, delta * SMOOTHING); + this.x += (this.target_x - this.x) * t; + this.y += (this.target_y - this.y) * t; + this.z += (this.target_z - this.z) * t; + } +} + +function color_from_name(name: string): [number, number, number] { + let hash = 0; + for (const ch of name) { + hash = (hash * 31 + ch.charCodeAt(0)) | 0; + } + const hue = ((hash % 360) + 360) % 360; + // hsl with s=0.6 l=0.6 to rgb + const c = 0.48; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = 0.36; + const [r, g, b] = hue < 60 + ? [c, x, 0] + : hue < 120 + ? [x, c, 0] + : hue < 180 + ? [0, c, x] + : hue < 240 + ? [0, x, c] + : hue < 300 + ? [x, 0, c] + : [c, 0, x]; + return [r + m, g + m, b + m]; +} diff --git a/client/game.ts b/client/game.ts deleted file mode 100644 index 8e98346..0000000 --- a/client/game.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Entity } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { ClientWorld } from "./client_world.ts"; -import { Dimension } from "./components/dimension.ts"; -import { create_player } from "./player.ts"; -import { UIButton } from "./components/ui_components.ts"; -import { open_about } from "./about.ts"; -import { canvas } from "./renderer/mod.ts"; - -export function start_game(world: ClientWorld) { - world.state = "game"; - world.clear_entities(); - - const dimension = new Entity("dimension"); - world.dimension?.dispose(); - world.dimension = new Dimension(world, world.connection.seed); - for (const [x, y, z, id, state] of world.connection.initial_changes) { - world.dimension.record_change(x, y, z, id, state); - } - dimension.add(world.dimension); - world.add_entity(dimension); - - create_player(world); - - // UI ! - const unpause_button = new Entity("unpausebutton"); - unpause_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80)); - unpause_button.add(new UIButton("Unpause", 320, 64, () => world.state = "paused")); - world.add_entity(unpause_button); - - const about_button = new Entity("aboutbutton"); - about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80)); - about_button.add(new UIButton("About", 320, 64, () => open_about())); - world.add_entity(about_button); -} diff --git a/client/game_mode.ts b/client/game_mode.ts new file mode 100644 index 0000000..1c1960c --- /dev/null +++ b/client/game_mode.ts @@ -0,0 +1,84 @@ +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { AIR, FACE_OFFSETS } from "$/common/constants.ts"; +import type { Client } from "./client.ts"; +import type { BlockHitResult } from "./level/client_level.ts"; + +// breaking and using blocks against a server, like minecraft's MultiPlayerGameMode. the client times breaking +// and guesses the results so they feel instant, the server decides what really happens +export class MultiPlayerGameMode { + client: Client; + + // the block being broken, and how far along it is in seconds out of destroy_time + destroy_pos: { x: number; y: number; z: number } | undefined; + destroy_progress = 0; + destroy_time = 0; + + constructor(client: Client) { + this.client = client; + } + + // the click that starts breaking, for mods' on_click + start_destroy_block(hit: BlockHitResult) { + this.client.connection.send({ type: "hit_block", x: hit.x, y: hit.y, z: hit.z }); + } + + // called every frame the attack button is held on a block + continue_destroy_block(hit: BlockHitResult, delta: number) { + const block_info = EverythingRegistry.get_by_id("blocks", hit.block)!; + const tool_type = this.#held_item()?.tool_type; + + this.destroy_pos = { x: hit.x, y: hit.y, z: hit.z }; + this.destroy_time = block_info.toughness ?? 9999; + this.destroy_progress += delta * (tool_type === block_info.tool_to_break ? 2 : 1); + + if (this.destroy_progress >= this.destroy_time) { + // show it right away, the server decides drops and corrects us if it disagrees + this.client.level.break_block(hit.x, hit.y, hit.z); + this.client.connection.send({ type: "break_block", x: hit.x, y: hit.y, z: hit.z }); + this.destroy_progress = 0; + this.destroy_time = 0; + } + } + + stop_destroy_block() { + this.destroy_pos = undefined; + this.destroy_progress = 0; + this.destroy_time = 0; + } + + // right click on a block + use_item_on(hit: BlockHitResult) { + const level = this.client.level; + this.client.connection.send({ type: "use_block", x: hit.x, y: hit.y, z: hit.z, face: hit.face }); + + // guess that it places the held block, unless the block does something when used + const block_info = EverythingRegistry.get_by_id("blocks", hit.block)!; + const offset = FACE_OFFSETS[hit.face]; + const target = { x: hit.x + offset.x, y: hit.y + offset.y, z: hit.z + offset.z }; + const target_id = level.get_block(target.x, target.y, target.z); + const replaceable = target_id === AIR || + EverythingRegistry.get_by_id("blocks", target_id)?.replaceable; + const held = this.#held_item(); + // items with components might do something else on the server, like on_use + const place_id = held?.components ? undefined : held?.block_id; + if (!block_info.interactive && place_id && replaceable) { + level.add_block({ ...target, id: place_id }); + const slot = this.#held_slot(); + slot.amount = slot.amount! - 1; + } + } + + // right click on nothing + use_item() { + this.client.connection.send({ type: "use_item" }); + } + + #held_slot() { + const inventories = this.client.player.inventories; + return inventories.inventory.get_slot(inventories.hotbar_selected); + } + + #held_item() { + return EverythingRegistry.get("items", this.#held_slot().type_id ?? ""); + } +} diff --git a/client/gui/chat_component.ts b/client/gui/chat_component.ts new file mode 100644 index 0000000..38418ba --- /dev/null +++ b/client/gui/chat_component.ts @@ -0,0 +1,38 @@ +import { canvas, draw_rect, draw_text } from "$/client/renderer/mod.ts"; + +const LINE_HEIGHT = 24; +const VISIBLE_SECONDS = 10; +const MAX_LINES = 10; +const MAX_HISTORY = 100; + +interface ChatLine { + text: string; + time: number; +} + +// the chat log, like minecraft's ChatComponent +export class ChatComponent { + lines: ChatLine[] = []; + + add(text: string) { + this.lines.push({ text, time: performance.now() }); + if (this.lines.length > MAX_HISTORY) { + this.lines.shift(); + } + } + + // above the bottom left corner. `all` shows old messages too, for when the chat is open + render(all: boolean, bottom = canvas.height - 100) { + const now = performance.now(); + const lines = this.lines + .filter((line) => all || now - line.time < VISIBLE_SECONDS * 1000) + .slice(-MAX_LINES); + + let y = bottom - lines.length * LINE_HEIGHT; + for (const line of lines) { + draw_rect(0, y, 600, LINE_HEIGHT, [0, 0, 0, 0.4]); + draw_text(line.text, 4, y, 2, [1, 1, 1, 1]); + y += LINE_HEIGHT; + } + } +} diff --git a/client/gui/debug_overlay.ts b/client/gui/debug_overlay.ts new file mode 100644 index 0000000..a253b2a --- /dev/null +++ b/client/gui/debug_overlay.ts @@ -0,0 +1,45 @@ +import type { Client } from "$/client/client.ts"; +import { DebugUI } from "$/client/debug_ui.ts"; + +// f3: every entity's fields, editable +export class DebugOverlay { + render(client: Client) { + DebugUI.begin("Entities", 10, 10, 300); + + for (const entity of client.level.entities.values()) { + if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) { + this.#render_fields(entity); + } + } + if (DebugUI.collapsing_header("Camera")) { + this.#render_fields(client.camera); + } + if (DebugUI.collapsing_header("Options")) { + this.#render_fields(client.options); + } + + DebugUI.end(); + } + + // deno-lint-ignore no-explicit-any + #render_fields(object: any) { + for (const key in object) { + const value = object[key]; + if (typeof value === "number") { + object[key] = DebugUI.float_input(key, value); + } else if (typeof value === "string") { + object[key] = DebugUI.text_input(key, value); + } else if (typeof value === "boolean") { + object[key] = DebugUI.checkbox(key, value); + } else if (Array.isArray(value)) { + DebugUI.text(`${key}: ${JSON.stringify(value.slice(0, 10))}`); + } else if (value && typeof value === "object" && value.constructor !== Object) { + // other objects like the level point back at this one, only name them + DebugUI.text(`${key}: ${value.constructor.name}`); + } else { + DebugUI.text(`${key}: ${JSON.stringify(value)}`); + } + DebugUI.separator(); + } + } +} diff --git a/client/gui/gui_chat.ts b/client/gui/gui_chat.ts index a8fb574..25a3f45 100644 --- a/client/gui/gui_chat.ts +++ b/client/gui/gui_chat.ts @@ -1,29 +1,27 @@ import { GuiScreen } from "./gui_screen.ts"; import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mod.ts"; import { InputManager } from "../input_manager.ts"; -import { ClientWorld } from "../client_world.ts"; -import { PlayerComponent } from "../player.ts"; +import type { Client } from "../client.ts"; import { MAX_CHAT_LENGTH } from "$/common/protocol.ts"; -import { render_chat_log } from "../systems/rendering/network.ts"; export class GuiChat extends GuiScreen { - world: ClientWorld; + client: Client; text_typed = ""; caret = 0; key_repeat_timer = 0; show_caret = true; - constructor(world: ClientWorld) { + constructor(client: Client) { super(); - this.world = world; + this.client = client; } override on_render(): void { draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); const y = canvas.height - 32; - render_chat_log(this.world, true, y - 8); + this.client.chat.render(true, y - 8); draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]); draw_text(this.text_typed, 0, y, 2, [1, 1, 1]); @@ -103,14 +101,10 @@ export class GuiChat extends GuiScreen { submit() { // commands like /give run on the server too if (this.text_typed.trim().length > 0) { - this.world.connection.send({ type: "chat", text: this.text_typed }); + this.client.connection.send({ type: "chat", text: this.text_typed }); } this.text_typed = ""; - const [player] = this.world.get_tag("player")!; - const player_component = player.get(PlayerComponent); - if (player_component) { - player_component.pop_screen(); - } + this.client.pop_screen(); } } diff --git a/client/gui/gui_container.ts b/client/gui/gui_container.ts index 65e4dac..b0f0eb2 100644 --- a/client/gui/gui_container.ts +++ b/client/gui/gui_container.ts @@ -3,7 +3,7 @@ import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/render import { AssetManager } from "../assets.ts"; import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { ClientMessage, ScreenLayout } from "$/common/protocol.ts"; -import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; +import { draw_nine_slice } from "../rendering/render_utils.ts"; import { get_sprite_region } from "$/client/sprites.ts"; import { ClientInventories } from "../inventory.ts"; diff --git a/client/gui/gui_player_inventory.ts b/client/gui/gui_player_inventory.ts index 18f9707..54eb06b 100644 --- a/client/gui/gui_player_inventory.ts +++ b/client/gui/gui_player_inventory.ts @@ -2,7 +2,7 @@ import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } fro import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts"; import { AssetManager } from "../assets.ts"; import { SLOT_SIZE } from "../../common/constants.ts"; -import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; +import { draw_nine_slice } from "../rendering/render_utils.ts"; import { ClientInventories } from "../inventory.ts"; import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; diff --git a/client/gui/gui_screen.ts b/client/gui/gui_screen.ts index 331d0a8..20b6f8d 100644 --- a/client/gui/gui_screen.ts +++ b/client/gui/gui_screen.ts @@ -6,7 +6,7 @@ import { AssetManager } from "../assets.ts"; import { InputManager } from "../input_manager.ts"; import { ClientInventories } from "../inventory.ts"; import { canvas, Texture } from "../renderer/mod.ts"; -import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts"; +import { draw_item, draw_nine_slice } from "../rendering/render_utils.ts"; export class Slot { container: ContainerKey; diff --git a/client/gui/gui_systems.ts b/client/gui/gui_systems.ts deleted file mode 100644 index 8eba33d..0000000 --- a/client/gui/gui_systems.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { ClientWorld } from "../client_world.ts"; -import { PlayerComponent } from "../player.ts"; - -export class GuiRenderSystem extends System { - update(world: ClientWorld, _delta: number): void { - const [player] = world.get_tag("player")!; - const player_component = player.get(PlayerComponent)!; - - player_component.screens.at(-1)?.on_render(); - } -} - -export class GuiTickSystem extends System { - update(world: ClientWorld, delta: number): void { - const [player] = world.get_tag("player")!; - const player_component = player.get(PlayerComponent)!; - - player_component.screens.at(-1)?.on_tick(delta); - } -} diff --git a/client/gui/hud.ts b/client/gui/hud.ts new file mode 100644 index 0000000..8221df3 --- /dev/null +++ b/client/gui/hud.ts @@ -0,0 +1,66 @@ +import { SLOT_SIZE } from "$/common/constants.ts"; +import { AssetManager } from "$/client/assets.ts"; +import type { Client } from "$/client/client.ts"; +import type { ClientInventories } from "$/client/inventory.ts"; +import { draw_item, draw_nine_slice } from "$/client/rendering/render_utils.ts"; +import { canvas, draw_rect_stroke, Texture } from "$/client/renderer/mod.ts"; + +const PADDING = 10; +const CROSSHAIR_SIZE = 8; + +// what's drawn over the world while playing, like minecraft's Gui: hotbar, crosshair and chat +export class Hud { + render(client: Client) { + this.#render_hotbar(client.player.inventories); + this.#render_crosshair(); + client.chat.render(false); + } + + #render_hotbar(inventories: ClientInventories) { + const ui = AssetManager.instance.get("bworld:ui"); + + const hotbar_width = PADDING * 2 + SLOT_SIZE * 9; + const hotbar_height = PADDING * 2 + SLOT_SIZE; + + const x = canvas.width / 2 - hotbar_width / 2; + const y = canvas.height - hotbar_height; + + draw_nine_slice(ui, 160, 0, 16, 16, 4, 4, 4, 4, x, y, hotbar_width, hotbar_height); + + for (let index = 0; index < 9; index += 1) { + const selected = inventories.hotbar_selected === index; + draw_nine_slice( + ui, + selected ? 19 * 16 : 160 + 32, + selected ? 16 : 0, + 16, + 16, + 4, + 4, + 4, + 4, + x + PADDING + index * SLOT_SIZE, + y + PADDING, + SLOT_SIZE, + SLOT_SIZE, + ); + } + + for (let index = 0; index < 9; index += 1) { + const item = inventories.inventory.get_item(index); + if (item) { + draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING); + } + } + } + + #render_crosshair() { + draw_rect_stroke( + (canvas.width - CROSSHAIR_SIZE) / 2, + (canvas.height - CROSSHAIR_SIZE) / 2, + CROSSHAIR_SIZE, + CROSSHAIR_SIZE, + [0, 0, 0, 0.6], + ); + } +} diff --git a/client/components/dimension.ts b/client/level/client_level.ts similarity index 86% rename from client/components/dimension.ts rename to client/level/client_level.ts index c1fc0e2..96be668 100644 --- a/client/components/dimension.ts +++ b/client/level/client_level.ts @@ -1,4 +1,3 @@ -import { Component } from "$/common/ecs/mod.ts"; import { block_value, chunk_key, default_block_value } from "$/common/utils.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { @@ -11,13 +10,13 @@ import { } from "$/common/everything_registry.ts"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts"; import { AssetManager } from "../assets.ts"; -import { ClientWorld } from "../client_world.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts"; import { worldgen_mods } from "../mods.ts"; import { type FromChunkWorker, TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts"; import { create_index_buffer, create_vertex_buffer, destroy_buffer, Texture } from "../renderer/mod.ts"; import { crosses_planes } from "../workers/translucent_sort.ts"; -import { Camera } from "./camera.ts"; +import { Camera } from "../camera.ts"; +import type { Entity } from "../entity/entity.ts"; export interface Block { id: string; @@ -64,12 +63,21 @@ const FLOATS_PER_QUAD = 4 * TERRAIN_VERTEX_FLOATS; export { chunk_key }; +// the block being looked at and which face of it +export interface BlockHitResult { + x: number; + y: number; + z: number; + block: number; + face: Faces; +} + const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; // light spreads diagonally too, so meshing and lighting need all 8 const ALL_NEIGHBOR_OFFSETS = [...NEIGHBOR_OFFSETS, [-1, -1], [1, -1], [-1, 1], [1, 1]] as const; -export class Dimension extends Component { - world: ClientWorld; +// what minecraft calls the ClientLevel: this client's copy of the world, its chunks and the entities in it +export class ClientLevel { image: Texture = AssetManager.instance.get("bworld:textures"); chunks = new Map(); second_timer = 0; @@ -84,9 +92,10 @@ export class Dimension extends Component { // chunks being generated by a worker, by chunk key pending_generation = new Map(); - constructor(world: ClientWorld, seed = "seed") { - super(); - this.world = world; + // every entity this client knows about, the local player included, by id + entities = new Map(); + + constructor(seed = "seed") { this.seed = seed; this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message)); @@ -106,6 +115,63 @@ export class Dimension extends Component { }); } + add_entity(entity: Entity) { + this.entities.set(entity.id, entity); + } + + remove_entity(id: string) { + this.entities.delete(id); + } + + tick(delta: number) { + for (const entity of this.entities.values()) { + entity.tick(delta); + } + } + + // generates the chunks around a position and forgets the ones too far away. one extra ring past the render + // distance gets generated so the edge has neighbors to mesh against + update_loaded_chunks(x: number, z: number, render_distance: number) { + const center_x = Math.floor(x / CHUNK_SIZE); + const center_z = Math.floor(z / CHUNK_SIZE); + const load_distance = render_distance + 1; + const out_of_range = (cx: number, cz: number) => + Math.max(Math.abs(cx - center_x), Math.abs(cz - center_z)) > load_distance; + + // collect first, deleting from the map while iterating it skips entries + const to_unload = [...this.chunks.values()].filter((chunk) => out_of_range(chunk.x, chunk.z)); + for (const chunk of to_unload) { + this.unload_chunk(chunk.x, chunk.z); + } + for (const pending of [...this.pending_generation.values()]) { + if (out_of_range(pending.x, pending.z)) { + this.cancel_chunk_request(pending.x, pending.z); + } + } + + // dont queue up the whole area at once, so walking somewhere new gets the close chunks first + const max_in_flight = this.workers.size * 2; + if (this.pending_generation.size >= max_in_flight) { + return; + } + + const missing: { x: number; z: number; distance: number }[] = []; + for (let cx = center_x - load_distance; cx <= center_x + load_distance; cx += 1) { + for (let cz = center_z - load_distance; cz <= center_z + load_distance; cz += 1) { + if (!this.is_generated(cx, cz) && !this.is_generating(cx, cz)) { + const dx = cx - center_x; + const dz = cz - center_z; + missing.push({ x: cx, z: cz, distance: dx * dx + dz * dz }); + } + } + } + missing.sort((a, b) => a.distance - b.distance); + + for (const chunk of missing.slice(0, max_in_flight - this.pending_generation.size)) { + this.request_chunk(chunk.x, chunk.z); + } + } + dispose() { this.workers.terminate(); for (const chunk of this.chunks.values()) { @@ -532,24 +598,21 @@ export class Dimension extends Component { chunk.translucent_sort = undefined; } - get_looked_block( - dimension: Dimension, - camera: Camera, + // the first block along the view of something at x/y/z looking at yaw/pitch, like minecraft's pick + pick( + x: number, + y: number, + z: number, + yaw: number, + pitch: number, max_distance = 6, step = 0.05, - ): { x: number; y: number; z: number; block: number; face: Faces } | undefined { - const yaw = camera.yaw; - const pitch = camera.pitch; - + ): BlockHitResult | undefined { const cos_pitch = Math.cos(pitch); const dx = -Math.sin(yaw) * cos_pitch; const dy = Math.sin(pitch); const dz = -Math.cos(yaw) * cos_pitch; - let x = camera.x; - let y = camera.y; - let z = camera.z; - let prev_bx = Math.floor(x); let prev_by = Math.floor(y); let prev_bz = Math.floor(z); @@ -570,7 +633,7 @@ export class Dimension extends Component { continue; } - const block = dimension.get_block(bx, by, bz); + const block = this.get_block(bx, by, bz); if (block && block !== AIR && block !== VOID) { let face: Faces; diff --git a/client/main.ts b/client/main.ts index 4422fef..a82d688 100644 --- a/client/main.ts +++ b/client/main.ts @@ -1,5 +1,5 @@ import { AssetManager } from "./assets.ts"; -import { ClientWorld } from "./client_world.ts"; +import { Client } from "./client.ts"; import { InputManager } from "./input_manager.ts"; import { Connection, get_player_name } from "./network.ts"; import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts"; @@ -7,18 +7,18 @@ import { confirm_mods } from "./confirm_mods.ts"; import { ModLoadError } from "$/common/mod_loader.ts"; import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts"; import { is_stopped, show_fatal_error } from "./fatal.ts"; -import { load_client_mods, set_mods_world } from "./mods.ts"; +import { load_client_mods, set_mods_client } from "./mods.ts"; export class ClientLoop { running = false; last_time = 0; - world: ClientWorld; + client: Client; frame_count = 0; last_fps_time = 0; - constructor(world: ClientWorld) { - this.world = world; + constructor(client: Client) { + this.client = client; } start() { @@ -49,7 +49,7 @@ export class ClientLoop { begin_drawing(); clear_background(0.69, 0.8, 1, 1.0); - this.world.update(delta); + this.client.run_frame(delta); end_drawing(); this.frame_count += 1; @@ -121,11 +121,11 @@ async function join_server(): Promise { try { const connection = await join_server(); - const client_world = new ClientWorld(connection); - set_mods_world(client_world); - client_world.add_chat("Connected to the server"); + const client = new Client(connection); + set_mods_client(client); + client.chat.add("Connected to the server"); - const loop = new ClientLoop(client_world); + const loop = new ClientLoop(client); loop.start(); console.log("Game started"); diff --git a/client/main_menu.ts b/client/main_menu.ts deleted file mode 100644 index 0423bc5..0000000 --- a/client/main_menu.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Entity } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { ClientWorld } from "./client_world.ts"; -import { UIButton } from "./components/ui_components.ts"; -import { open_about } from "./about.ts"; -import { start_game } from "./game.ts"; -import { canvas } from "./renderer/mod.ts"; - -export function create_main_menu(world: ClientWorld) { - const play_button = new Entity("play"); - play_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 - 80)); - play_button.add(new UIButton("Play", 320, 64, () => start_game(world))); - world.add_entity(play_button); - - const about_button = new Entity("aboutbutton"); - about_button.add(new Position(canvas.width / 2 - 150, canvas.height / 2 + 80)); - about_button.add(new UIButton("About", 320, 64, () => open_about())); - world.add_entity(about_button); -} diff --git a/client/mods.ts b/client/mods.ts index 459e7f1..c352e29 100644 --- a/client/mods.ts +++ b/client/mods.ts @@ -6,18 +6,17 @@ import type { ClientContext } from "$/common/mod_api/client.ts"; import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts"; import type { OreJson } from "$/common/mod_data.ts"; import { AIR_ID } from "$/common/protocol.ts"; -import { Position } from "$/common/components/position.ts"; -import type { ClientWorld } from "./client_world.ts"; +import type { Client } from "./client.ts"; import { code_url, fetch_verified } from "./handshake.ts"; // what the chunk workers need to generate the same world as the server export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] }; -// set once the world exists, mods can't look at it during setup -let world: ClientWorld | undefined; +// set once the game is running, mods can't look at it during setup +let client: Client | undefined; -export function set_mods_world(client_world: ClientWorld) { - world = client_world; +export function set_mods_client(game_client: Client) { + client = game_client; } // downloads every mod's files and checks them against the hashes the server listed, then registers the data and @@ -65,9 +64,9 @@ function client_context(listing: ModListing): ClientContext { throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`); }, }); - const need_world = () => { - if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`); - return world; + const need_client = () => { + if (!client) throw new Error(`[${mod}] the world isn't there yet during setup`); + return client; }; return { @@ -78,17 +77,16 @@ function client_context(listing: ModListing): ClientContext { net: not_yet("net", "step 8") as ClientContext["net"], player: { get name() { - return need_world().connection.name; + return need_client().connection.name; }, get position() { - const [player] = need_world().get_tag("player")!; - const position = player.get(Position)!; - return { x: position.x, y: position.y, z: position.z }; + const { x, y, z } = need_client().player; + return { x, y, z }; }, }, world: { get_block(x, y, z) { - const nid = need_world().dimension.get_block(x, y, z); + const nid = need_client().level.get_block(x, y, z); if (nid === AIR) return AIR_ID; return EverythingRegistry.get_by_id("blocks", nid)?.id; }, diff --git a/client/network.ts b/client/network.ts index 4692f8a..1b62df1 100644 --- a/client/network.ts +++ b/client/network.ts @@ -2,14 +2,6 @@ import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/ import type { ModListing } from "$/common/mod_loader.ts"; import type { Join, ServerSocket, Welcome } from "./handshake.ts"; -export interface RemotePlayer extends PlayerInfo { - // where we draw them, eased towards x/y/z so movement isnt choppy - display_x: number; - display_y: number; - display_z: number; - color: [number, number, number]; -} - export class Connection { #server: ServerSocket; id: string; @@ -19,7 +11,8 @@ export class Connection { initial_changes: BlockChange[]; spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; selected_slot: number; - players = new Map(); + // who was already there when we joined, they become entities in the level + initial_players: PlayerInfo[]; constructor(server: ServerSocket, welcome: Welcome, join: Join) { this.#server = server; @@ -30,12 +23,10 @@ export class Connection { this.initial_changes = join.changes; this.spawn = join.spawn; this.selected_slot = join.selected_slot; - for (const player of join.players) { - this.add_player(player); - } + this.initial_players = join.players; } - // handled by the network system inside the game loop, not whenever the socket feels like it + // handled by the packet listener inside the game loop, not whenever the socket feels like it get incoming(): ServerMessage[] { return this.#server.messages; } @@ -47,40 +38,6 @@ export class Connection { send(message: ClientMessage) { this.#server.send(message); } - - add_player(player: PlayerInfo) { - this.players.set(player.id, { - ...player, - display_x: player.x, - display_y: player.y, - display_z: player.z, - color: color_from_name(player.name), - }); - } -} - -function color_from_name(name: string): [number, number, number] { - let hash = 0; - for (const ch of name) { - hash = (hash * 31 + ch.charCodeAt(0)) | 0; - } - const hue = ((hash % 360) + 360) % 360; - // hsl with s=0.6 l=0.6 to rgb - const c = 0.48; - const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); - const m = 0.36; - const [r, g, b] = hue < 60 - ? [c, x, 0] - : hue < 120 - ? [x, c, 0] - : hue < 180 - ? [0, c, x] - : hue < 240 - ? [0, x, c] - : hue < 300 - ? [x, 0, c] - : [c, 0, x]; - return [r + m, g + m, b + m]; } export function get_player_name(): string { diff --git a/client/options.ts b/client/options.ts new file mode 100644 index 0000000..9c077a8 --- /dev/null +++ b/client/options.ts @@ -0,0 +1,28 @@ +import type { KeyCode } from "./input_manager.ts"; + +// the player's settings, like minecraft's Options and its key mappings +export class Options { + render_distance = 6; + + key_forward: KeyCode = "KeyW"; + key_back: KeyCode = "KeyS"; + key_left: KeyCode = "KeyA"; + key_right: KeyCode = "KeyD"; + key_jump: KeyCode = "Space"; + key_sprint: KeyCode = "ShiftLeft"; + key_inventory: KeyCode = "KeyE"; + key_chat: KeyCode = "KeyT"; + key_debug: KeyCode = "F3"; + key_fullscreen: KeyCode = "F11"; + key_hotbar: KeyCode[] = [ + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", + "Digit9", + ]; +} diff --git a/client/packet_listener.ts b/client/packet_listener.ts new file mode 100644 index 0000000..03e124e --- /dev/null +++ b/client/packet_listener.ts @@ -0,0 +1,88 @@ +import type { ServerMessage } from "$/common/protocol.ts"; +import { Container, ItemStack } from "$/common/inventory.ts"; +import type { Client } from "./client.ts"; +import { GuiContainer } from "./gui/gui_container.ts"; +import { RemotePlayer } from "./entity/remote_player.ts"; + +// applies what the server sends, like minecraft's ClientPacketListener. messages queue up on the connection and +// get handled here once per frame, inside the game loop +export class ClientPacketListener { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + handle_packets() { + const connection = this.client.connection; + for (const message of connection.incoming) { + this.#handle(message); + } + connection.incoming.length = 0; + } + + #handle(message: ServerMessage) { + const client = this.client; + const level = client.level; + const inventories = client.player.inventories; + + switch (message.type) { + case "player_join": + level.add_entity(new RemotePlayer(level, message.player)); + break; + case "player_leave": + level.remove_entity(message.id); + break; + case "player_move": { + const player = level.entities.get(message.id); + if (player instanceof RemotePlayer) { + player.lerp_to(message.x, message.y, message.z, message.yaw, message.pitch); + } + break; + } + case "set_block": + level.record_change(message.x, message.y, message.z, message.id, message.state); + level.apply_change(message.x, message.y, message.z, message.id, message.state); + break; + case "chat": + client.chat.add(message.from ? `<${message.from}> ${message.text}` : message.text); + break; + case "container": { + let container = message.container === "screen" ? inventories.screen : inventories[message.container]; + if (message.container === "screen" && container?.size !== message.items.length) { + container = inventories.screen = new Container(message.items.length); + } + container?.load(message.items); + break; + } + case "cursor": + inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined; + break; + case "open_screen": { + // replace anything open locally without telling the server, it just opened this one + client.screens.length = 0; + const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1)); + inventories.screen = new Container(size); + client.push_screen( + new GuiContainer(inventories, (m) => client.connection.send(m), message.layout, message.properties), + ); + break; + } + case "screen_properties": { + const screen = client.screen; + if (screen instanceof GuiContainer) { + screen.properties = message.properties; + } + break; + } + case "teleport": + client.player.set_position(message.x, message.y, message.z); + break; + case "close_screen": + // closed by the server (the block broke), it already put everything back + client.screens = client.screens.filter((s) => !(s instanceof GuiContainer)); + inventories.screen = undefined; + break; + } + } +} diff --git a/client/player.ts b/client/player.ts deleted file mode 100644 index 891cc93..0000000 --- a/client/player.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Position } from "$/common/components/position.ts"; -import { Velocity } from "$/common/components/velocity.ts"; -import { Component, Entity } from "$/common/ecs/mod.ts"; -import { Camera } from "$/client/components/camera.ts"; -import { ClientInventories } from "./inventory.ts"; -import { PlayerControls } from "$/client/components/player_controls.ts"; -import { ClientWorld } from "./client_world.ts"; -import { GuiScreen } from "./gui/gui_screen.ts"; -import { CollisionCuboid } from "./components/collision.ts"; - -export class PlayerComponent extends Component { - inventories = new ClientInventories(); - screens: GuiScreen[] = []; - render_distance = 6; - - breaking_block?: { x: number; y: number; z: number }; - break_progress = 0; - break_progress_max = 0; - - pop_screen() { - const screen = this.screens.pop(); - if (screen) { - screen.on_close(); - } - } -} - -export function create_player(world: ClientWorld) { - const player = new Entity("player"); - const spawn = world.connection.spawn; - player.add(new Position(spawn.x, spawn.y, spawn.z)); - player.add(new Velocity(0, 0, 0)); - player.add(new PlayerControls()); - - const player_component = player.add(new PlayerComponent()); - player_component.inventories.hotbar_selected = world.connection.selected_slot; - const camera = player.add(new Camera()); - camera.yaw = spawn.yaw; - camera.pitch = spawn.pitch; - player.add(new CollisionCuboid(0.55, 1.79, 0.55)); - - world.add_entity(player); - - const player_hand = new Entity("playerhand"); - player_hand.add(new Position(0, 0)); - world.add_entity(player_hand); - - world.add_tag("player", [player, player_hand]); - - return player; -} diff --git a/client/renderer/core.ts b/client/renderer/core.ts index 52842c6..e080ce2 100644 --- a/client/renderer/core.ts +++ b/client/renderer/core.ts @@ -1,4 +1,4 @@ -import { Camera } from "../components/camera.ts"; +import type { Camera } from "../camera.ts"; import { mat4 } from "gl-matrix"; import type { RenderLayer } from "$/common/everything_registry.ts"; import { TERRAIN_VERTEX_FLOATS } from "../workers/chunk_messages.ts"; diff --git a/client/rendering/game_renderer.ts b/client/rendering/game_renderer.ts new file mode 100644 index 0000000..fc5a12f --- /dev/null +++ b/client/rendering/game_renderer.ts @@ -0,0 +1,30 @@ +import type { Client } from "$/client/client.ts"; +import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts"; +import { Hud } from "$/client/gui/hud.ts"; +import { DebugOverlay } from "$/client/gui/debug_overlay.ts"; +import { LevelRenderer } from "./level_renderer.ts"; + +// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top +export class GameRenderer { + level_renderer = new LevelRenderer(); + hud = new Hud(); + debug_overlay = new DebugOverlay(); + + render(client: Client) { + const camera = client.camera; + camera.setup(client.player); + + begin_mode_3d(camera); + this.level_renderer.render_opaque(client.level, camera); + this.level_renderer.render_destroy_progress(client.game_mode); + this.level_renderer.render_entities(client.level, client.player); + this.level_renderer.render_translucent(client.level, camera); + end_mode_3d(); + + this.hud.render(client); + client.screen?.on_render(); + if (client.debugging) { + this.debug_overlay.render(client); + } + } +} diff --git a/client/rendering/level_renderer.ts b/client/rendering/level_renderer.ts new file mode 100644 index 0000000..45a44aa --- /dev/null +++ b/client/rendering/level_renderer.ts @@ -0,0 +1,130 @@ +import { TEXTURE_SIZE } from "$/common/constants.ts"; +import { CHUNK_SIZE, ClientLevel } from "$/client/level/client_level.ts"; +import { Camera } from "$/client/camera.ts"; +import { AssetManager } from "$/client/assets.ts"; +import { get_sprite_region } from "$/client/sprites.ts"; +import type { MultiPlayerGameMode } from "$/client/game_mode.ts"; +import type { Entity } from "$/client/entity/entity.ts"; +import { RemotePlayer } from "$/client/entity/remote_player.ts"; +import { + draw_terrain, + flush_batch, + push_back_face, + push_bottom_face, + push_box, + push_front_face, + push_left_face, + push_right_face, + push_top_face, + set_current_texture, + Texture, + white_tex, +} from "$/client/renderer/mod.ts"; + +const BREAKING_FACES = [ + push_back_face, + push_bottom_face, + push_front_face, + push_left_face, + push_right_face, + push_top_face, +]; + +// draws the level, like minecraft's LevelRenderer: terrain in layers, block breaking and entities +export class LevelRenderer { + // solid and cutout terrain, drawn before entities + render_opaque(level: ClientLevel, camera: Camera) { + level.request_meshes(camera); + level.update_translucent_sorting(camera); + + set_current_texture(level.image.tex); + + for (const chunk of level.chunks.values()) { + const mesh = chunk.meshes.solid; + if (mesh) { + draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count); + } + } + + for (const chunk of level.chunks.values()) { + const mesh = chunk.meshes.cutout; + if (mesh) { + draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count); + } + } + } + + // translucent terrain, drawn after entities so they show through water and glass. + // chunks go back to front, and each chunk's quads are already sorted back to front + render_translucent(level: ClientLevel, camera: Camera) { + const distance_sq = (x: number, z: number) => { + const dx = (x + 0.5) * CHUNK_SIZE - camera.x; + const dz = (z + 0.5) * CHUNK_SIZE - camera.z; + return dx * dx + dz * dz; + }; + const chunks = [...level.chunks.values()] + .filter((chunk) => chunk.meshes.translucent) + .map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) })) + .sort((a, b) => b.distance - a.distance); + + set_current_texture(level.image.tex); + + for (const { mesh } of chunks) { + draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer); + } + } + + // every entity but the one the camera is in + render_entities(level: ClientLevel, camera_entity: Entity) { + flush_batch(); + set_current_texture(white_tex!); + + for (const entity of level.entities.values()) { + if (entity !== camera_entity && entity instanceof RemotePlayer) { + render_player(entity); + } + } + + flush_batch(); + } + + // the cracks on the block being broken + render_destroy_progress(game_mode: MultiPlayerGameMode) { + const block = game_mode.destroy_pos; + if (!block) { + return; + } + + const progress = Math.max(0, Math.min(1, game_mode.destroy_progress / game_mode.destroy_time)); + const stage = Math.round(progress * 8); + if (Number.isNaN(stage)) { + return; + } + + const tex = AssetManager.instance.get("bworld:textures"); + const region = get_sprite_region(`engine:break_${stage}`); + for (const push_face of BREAKING_FACES) { + push_face( + tex, + block.x, + block.y, + block.z, + region.x * TEXTURE_SIZE, + region.y * TEXTURE_SIZE, + TEXTURE_SIZE, + TEXTURE_SIZE, + ); + } + } +} + +// a box body and head in the player's color +function render_player(player: RemotePlayer) { + const [r, g, b] = player.color; + const { x, y, z } = player; + + // body + push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b); + // head + push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65); +} diff --git a/client/systems/rendering/render_utils.ts b/client/rendering/render_utils.ts similarity index 100% rename from client/systems/rendering/render_utils.ts rename to client/rendering/render_utils.ts diff --git a/client/systems/collision_system.ts b/client/systems/collision_system.ts deleted file mode 100644 index 7814071..0000000 --- a/client/systems/collision_system.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { ClientWorld } from "../client_world.ts"; -import { CollisionCuboid } from "$/client/components/collision.ts"; -import { Velocity } from "$/common/components/velocity.ts"; -import { Dimension } from "../components/dimension.ts"; - -export class CollisionSystem extends System { - override update(world: ClientWorld, delta: number): void { - for (const entity of world.get_entities()) { - const position = entity.get(Position); - const velocity = entity.get(Velocity); - const cuboid = entity.get(CollisionCuboid); - - if (!position || !velocity || !cuboid) { - continue; - } - - velocity.vy += cuboid.gravity * delta; - - let new_position = position.clone(); - - new_position.x += velocity.vx * delta; - - let collisions = this.check_collision(new_position, velocity, cuboid, world.dimension); - - cuboid.colliding_x = collisions.x; - if (collisions.x !== 0) { - velocity.vx = 0; - } - - new_position = position.clone(); - - new_position.y += velocity.vy * delta; - - collisions = this.check_collision(new_position, velocity, cuboid, world.dimension); - - cuboid.colliding_y = collisions.y; - if (collisions.y !== 0) { - velocity.vy = 0; - } - - new_position = position.clone(); - - new_position.z += velocity.vz * delta; - - collisions = this.check_collision(new_position, velocity, cuboid, world.dimension); - - cuboid.colliding_z = collisions.z; - if (collisions.z !== 0) { - velocity.vz = 0; - } - } - } - - check_collision( - position: Position, - velocity: Velocity, - cuboid: CollisionCuboid, - dimension: Dimension, - ): { x: number; y: number; z: number } { - const collisions = { x: 0, y: 0, z: 0 }; - - const min_x = Math.floor(position.x - cuboid.width / 2); - const max_x = Math.floor(position.x + cuboid.width / 2); - const min_y = Math.floor(position.y); - const max_y = Math.floor(position.y + cuboid.height); - const min_z = Math.floor(position.z - cuboid.depth / 2); - const max_z = Math.floor(position.z + cuboid.depth / 2); - - for (let x = min_x; x <= max_x; x++) { - for (let y = min_y; y <= max_y; y++) { - for (let z = min_z; z <= max_z; z++) { - const block = dimension.get_block(x, y, z); - if (block && block !== 0) { - if (velocity.vx > 0) { - collisions.x = -1; - } - if (velocity.vx < 0) { - collisions.x = 1; - } - if (velocity.vy > 0) { - collisions.y = -1; - } - if (velocity.vy < 0) { - collisions.y = 1; - } - if (velocity.vz > 0) { - collisions.z = -1; - } - if (velocity.vz < 0) { - collisions.z = 1; - } - } - } - } - } - - return collisions; - } -} diff --git a/client/systems/debug_system.ts b/client/systems/debug_system.ts deleted file mode 100644 index 31044c2..0000000 --- a/client/systems/debug_system.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { ClientWorld } from "$/client/client_world.ts"; -import { DebugUI } from "$/client/debug_ui.ts"; - -export class DebugSystem extends System { - constructor() { - super(); - } - - update(world: ClientWorld, _delta: number): void { - if (!world.debugging) { - return; - } - - DebugUI.begin("Entities", 10, 10, 300); - - for (const entity of world.get_entities()) { - if (DebugUI.collapsing_header("Entity - " + entity.id)) { - for (const component of entity.get_all()) { - if (DebugUI.collapsing_header(`${component.constructor.name}##${entity.id}`)) { - this.render_component(component); - } - } - } - } - - DebugUI.end(); - } - - // deno-lint-ignore no-explicit-any - render_component(component: any) { - for (const key in component) { - if (key === "__component") { - continue; - } - if (typeof component[key] === "number") { - component[key] = DebugUI.float_input( - key, - component[key], - ); - } else if (typeof component[key] === "string") { - component[key] = DebugUI.text_input( - key, - component[key], - ); - } else if (typeof component[key] === "boolean") { - component[key] = DebugUI.checkbox( - key, - component[key], - ); - } else if (Array.isArray(component[key])) { - DebugUI.text(`${key}: ${JSON.stringify(component[key].slice(0, 10))}`); - } else { - DebugUI.text(`${key}: ${JSON.stringify(component[key])}`); - } - DebugUI.separator(); - } - } -} diff --git a/client/systems/network_system.ts b/client/systems/network_system.ts deleted file mode 100644 index b20c184..0000000 --- a/client/systems/network_system.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { ClientWorld } from "../client_world.ts"; -import { Camera } from "../components/camera.ts"; -import { Container, ItemStack } from "$/common/inventory.ts"; -import { PlayerComponent } from "../player.ts"; -import { GuiContainer } from "../gui/gui_container.ts"; -import { show_fatal_error } from "../fatal.ts"; - -const MOVE_SEND_INTERVAL = 1 / 10; -const REMOTE_PLAYER_SMOOTHING = 12; - -export class NetworkSystem extends System { - move_timer = 0; - - update(world: ClientWorld, delta: number): void { - const connection = world.connection; - const [local_player] = world.get_tag("player")!; - const player_component = local_player.get(PlayerComponent)!; - const inventories = player_component.inventories; - - for (const message of connection.incoming) { - switch (message.type) { - case "player_join": - connection.add_player(message.player); - break; - case "player_leave": - connection.players.delete(message.id); - break; - case "player_move": { - const player = connection.players.get(message.id); - if (player) { - player.x = message.x; - player.y = message.y; - player.z = message.z; - player.yaw = message.yaw; - player.pitch = message.pitch; - } - break; - } - case "set_block": - world.dimension.record_change(message.x, message.y, message.z, message.id, message.state); - world.dimension.apply_change(message.x, message.y, message.z, message.id, message.state); - break; - case "chat": - world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text); - break; - case "container": { - let container = message.container === "screen" - ? inventories.screen - : inventories[message.container]; - if (message.container === "screen" && container?.size !== message.items.length) { - container = inventories.screen = new Container(message.items.length); - } - container?.load(message.items); - break; - } - case "cursor": - inventories.cursor.item = message.item ? ItemStack.from_data(message.item) : undefined; - break; - case "open_screen": { - // replace anything open locally without telling the server, it just opened this one - player_component.screens.length = 0; - const size = Math.max(0, ...message.layout.slots.map((slot) => slot.index + 1)); - inventories.screen = new Container(size); - player_component.screens.push( - new GuiContainer(inventories, (m) => connection.send(m), message.layout, message.properties), - ); - break; - } - case "screen_properties": { - const screen = player_component.screens.at(-1); - if (screen instanceof GuiContainer) { - screen.properties = message.properties; - } - break; - } - case "teleport": { - const position = local_player.get(Position)!; - position.x = message.x; - position.y = message.y; - position.z = message.z; - break; - } - case "close_screen": - // closed by the server (the block broke), it already put everything back - player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer)); - inventories.screen = undefined; - break; - } - } - connection.incoming.length = 0; - - if (connection.closed) { - show_fatal_error("Lost connection to the server"); - return; - } - - const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING); - for (const player of connection.players.values()) { - player.display_x += (player.x - player.display_x) * t; - player.display_y += (player.y - player.display_y) * t; - player.display_z += (player.z - player.display_z) * t; - } - - this.move_timer += delta; - if (this.move_timer >= MOVE_SEND_INTERVAL) { - this.move_timer = 0; - const [player] = world.get_tag("player")!; - const position = player.get(Position)!; - const camera = player.get(Camera)!; - connection.send({ - type: "move", - x: position.x, - y: position.y, - z: position.z, - yaw: camera.yaw, - pitch: camera.pitch, - }); - } - } -} diff --git a/client/systems/player_controls.ts b/client/systems/player_controls.ts deleted file mode 100644 index 7a44198..0000000 --- a/client/systems/player_controls.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Velocity } from "$/common/components/velocity.ts"; -import { InputManager } from "../input_manager.ts"; -import { ClientWorld } from "../client_world.ts"; -import { PlayerControls } from "../components/player_controls.ts"; -import { Camera } from "../components/camera.ts"; -import { Position } from "../../common/components/position.ts"; -import { PlayerComponent } from "../player.ts"; -import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts"; -import { CollisionCuboid } from "../components/collision.ts"; -import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; -import { AIR, FACE_OFFSETS } from "$/common/constants.ts"; -import { ClientMessage } from "$/common/protocol.ts"; -import { GuiChat } from "../gui/gui_chat.ts"; -import { GuiInventoryScreen } from "../gui/gui_screen.ts"; - -export class PlayerControlsSystem extends System { - constructor() { - super(); - } - - update(world: ClientWorld, delta: number): void { - const [player] = world.get_tag("player")!; - const velocity = player.get(Velocity)!; - const controls = player.get(PlayerControls)!; - const player_component = player.get(PlayerComponent)!; - const position = player.get(Position)!; - const camera = player.get(Camera)!; - const send = (message: ClientMessage) => world.connection.send(message); - - if (player_component.screens.length === 0) { - let input_x = 0; - let input_z = 0; - - if (InputManager.is_key_down(controls.move_left)) { - input_x -= 1; - } - if (InputManager.is_key_down(controls.move_right)) { - input_x += 1; - } - if (InputManager.is_key_down(controls.move_forward)) { - input_z -= 1; - } - if (InputManager.is_key_down(controls.move_backwards)) { - input_z += 1; - } - - const size = Math.hypot(input_x, input_z); - if (size > 0) { - input_x /= size; - input_z /= size; - } - - const sin = Math.sin(camera.yaw); - const cos = Math.cos(camera.yaw); - - const forwardX = sin; - const forwardZ = cos; - - const rightX = cos; - const rightZ = -sin; - - const speed_modifier = InputManager.is_key_down(controls.sprint_key) ? 1.75 : 1; - - velocity.vx = (forwardX * input_z + rightX * input_x) * controls.move_speed * speed_modifier; - velocity.vz = (forwardZ * input_z + rightZ * input_x) * controls.move_speed * speed_modifier; - - const cuboid = player.get(CollisionCuboid); - - if (InputManager.is_key_down("Space") && cuboid?.colliding_y === 1) { - velocity.vy += controls.jump_force; - } - } - - if (InputManager.is_key_pressed(controls.open_inventory)) { - if (player_component.screens.length === 0) { - player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send)); - } else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) { - player_component.pop_screen(); - } - } - - if (InputManager.is_key_pressed(controls.open_chat)) { - if (player_component.screens.length === 0) { - player_component.screens.push(new GuiChat(world)); - } - } - - if (InputManager.is_key_pressed("Escape")) { - player_component.pop_screen(); - } - - if (InputManager.is_key_pressed(controls.open_debug)) { - world.debugging = !world.debugging; - } - - if (InputManager.is_key_pressed("F11")) { - InputManager.toggle_fullscreen(); - } - - camera.x = position.x; - camera.y = position.y + 1.69; - camera.z = position.z; - - if (InputManager.is_mouse_grabbed()) { - const mouse_delta = InputManager.get_mouse_delta(); - camera.yaw += -mouse_delta.x * 0.001; - camera.pitch += -mouse_delta.y * 0.001; - - const limit = Math.PI / 2 - 0.01; - camera.pitch = Math.max(-limit, Math.min(limit, camera.pitch)); - } - - InputManager.set_mouse_grabbed(player_component.screens.length === 0); - - if (InputManager.is_mouse_down(0) && player_component.screens.length === 0) { - player_component.breaking_block = { x: 0, y: 9999, z: 0 }; - } else { - player_component.breaking_block = undefined; - player_component.break_progress_max = 0; - player_component.break_progress = 0; - } - - const block = world.dimension.get_looked_block(world.dimension, camera); - const inventories = player_component.inventories; - const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected); - const holding_item_info = EverythingRegistry.get("items", hotbar_slot.type_id ?? ""); - - if (block && player_component.screens.length === 0) { - const block_info = EverythingRegistry.get_by_id("blocks", block.block)!; - if (InputManager.is_mouse_pressed(0)) { - // for mods' on_click, breaking itself is timed here and sent when done - send({ type: "hit_block", x: block.x, y: block.y, z: block.z }); - } - if (player_component.breaking_block) { - player_component.breaking_block = { x: block.x, y: block.y, z: block.z }; - player_component.break_progress_max = block_info.toughness ?? 9999; - let multiplier = 1; - if (holding_item_info?.tool_type === block_info.tool_to_break) { - multiplier *= 2; - } - player_component.break_progress += delta * multiplier; - if (player_component.break_progress >= player_component.break_progress_max) { - // show it right away, the server decides drops and corrects us if it disagrees - world.dimension.break_block(block.x, block.y, block.z); - send({ type: "break_block", x: block.x, y: block.y, z: block.z }); - player_component.break_progress_max = 0; - player_component.break_progress = 0; - } - } else if (InputManager.is_mouse_pressed(2)) { - send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face }); - - // guess that it places the held block, unless the block does something when used - const offset = FACE_OFFSETS[block.face]; - const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z }; - const target_id = world.dimension.get_block(target.x, target.y, target.z); - const replaceable = target_id === AIR || - EverythingRegistry.get_by_id("blocks", target_id)?.replaceable; - // items with components might do something else on the server, like on_use - const place_id = holding_item_info?.components ? undefined : holding_item_info?.block_id; - if (!block_info.interactive && place_id && replaceable) { - world.dimension.add_block({ ...target, id: place_id }); - hotbar_slot.amount = hotbar_slot.amount! - 1; - } - } - } else { - player_component.breaking_block = undefined; - player_component.break_progress_max = 0; - player_component.break_progress = 0; - if (!block && player_component.screens.length === 0 && InputManager.is_mouse_pressed(2)) { - send({ type: "use_item" }); - } - } - - if (player_component.screens.length === 0) { - 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 hotbar_keys = [ - controls.hotbar_1, - controls.hotbar_2, - controls.hotbar_3, - controls.hotbar_4, - controls.hotbar_5, - controls.hotbar_6, - controls.hotbar_7, - controls.hotbar_8, - controls.hotbar_9, - ]; - const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key)); - if (pressed !== -1) { - inventories.hotbar_selected = pressed; - } - - if (inventories.hotbar_selected !== previous) { - send({ type: "select_slot", slot: inventories.hotbar_selected }); - } - } - } -} diff --git a/client/systems/render_system.ts b/client/systems/render_system.ts deleted file mode 100644 index 83be7c1..0000000 --- a/client/systems/render_system.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts"; -import { Dimension } from "../components/dimension.ts"; -import { Camera } from "$/client/components/camera.ts"; - -import { render_animated_sprite, render_sprite } from "./rendering/sprites.ts"; -import { render_dimension_opaque, render_dimension_translucent } from "./rendering/dimension.ts"; -import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts"; -import { PlayerComponent } from "../player.ts"; -import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts"; -import { ClientWorld } from "../client_world.ts"; -import { render_chat_log, render_remote_players } from "./rendering/network.ts"; - -export class RenderSystem extends System { - constructor() { - super(); - } - - update(world: ClientWorld, _delta: number): void { - const camera_entity = world.get_entities().values().find((e) => e.get(Camera)); - const camera = camera_entity?.get(Camera); - - if (!camera) { - return; - } - - begin_mode_3d(camera); - - for (const entity of world.get_entities()) { - const dimension = entity.get(Dimension); - - if (dimension) { - render_dimension_opaque(dimension, camera); - } - - const player_component = entity.get(PlayerComponent); - if (player_component) { - render_player_breaking(player_component); - } - } - - if (world.connection) { - render_remote_players(world.connection); - } - - for (const entity of world.get_entities()) { - const dimension = entity.get(Dimension); - if (dimension) { - render_dimension_translucent(dimension, camera); - } - } - - end_mode_3d(); - - for (const entity of world.get_entities()) { - const position = entity.get(Position); - - const sprite = entity.get(Sprite); - if (position && sprite) { - render_sprite(sprite, position); - } - - const animated_sprite = entity.get(AnimatedSprite); - if (position && animated_sprite) { - render_animated_sprite(animated_sprite, position); - } - - const player_component = entity.get(PlayerComponent); - if (player_component) { - render_player_hotbar(player_component.inventories); - render_player_crosshair(); - } - } - - render_chat_log(world, false); - } -} diff --git a/client/systems/rendering/dimension.ts b/client/systems/rendering/dimension.ts deleted file mode 100644 index d9484a1..0000000 --- a/client/systems/rendering/dimension.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CHUNK_SIZE, Dimension } from "$/client/components/dimension.ts"; -import { Camera } from "$/client/components/camera.ts"; -import { draw_terrain, set_current_texture } from "$/client/renderer/mod.ts"; - -// solid and cutout terrain, drawn before entities -export function render_dimension_opaque(dimension: Dimension, camera: Camera) { - dimension.request_meshes(camera); - dimension.update_translucent_sorting(camera); - - set_current_texture(dimension.image.tex); - - for (const chunk of dimension.chunks.values()) { - const mesh = chunk.meshes.solid; - if (mesh) { - draw_terrain("solid", mesh.vertex_buffer, mesh.quad_count); - } - } - - for (const chunk of dimension.chunks.values()) { - const mesh = chunk.meshes.cutout; - if (mesh) { - draw_terrain("cutout", mesh.vertex_buffer, mesh.quad_count); - } - } -} - -// translucent terrain, drawn after entities so they show through water and glass. -// chunks go back to front, and each chunk's quads are already sorted back to front -export function render_dimension_translucent(dimension: Dimension, camera: Camera) { - const distance_sq = (x: number, z: number) => { - const dx = (x + 0.5) * CHUNK_SIZE - camera.x; - const dz = (z + 0.5) * CHUNK_SIZE - camera.z; - return dx * dx + dz * dz; - }; - const chunks = [...dimension.chunks.values()] - .filter((chunk) => chunk.meshes.translucent) - .map((chunk) => ({ mesh: chunk.meshes.translucent!, distance: distance_sq(chunk.x, chunk.z) })) - .sort((a, b) => b.distance - a.distance); - - set_current_texture(dimension.image.tex); - - for (const { mesh } of chunks) { - draw_terrain("translucent", mesh.vertex_buffer, mesh.quad_count, mesh.index_buffer); - } -} diff --git a/client/systems/rendering/network.ts b/client/systems/rendering/network.ts deleted file mode 100644 index f624d06..0000000 --- a/client/systems/rendering/network.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ClientWorld } from "$/client/client_world.ts"; -import { Connection } from "$/client/network.ts"; -import { - canvas, - draw_rect, - draw_text, - flush_batch, - push_box, - set_current_texture, - white_tex, -} from "$/client/renderer/mod.ts"; - -const CHAT_LINE_HEIGHT = 24; -const CHAT_VISIBLE_SECONDS = 10; -const CHAT_MAX_LINES = 10; - -export function render_remote_players(connection: Connection) { - if (connection.players.size === 0) { - return; - } - - flush_batch(); - set_current_texture(white_tex!); - - for (const player of connection.players.values()) { - const [r, g, b] = player.color; - const x = player.display_x; - const y = player.display_y; - const z = player.display_z; - - // body - push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b); - // head - push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65); - } - - flush_batch(); -} - -// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open) -export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) { - const now = performance.now(); - const lines = world.chat_log - .filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000) - .slice(-CHAT_MAX_LINES); - - let y = bottom - lines.length * CHAT_LINE_HEIGHT; - for (const line of lines) { - draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]); - draw_text(line.text, 4, y, 2, [1, 1, 1, 1]); - y += CHAT_LINE_HEIGHT; - } -} diff --git a/client/systems/rendering/player.ts b/client/systems/rendering/player.ts deleted file mode 100644 index f752c02..0000000 --- a/client/systems/rendering/player.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; -import { AssetManager } from "$/client/assets.ts"; -import { ClientInventories } from "../../inventory.ts"; -import { draw_item, draw_nine_slice } from "./render_utils.ts"; -import { - canvas, - draw_rect_stroke, - push_back_face, - push_bottom_face, - push_front_face, - push_left_face, - push_right_face, - push_top_face, - Texture, -} from "$/client/renderer/mod.ts"; -import { PlayerComponent } from "../../player.ts"; -import { get_sprite_region } from "$/client/sprites.ts"; - -const PADDING = 10; - -export function render_player_hotbar(inventories: ClientInventories) { - const ui = AssetManager.instance.get("bworld:ui"); - - const hotbar_width = PADDING * 2 + SLOT_SIZE * 9; - const hotbar_height = PADDING * 2 + SLOT_SIZE; - - const x = canvas.width / 2 - hotbar_width / 2; - const y = canvas.height - hotbar_height; - - draw_nine_slice( - ui, - 160, - 0, - 16, - 16, - 4, - 4, - 4, - 4, - x, - y, - hotbar_width, - hotbar_height, - ); - - for (let index = 0; index < 9; index += 1) { - draw_nine_slice( - ui, - inventories.hotbar_selected === index ? 19 * 16 : 160 + 32, - inventories.hotbar_selected === index ? 16 : 0, - 16, - 16, - 4, - 4, - 4, - 4, - x + PADDING + index * SLOT_SIZE, - y + PADDING, - SLOT_SIZE, - SLOT_SIZE, - ); - } - - for (let index = 0; index < 9; index += 1) { - const item = inventories.inventory.get_item(index); - if (item) { - draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING); - } - } -} - -export function render_player_crosshair() { - const CROSSHAIR_SIZE = 8; - draw_rect_stroke( - (canvas.width - CROSSHAIR_SIZE) / 2, - (canvas.height - CROSSHAIR_SIZE) / 2, - CROSSHAIR_SIZE, - CROSSHAIR_SIZE, - [0, 0, 0, 0.6], - ); -} - -const FACE_FUNCTIONS = [ - push_back_face, - push_bottom_face, - push_front_face, - push_left_face, - push_right_face, - push_top_face, -]; -export function render_player_breaking(player_component: PlayerComponent) { - const block = player_component.breaking_block; - if (block) { - const tex = AssetManager.instance.get("bworld:textures"); - - const progress = Math.max( - 0, - Math.min(1, player_component.break_progress / player_component.break_progress_max), - ); - const break_sprite = Math.round(progress * 8); - - if (Number.isNaN(break_sprite)) { - return; - } - const region = get_sprite_region(`engine:break_${break_sprite}`); - - for (const fn of FACE_FUNCTIONS) { - fn( - tex, - block.x, - block.y, - block.z, - region.x * TEXTURE_SIZE, - region.y * TEXTURE_SIZE, - TEXTURE_SIZE, - TEXTURE_SIZE, - ); - } - } -} diff --git a/client/systems/rendering/sprites.ts b/client/systems/rendering/sprites.ts deleted file mode 100644 index aef3169..0000000 --- a/client/systems/rendering/sprites.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Position } from "$/common/components/position.ts"; -import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts"; -import { draw_texture_region } from "$/client/renderer/mod.ts"; - -export function render_sprite(sprite: Sprite, position: Position) { - draw_texture_region( - sprite.image, - sprite.source_x, - sprite.source_y, - sprite.source_width, - sprite.source_height, - position.x, - position.y, - sprite.width, - sprite.height, - sprite.flip_x, - sprite.flip_y, - ); -} - -export function render_animated_sprite( - animated_sprite: AnimatedSprite, - position: Position, -) { - const current_animation = animated_sprite.states[animated_sprite.current_state]; - if (!current_animation) { - console.error(`Missing animation for state ${animated_sprite.current_state}`); - return; - } - - draw_texture_region( - animated_sprite.image, - current_animation.source_x[animated_sprite.animation_frame], - current_animation.source_y[animated_sprite.animation_frame], - current_animation.source_width, - current_animation.source_height, - position.x, - position.y, - animated_sprite.width, - animated_sprite.height, - animated_sprite.flip_x, - animated_sprite.flip_y, - ); - - animated_sprite.timer += 1; - if (animated_sprite.timer >= current_animation.duration) { - animated_sprite.timer = 0; - animated_sprite.animation_frame += 1; - - if (animated_sprite.animation_frame >= current_animation.source_x.length) { - animated_sprite.animation_frame = 0; - } - } -} diff --git a/client/systems/ui_interaction_system.ts b/client/systems/ui_interaction_system.ts deleted file mode 100644 index 0c76f79..0000000 --- a/client/systems/ui_interaction_system.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { point_inside_rec } from "$/common/utils.ts"; -import { ClientWorld } from "$/client/client_world.ts"; -import { UIButton } from "$/client/components/ui_components.ts"; -import { InputManager } from "$/client/input_manager.ts"; - -export class UIInteractionSystem implements System { - update(world: ClientWorld, _delta: number) { - const mouse = InputManager.get_mouse_position(); - - for (const entity of world.get_entities()) { - const position = entity.get(Position); - const button = entity.get(UIButton); - - if (position && button) { - const hovered = point_inside_rec(mouse.x, mouse.y, position.x, position.y, button.width, button.height); - button.hovered = hovered; - if (hovered && InputManager.is_mouse_pressed(0)) { - InputManager.consume_mouse(0); - button.on_click(); - } - } - } - } -} diff --git a/client/systems/ui_render_system.ts b/client/systems/ui_render_system.ts deleted file mode 100644 index 5955616..0000000 --- a/client/systems/ui_render_system.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { ClientWorld } from "$/client/client_world.ts"; -import { UIButton } from "$/client/components/ui_components.ts"; -import { draw_nine_slice } from "./rendering/render_utils.ts"; -import { AssetManager } from "../assets.ts"; -import { draw_text, Texture } from "../renderer/mod.ts"; - -const SCALE = 4; - -export class UIRenderSystem implements System { - constructor() {} - - update(world: ClientWorld, _delta: number) { - const ui = AssetManager.instance.get("bworld:ui"); - - for (const entity of world.get_entities()) { - const position = entity.get(Position); - const button = entity.get(UIButton); - - if (position && button) { - draw_nine_slice( - ui, - 16 * (button.hovered ? 14 : 11), - 16 * (button.hovered ? 1 : 0), - 16, - 16, - 3, - 3, - 3, - 3, - position.x / SCALE, - position.y / SCALE, - button.width / SCALE, - button.height / SCALE, - ); - // const measure = measure_text(ctx, button.text, 1.25); - draw_text( - button.text, - (position.x / SCALE) + (button.width / SCALE / 2) - (100 / 2), - position.y / SCALE + (button.height / SCALE / 2), - //1.5, - //"white", - //"middle", - ); - } - } - } -} diff --git a/client/systems/world_generation_system.ts b/client/systems/world_generation_system.ts deleted file mode 100644 index a32172c..0000000 --- a/client/systems/world_generation_system.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { ClientWorld } from "../client_world.ts"; -import { Position } from "../../common/components/position.ts"; -import { CHUNK_SIZE } from "../components/dimension.ts"; -import { PlayerComponent } from "$/client/player.ts"; - -export class WorldGenerationSystem extends System { - constructor() { - super(); - } - - update(world: ClientWorld, _delta: number): void { - const [player] = world.get_tag("player")!; - const position = player.get(Position)!; - const player_component = player.get(PlayerComponent)!; - const dimension = world.dimension; - - const player_chunk_x = Math.floor(position.x / CHUNK_SIZE); - const player_chunk_z = Math.floor(position.z / CHUNK_SIZE); - - // one extra ring gets generated so the edge of the render distance has neighbors to mesh against - const load_distance = player_component.render_distance + 1; - const out_of_range = (x: number, z: number) => - Math.max(Math.abs(x - player_chunk_x), Math.abs(z - player_chunk_z)) > load_distance; - - // collect first, deleting from the map while iterating it skips entries - const to_unload = []; - for (const chunk of dimension.chunks.values()) { - if (out_of_range(chunk.x, chunk.z)) { - to_unload.push(chunk); - } - } - for (const chunk of to_unload) { - dimension.unload_chunk(chunk.x, chunk.z); - } - for (const pending of [...dimension.pending_generation.values()]) { - if (out_of_range(pending.x, pending.z)) { - dimension.cancel_chunk_request(pending.x, pending.z); - } - } - - // dont queue up the whole area at once, so walking somewhere new gets the close chunks first - const max_in_flight = dimension.workers.size * 2; - if (dimension.pending_generation.size >= max_in_flight) { - return; - } - - const missing: { x: number; z: number; distance: number }[] = []; - for (let x = player_chunk_x - load_distance; x <= player_chunk_x + load_distance; x += 1) { - for (let z = player_chunk_z - load_distance; z <= player_chunk_z + load_distance; z += 1) { - if (!dimension.is_generated(x, z) && !dimension.is_generating(x, z)) { - const dx = x - player_chunk_x; - const dz = z - player_chunk_z; - missing.push({ x, z, distance: dx * dx + dz * dz }); - } - } - } - missing.sort((a, b) => a.distance - b.distance); - - for (const chunk of missing.slice(0, max_in_flight - dimension.pending_generation.size)) { - dimension.request_chunk(chunk.x, chunk.z); - } - } -} diff --git a/common/components/position.ts b/common/components/position.ts deleted file mode 100644 index 16a53d2..0000000 --- a/common/components/position.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component } from "$/common/ecs/component.ts"; - -export class Position extends Component { - x: number; - y: number; - z: number; - - constructor(x: number, y: number, z = 0) { - super(); - this.x = x; - this.y = y; - this.z = z; - } - - clone() { - return new Position(this.x, this.y, this.z); - } -} diff --git a/common/components/velocity.ts b/common/components/velocity.ts deleted file mode 100644 index 0dbe215..0000000 --- a/common/components/velocity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component } from "$/common/ecs/component.ts"; - -export class Velocity extends Component { - vx: number; - vy: number; - vz: number; - - constructor(vx: number, vy: number, vz = 0) { - super(); - this.vx = vx; - this.vy = vy; - this.vz = vz; - } -} diff --git a/common/ecs/component.ts b/common/ecs/component.ts deleted file mode 100644 index c22cde8..0000000 --- a/common/ecs/component.ts +++ /dev/null @@ -1,9 +0,0 @@ -export abstract class Component { - __component = true; -} - -export abstract class SerializableComponent extends Component { - abstract serialize(): unknown; - // right, this is static you cant do this,, - // abstract deserialize(data: unknown): T; -} diff --git a/common/ecs/entity.ts b/common/ecs/entity.ts deleted file mode 100644 index e4ba0a4..0000000 --- a/common/ecs/entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Component } from "./component.ts"; - -// deno-lint-ignore no-explicit-any -type ComponentConstructor = new (...args: any[]) => T; - -export class Entity { - id: string; - // deno-lint-ignore no-explicit-any - components = new Map, Component>(); - active = true; - - constructor(id: string = crypto.randomUUID()) { - this.id = id; - } - - add(component: T): T { - this.components.set(component.constructor as ComponentConstructor, component); - return component; - } - - get(type: ComponentConstructor): T | undefined { - return this.components.get(type) as T; - } - - get_all(): Iterable { - return this.components.values(); - } -} diff --git a/common/ecs/mod.ts b/common/ecs/mod.ts deleted file mode 100644 index 2249d13..0000000 --- a/common/ecs/mod.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./component.ts"; -export * from "./entity.ts"; -export * from "./system.ts"; -export * from "./world.ts"; diff --git a/common/ecs/system.ts b/common/ecs/system.ts deleted file mode 100644 index c455073..0000000 --- a/common/ecs/system.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { World } from "./world.ts"; - -export abstract class System { - abstract update(world: World, delta: number): void; -} diff --git a/common/ecs/world.ts b/common/ecs/world.ts deleted file mode 100644 index 5d03e17..0000000 --- a/common/ecs/world.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Entity } from "./entity.ts"; -import type { System } from "./system.ts"; - -export class World { - #entities = new Set(); - #entities_for_deletion = new Set(); - - #systems = new Map>(); - #tags = new Map(); - #states = new Set(); - - #state: string = ""; - #new_state: string | undefined; - - constructor(initial_state: string) { - this.#state = initial_state; - this.add_state("*"); - } - - add_state(new_state: string) { - this.#states.add(new_state); - this.#systems.set(new_state, new Set()); - } - - add_entity(entity: Entity) { - this.#entities.add(entity); - } - - add_system(system: System, state: string) { - console.assert(this.#states.has(state)); - this.#systems.get(state)!.add(system); - } - - add_tag(tag: string, entities: Entity[]) { - this.#tags.set(tag, entities); - } - - get_tag(tag: string) { - return this.#tags.get(tag); - } - - update(delta: number) { - for (const system of this.#systems.get("*") ?? []) { - system.update(this, delta); - } - for (const system of this.#systems.get(this.#state) ?? []) { - system.update(this, delta); - } - // should be faster than Set.prototype.difference lol - for (const entity of this.#entities_for_deletion) { - this.#entities.delete(entity); - } - if (this.#new_state) { - this.#state = this.#new_state; - this.#new_state = undefined; - } - } - - get_entities() { - return this.#entities; - } - - delete_entity(entity: Entity) { - this.#entities_for_deletion.add(entity); - } - - clear_entities() { - for (const entity of this.#entities) { - this.#entities_for_deletion.add(entity); - } - } - - get state() { - return this.#state; - } - - set state(new_state: string) { - this.#new_state = new_state; - } -} diff --git a/common/systems/movement_system.ts b/common/systems/movement_system.ts deleted file mode 100644 index d94fdcb..0000000 --- a/common/systems/movement_system.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { System, World } from "$/common/ecs/mod.ts"; -import { Position } from "$/common/components/position.ts"; -import { Velocity } from "$/common/components/velocity.ts"; - -export class MovementSystem extends System { - update(world: World, delta: number): void { - for (const entity of world.get_entities()) { - const position = entity.get(Position); - const velocity = entity.get(Velocity); - - if (position && velocity) { - position.x += velocity.vx * delta; - position.y += velocity.vy * delta; - position.z += velocity.vz * delta; - } - } - } -}