From 79faa556dee3e5a13defd3f3d5b039b3033ebc32 Mon Sep 17 00:00:00 2001 From: Paula Date: Thu, 24 Sep 2026 19:34:07 -0300 Subject: [PATCH] Server authority --- MODS.md | 31 +- client/blocks/chest.ts | 44 -- client/blocks/dirt.ts | 27 -- client/blocks/furnace.ts | 200 --------- client/blocks/grass.ts | 25 -- client/client_world.ts | 7 +- client/components/dimension.ts | 94 +--- client/fatal.ts | 26 ++ client/game.ts | 4 +- client/gui/gui_chat.ts | 31 +- client/gui/gui_chest.ts | 60 --- client/gui/gui_container.ts | 119 +++++ client/gui/gui_furnace.ts | 211 --------- client/gui/gui_player_inventory.ts | 230 +--------- client/gui/gui_screen.ts | 260 ++++------- client/inventory.ts | 145 +----- client/main.ts | 26 +- client/network.ts | 16 +- client/player.ts | 14 +- client/sprites.ts | 9 + client/systems/dimension_logic.ts | 44 -- client/systems/network_system.ts | 48 +- client/systems/player_controls.ts | 105 ++--- client/systems/render_system.ts | 2 +- client/systems/rendering/player.ts | 12 +- client/systems/rendering/render_utils.ts | 4 +- client/workers/chunk_worker.ts | 35 +- common/blocks/chest.ts | 16 + {client => common}/blocks/coal_ore.ts | 0 {client => common}/blocks/copper_ore.ts | 0 {client => common}/blocks/crops.ts | 4 +- common/blocks/dirt.ts | 15 + common/blocks/furnace.ts | 16 + {client => common}/blocks/glass.ts | 0 {client => common}/blocks/gold_ore.ts | 0 common/blocks/grass.ts | 12 + {client => common}/blocks/hoed_dirt.ts | 0 {client => common}/blocks/iron_ore.ts | 0 {client => common}/blocks/leaves.ts | 0 {client => common}/blocks/log.ts | 0 {client => common}/blocks/mod.ts | 0 {client => common}/blocks/planks.ts | 0 {client => common}/blocks/sand.ts | 0 {client => common}/blocks/snow.ts | 0 {client => common}/blocks/stone.ts | 0 {client => common}/blocks/tin_ore.ts | 0 {client => common}/blocks/water.ts | 0 common/constants.ts | 10 + common/everything_registry.ts | 14 +- {client => common}/generation.ts | 46 +- common/inventory.ts | 270 +++++++++++ {client => common}/items/axe.ts | 0 {client => common}/items/coal.ts | 0 {client => common}/items/copper_ingot.ts | 0 {client => common}/items/gold_ingot.ts | 0 {client => common}/items/hoe.ts | 0 {client => common}/items/iron_ingot.ts | 0 {client => common}/items/mod.ts | 0 {client => common}/items/pickaxe.ts | 0 {client => common}/items/stick.ts | 0 {client => common}/items/tin_ingot.ts | 0 {client => common}/items/watering_can.ts | 0 {client => common}/items/wood_pickaxe.ts | 0 common/protocol.ts | 53 ++- common/utils.ts | 15 +- deno.json | 2 +- server/game/blocks.ts | 278 ++++++++++++ server/game/crafting.ts | 131 ++++++ server/game/game_server.ts | 541 +++++++++++++++++++++++ server/game/host_protocol.ts | 15 + server/game/lru.ts | 35 ++ server/game/player.ts | 86 ++++ server/game/worker.ts | 57 +++ server/game/world.ts | 186 ++++++++ server/main.ts | 248 +++-------- 75 files changed, 2247 insertions(+), 1632 deletions(-) delete mode 100644 client/blocks/chest.ts delete mode 100644 client/blocks/dirt.ts delete mode 100644 client/blocks/furnace.ts delete mode 100644 client/blocks/grass.ts create mode 100644 client/fatal.ts delete mode 100644 client/gui/gui_chest.ts create mode 100644 client/gui/gui_container.ts delete mode 100644 client/gui/gui_furnace.ts create mode 100644 client/sprites.ts delete mode 100644 client/systems/dimension_logic.ts create mode 100644 common/blocks/chest.ts rename {client => common}/blocks/coal_ore.ts (100%) rename {client => common}/blocks/copper_ore.ts (100%) rename {client => common}/blocks/crops.ts (95%) create mode 100644 common/blocks/dirt.ts create mode 100644 common/blocks/furnace.ts rename {client => common}/blocks/glass.ts (100%) rename {client => common}/blocks/gold_ore.ts (100%) create mode 100644 common/blocks/grass.ts rename {client => common}/blocks/hoed_dirt.ts (100%) rename {client => common}/blocks/iron_ore.ts (100%) rename {client => common}/blocks/leaves.ts (100%) rename {client => common}/blocks/log.ts (100%) rename {client => common}/blocks/mod.ts (100%) rename {client => common}/blocks/planks.ts (100%) rename {client => common}/blocks/sand.ts (100%) rename {client => common}/blocks/snow.ts (100%) rename {client => common}/blocks/stone.ts (100%) rename {client => common}/blocks/tin_ore.ts (100%) rename {client => common}/blocks/water.ts (100%) rename {client => common}/generation.ts (82%) create mode 100644 common/inventory.ts rename {client => common}/items/axe.ts (100%) rename {client => common}/items/coal.ts (100%) rename {client => common}/items/copper_ingot.ts (100%) rename {client => common}/items/gold_ingot.ts (100%) rename {client => common}/items/hoe.ts (100%) rename {client => common}/items/iron_ingot.ts (100%) rename {client => common}/items/mod.ts (100%) rename {client => common}/items/pickaxe.ts (100%) rename {client => common}/items/stick.ts (100%) rename {client => common}/items/tin_ingot.ts (100%) rename {client => common}/items/watering_can.ts (100%) rename {client => common}/items/wood_pickaxe.ts (100%) create mode 100644 server/game/blocks.ts create mode 100644 server/game/crafting.ts create mode 100644 server/game/game_server.ts create mode 100644 server/game/host_protocol.ts create mode 100644 server/game/lru.ts create mode 100644 server/game/player.ts create mode 100644 server/game/worker.ts create mode 100644 server/game/world.ts diff --git a/MODS.md b/MODS.md index 15c1d5d..548ebbe 100644 --- a/MODS.md +++ b/MODS.md @@ -4,7 +4,7 @@ Status: **draft, format_version 1**. Nothing here is implemented yet, see [Imple Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. A mod is installed **on the server**. Players who join download its client code, data and textures from the server automatically, so they don't -install anything themselves. Single player works the same way, with a server running inside the browser. +install anything themselves. bworld is always played on a server; there is no single player mode. This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts @@ -27,7 +27,6 @@ too, since the client is already a web page. - [Mod channels](#mod-channels) - [World generation](#world-generation) - [Delivery to clients](#delivery-to-clients) -- [Single player](#single-player) - [Security](#security) - [Example mod](#example-mod) - [Implementation plan](#implementation-plan) @@ -256,9 +255,8 @@ export function setup(ctx: ServerContext) { } ``` -Server scripts run in the game server worker. They get the mod API and standard JavaScript, and **no Deno, Node or DOM -APIs**. That lets the same script run on a dedicated server and in the browser for [single player](#single-player). They -have no file or network access; persistent state goes through `ctx.storage`. +Server scripts run in the game server worker with **no Deno permissions** (see [Security](#security)). They get the mod +API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`. ```ts interface ServerContext { @@ -750,20 +748,6 @@ client server - The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded. - Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another. -## Single player - -Single player runs the same game server inside the browser, like Minecraft's integrated server: - -- The game server core (world, inventories, tile data, mod server scripts) is written against a small host interface for - **transport** and **storage**, with no Deno or DOM APIs. -- On a dedicated server, the host is `server/main.ts`: WebSocket transport, files for storage. The core runs in a Deno - worker. -- In single player, the host is the page: `postMessage` transport, IndexedDB for storage. The core runs in a web worker, - and `server.js` bundles are loaded from `server_mods/`, which the local build serves only to this page. - -The client uses the same protocol either way, so a mod written and tested in single player behaves the same on a -dedicated server. - ## Security **Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on @@ -900,16 +884,17 @@ class SmelterStats implements ModScreen { ## Implementation plan -None of this exists yet. Each step keeps the game working: +Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working: 1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package) - and have the server generate terrain. Pull world state, chunks, tile data and player inventories into an - environment-free module behind a transport and storage interface. Run it in a Deno worker from `server/main.ts`. + and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server + module that runs in a Deno worker, with `server/main.ts` handling HTTP, WebSockets and files. 2. **Server authority.** Change the protocol from "here's the block I changed" to intents (`break_block`, `place_block`, `interact`, `select_slot`, `container_click`). The client keeps showing breaks and places immediately and accepts corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced containers. -3. **Integrated server.** Run the same core in a web worker for single player, with IndexedDB storage. +3. **Server only.** Remove the offline fallback in `client/main.ts`, which currently starts a local game when it can't + reach a server, and show a connection error instead. 4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries, build the combined atlas (textures are currently all named `bworld:`), bundle scripts per side, and write hashed output to `build/mods/` and `server_mods/`. diff --git a/client/blocks/chest.ts b/client/blocks/chest.ts deleted file mode 100644 index 2964579..0000000 --- a/client/blocks/chest.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; -import { Container, Inventory } from "../inventory.ts"; -import { PlayerComponent } from "../player.ts"; -import { GuiChest } from "$/client/gui/gui_chest.ts"; - -interface TileChestData { - inventory: Inventory; -} - -const block = EverythingRegistry.register("blocks", "bworld:chest", { - id: "bworld:chest", - textures: "bworld:planks", - toughness: 8, - requires_tool: false, - tool_to_break: "axe", - drop_table: "bworld:chest", - - has_collision: false, - on_interact(dimension, block) { - const [player] = dimension.world.get_tag("player")!; - const player_component = player.get(PlayerComponent)!; - const block_data = dimension.get_block_data(block.x, block.y, block.z); - if (block_data && block_data.data.inventory) { - const gui = new GuiChest(block_data.data.inventory, player_component.player_inventory); - player_component.screens.push(gui); - return true; - } - return false; - }, - on_create(dimension, block) { - dimension.add_block_data({ - id: block.id, - x: block.x, - y: block.y, - z: block.z, - data: { - inventory: new Inventory(new Container(9 * 3)), - }, - }); - }, -}); - -register_block_item(block); diff --git a/client/blocks/dirt.ts b/client/blocks/dirt.ts deleted file mode 100644 index 7a61e3e..0000000 --- a/client/blocks/dirt.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { register_block_item } from "$/common/utils.ts"; -import { PlayerComponent } from "../player.ts"; - -const block = EverythingRegistry.register("blocks", "bworld:dirt", { - id: "bworld:dirt", - textures: "bworld:dirt", - has_collision: false, - drop_table: "bworld:dirt", - toughness: 2, - requires_tool: false, - tool_to_break: "shovel", - - on_interact(dimension, block) { - const [player] = dimension.world.get_tag("player")!; - const player_inventory = player.get(PlayerComponent)!.player_inventory; - const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected); - if (maybe_item && maybe_item.type_id === "bworld:hoe") { - dimension.add_block({ x: block.x, y: block.y, z: block.z, id: "bworld:hoed_dirt" }); - dimension.sync_block(block.x, block.y, block.z); - return true; - } - return false; - }, -}); - -register_block_item(block); diff --git a/client/blocks/furnace.ts b/client/blocks/furnace.ts deleted file mode 100644 index 7f27359..0000000 --- a/client/blocks/furnace.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { Container, Inventory, ItemStack } from "../inventory.ts"; -import { PlayerComponent } from "../player.ts"; -import { GuiFurnace } from "$/client/gui/gui_furnace.ts"; -import { register_block_item } from "../../common/utils.ts"; - -interface FurnaceRecipe { - input: string; - output: ItemStack; - cook_time: number; -} - -const FURNACE_RECIPES: FurnaceRecipe[] = [ - { - input: "bworld:log", - output: new ItemStack("bworld:coal", 1), - cook_time: 100, - }, - { - input: "bworld:coal_ore", - output: new ItemStack("bworld:coal", 1), - cook_time: 100, - }, - { - input: "bworld:iron_ore", - output: new ItemStack("bworld:iron_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:tin_ore", - output: new ItemStack("bworld:tin_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:copper_ore", - output: new ItemStack("bworld:copper_ingot", 1), - cook_time: 200, - }, - { - input: "bworld:gold_ore", - output: new ItemStack("bworld:gold_ingot", 1), - cook_time: 200, - }, -]; - -function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined { - if (!input) { - return; - } - - return FURNACE_RECIPES.find((r) => r.input === input.type_id); -} - -function can_craft(container: Container, recipe?: FurnaceRecipe) { - if (!recipe) { - return false; - } - - const output = container.get_item(2); - - if (!output) { - return true; - } - - if (output.type_id !== recipe.output.type_id) { - return false; - } - - return output.amount < output.max_amount; -} - -const FUEL_VALUES: Record = { - "bworld:coal": 1000, - "bworld:log": 100, -}; - -function get_fuel_value(item?: ItemStack | undefined): number { - if (!item) { - return 0; - } - return FUEL_VALUES[item.type_id] ?? 0; -} - -function has_fuel(container: Container) { - return get_fuel_value(container.get_item(1)) > 0; -} - -function consume_fuel(container: Container): number { - const fuel = container.get_slot(1)!; - const value = get_fuel_value(fuel.get_item()); - - if (fuel.has_item()) { - fuel.amount! -= 1; - } - - return value; -} - -function craft(container: Container, recipe: FurnaceRecipe) { - const input = container.get_item(0)!; - const output = container.get_item(2); - - if (!output) { - container.set_item(2, recipe.output.clone()); - } else { - output.amount += recipe.output.amount; - } - - input.amount -= 1; - container.set_item(0, input.amount > 0 ? input : undefined); -} - -interface TileChestData { - inventory: Inventory; - progress: number; - progress_max: number; - fuel: number; - fuel_max: number; -} - -const block = EverythingRegistry.register("blocks", "bworld:furnace", { - id: "bworld:furnace", - textures: { front: "bworld:furnace", side: "bworld:stone" }, - has_collision: true, - drop_table: "bworld:furnace", - toughness: 5, - requires_tool: true, - tool_to_break: "pickaxe", - - on_interact(dimension, block) { - const [player] = dimension.world.get_tag("player")!; - const player_component = player.get(PlayerComponent)!; - const block_data = dimension.get_block_data(block.x, block.y, block.z); - if (block_data && block_data.data.inventory) { - const gui = new GuiFurnace(block_data.data.inventory, player_component.player_inventory, block_data.data); - player_component.screens.push(gui); - return true; - } - return false; - }, - on_create(dimension, block) { - dimension.add_block_data({ - id: block.id, - x: block.x, - y: block.y, - z: block.z, - data: { - inventory: new Inventory(new Container(3)), - progress: 0, - progress_max: 0, - fuel: 0, - fuel_max: 0, - }, - }); - }, - on_break() { - // TODO: remove block data - }, - on_tick(dimension, block) { - const block_data = dimension.get_block_data(block.x, block.y, block.z); - if (!block_data) { - return; - } - - const data = block_data.data; - const container = data.inventory.container; - - const input = container.get_item(0); - const recipe = get_recipe(input); - - // burn fuel - if (data.fuel > 0) { - data.fuel -= 1; - } - - if (!can_craft(container, recipe)) { - data.progress = 0; - return; - } - - // refuel - if (data.fuel === 0 && has_fuel(container)) { - data.fuel = consume_fuel(container); - data.fuel_max = data.fuel; - } - - // cook - if (data.fuel > 0 && recipe) { - data.progress_max = recipe.cook_time; - data.progress += 1; - - if (data.progress >= recipe.cook_time) { - data.progress = 0; - craft(container, recipe); - } - } - }, -}); - -register_block_item(block); diff --git a/client/blocks/grass.ts b/client/blocks/grass.ts deleted file mode 100644 index 20b0b7c..0000000 --- a/client/blocks/grass.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { PlayerComponent } from "../player.ts"; - -EverythingRegistry.register("blocks", "bworld:grass", { - id: "bworld:grass", - textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" }, - has_collision: false, - drop_table: "bworld:dirt", - toughness: 2, - requires_tool: false, - tool_to_break: "shovel", - - on_interact(dimension, block) { - const [player] = dimension.world.get_tag("player")!; - const player_inventory = player.get(PlayerComponent)!.player_inventory; - const item = player_inventory.container.get_item(player_inventory.hotbar_selected); - if (item?.type_id === "bworld:hoe") { - block.id = "bworld:hoed_dirt"; - dimension.add_block(block); - dimension.sync_block(block.x, block.y, block.z); - return true; - } - return false; - }, -}); diff --git a/client/client_world.ts b/client/client_world.ts index c201129..7662ff5 100644 --- a/client/client_world.ts +++ b/client/client_world.ts @@ -8,7 +8,6 @@ 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 { DimensionLogicSystem } from "./systems/dimension_logic.ts"; import { Dimension } from "./components/dimension.ts"; import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts"; import { WorldGenerationSystem } from "./systems/world_generation_system.ts"; @@ -27,11 +26,10 @@ export class ClientWorld extends World { dimension!: Dimension; - // undefined when playing single player - connection?: Connection; + connection: Connection; chat_log: ChatLine[] = []; - constructor(connection?: Connection) { + constructor(connection: Connection) { super("game"); this.connection = connection; @@ -55,7 +53,6 @@ export class ClientWorld extends World { this.add_system(new GuiTickSystem(), "game"); this.add_system(new PlayerControlsSystem(), "game"); this.add_system(new WorldGenerationSystem(), "game"); - this.add_system(new DimensionLogicSystem(), "game"); this.add_system(new CollisionSystem(), "game"); this.add_system(new MovementSystem(), "game"); diff --git a/client/components/dimension.ts b/client/components/dimension.ts index 505d858..0436d29 100644 --- a/client/components/dimension.ts +++ b/client/components/dimension.ts @@ -1,4 +1,5 @@ import { Component } from "$/common/ecs/mod.ts"; +import { chunk_key } from "$/common/utils.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts"; @@ -6,19 +7,9 @@ import { AssetManager } from "../assets.ts"; import { ClientWorld } from "../client_world.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts"; import type { FromChunkWorker } from "../workers/chunk_messages.ts"; -import { ItemStack } from "../inventory.ts"; -import { PlayerComponent } from "../player.ts"; import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts"; import { Camera } from "./camera.ts"; -export interface BlockData { - id: string; - x: number; - y: number; - z: number; - data: T; -} - export interface Block { id: string; x: number; @@ -32,7 +23,6 @@ export interface Chunk { x: number; z: number; blocks: Uint32Array; - blocks_data: BlockData[]; generated: boolean; dirty: boolean; // bumped on every mesh request so late results from older requests get ignored @@ -43,10 +33,7 @@ export interface Chunk { transparent_vertex_count?: number; } -// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768 -export function chunk_key(x: number, z: number) { - return (x + 32768) * 65536 + (z + 32768); -} +export { chunk_key }; const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; @@ -99,7 +86,6 @@ export class Dimension extends Component { x, z, blocks, - blocks_data: [], dirty: true, generated: false, mesh_version: 0, @@ -120,7 +106,7 @@ export class Dimension extends Component { chunk = this.add_chunk(block_chunk_x, block_chunk_z); } - const [nid, block_info] = EverythingRegistry.get_full("blocks", block.id)!; + const nid = EverythingRegistry.get_id("blocks", block.id)!; const lx = block.x - block_chunk_x * CHUNK_SIZE; const lz = block.z - block_chunk_z * CHUNK_SIZE; @@ -130,9 +116,7 @@ export class Dimension extends Component { chunk.blocks[index] = nid; chunk.dirty = true; - if (block_info?.on_create) { - block_info?.on_create(this, block); - } + this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz); } get_block(x: number, y: number, z: number) { @@ -154,33 +138,8 @@ export class Dimension extends Component { return chunk.blocks[index] & ID_MASK; } - add_block_data(block_data: BlockData) { - const block_chunk_x = Math.floor(block_data.x / CHUNK_SIZE); - const block_chunk_z = Math.floor(block_data.z / CHUNK_SIZE); - let chunk = this.get_chunk(block_chunk_x, block_chunk_z); - if (!chunk) { - chunk = this.add_chunk(block_chunk_x, block_chunk_z); - } - - chunk.blocks_data.push(block_data); - } - - get_block_data(x: number, y: number, z: number) { - const chunk_x = Math.floor(x / CHUNK_SIZE); - const chunk_z = Math.floor(z / CHUNK_SIZE); - - const chunk = this.get_chunk(chunk_x, chunk_z); - - if (!chunk) { - return undefined; - } - - return chunk.blocks_data.find((block_data) => - block_data.x === x && block_data.y === y && block_data.z === z - ) as BlockData; - } - - break_block(x: number, y: number, z: number, drop_item: boolean = true) { + // only changes what this client shows, drops and everything else happen on the server + break_block(x: number, y: number, z: number) { const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_z = Math.floor(z / CHUNK_SIZE); const chunk = this.get_chunk(block_chunk_x, block_chunk_z); @@ -188,14 +147,6 @@ export class Dimension extends Component { return; } - const block_info = EverythingRegistry.get_by_id("blocks", this.get_block(x, y, z))!; - - if (drop_item && block_info.drop_table) { - const [player] = this.world.get_tag("player")!; - const player_component = player.get(PlayerComponent)!; - player_component.player_inventory.container.add_item(new ItemStack(block_info.drop_table)); - } - const lx = x - block_chunk_x * CHUNK_SIZE; const lz = z - block_chunk_z * CHUNK_SIZE; const ly = y; @@ -203,7 +154,11 @@ export class Dimension extends Component { const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; chunk.blocks[index] = AIR; chunk.dirty = true; + this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz); + } + // a block on a chunk's edge changes which faces its neighbor shows + #mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) { if (lx === 0) { const n = this.get_chunk(block_chunk_x - 1, block_chunk_z); if (n) { @@ -226,10 +181,6 @@ export class Dimension extends Component { n.dirty = true; } } - - if (block_info?.on_break) { - block_info.on_break(this, { x, y, z, id: block_info.id }); - } } index_to_xyz(index: number) { @@ -252,18 +203,7 @@ export class Dimension extends Component { chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]); } - // call after the player changes a block so it gets saved and sent to the server - sync_block(x: number, y: number, z: number) { - const numeric_id = this.get_block(x, y, z); - const id = numeric_id === AIR ? AIR_ID : EverythingRegistry.get_by_id("blocks", numeric_id)?.id; - if (!id) { - return; - } - this.record_change(x, y, z, id); - this.world.connection?.send({ type: "set_block", x, y, z, id }); - } - - // set a block without drops or syncing, for changes that came from somewhere else + // set a block the server told us about apply_change(x: number, y: number, z: number, id: string) { const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)); if (!chunk || !chunk.generated) { @@ -273,7 +213,7 @@ export class Dimension extends Component { const current = this.get_block(x, y, z); if (id === AIR_ID) { if (current !== AIR) { - this.break_block(x, y, z, false); + this.break_block(x, y, z); } return; } @@ -423,7 +363,8 @@ export class Dimension extends Component { chunk.transparent_vertex_count = message.transparent_count / 9; } - // sets a block from generation, no hooks, makes a placeholder chunk like add_block does + // a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first. + // the server builds chunks the same way (server/game/world.ts) #set_block_raw(x: number, y: number, z: number, nid: number) { if (y < 0 || y >= CHUNK_HEIGHT) { return; @@ -433,8 +374,11 @@ export class Dimension extends Component { const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z); const lx = x - chunk_x * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE; - chunk.blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; - chunk.dirty = true; + const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx; + if (chunk.blocks[index] === AIR) { + chunk.blocks[index] = nid; + chunk.dirty = true; + } } delete_chunk_mesh(chunk: Chunk) { diff --git a/client/fatal.ts b/client/fatal.ts new file mode 100644 index 0000000..96d894c --- /dev/null +++ b/client/fatal.ts @@ -0,0 +1,26 @@ +let stopped = false; + +// replaces the game with a message, for when it can't go on (no server, lost connection) +export function show_fatal_error(message: string) { + if (stopped) { + return; + } + stopped = true; + document.exitPointerLock?.(); + + const overlay = document.createElement("div"); + overlay.style.cssText = + "position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;" + + "gap:12px;background:rgba(0,0,0,0.85);color:white;font:20px system-ui,sans-serif;text-align:center;"; + const text = document.createElement("div"); + text.textContent = message; + const hint = document.createElement("div"); + hint.textContent = "Reload the page to try again."; + hint.style.cssText = "font-size:14px;opacity:0.7;"; + overlay.append(text, hint); + document.body.append(overlay); +} + +export function is_stopped() { + return stopped; +} diff --git a/client/game.ts b/client/game.ts index bdd54c0..47e8b37 100644 --- a/client/game.ts +++ b/client/game.ts @@ -13,8 +13,8 @@ export function start_game(world: ClientWorld) { const dimension = new Entity("dimension"); world.dimension?.dispose(); - world.dimension = new Dimension(world, world.connection?.seed); - for (const [x, y, z, id] of world.connection?.initial_changes ?? []) { + world.dimension = new Dimension(world, world.connection.seed); + for (const [x, y, z, id] of world.connection.initial_changes) { world.dimension.record_change(x, y, z, id); } dimension.add(world.dimension); diff --git a/client/gui/gui_chat.ts b/client/gui/gui_chat.ts index b52c84b..a8fb574 100644 --- a/client/gui/gui_chat.ts +++ b/client/gui/gui_chat.ts @@ -3,7 +3,6 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo import { InputManager } from "../input_manager.ts"; import { ClientWorld } from "../client_world.ts"; import { PlayerComponent } from "../player.ts"; -import { ItemStack } from "../inventory.ts"; import { MAX_CHAT_LENGTH } from "$/common/protocol.ts"; import { render_chat_log } from "../systems/rendering/network.ts"; @@ -102,14 +101,9 @@ export class GuiChat extends GuiScreen { override on_close(): void {} submit() { - if (this.text_typed.startsWith("/")) { - this.command(); - } else if (this.text_typed.trim().length > 0) { - if (this.world.connection) { - this.world.connection.send({ type: "chat", text: this.text_typed }); - } else { - this.world.add_chat(this.text_typed); - } + // 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.text_typed = ""; @@ -119,23 +113,4 @@ export class GuiChat extends GuiScreen { player_component.pop_screen(); } } - - command() { - if (this.text_typed.startsWith("/give")) { - let [_, item_id, quantity] = this.text_typed.split(" "); - - if (!item_id.includes(":")) { - item_id = `bworld:${item_id}`; - } - if (quantity === undefined) { - quantity = "1"; - } - - const [player] = this.world.get_tag("player")!; - const player_component = player.get(PlayerComponent); - if (player_component) { - player_component.player_inventory.container.add_item(new ItemStack(item_id, Number(quantity))); - } - } - } } diff --git a/client/gui/gui_chest.ts b/client/gui/gui_chest.ts deleted file mode 100644 index ef70bab..0000000 --- a/client/gui/gui_chest.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Inventory, PlayerInventory } from "../inventory.ts"; -import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts"; -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"; - -const PADDING = 10; - -export class GuiChest extends GuiInventoryScreen { - override inventory_width = PADDING * 2 + SLOT_SIZE * 9; - override inventory_height = PADDING * 4 + SLOT_SIZE * 7; - - constructor(inventory: Inventory, player_inventory: PlayerInventory) { - super(inventory, player_inventory, undefined); - - add_player_hotbar(this, player_inventory, PADDING, PADDING); - add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE); - - const chest_x = PADDING; - const chest_y = PADDING * 3 + SLOT_SIZE * 4; - - for (let i = 0; i < 3; ++i) { - for (let l = 0; l < 9; ++l) { - this.slots.push( - new Slot( - this.inventory, - l + i * 9, - chest_x + l * SLOT_SIZE, - chest_y + i * SLOT_SIZE, - ), - ); - } - } - } - - override on_render(): void { - draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); - - const ui = AssetManager.instance.get("bworld:ui"); - - draw_nine_slice( - ui, - 160, - 0, - 16, - 16, - 4, - 4, - 4, - 4, - this.x, - this.y, - this.inventory_width, - this.inventory_height, - ); - - super.on_render(); - } -} diff --git a/client/gui/gui_container.ts b/client/gui/gui_container.ts new file mode 100644 index 0000000..65e4dac --- /dev/null +++ b/client/gui/gui_container.ts @@ -0,0 +1,119 @@ +import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts"; +import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts"; +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 { get_sprite_region } from "$/client/sprites.ts"; +import { ClientInventories } from "../inventory.ts"; + +const PADDING = 10; + +// a screen opened by the server (chest, furnace, ...), drawn from the layout it sent +export class GuiContainer extends GuiInventoryScreen { + layout: ScreenLayout; + properties: Record; + + constructor( + inventories: ClientInventories, + send: (message: ClientMessage) => void, + layout: ScreenLayout, + properties: Record, + ) { + super(inventories, send); + this.layout = layout; + this.properties = properties; + + this.inventory_width = PADDING * 2 + SLOT_SIZE * 9; + this.inventory_height = PADDING * 4 + SLOT_SIZE * (4 + layout.rows); + + add_player_hotbar(this, PADDING, PADDING); + add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE); + + const area_y = PADDING * 3 + SLOT_SIZE * 4; + for (const slot of layout.slots) { + this.slots.push( + new Slot("screen", slot.index, PADDING + slot.x * SLOT_SIZE, area_y + slot.y * SLOT_SIZE, slot.output), + ); + } + } + + override on_render(): void { + draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); + + const ui = AssetManager.instance.get("bworld:ui"); + + draw_nine_slice( + ui, + 160, + 0, + 16, + 16, + 4, + 4, + 4, + 4, + this.x, + this.y, + this.inventory_width, + this.inventory_height, + ); + + this.draw_bars(); + + super.on_render(); + } + + draw_bars() { + const atlas = AssetManager.instance.get("bworld:textures"); + const area_y = PADDING * 3 + SLOT_SIZE * 4; + + for (const bar of this.layout.bars) { + const x = this.x + PADDING + bar.x * SLOT_SIZE; + const y = this.y + area_y + bar.y * SLOT_SIZE; + const max = this.properties[bar.max] ?? 0; + const pct = max > 0 ? Math.max(0, Math.min(1, (this.properties[bar.value] ?? 0) / max)) : 0; + + const empty = get_sprite_region(bar.empty_texture); + draw_texture_region( + atlas, + empty.x * TEXTURE_SIZE, + empty.y * TEXTURE_SIZE, + TEXTURE_SIZE, + TEXTURE_SIZE, + x, + y, + SLOT_SIZE, + SLOT_SIZE, + ); + + const full = get_sprite_region(bar.full_texture); + if (bar.direction === "right") { + draw_texture_region( + atlas, + full.x * TEXTURE_SIZE, + full.y * TEXTURE_SIZE, + TEXTURE_SIZE * pct, + TEXTURE_SIZE, + x, + y, + SLOT_SIZE * pct, + SLOT_SIZE, + ); + } else { + // fills from the bottom up + draw_texture_region( + atlas, + full.x * TEXTURE_SIZE, + full.y * TEXTURE_SIZE + TEXTURE_SIZE * (1 - pct), + TEXTURE_SIZE, + TEXTURE_SIZE * pct, + x, + y + SLOT_SIZE * (1 - pct), + SLOT_SIZE, + SLOT_SIZE * pct, + ); + } + } + } +} diff --git a/client/gui/gui_furnace.ts b/client/gui/gui_furnace.ts deleted file mode 100644 index b52bf99..0000000 --- a/client/gui/gui_furnace.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Inventory, PlayerInventory } from "../inventory.ts"; -import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts"; -import { canvas, draw_rect, draw_texture_region, Texture } from "$/client/renderer/mod.ts"; -import { AssetManager } from "../assets.ts"; -import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; -import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; -import { get_sprite_region } from "$/common/utils.ts"; - -const PADDING = 10; - -// crazy this is how i found out interface X {} is diffrent then type X = {} -type TileChestData = { - inventory: Inventory; - progress: number; - progress_max: number; - fuel: number; - fuel_max: number; -}; - -export class GuiFurnace extends GuiInventoryScreen { - override inventory_width = PADDING * 2 + SLOT_SIZE * 9; - override inventory_height = PADDING * 4 + SLOT_SIZE * 7; - - constructor( - inventory: Inventory, - player_inventory: PlayerInventory, - properties: TileChestData, - ) { - super(inventory, player_inventory, properties); - - add_player_hotbar(this, player_inventory, PADDING, PADDING); - add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE); - - const furnace_y = PADDING * 3 + SLOT_SIZE * 4; - - this.slots.push( - new Slot( - this.inventory, - 0, - this.inventory_width / 2 - SLOT_SIZE, - furnace_y, - ), - ); - this.slots.push( - new Slot( - this.inventory, - 1, - this.inventory_width / 2 - SLOT_SIZE, - furnace_y + SLOT_SIZE * 2, - ), - ); - this.slots.push( - new Slot( - this.inventory, - 2, - this.inventory_width / 2 + SLOT_SIZE, - furnace_y + SLOT_SIZE, - ), - ); - } - - override on_render(): void { - draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); - - const ui = AssetManager.instance.get("bworld:ui"); - - draw_nine_slice( - ui, - 160, - 0, - 16, - 16, - 4, - 4, - 4, - 4, - this.x, - this.y, - this.inventory_width, - this.inventory_height, - ); - - this.draw_fire(); - this.draw_arrow(); - - super.on_render(); - } - - override handle_left_click(slot: Slot): void { - if (slot.inventory === this.inventory && slot.index === 2) { - if (this.inventory.container.get_item(2)) { - this.pickup(); - } - } else { - super.handle_left_click(slot); - } - } - - override handle_right_click(slot: Slot): void { - if (slot.inventory === this.inventory && slot.index === 2) { - if (this.inventory.container.get_item(2)) { - this.pickup(); - } - } else { - super.handle_right_click(slot); - } - } - - pickup() { - const holding_item = this.player_inventory.holding_item; - const item = this.inventory.container.get_item(2)!; - - if (holding_item) { - if (holding_item.type_id === item.type_id) { - const items_left = holding_item.max_amount - holding_item.amount; - if (items_left >= item.amount) { - holding_item.amount += item.amount; - this.inventory.container.set_item(2, undefined); - } - } - } else { - this.player_inventory.holding_item = item; - this.inventory.container.set_item(2, undefined); - } - } - - draw_fire() { - const atlas = AssetManager.instance.get("bworld:textures"); - - const fire_empty_region = get_sprite_region("bworld:fire_empty"); - const fire_full_region = get_sprite_region("bworld:fire_full"); - - const fuel_pct = Math.max(0, Math.min(1, this.properties.fuel / this.properties.fuel_max)); - const full_height = SLOT_SIZE * fuel_pct; - - const furnace_y = PADDING * 3 + SLOT_SIZE * 4; - - draw_texture_region( - atlas, - fire_empty_region.x * TEXTURE_SIZE, - fire_empty_region.y * TEXTURE_SIZE, - TEXTURE_SIZE, - TEXTURE_SIZE, - this.x + this.inventory_width / 2 - SLOT_SIZE, - this.y + furnace_y + SLOT_SIZE, - SLOT_SIZE, - SLOT_SIZE, - ); - - const fire_draw_x = this.x + this.inventory_width / 2 - SLOT_SIZE; - const fire_draw_y = this.y + furnace_y + SLOT_SIZE; - - const fire_src_height = TEXTURE_SIZE * fuel_pct; - - const fire_src_y_offset = TEXTURE_SIZE - fire_src_height; - const fire_dst_y_offset = SLOT_SIZE - full_height; - - draw_texture_region( - atlas, - fire_full_region.x * TEXTURE_SIZE, - fire_full_region.y * TEXTURE_SIZE + fire_src_y_offset, - TEXTURE_SIZE, - fire_src_height, - fire_draw_x, - fire_draw_y + fire_dst_y_offset, - SLOT_SIZE, - full_height, - ); - } - - draw_arrow() { - const atlas = AssetManager.instance.get("bworld:textures"); - - const arrow_empty_region = get_sprite_region("bworld:arrow_empty"); - const arrow_full_region = get_sprite_region("bworld:arrow_full"); - - const progress_pct = Math.max(0, Math.min(1, this.properties.progress / this.properties.progress_max)); - const full_width = SLOT_SIZE * progress_pct; - - const furnace_y = PADDING * 3 + SLOT_SIZE * 4; - - draw_texture_region( - atlas, - arrow_empty_region.x * TEXTURE_SIZE, - arrow_empty_region.y * TEXTURE_SIZE, - TEXTURE_SIZE, - TEXTURE_SIZE, - this.x + this.inventory_width / 2, - this.y + furnace_y + SLOT_SIZE, - SLOT_SIZE, - SLOT_SIZE, - ); - - const draw_x = this.x + this.inventory_width / 2; - const draw_y = this.y + furnace_y + SLOT_SIZE; - - const src_width = TEXTURE_SIZE * progress_pct; - - draw_texture_region( - atlas, - arrow_full_region.x * TEXTURE_SIZE, - arrow_full_region.y * TEXTURE_SIZE, - src_width, - TEXTURE_SIZE, - draw_x, - draw_y, - full_width, - SLOT_SIZE, - ); - } -} diff --git a/client/gui/gui_player_inventory.ts b/client/gui/gui_player_inventory.ts index ffc39aa..18f9707 100644 --- a/client/gui/gui_player_inventory.ts +++ b/client/gui/gui_player_inventory.ts @@ -1,96 +1,23 @@ -import { Container, Inventory, ItemStack, PlayerInventory } from "../inventory.ts"; import { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts"; 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"; - -export interface CraftingRecipe { - width: number; - height: number; - pattern: (string | undefined)[]; - result: ItemStack; -} - -const recipes: CraftingRecipe[] = [ - { - width: 3, - height: 3, - pattern: [ - "bworld:planks", - "bworld:planks", - "bworld:planks", - "bworld:planks", - undefined, - "bworld:planks", - "bworld:planks", - "bworld:planks", - "bworld:planks", - ], - result: new ItemStack("bworld:chest", 1), - }, - { - width: 3, - height: 3, - pattern: [ - "bworld:stone", - "bworld:stone", - "bworld:stone", - "bworld:stone", - undefined, - "bworld:stone", - "bworld:stone", - "bworld:stone", - "bworld:stone", - ], - result: new ItemStack("bworld:furnace", 1), - }, - { - width: 1, - height: 1, - pattern: [ - "bworld:log", - ], - result: new ItemStack("bworld:planks", 2), - }, - { - width: 1, - height: 2, - pattern: [ - "bworld:planks", - "bworld:planks", - ], - result: new ItemStack("bworld:stick", 2), - }, - { - width: 3, - height: 3, - pattern: [ - "bworld:planks", - "bworld:planks", - "bworld:planks", - undefined, - "bworld:stick", - undefined, - undefined, - "bworld:stick", - undefined, - ], - result: new ItemStack("bworld:wood_pickaxe", 1), - }, -]; +import { ClientInventories } from "../inventory.ts"; +import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; const PADDING = 10; +// the recipes live on the server, which fills in the result slot export class GuiPlayerInventory extends GuiInventoryScreen { override inventory_width = PADDING * 2 + SLOT_SIZE * 9; override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING; - constructor(player_inventory: PlayerInventory) { - super(new Inventory(new Container(10)), player_inventory, undefined); + constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) { + super(inventories, send); - add_player_hotbar(this, player_inventory, PADDING, PADDING); - add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE); + add_player_hotbar(this, PADDING, PADDING); + add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE); const crafting_x = PADDING; const crafting_y = PADDING * 3 + SLOT_SIZE * 4; @@ -98,17 +25,14 @@ export class GuiPlayerInventory extends GuiInventoryScreen { for (let i = 0; i < 3; i += 1) { for (let j = 0; j < 3; j += 1) { this.slots.push( - new Slot(this.inventory, j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE), + new Slot("crafting", j * 3 + i, crafting_x + i * SLOT_SIZE, crafting_y + j * SLOT_SIZE), ); } } - this.slots.push(new Slot(this.inventory, 9, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE)); - } - - override on_tick(delta: number): void { - super.on_tick(delta); - this.update_crafting_result(); + this.slots.push( + new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true), + ); } override on_render(): void { @@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen { super.on_render(); } - - override on_close(): void { - for (let i = 0; i < 8; i += 1) { - const item = this.inventory.container.get_item(i); - if (item) { - this.player_inventory.container.add_item(item); - } - } - } - - get_crafting_grid(): (string | undefined)[] { - const grid: (string | undefined)[] = []; - - for (let i = 0; i < 9; i++) { - const item = this.inventory.container.get_item(i); - grid.push(item?.type_id); - } - - return grid; - } - - matches_recipe( - grid: (string | undefined)[], - recipe: CraftingRecipe, - ): boolean { - for (let y = 0; y <= 3 - recipe.height; y++) { - for (let x = 0; x <= 3 - recipe.width; x++) { - let match = true; - - for (let gy = 0; gy < 3; gy++) { - for (let gx = 0; gx < 3; gx++) { - const grid_index = gy * 3 + gx; - - if ( - gx >= x && - gx < x + recipe.width && - gy >= y && - gy < y + recipe.height - ) { - const rx = gx - x; - const ry = gy - y; - const recipe_index = ry * recipe.width + rx; - - if (grid[grid_index] !== recipe.pattern[recipe_index]) { - match = false; - break; - } - } else { - if (grid[grid_index] !== undefined) { - match = false; - break; - } - } - } - if (!match) break; - } - - if (match) return true; - } - } - - return false; - } - - update_crafting_result() { - const grid = this.get_crafting_grid(); - - for (const recipe of recipes) { - if (this.matches_recipe(grid, recipe)) { - this.inventory.container.set_item( - 9, - recipe.result.clone(), - ); - return; - } - } - - this.inventory.container.set_item(9, undefined); - } - - consume_recipe_items() { - for (let i = 0; i < 9; i++) { - const item = this.inventory.container.get_item(i); - if (!item) { - continue; - } - - item.amount -= 1; - - if (item.amount <= 0) { - this.inventory.container.set_item(i, undefined); - } - } - } - - override handle_left_click(slot: Slot): void { - if (slot.inventory === this.inventory && slot.index === 9) { - if (this.inventory.container.get_item(9)) { - this.craft(); - } - } else { - super.handle_left_click(slot); - } - } - - override handle_right_click(slot: Slot): void { - if (slot.inventory === this.inventory && slot.index === 9) { - if (this.inventory.container.get_item(9)) { - this.craft(); - } - } else { - super.handle_right_click(slot); - } - } - - craft() { - const holding_item = this.player_inventory.holding_item; - const item = this.inventory.container.get_item(9)!; - - if (holding_item) { - if (holding_item.type_id === item.type_id) { - const items_left = holding_item.max_amount - holding_item.amount; - if (items_left >= item.amount) { - this.consume_recipe_items(); - holding_item.amount += item.amount; - } - } - } else { - this.player_inventory.holding_item = item; - this.consume_recipe_items(); - } - } } diff --git a/client/gui/gui_screen.ts b/client/gui/gui_screen.ts index dc610b9..331d0a8 100644 --- a/client/gui/gui_screen.ts +++ b/client/gui/gui_screen.ts @@ -1,27 +1,28 @@ import { SLOT_SIZE } from "$/common/constants.ts"; +import { click_slot, Container } from "$/common/inventory.ts"; +import { ClientMessage, ContainerKey, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; import { point_inside_rec } from "../../common/utils.ts"; import { AssetManager } from "../assets.ts"; import { InputManager } from "../input_manager.ts"; -import { Inventory, ItemStack, PlayerInventory } from "../inventory.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"; export class Slot { - inventory: Inventory; + container: ContainerKey; index: number; // relative to screen top left x: number; y: number; + // can only be taken from, like the crafting or furnace result + output: boolean; - constructor(inventory: Inventory, index: number, x: number, y: number) { - this.inventory = inventory; + constructor(container: ContainerKey, index: number, x: number, y: number, output = false) { + this.container = container; this.index = index; this.x = x; this.y = y; - } - - get item(): ItemStack | undefined { - return this.inventory.container.get_item(this.index); + this.output = output; } } @@ -34,25 +35,32 @@ export abstract class GuiScreen { abstract on_close(): void; } -export class GuiInventoryScreen | undefined> - extends GuiScreen { - inventory: Inv; - player_inventory: PlayerInventory; - properties: Properties; +// a screen of slots. the server owns the contents: clicks are sent to it and guessed locally +// so they feel instant, then whatever the server syncs back wins +export class GuiInventoryScreen extends GuiScreen { + inventories: ClientInventories; + send: (message: ClientMessage) => void; slots: Slot[] = []; + hovering: Slot | undefined; inventory_width: number = 500; inventory_height: number = 500; - constructor( - inventory: Inv, - player_inventory: PlayerInventory, - properties: Properties, - ) { + constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) { super(); - this.inventory = inventory; - this.player_inventory = player_inventory; - this.properties = properties; + this.inventories = inventories; + this.send = send; + } + + get_container(key: ContainerKey): Container | undefined { + switch (key) { + case "inventory": + return this.inventories.inventory; + case "crafting": + return this.inventories.crafting; + case "screen": + return this.inventories.screen; + } } on_tick(_delta: number): void { @@ -60,20 +68,17 @@ export class GuiInventoryScreen("bworld:ui"); for (const slot of this.slots) { + const hovered = slot === this.hovering; draw_nine_slice( ui, - slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32, - slot.inventory.hovering_slot === slot.index ? 16 : 0, + hovered ? 19 * 16 : 160 + 32, + hovered ? 16 : 0, 16, 16, 4, @@ -88,198 +93,83 @@ export class GuiInventoryScreen { - type_id: string; - amount: number; - max_amount: number; - data?: T; - - constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { - this.type_id = type_id; - this.amount = amount; - this.max_amount = max_amount; - this.data = undefined; - const item_info = EverythingRegistry.get("items", type_id); - if (item_info?.on_create) { - item_info.on_create(this); - } - } - - clone(): ItemStack { - return new ItemStack(this.type_id, this.amount, this.max_amount); - } -} - -export class ContainerSlot { - #item_stack: ItemStack | undefined; - - has_item() { - return this.#item_stack !== undefined; - } - - set_item(item_stack: ItemStack | undefined) { - if ((item_stack?.amount ?? 0) <= 0) { - item_stack = undefined; - } - this.#item_stack = item_stack; - } - - get_item() { - return this.#item_stack; - } - - get type_id() { - return this.#item_stack?.type_id; - } - - set amount(new_amount: number) { - if (this.#item_stack) { - this.#item_stack.amount = new_amount; - if (this.#item_stack.amount <= 0) { - this.#item_stack = undefined; - } - } - } - - get amount(): number | undefined { - return this.#item_stack?.amount; - } - - get max_amount() { - return this.#item_stack?.max_amount; - } -} - -export class Container { - #slots: ContainerSlot[] = []; - readonly size: number; - - constructor(size: number) { - this.size = size; - for (let i = 0; i < size; i += 1) { - this.#slots.push(new ContainerSlot()); - } - } - - add_item(item_stack: ItemStack) { - for (const slot of this.#slots.filter((slot) => slot.has_item())) { - const slot_item = slot.get_item()!; - if (slot_item.type_id === item_stack.type_id) { - const missing = slot_item.max_amount - slot_item.amount; - const adding = Math.min(missing, item_stack.amount); - slot_item.amount += adding; - item_stack.amount -= adding; - if (item_stack.amount === 0) { - return; - } - } - } - for (const slot of this.#slots) { - if (!slot.has_item()) { - slot.set_item(item_stack); - return; - } - } - // TODO: drop item on ground - console.error("Failed to place item in container"); - } - - get_item(slot: number): ItemStack | undefined { - return this.#slots[slot]?.get_item(); - } - - get_slot(slot: number): ContainerSlot { - return this.#slots[slot]; - } - - set_item(slot: number, item: ItemStack | undefined) { - this.#slots[slot].set_item(item); - } -} - -export interface ContainerLayout { - slots: { type: string; x: number; y: number }[]; - offset_x: number; - offset_y: number; -} - -export class Inventory { - container: Container; - - hovering_slot: number = -1; - - constructor(container: Container) { - this.container = container; - } -} - -export class PlayerInventory extends Inventory { - owner_id: string; - holding_item: ItemStack | undefined; - hotbar_selected: number = 0; - - constructor(owner_id: string) { - const container = new Container(9 * 4); - super(container); - this.owner_id = owner_id; - } +// the local copies of the containers the server syncs to this player +export class ClientInventories { + inventory = new Container(9 * 4); + crafting = new Container(10); + // the open server screen's container + screen: Container | undefined; + cursor: Cursor = { item: undefined }; + hotbar_selected = 0; } diff --git a/client/main.ts b/client/main.ts index aa97f9c..1d57904 100644 --- a/client/main.ts +++ b/client/main.ts @@ -3,9 +3,10 @@ import { ClientWorld } from "./client_world.ts"; import { InputManager } from "./input_manager.ts"; import { Connection, get_player_name, get_server_url } from "./network.ts"; import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts"; +import { is_stopped, show_fatal_error } from "./fatal.ts"; -await import("./blocks/mod.ts"); -await import("./items/mod.ts"); +await import("$/common/blocks/mod.ts"); +await import("$/common/items/mod.ts"); export class ClientLoop { running = false; @@ -39,7 +40,7 @@ export class ClientLoop { } loop(time: number) { - if (!this.running) { + if (!this.running || is_stopped()) { return; } @@ -91,17 +92,18 @@ await AssetManager.instance.load_all(); init_font(); +// the game only runs against a server, it owns the world and everything in it const connection = await Connection.open(get_server_url(), get_player_name()); if (connection) { console.log(`Connected to ${get_server_url()}`); + + const client_world = new ClientWorld(connection); + client_world.add_chat("Connected to the server"); + + const loop = new ClientLoop(client_world); + loop.start(); + + console.log("Game started"); } else { - console.log("Couldn't reach a server, playing single player"); + show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`); } - -const client_world = new ClientWorld(connection); -client_world.add_chat(connection ? "Connected to the server" : "Playing single player"); - -const loop = new ClientLoop(client_world); -loop.start(); - -console.log("Game started"); diff --git a/client/network.ts b/client/network.ts index eed4e52..2e99fc9 100644 --- a/client/network.ts +++ b/client/network.ts @@ -15,18 +15,22 @@ export class Connection { id: string; seed: string; initial_changes: BlockChange[]; + spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; + selected_slot: number; players = new Map(); // handled by the network system inside the game loop, not whenever the socket feels like it incoming: ServerMessage[] = []; closed = false; - constructor(socket: WebSocket, id: string, seed: string, players: PlayerInfo[], changes: BlockChange[]) { + constructor(socket: WebSocket, welcome: Extract) { this.socket = socket; - this.id = id; - this.seed = seed; - this.initial_changes = changes; - for (const player of players) { + this.id = welcome.id; + this.seed = welcome.seed; + this.initial_changes = welcome.changes; + this.spawn = welcome.spawn; + this.selected_slot = welcome.selected_slot; + for (const player of welcome.players) { this.add_player(player); } @@ -77,7 +81,7 @@ export class Connection { const message: ServerMessage = JSON.parse(event.data); if (message.type === "welcome") { clearTimeout(timeout); - resolve(new Connection(socket, message.id, message.seed, message.players, message.changes)); + resolve(new Connection(socket, message)); } }, { once: true }); diff --git a/client/player.ts b/client/player.ts index 3938b13..891cc93 100644 --- a/client/player.ts +++ b/client/player.ts @@ -2,14 +2,14 @@ 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 { PlayerInventory } from "./inventory.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 { - player_inventory = new PlayerInventory(""); + inventories = new ClientInventories(); screens: GuiScreen[] = []; render_distance = 6; @@ -27,12 +27,16 @@ export class PlayerComponent extends Component { export function create_player(world: ClientWorld) { const player = new Entity("player"); - player.add(new Position(0, 100, 0)); + 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()); - player.add(new PlayerComponent()); - player.add(new Camera()); + 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); diff --git a/client/sprites.ts b/client/sprites.ts new file mode 100644 index 0000000..b2269fd --- /dev/null +++ b/client/sprites.ts @@ -0,0 +1,9 @@ +import { SpriteRegion } from "$/common/constants.ts"; +import { AssetManager } from "./assets.ts"; + +type TexturesInfo = Record; + +export function get_sprite_region(id: string): SpriteRegion { + const textures_info = AssetManager.instance.get("bworld:textures_info"); + return textures_info?.[id] ?? { x: 0, y: 0 }; +} diff --git a/client/systems/dimension_logic.ts b/client/systems/dimension_logic.ts deleted file mode 100644 index 9e615e2..0000000 --- a/client/systems/dimension_logic.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { System } from "$/common/ecs/mod.ts"; -import { ClientWorld } from "../client_world.ts"; -import { Dimension } from "../components/dimension.ts"; -import { TICK_DELTA } from "$/common/constants.ts"; -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; - -export class DimensionLogicSystem extends System { - update(world: ClientWorld, delta: number): void { - const dimension = world.dimension; - - dimension.second_timer += delta; - dimension.tick_timer += delta; - - if (dimension.second_timer >= 1) { - this.handle_second(world, dimension); - } - if (dimension.tick_timer >= TICK_DELTA) { - this.handle_tick(world, dimension); - } - } - handle_second(world: ClientWorld, dimension: Dimension) { - for (const chunk of dimension.chunks.values()) { - for (const tickable of chunk.blocks_data) { - const tile_info = EverythingRegistry.get("blocks", tickable.id); - if (tile_info && tile_info.on_second) { - tile_info.on_second(world.dimension, tickable, dimension.second_timer); - } - } - } - dimension.second_timer = 0; - } - - handle_tick(world: ClientWorld, dimension: Dimension) { - for (const chunk of dimension.chunks.values()) { - for (const tickable of chunk.blocks_data) { - const tile_info = EverythingRegistry.get("blocks", tickable.id); - if (tile_info && tile_info.on_tick) { - tile_info.on_tick(world.dimension, tickable, dimension.tick_timer); - } - } - } - dimension.tick_timer = 0; - } -} diff --git a/client/systems/network_system.ts b/client/systems/network_system.ts index 90a5dff..edb8e8f 100644 --- a/client/systems/network_system.ts +++ b/client/systems/network_system.ts @@ -2,6 +2,10 @@ 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; @@ -11,9 +15,9 @@ export class NetworkSystem extends System { update(world: ClientWorld, delta: number): void { const connection = world.connection; - if (!connection) { - return; - } + 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) { @@ -41,13 +45,47 @@ export class NetworkSystem extends System { 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 "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) { - world.add_chat("Lost connection to the server"); - world.connection = undefined; + show_fatal_error("Lost connection to the server"); return; } diff --git a/client/systems/player_controls.ts b/client/systems/player_controls.ts index d69d14a..2a51c83 100644 --- a/client/systems/player_controls.ts +++ b/client/systems/player_controls.ts @@ -9,6 +9,8 @@ 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"; @@ -24,6 +26,7 @@ export class PlayerControlsSystem extends System { 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; @@ -71,7 +74,7 @@ export class PlayerControlsSystem extends System { if (InputManager.is_key_pressed(controls.open_inventory)) { if (player_component.screens.length === 0) { - player_component.screens.push(new GuiPlayerInventory(player_component.player_inventory)); + player_component.screens.push(new GuiPlayerInventory(player_component.inventories, send)); } else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) { player_component.pop_screen(); } @@ -119,10 +122,9 @@ export class PlayerControlsSystem extends System { } const block = world.dimension.get_looked_block(world.dimension, camera); - const holding_item = player_component.player_inventory.container.get_item( - player_component.player_inventory.hotbar_selected, - ); - const holding_item_info = EverythingRegistry.get("items", holding_item?.type_id ?? ""); + 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)!; @@ -135,47 +137,24 @@ export class PlayerControlsSystem extends System { } player_component.break_progress += delta * multiplier; if (player_component.break_progress >= player_component.break_progress_max) { - const drop_item = !block_info.requires_tool || - holding_item_info?.tool_type === block_info.tool_to_break; - world.dimension.break_block(block.x, block.y, block.z, drop_item); - world.dimension.sync_block(block.x, block.y, block.z); + // 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)) { - // interact if possible - const interacted = block_info?.on_interact?.(world.dimension, { - x: block.x, - y: block.y, - z: block.z, - id: block_info.id, - }); - if (!interacted) { - const inventory = player_component.player_inventory; - const hotbar_slot = inventory.container.get_slot(inventory.hotbar_selected); - if (hotbar_slot && hotbar_slot.has_item()) { - const item = hotbar_slot.get_item()!; - const item_info = EverythingRegistry.get("items", item.type_id); - if (item_info && item_info.block_id) { - const FACE_OFFSETS = { - top: { x: 0, y: 1, z: 0 }, - bottom: { x: 0, y: -1, z: 0 }, - north: { x: 0, y: 0, z: -1 }, - south: { x: 0, y: 0, z: 1 }, - west: { x: -1, y: 0, z: 0 }, - east: { x: 1, y: 0, z: 0 }, - }; - const offset = FACE_OFFSETS[block.face]; - world.dimension.add_block({ - x: block.x + offset.x, - y: block.y + offset.y, - z: block.z + offset.z, - id: item_info.block_id, - }); - world.dimension.sync_block(block.x + offset.x, block.y + offset.y, block.z + offset.z); - hotbar_slot.amount! -= 1; - } - } + 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)?.id === "bworld:water"; + if (!block_info.interactive && holding_item_info?.block_id && replaceable) { + world.dimension.add_block({ ...target, id: holding_item_info.block_id }); + hotbar_slot.amount = hotbar_slot.amount! - 1; } } } else { @@ -185,32 +164,32 @@ export class PlayerControlsSystem extends System { } if (player_component.screens.length === 0) { - const player_inventory = player_component.player_inventory; + const previous = inventories.hotbar_selected; const scroll = InputManager.get_wheel_delta(); if (scroll > 0) { - player_inventory.hotbar_selected = Math.min(8, player_inventory.hotbar_selected + 1); + inventories.hotbar_selected = Math.min(8, inventories.hotbar_selected + 1); } else if (scroll < 0) { - player_inventory.hotbar_selected = Math.max(0, player_inventory.hotbar_selected - 1); + inventories.hotbar_selected = Math.max(0, inventories.hotbar_selected - 1); } - if (InputManager.is_key_pressed(controls.hotbar_1)) { - player_inventory.hotbar_selected = 0; - } else if (InputManager.is_key_pressed(controls.hotbar_2)) { - player_inventory.hotbar_selected = 1; - } else if (InputManager.is_key_pressed(controls.hotbar_3)) { - player_inventory.hotbar_selected = 2; - } else if (InputManager.is_key_pressed(controls.hotbar_4)) { - player_inventory.hotbar_selected = 3; - } else if (InputManager.is_key_pressed(controls.hotbar_5)) { - player_inventory.hotbar_selected = 4; - } else if (InputManager.is_key_pressed(controls.hotbar_6)) { - player_inventory.hotbar_selected = 5; - } else if (InputManager.is_key_pressed(controls.hotbar_7)) { - player_inventory.hotbar_selected = 6; - } else if (InputManager.is_key_pressed(controls.hotbar_8)) { - player_inventory.hotbar_selected = 7; - } else if (InputManager.is_key_pressed(controls.hotbar_9)) { - player_inventory.hotbar_selected = 8; + 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 index 4bb0c76..740f469 100644 --- a/client/systems/render_system.ts +++ b/client/systems/render_system.ts @@ -61,7 +61,7 @@ export class RenderSystem extends System { const player_component = entity.get(PlayerComponent); if (player_component) { - render_player_hotbar(player_component.player_inventory); + render_player_hotbar(player_component.inventories); render_player_crosshair(); } } diff --git a/client/systems/rendering/player.ts b/client/systems/rendering/player.ts index d15a34f..7b087ca 100644 --- a/client/systems/rendering/player.ts +++ b/client/systems/rendering/player.ts @@ -1,6 +1,6 @@ import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { AssetManager } from "$/client/assets.ts"; -import { PlayerInventory } from "../../inventory.ts"; +import { ClientInventories } from "../../inventory.ts"; import { draw_item, draw_nine_slice } from "./render_utils.ts"; import { canvas, @@ -14,11 +14,11 @@ import { Texture, } from "$/client/renderer/mod.ts"; import { PlayerComponent } from "../../player.ts"; -import { get_sprite_region } from "../../../common/utils.ts"; +import { get_sprite_region } from "$/client/sprites.ts"; const PADDING = 10; -export function render_player_hotbar(player_inventory: PlayerInventory) { +export function render_player_hotbar(inventories: ClientInventories) { const ui = AssetManager.instance.get("bworld:ui"); const hotbar_width = PADDING * 2 + SLOT_SIZE * 9; @@ -46,8 +46,8 @@ export function render_player_hotbar(player_inventory: PlayerInventory) { for (let index = 0; index < 9; index += 1) { draw_nine_slice( ui, - player_inventory.hotbar_selected === index ? 19 * 16 : 160 + 32, - player_inventory.hotbar_selected === index ? 16 : 0, + inventories.hotbar_selected === index ? 19 * 16 : 160 + 32, + inventories.hotbar_selected === index ? 16 : 0, 16, 16, 4, @@ -62,7 +62,7 @@ export function render_player_hotbar(player_inventory: PlayerInventory) { } for (let index = 0; index < 9; index += 1) { - const item = player_inventory.container.get_item(index); + const item = inventories.inventory.get_item(index); if (item) { draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING); } diff --git a/client/systems/rendering/render_utils.ts b/client/systems/rendering/render_utils.ts index fcf8d80..adcd919 100644 --- a/client/systems/rendering/render_utils.ts +++ b/client/systems/rendering/render_utils.ts @@ -1,8 +1,8 @@ import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; -import { get_sprite_region } from "$/common/utils.ts"; +import { get_sprite_region } from "$/client/sprites.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { AssetManager } from "$/client/assets.ts"; -import { ItemStack } from "../../inventory.ts"; +import { ItemStack } from "$/common/inventory.ts"; import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts"; export function draw_nine_slice( diff --git a/client/workers/chunk_worker.ts b/client/workers/chunk_worker.ts index f05a6af..37d5c25 100644 --- a/client/workers/chunk_worker.ts +++ b/client/workers/chunk_worker.ts @@ -4,7 +4,7 @@ import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { SpriteRegion } from "$/common/constants.ts"; import type { Texture } from "../renderer/types.ts"; import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts"; -import { generate_chunk } from "../generation.ts"; +import { generate_raw_chunk } from "$/common/generation.ts"; const pad = 0.5; @@ -311,37 +311,8 @@ function post(message: FromChunkWorker, transfer: Transferable[]) { } function generate(chunk_x: number, chunk_z: number, seed: string) { - const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); - const spills: number[] = []; - - generate_chunk( - { - add_block(block) { - const nid = block_ids[block.id]; - if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) { - return; - } - const block_chunk_x = Math.floor(block.x / CHUNK_SIZE); - const block_chunk_z = Math.floor(block.z / CHUNK_SIZE); - if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { - spills.push(block.x, block.y, block.z, nid); - return; - } - const lx = block.x - chunk_x * CHUNK_SIZE; - const lz = block.z - chunk_z * CHUNK_SIZE; - blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = nid; - }, - }, - chunk_x, - chunk_z, - seed, - ); - - const spills_array = new Int32Array(spills); - post({ type: "generated", chunk_x, chunk_z, blocks, spills: spills_array }, [ - blocks.buffer, - spills_array.buffer, - ]); + const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids); + post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]); } function make_chunk_mesh( diff --git a/common/blocks/chest.ts b/common/blocks/chest.ts new file mode 100644 index 0000000..31042a6 --- /dev/null +++ b/common/blocks/chest.ts @@ -0,0 +1,16 @@ +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import { register_block_item } from "$/common/utils.ts"; + +// behavior lives in server/game/blocks.ts +const block = EverythingRegistry.register("blocks", "bworld:chest", { + id: "bworld:chest", + textures: "bworld:planks", + toughness: 8, + requires_tool: false, + tool_to_break: "axe", + drop_table: "bworld:chest", + has_collision: false, + interactive: true, +}); + +register_block_item(block); diff --git a/client/blocks/coal_ore.ts b/common/blocks/coal_ore.ts similarity index 100% rename from client/blocks/coal_ore.ts rename to common/blocks/coal_ore.ts diff --git a/client/blocks/copper_ore.ts b/common/blocks/copper_ore.ts similarity index 100% rename from client/blocks/copper_ore.ts rename to common/blocks/copper_ore.ts diff --git a/client/blocks/crops.ts b/common/blocks/crops.ts similarity index 95% rename from client/blocks/crops.ts rename to common/blocks/crops.ts index a47faa2..4a31122 100644 --- a/client/blocks/crops.ts +++ b/common/blocks/crops.ts @@ -1,6 +1,4 @@ -import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { ItemStack } from "../inventory.ts"; -import { PlayerComponent } from "../player.ts"; +import { EverythingRegistry } from "$/common/everything_registry.ts"; interface CropsRegistry { total_stages: number; diff --git a/common/blocks/dirt.ts b/common/blocks/dirt.ts new file mode 100644 index 0000000..5faf4f0 --- /dev/null +++ b/common/blocks/dirt.ts @@ -0,0 +1,15 @@ +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import { register_block_item } from "$/common/utils.ts"; + +// hoeing it is handled in server/game/blocks.ts +const block = EverythingRegistry.register("blocks", "bworld:dirt", { + id: "bworld:dirt", + textures: "bworld:dirt", + has_collision: false, + drop_table: "bworld:dirt", + toughness: 2, + requires_tool: false, + tool_to_break: "shovel", +}); + +register_block_item(block); diff --git a/common/blocks/furnace.ts b/common/blocks/furnace.ts new file mode 100644 index 0000000..add1cd4 --- /dev/null +++ b/common/blocks/furnace.ts @@ -0,0 +1,16 @@ +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import { register_block_item } from "$/common/utils.ts"; + +// behavior lives in server/game/blocks.ts +const block = EverythingRegistry.register("blocks", "bworld:furnace", { + id: "bworld:furnace", + textures: { front: "bworld:furnace", side: "bworld:stone" }, + has_collision: true, + drop_table: "bworld:furnace", + toughness: 5, + requires_tool: true, + tool_to_break: "pickaxe", + interactive: true, +}); + +register_block_item(block); diff --git a/client/blocks/glass.ts b/common/blocks/glass.ts similarity index 100% rename from client/blocks/glass.ts rename to common/blocks/glass.ts diff --git a/client/blocks/gold_ore.ts b/common/blocks/gold_ore.ts similarity index 100% rename from client/blocks/gold_ore.ts rename to common/blocks/gold_ore.ts diff --git a/common/blocks/grass.ts b/common/blocks/grass.ts new file mode 100644 index 0000000..46a7d95 --- /dev/null +++ b/common/blocks/grass.ts @@ -0,0 +1,12 @@ +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; + +// hoeing it is handled in server/game/blocks.ts +EverythingRegistry.register("blocks", "bworld:grass", { + id: "bworld:grass", + textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" }, + has_collision: false, + drop_table: "bworld:dirt", + toughness: 2, + requires_tool: false, + tool_to_break: "shovel", +}); diff --git a/client/blocks/hoed_dirt.ts b/common/blocks/hoed_dirt.ts similarity index 100% rename from client/blocks/hoed_dirt.ts rename to common/blocks/hoed_dirt.ts diff --git a/client/blocks/iron_ore.ts b/common/blocks/iron_ore.ts similarity index 100% rename from client/blocks/iron_ore.ts rename to common/blocks/iron_ore.ts diff --git a/client/blocks/leaves.ts b/common/blocks/leaves.ts similarity index 100% rename from client/blocks/leaves.ts rename to common/blocks/leaves.ts diff --git a/client/blocks/log.ts b/common/blocks/log.ts similarity index 100% rename from client/blocks/log.ts rename to common/blocks/log.ts diff --git a/client/blocks/mod.ts b/common/blocks/mod.ts similarity index 100% rename from client/blocks/mod.ts rename to common/blocks/mod.ts diff --git a/client/blocks/planks.ts b/common/blocks/planks.ts similarity index 100% rename from client/blocks/planks.ts rename to common/blocks/planks.ts diff --git a/client/blocks/sand.ts b/common/blocks/sand.ts similarity index 100% rename from client/blocks/sand.ts rename to common/blocks/sand.ts diff --git a/client/blocks/snow.ts b/common/blocks/snow.ts similarity index 100% rename from client/blocks/snow.ts rename to common/blocks/snow.ts diff --git a/client/blocks/stone.ts b/common/blocks/stone.ts similarity index 100% rename from client/blocks/stone.ts rename to common/blocks/stone.ts diff --git a/client/blocks/tin_ore.ts b/common/blocks/tin_ore.ts similarity index 100% rename from client/blocks/tin_ore.ts rename to common/blocks/tin_ore.ts diff --git a/client/blocks/water.ts b/common/blocks/water.ts similarity index 100% rename from client/blocks/water.ts rename to common/blocks/water.ts diff --git a/common/constants.ts b/common/constants.ts index 39e3c57..290d02e 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -22,3 +22,13 @@ export const STATE_SHIFT = 16; export const CHUNK_SIZE = 16; export const CHUNK_HEIGHT = 128; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; + +// where a block placed against each face of another block goes +export const FACE_OFFSETS: Record = { + top: { x: 0, y: 1, z: 0 }, + bottom: { x: 0, y: -1, z: 0 }, + north: { x: 0, y: 0, z: -1 }, + south: { x: 0, y: 0, z: 1 }, + west: { x: -1, y: 0, z: 0 }, + east: { x: 1, y: 0, z: 0 }, +}; diff --git a/common/everything_registry.ts b/common/everything_registry.ts index b2f11bb..c65b06e 100644 --- a/common/everything_registry.ts +++ b/common/everything_registry.ts @@ -1,5 +1,4 @@ -import { Block, Dimension } from "$/client/components/dimension.ts"; -import { ItemStack } from "$/client/inventory.ts"; +import type { ItemStack } from "./inventory.ts"; export class EverythingRegistry { static #key_to_id = new Map>(); @@ -94,12 +93,9 @@ export interface BlockRegistry { states?: BlockStateDefinition[]; variants?: Record; - on_create?(dimension: Dimension, block: Block): void; - on_break?(dimension: Dimension, block: Block): void; - on_click?(dimension: Dimension, block: Block): void; - on_interact?(dimension: Dimension, block: Block): boolean; - on_tick?(dimension: Dimension, block: Block, tick_delta: number): void; - on_second?(dimension: Dimension, block: Block, second_delta: number): void; + // right clicking it does something instead of placing a block, clients don't predict placing against it. + // behavior runs on the server, see server/game/blocks.ts + interactive?: boolean; compiled_states?: CompiledStateDefinition[]; } @@ -109,8 +105,6 @@ export interface ItemRegistry { block_id?: string; tool_type?: string; - place?(dimension: Dimension, block: Block): void; - on_create?(item: ItemStack): void; get_lore?(item: ItemStack): string; diff --git a/client/generation.ts b/common/generation.ts similarity index 82% rename from client/generation.ts rename to common/generation.ts index a628a15..bdb9e05 100644 --- a/client/generation.ts +++ b/common/generation.ts @@ -1,5 +1,5 @@ import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; -import { CHUNK_SIZE } from "$/common/constants.ts"; +import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts"; // generation runs in a worker now, so it only needs somewhere to put blocks export interface BlockSink { @@ -269,3 +269,47 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see } } } + +export interface RawChunk { + // numeric block ids, only what this chunk generated itself + blocks: Uint32Array; + // blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id + spills: Int32Array; +} + +// generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world: +// a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order +export function generate_raw_chunk( + chunk_x: number, + chunk_z: number, + seed: string, + block_ids: Record, +): RawChunk { + const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); + const spills: number[] = []; + + generate_chunk( + { + add_block(block) { + const nid = block_ids[block.id]; + if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) { + return; + } + const block_chunk_x = Math.floor(block.x / CHUNK_SIZE); + const block_chunk_z = Math.floor(block.z / CHUNK_SIZE); + if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { + spills.push(block.x, block.y, block.z, nid); + return; + } + const lx = block.x - chunk_x * CHUNK_SIZE; + const lz = block.z - chunk_z * CHUNK_SIZE; + blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = nid; + }, + }, + chunk_x, + chunk_z, + seed, + ); + + return { blocks, spills: new Int32Array(spills) }; +} diff --git a/common/inventory.ts b/common/inventory.ts new file mode 100644 index 0000000..f00d42b --- /dev/null +++ b/common/inventory.ts @@ -0,0 +1,270 @@ +import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; + +// how item stacks are saved and sent over the network +export interface ItemData { + id: string; + count: number; + data?: unknown; +} + +export class ItemStack { + type_id: string; + amount: number; + max_amount: number; + data?: T; + + constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { + this.type_id = type_id; + this.amount = amount; + this.max_amount = max_amount; + this.data = undefined; + const item_info = EverythingRegistry.get("items", type_id); + if (item_info?.on_create) { + item_info.on_create(this); + } + } + + clone(): ItemStack { + const item = new ItemStack(this.type_id, this.amount, this.max_amount); + item.data = structuredClone(this.data); + return item; + } + + to_data(): ItemData { + return this.data === undefined + ? { id: this.type_id, count: this.amount } + : { id: this.type_id, count: this.amount, data: this.data }; + } + + static from_data(data: ItemData): ItemStack { + const item = new ItemStack(data.id, data.count); + if (data.data !== undefined) { + item.data = data.data; + } + return item; + } +} + +export class ContainerSlot { + #item_stack: ItemStack | undefined; + + has_item() { + return this.#item_stack !== undefined; + } + + set_item(item_stack: ItemStack | undefined) { + if ((item_stack?.amount ?? 0) <= 0) { + item_stack = undefined; + } + this.#item_stack = item_stack; + } + + get_item() { + return this.#item_stack; + } + + get type_id() { + return this.#item_stack?.type_id; + } + + set amount(new_amount: number) { + if (this.#item_stack) { + this.#item_stack.amount = new_amount; + if (this.#item_stack.amount <= 0) { + this.#item_stack = undefined; + } + } + } + + get amount(): number | undefined { + return this.#item_stack?.amount; + } + + get max_amount() { + return this.#item_stack?.max_amount; + } +} + +export class Container { + #slots: ContainerSlot[] = []; + readonly size: number; + + constructor(size: number) { + this.size = size; + for (let i = 0; i < size; i += 1) { + this.#slots.push(new ContainerSlot()); + } + } + + // returns how many items didn't fit + add_item(item_stack: ItemStack): number { + for (const slot of this.#slots.filter((slot) => slot.has_item())) { + const slot_item = slot.get_item()!; + if (slot_item.type_id === item_stack.type_id) { + const missing = slot_item.max_amount - slot_item.amount; + const adding = Math.min(missing, item_stack.amount); + slot_item.amount += adding; + item_stack.amount -= adding; + if (item_stack.amount === 0) { + return 0; + } + } + } + for (const slot of this.#slots) { + if (!slot.has_item()) { + slot.set_item(item_stack); + return 0; + } + } + // TODO: drop item on ground + return item_stack.amount; + } + + get_item(slot: number): ItemStack | undefined { + return this.#slots[slot]?.get_item(); + } + + get_slot(slot: number): ContainerSlot { + return this.#slots[slot]; + } + + set_item(slot: number, item: ItemStack | undefined) { + this.#slots[slot].set_item(item); + } + + to_data(): (ItemData | null)[] { + return this.#slots.map((slot) => slot.get_item()?.to_data() ?? null); + } + + load(data: (ItemData | null)[]) { + for (let i = 0; i < this.size; i += 1) { + const item = data[i]; + this.#slots[i].set_item(item ? ItemStack.from_data(item) : undefined); + } + } + + clear() { + for (const slot of this.#slots) { + slot.set_item(undefined); + } + } +} + +// the item a player is carrying around with the mouse in an inventory screen +export interface Cursor { + item: ItemStack | undefined; +} + +export const LEFT_CLICK = 0; +export const RIGHT_CLICK = 2; + +// what clicking a normal slot does, the server runs this for real and clients run it to predict +export function click_slot(container: Container, index: number, cursor: Cursor, button: number) { + if (button === LEFT_CLICK) { + left_click(container, index, cursor); + } else if (button === RIGHT_CLICK) { + right_click(container, index, cursor); + } + if (cursor.item && cursor.item.amount <= 0) { + cursor.item = undefined; + } +} + +function swap_with_cursor(container: Container, index: number, cursor: Cursor) { + const slot = container.get_slot(index); + const original = cursor.item; + cursor.item = slot.get_item(); + slot.set_item(original); +} + +function left_click(container: Container, index: number, cursor: Cursor) { + const holding = cursor.item; + const slot = container.get_slot(index); + const slot_item = slot.get_item(); + + // if you aren't holding anything + // "swap" with nothing on your hand (pick it up) + if (!holding) { + swap_with_cursor(container, index, cursor); + return; + } + + // if you are holding something and slot type equals holding type + // try to add to stack + if (slot_item && slot.type_id === holding.type_id) { + const space_left = slot.max_amount! - slot_item.amount; + const amount_to_add = Math.min(space_left, holding.amount); + + slot_item.amount += amount_to_add; + holding.amount -= amount_to_add; + + return; + } + + // if something on hand but not the same + // swap + swap_with_cursor(container, index, cursor); +} + +function right_click(container: Container, index: number, cursor: Cursor) { + const holding = cursor.item; + const slot = container.get_slot(index); + const slot_item = slot.get_item(); + + // if holding something + if (holding) { + // and slot type equals holding type + // add 1 to matching stack + if (slot_item && slot.type_id === holding.type_id) { + if (slot_item.amount < slot_item.max_amount) { + slot_item.amount += 1; + holding.amount -= 1; + } + return; + } + + // place 1 into empty slot + if (!slot_item) { + const new_item = holding.clone(); + new_item.amount = 1; + slot.set_item(new_item); + holding.amount -= 1; + return; + } + + // if something on hand but not the same + // swap + swap_with_cursor(container, index, cursor); + return; + } + + // if player is holding nothing and clicks nothing, nothing happens + if (!slot_item) { + return; + } + + // pick up half of the stack + const original_amount = slot_item.amount; + const half = Math.floor(original_amount / 2); + + const picked_up = slot_item.clone(); + picked_up.amount = original_amount - half; + cursor.item = picked_up; + + slot.amount = half; +} + +// output slots (furnace result, crafting result) can only be taken from, all at once +// returns whether it was taken +export function take_output(item: ItemStack, cursor: Cursor): boolean { + const holding = cursor.item; + if (!holding) { + cursor.item = item.clone(); + return true; + } + if (holding.type_id === item.type_id && holding.max_amount - holding.amount >= item.amount) { + holding.amount += item.amount; + return true; + } + return false; +} diff --git a/client/items/axe.ts b/common/items/axe.ts similarity index 100% rename from client/items/axe.ts rename to common/items/axe.ts diff --git a/client/items/coal.ts b/common/items/coal.ts similarity index 100% rename from client/items/coal.ts rename to common/items/coal.ts diff --git a/client/items/copper_ingot.ts b/common/items/copper_ingot.ts similarity index 100% rename from client/items/copper_ingot.ts rename to common/items/copper_ingot.ts diff --git a/client/items/gold_ingot.ts b/common/items/gold_ingot.ts similarity index 100% rename from client/items/gold_ingot.ts rename to common/items/gold_ingot.ts diff --git a/client/items/hoe.ts b/common/items/hoe.ts similarity index 100% rename from client/items/hoe.ts rename to common/items/hoe.ts diff --git a/client/items/iron_ingot.ts b/common/items/iron_ingot.ts similarity index 100% rename from client/items/iron_ingot.ts rename to common/items/iron_ingot.ts diff --git a/client/items/mod.ts b/common/items/mod.ts similarity index 100% rename from client/items/mod.ts rename to common/items/mod.ts diff --git a/client/items/pickaxe.ts b/common/items/pickaxe.ts similarity index 100% rename from client/items/pickaxe.ts rename to common/items/pickaxe.ts diff --git a/client/items/stick.ts b/common/items/stick.ts similarity index 100% rename from client/items/stick.ts rename to common/items/stick.ts diff --git a/client/items/tin_ingot.ts b/common/items/tin_ingot.ts similarity index 100% rename from client/items/tin_ingot.ts rename to common/items/tin_ingot.ts diff --git a/client/items/watering_can.ts b/common/items/watering_can.ts similarity index 100% rename from client/items/watering_can.ts rename to common/items/watering_can.ts diff --git a/client/items/wood_pickaxe.ts b/common/items/wood_pickaxe.ts similarity index 100% rename from client/items/wood_pickaxe.ts rename to common/items/wood_pickaxe.ts diff --git a/common/protocol.ts b/common/protocol.ts index 21d1fce..a25c8b5 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -1,4 +1,6 @@ // messages sent between the client and the server, as json over a websocket +import type { Faces } from "./constants.ts"; +import type { ItemData } from "./inventory.ts"; export const AIR_ID = "bworld:air"; @@ -15,19 +17,64 @@ export interface PlayerInfo { // x, y, z, block id export type BlockChange = [number, number, number, string]; +// the containers a client can see and click. "screen" is whatever the open server screen shows +export type ContainerKey = "inventory" | "crafting" | "screen"; + +export const CRAFTING_RESULT_SLOT = 9; + +// a screen the server opens, drawn below the player's inventory and hotbar +export interface ScreenLayout { + // height of the screen's own area, in slots + rows: number; + // x and y in slots, can be fractional + // output slots can only be taken from, like the furnace result + slots: { index: number; x: number; y: number; output?: boolean }[]; + // progress bars filled from properties[value] / properties[max] + bars: { + x: number; + y: number; + value: string; + max: string; + direction: "up" | "right"; + empty_texture: string; + full_texture: string; + }[]; +} + +// clients send what the player is trying to do, the server decides what happens export type ClientMessage = | { type: "hello"; name: string } | { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number } - | { type: "set_block"; x: number; y: number; z: number; id: string } + | { type: "break_block"; x: number; y: number; z: number } + // right click on a block: interact with it, or place the held block against `face` + | { type: "use_block"; x: number; y: number; z: number; face: Faces } + | { type: "select_slot"; slot: number } + | { type: "click"; container: ContainerKey; index: number; button: number } + // closes the open screen, including the player's own inventory screen + | { type: "close_screen" } | { type: "chat"; text: string }; export type ServerMessage = - | { type: "welcome"; id: string; seed: string; players: PlayerInfo[]; changes: BlockChange[] } + | { + type: "welcome"; + id: string; + seed: string; + players: PlayerInfo[]; + changes: BlockChange[]; + spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; + selected_slot: number; + } | { type: "player_join"; player: PlayerInfo } | { type: "player_leave"; id: string } | { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number } + // also sent to the player who caused it, which corrects anything their client predicted wrong | { type: "set_block"; x: number; y: number; z: number; id: string } - | { type: "chat"; from?: string; text: string }; + | { type: "chat"; from?: string; text: string } + | { type: "container"; container: ContainerKey; items: (ItemData | null)[] } + | { type: "cursor"; item: ItemData | null } + | { type: "open_screen"; layout: ScreenLayout; properties: Record } + | { type: "screen_properties"; properties: Record } + | { type: "close_screen" }; export const MAX_NAME_LENGTH = 16; export const MAX_CHAT_LENGTH = 256; diff --git a/common/utils.ts b/common/utils.ts index 9c6d7bb..b2dc8e6 100644 --- a/common/utils.ts +++ b/common/utils.ts @@ -1,5 +1,4 @@ -import { AssetManager } from "../client/assets.ts"; -import { ID_MASK, SpriteRegion, STATE_SHIFT } from "./constants.ts"; +import { ID_MASK, STATE_SHIFT } from "./constants.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; export function point_inside_rec( @@ -16,13 +15,6 @@ export function point_inside_rec( point_y < rec_y + rec_h; } -type TexturesInfo = Record; - -export function get_sprite_region(id: string): SpriteRegion { - const textures_info = AssetManager.instance.get("bworld:textures_info"); - return textures_info?.[id] ?? { x: 0, y: 0 }; -} - export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) { const x0 = sqx; const y0 = sqy; @@ -106,3 +98,8 @@ function compile_block_states(block_info: BlockRegistry) { return { name: s.name, mask, shift }; }); } + +// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768 +export function chunk_key(x: number, z: number) { + return (x + 32768) * 65536 + (z + 32768); +} diff --git a/deno.json b/deno.json index 5ecf429..79fda57 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,7 @@ "tasks": { "build": "deno run -A build.ts", "serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build", - "server": "deno run --allow-net --allow-read --allow-write --allow-env server/main.ts" + "server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts" }, "compilerOptions": { "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] diff --git a/server/game/blocks.ts b/server/game/blocks.ts new file mode 100644 index 0000000..9483614 --- /dev/null +++ b/server/game/blocks.ts @@ -0,0 +1,278 @@ +import { Container, ItemStack } from "$/common/inventory.ts"; +import { ScreenLayout } from "$/common/protocol.ts"; +import type { GameServer } from "./game_server.ts"; +import type { ServerPlayer } from "./player.ts"; +import type { Tile } from "./world.ts"; + +// what blocks do, only on the server. blocks without an entry just sit there +export interface BlockBehavior { + // set up a tile for this block. blocks with this get a tile when placed + create_tile?(tile: Tile): void; + // right click, return true if it did something so no block gets placed + on_interact?( + game: GameServer, + block: { x: number; y: number; z: number; id: string }, + player: ServerPlayer, + ): boolean; + on_break?(game: GameServer, tile: Tile, player: ServerPlayer | undefined): void; + on_tick?(game: GameServer, tile: Tile): void; + on_second?(game: GameServer, tile: Tile): void; +} + +export const BLOCK_BEHAVIORS: Record = {}; + +// give the breaking player whatever was inside +function give_container_contents(tile: Tile, player: ServerPlayer | undefined) { + if (!player) { + return; + } + for (const container of Object.values(tile.containers)) { + for (let i = 0; i < container.size; i++) { + const item = container.get_item(i); + if (item) { + player.give(item); + } + } + } +} + +function hoe_into_hoed_dirt( + game: GameServer, + block: { x: number; y: number; z: number }, + player: ServerPlayer, +): boolean { + if (player.held_item?.type_id !== "bworld:hoe") { + return false; + } + game.set_block(block.x, block.y, block.z, "bworld:hoed_dirt", player); + return true; +} + +BLOCK_BEHAVIORS["bworld:grass"] = { on_interact: hoe_into_hoed_dirt }; +BLOCK_BEHAVIORS["bworld:dirt"] = { on_interact: hoe_into_hoed_dirt }; + +// chest + +const CHEST_LAYOUT: ScreenLayout = { + rows: 3, + slots: Array.from({ length: 9 * 3 }, (_, index) => ({ index, x: index % 9, y: Math.floor(index / 9) })), + bars: [], +}; + +BLOCK_BEHAVIORS["bworld:chest"] = { + create_tile(tile) { + tile.containers.main = new Container(9 * 3); + }, + on_interact(game, block, player) { + const tile = game.get_or_create_tile(block.x, block.y, block.z); + game.open_screen(player, { + tile, + container: tile.containers.main, + layout: CHEST_LAYOUT, + properties: () => ({}), + }); + return true; + }, + on_break(_game, tile, player) { + give_container_contents(tile, player); + }, +}; + +// furnace + +interface FurnaceRecipe { + input: string; + output: ItemStack; + cook_time: number; +} + +const FURNACE_RECIPES: FurnaceRecipe[] = [ + { + input: "bworld:log", + output: new ItemStack("bworld:coal", 1), + cook_time: 100, + }, + { + input: "bworld:coal_ore", + output: new ItemStack("bworld:coal", 1), + cook_time: 100, + }, + { + input: "bworld:iron_ore", + output: new ItemStack("bworld:iron_ingot", 1), + cook_time: 200, + }, + { + input: "bworld:tin_ore", + output: new ItemStack("bworld:tin_ingot", 1), + cook_time: 200, + }, + { + input: "bworld:copper_ore", + output: new ItemStack("bworld:copper_ingot", 1), + cook_time: 200, + }, + { + input: "bworld:gold_ore", + output: new ItemStack("bworld:gold_ingot", 1), + cook_time: 200, + }, +]; + +const FUEL_VALUES: Record = { + "bworld:coal": 1000, + "bworld:log": 100, +}; + +interface FurnaceData { + progress: number; + progress_max: number; + fuel: number; + fuel_max: number; +} + +function get_recipe(input?: ItemStack | undefined): FurnaceRecipe | undefined { + if (!input) { + return; + } + + return FURNACE_RECIPES.find((r) => r.input === input.type_id); +} + +function can_craft(container: Container, recipe?: FurnaceRecipe) { + if (!recipe) { + return false; + } + + const output = container.get_item(2); + + if (!output) { + return true; + } + + if (output.type_id !== recipe.output.type_id) { + return false; + } + + return output.amount < output.max_amount; +} + +function get_fuel_value(item?: ItemStack | undefined): number { + if (!item) { + return 0; + } + return FUEL_VALUES[item.type_id] ?? 0; +} + +function has_fuel(container: Container) { + return get_fuel_value(container.get_item(1)) > 0; +} + +function consume_fuel(container: Container): number { + const fuel = container.get_slot(1)!; + const value = get_fuel_value(fuel.get_item()); + + if (fuel.has_item()) { + fuel.amount! -= 1; + } + + return value; +} + +function craft(container: Container, recipe: FurnaceRecipe) { + const input = container.get_item(0)!; + const output = container.get_item(2); + + if (!output) { + container.set_item(2, recipe.output.clone()); + } else { + output.amount += recipe.output.amount; + } + + input.amount -= 1; + container.set_item(0, input.amount > 0 ? input : undefined); +} + +const FURNACE_LAYOUT: ScreenLayout = { + rows: 3, + slots: [ + { index: 0, x: 3.5, y: 0 }, + { index: 1, x: 3.5, y: 2 }, + { index: 2, x: 5.5, y: 1, output: true }, + ], + bars: [ + { + x: 3.5, + y: 1, + value: "fuel", + max: "fuel_max", + direction: "up", + empty_texture: "bworld:fire_empty", + full_texture: "bworld:fire_full", + }, + { + x: 4.5, + y: 1, + value: "progress", + max: "progress_max", + direction: "right", + empty_texture: "bworld:arrow_empty", + full_texture: "bworld:arrow_full", + }, + ], +}; + +BLOCK_BEHAVIORS["bworld:furnace"] = { + create_tile(tile) { + tile.containers.main = new Container(3); + tile.data = { progress: 0, progress_max: 0, fuel: 0, fuel_max: 0 } satisfies FurnaceData; + }, + on_interact(game, block, player) { + const tile = game.get_or_create_tile(block.x, block.y, block.z); + const data = tile.data as unknown as FurnaceData; + game.open_screen(player, { + tile, + container: tile.containers.main, + layout: FURNACE_LAYOUT, + properties: () => ({ ...data }), + }); + return true; + }, + on_break(_game, tile, player) { + give_container_contents(tile, player); + }, + on_tick(_game, tile) { + const data = tile.data as unknown as FurnaceData; + const container = tile.containers.main; + + const input = container.get_item(0); + const recipe = get_recipe(input); + + // burn fuel + if (data.fuel > 0) { + data.fuel -= 1; + } + + if (!can_craft(container, recipe)) { + data.progress = 0; + return; + } + + // refuel + if (data.fuel === 0 && has_fuel(container)) { + data.fuel = consume_fuel(container); + data.fuel_max = data.fuel; + } + + // cook + if (data.fuel > 0 && recipe) { + data.progress_max = recipe.cook_time; + data.progress += 1; + + if (data.progress >= recipe.cook_time) { + data.progress = 0; + craft(container, recipe); + } + } + }, +}; diff --git a/server/game/crafting.ts b/server/game/crafting.ts new file mode 100644 index 0000000..f284c66 --- /dev/null +++ b/server/game/crafting.ts @@ -0,0 +1,131 @@ +import { Container, ItemStack } from "$/common/inventory.ts"; +import { CRAFTING_RESULT_SLOT } from "$/common/protocol.ts"; + +export interface CraftingRecipe { + width: number; + height: number; + pattern: (string | undefined)[]; + result: { id: string; count: number }; +} + +const recipes: CraftingRecipe[] = [ + { + width: 3, + height: 3, + pattern: [ + "bworld:planks", + "bworld:planks", + "bworld:planks", + "bworld:planks", + undefined, + "bworld:planks", + "bworld:planks", + "bworld:planks", + "bworld:planks", + ], + result: { id: "bworld:chest", count: 1 }, + }, + { + width: 3, + height: 3, + pattern: [ + "bworld:stone", + "bworld:stone", + "bworld:stone", + "bworld:stone", + undefined, + "bworld:stone", + "bworld:stone", + "bworld:stone", + "bworld:stone", + ], + result: { id: "bworld:furnace", count: 1 }, + }, + { + width: 1, + height: 1, + pattern: [ + "bworld:log", + ], + result: { id: "bworld:planks", count: 2 }, + }, + { + width: 1, + height: 2, + pattern: [ + "bworld:planks", + "bworld:planks", + ], + result: { id: "bworld:stick", count: 2 }, + }, + { + width: 3, + height: 3, + pattern: [ + "bworld:planks", + "bworld:planks", + "bworld:planks", + undefined, + "bworld:stick", + undefined, + undefined, + "bworld:stick", + undefined, + ], + result: { id: "bworld:wood_pickaxe", count: 1 }, + }, +]; + +function get_crafting_grid(crafting: Container): (string | undefined)[] { + const grid: (string | undefined)[] = []; + for (let i = 0; i < 9; i++) { + grid.push(crafting.get_item(i)?.type_id); + } + return grid; +} + +function matches_recipe(grid: (string | undefined)[], recipe: CraftingRecipe): boolean { + for (let y = 0; y <= 3 - recipe.height; y++) { + for (let x = 0; x <= 3 - recipe.width; x++) { + let match = true; + + for (let gy = 0; gy < 3; gy++) { + for (let gx = 0; gx < 3; gx++) { + const grid_index = gy * 3 + gx; + + if (gx >= x && gx < x + recipe.width && gy >= y && gy < y + recipe.height) { + const recipe_index = (gy - y) * recipe.width + (gx - x); + if (grid[grid_index] !== recipe.pattern[recipe_index]) { + match = false; + break; + } + } else if (grid[grid_index] !== undefined) { + match = false; + break; + } + } + if (!match) break; + } + + if (match) return true; + } + } + + return false; +} + +// puts what the grid makes in the result slot +export function update_crafting_result(crafting: Container) { + const grid = get_crafting_grid(crafting); + const recipe = recipes.find((recipe) => matches_recipe(grid, recipe)); + crafting.set_item(CRAFTING_RESULT_SLOT, recipe ? new ItemStack(recipe.result.id, recipe.result.count) : undefined); +} + +export function consume_recipe_items(crafting: Container) { + for (let i = 0; i < 9; i++) { + const slot = crafting.get_slot(i); + if (slot.has_item()) { + slot.amount = slot.amount! - 1; + } + } +} diff --git a/server/game/game_server.ts b/server/game/game_server.ts new file mode 100644 index 0000000..94da493 --- /dev/null +++ b/server/game/game_server.ts @@ -0,0 +1,541 @@ +import "$/common/blocks/mod.ts"; +import "$/common/items/mod.ts"; + +import { + AIR, + CHUNK_HEIGHT, + CHUNK_SIZE, + FACE_OFFSETS, + Faces, + faces, + TICK_DELTA, + TICKS_PER_SECOND, +} from "$/common/constants.ts"; +import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; +import { click_slot, Container, ItemData, ItemStack, take_output } from "$/common/inventory.ts"; +import { + AIR_ID, + BlockChange, + ClientMessage, + ContainerKey, + CRAFTING_RESULT_SLOT, + MAX_CHAT_LENGTH, + MAX_NAME_LENGTH, + ServerMessage, +} from "$/common/protocol.ts"; +import { BLOCK_BEHAVIORS } from "./blocks.ts"; +import { consume_recipe_items, update_crafting_result } from "./crafting.ts"; +import { OpenScreen, SavedPlayer, ServerPlayer } from "./player.ts"; +import { ServerWorld, Tile } from "./world.ts"; + +// how far from a player's eyes a block can be changed, a bit more than the client's reach +const MAX_REACH = 8; +const EYE_HEIGHT = 1.69; +// tiles further than this many chunks from every player don't tick +const SIMULATION_DISTANCE = 6; +const MAX_GIVE = 64 * 36; + +// everything the game server needs from whatever runs it +export interface GameHost { + send(conn: number, data: string): void; + close(conn: number): void; +} + +export interface SavedWorld { + version: 2; + seed: string; + changes: BlockChange[]; + tiles: { + id: string; + x: number; + y: number; + z: number; + data: Record; + containers: Record; + }[]; + players: Record; +} + +export class GameServer { + world: ServerWorld; + #host: GameHost; + #players = new Map(); + #saved_players: Record = {}; + #tick = 0; + + constructor(host: GameHost, save: string | undefined, default_seed: string) { + this.#host = host; + + // version 1 saves only had the seed and block changes + const saved: Partial | undefined = save ? JSON.parse(save) : undefined; + this.world = new ServerWorld(saved?.seed ?? default_seed); + this.world.load_changes(saved?.changes ?? []); + for (const tile of saved?.tiles ?? []) { + const containers: Record = {}; + for (const [name, items] of Object.entries(tile.containers)) { + containers[name] = new Container(items.length); + containers[name].load(items); + } + this.world.tiles.set(`${tile.x},${tile.y},${tile.z}`, { ...tile, containers }); + } + this.#saved_players = saved?.players ?? {}; + this.world.dirty = saved?.version !== 2; + + this.world.on_block_change = (x, y, z, id) => this.#broadcast({ type: "set_block", x, y, z, id }); + } + + // connections + + on_connect(_conn: number) {} + + on_disconnect(conn: number) { + const player = this.#players.get(conn); + if (!player) { + return; + } + this.#close_screen(player); + this.#saved_players[player.name] = player.save(); + this.world.dirty = true; + this.#players.delete(conn); + this.#broadcast({ type: "player_leave", id: player.id }); + this.#broadcast({ type: "chat", text: `${player.name} left` }); + console.log(`${player.name} left (${this.#players.size} online)`); + } + + on_message(conn: number, data: string) { + let message: ClientMessage; + try { + message = JSON.parse(data); + } catch { + return; + } + if (typeof message !== "object" || message === null) { + return; + } + + const player = this.#players.get(conn); + if (!player) { + if (message.type === "hello") { + this.#join(conn, message.name); + } + return; + } + + this.#handle(player, message); + this.#sync(player); + } + + // game loop, the host calls this TICKS_PER_SECOND times a second + + tick() { + this.#tick += 1; + const second = this.#tick % TICKS_PER_SECOND === 0; + + for (const tile of [...this.world.tiles.values()]) { + const behavior = BLOCK_BEHAVIORS[tile.id]; + if (!behavior?.on_tick && !behavior?.on_second) { + continue; + } + if (!this.#near_any_player(tile.x, tile.z)) { + continue; + } + behavior.on_tick?.(this, tile); + if (second) { + behavior.on_second?.(this, tile); + } + // tile data can change on any tick, saving is cheap enough to not track it exactly + this.world.dirty = true; + } + + for (const player of this.#players.values()) { + this.#sync(player); + } + } + + save(): string { + for (const player of this.#players.values()) { + this.#saved_players[player.name] = player.save(); + } + const saved: SavedWorld = { + version: 2, + seed: this.world.seed, + changes: this.world.all_changes(), + tiles: [...this.world.tiles.values()].map((tile) => ({ + id: tile.id, + x: tile.x, + y: tile.y, + z: tile.z, + data: tile.data, + containers: Object.fromEntries( + Object.entries(tile.containers).map(([name, container]) => [name, container.to_data()]), + ), + })), + players: this.#saved_players, + }; + this.world.dirty = false; + return JSON.stringify(saved); + } + + // used by block behaviors + + set_block(x: number, y: number, z: number, id: string, player?: ServerPlayer) { + const old_tile = this.world.get_tile(x, y, z); + if (old_tile) { + BLOCK_BEHAVIORS[old_tile.id]?.on_break?.(this, old_tile, player); + this.world.remove_tile(x, y, z); + for (const other of this.#players.values()) { + if (other.screen?.tile === old_tile) { + this.#close_screen(other); + this.#send(other, { type: "close_screen" }); + } + } + } + + this.world.set_block(x, y, z, id); + + if (BLOCK_BEHAVIORS[id]?.create_tile) { + this.get_or_create_tile(x, y, z); + } + } + + get_or_create_tile(x: number, y: number, z: number): Tile { + let tile = this.world.get_tile(x, y, z); + if (!tile) { + // blocks placed before tiles were saved have none yet + const id = this.world.get_block_id(x, y, z); + tile = { id, x, y, z, data: {}, containers: {} }; + BLOCK_BEHAVIORS[id]?.create_tile?.(tile); + this.world.add_tile(tile); + } + return tile; + } + + open_screen(player: ServerPlayer, screen: OpenScreen) { + this.#close_screen(player); + player.screen = screen; + this.#send(player, { type: "open_screen", layout: screen.layout, properties: screen.properties() }); + player.sent.set("properties", JSON.stringify(screen.properties())); + player.sent.delete("screen"); + } + + // messages + + #join(conn: number, raw_name: unknown) { + const player = new ServerPlayer(conn, this.#unique_name(raw_name)); + const saved = this.#saved_players[player.name]; + if (saved) { + player.load(saved); + } + + this.#send(player, { + type: "welcome", + id: player.id, + seed: this.world.seed, + players: [...this.#players.values()].map((p) => p.info()), + changes: this.world.all_changes(), + spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch }, + selected_slot: player.selected_slot, + }); + this.#players.set(conn, player); + this.#sync(player); + + this.#broadcast({ type: "player_join", player: player.info() }, player); + this.#broadcast({ type: "chat", text: `${player.name} joined` }); + console.log(`${player.name} joined (${this.#players.size} online)`); + } + + #handle(player: ServerPlayer, message: ClientMessage) { + switch (message.type) { + case "move": { + const { x, y, z, yaw, pitch } = message; + if (![x, y, z, yaw, pitch].every(is_number)) { + return; + } + Object.assign(player, { x, y, z, yaw, pitch }); + this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, player); + break; + } + case "break_block": + if (is_block_position(message.x, message.y, message.z)) { + this.#break_block(player, message.x, message.y, message.z); + } + break; + case "use_block": + if (is_block_position(message.x, message.y, message.z) && faces.includes(message.face)) { + this.#use_block(player, message.x, message.y, message.z, message.face); + } + break; + case "select_slot": + if (is_int(message.slot) && message.slot >= 0 && message.slot < 9) { + player.selected_slot = message.slot; + } + break; + case "click": + this.#click(player, message.container, message.index, message.button); + break; + case "close_screen": + this.#close_screen(player); + break; + case "chat": + this.#chat(player, message.text); + break; + } + } + + #break_block(player: ServerPlayer, x: number, y: number, z: number) { + const info = this.world.get_block_info(x, y, z); + // air, or something like water that can't be broken + if (!info || info.toughness === undefined || !this.#in_reach(player, x, y, z)) { + this.#correct(player, x, y, z); + return; + } + + const held = EverythingRegistry.get("items", player.held_item?.type_id ?? ""); + const drops = info.drop_table && (!info.requires_tool || held?.tool_type === info.tool_to_break); + + this.set_block(x, y, z, AIR_ID, player); + if (drops) { + player.give(new ItemStack(info.drop_table!)); + } + } + + #use_block(player: ServerPlayer, x: number, y: number, z: number, face: Faces) { + const offset = FACE_OFFSETS[face]; + const [tx, ty, tz] = [x + offset.x, y + offset.y, z + offset.z]; + + const info = this.world.get_block_info(x, y, z); + if (!info || !this.#in_reach(player, x, y, z)) { + this.#correct(player, x, y, z); + this.#correct(player, tx, ty, tz); + return; + } + + if (BLOCK_BEHAVIORS[info.id]?.on_interact?.(this, { x, y, z, id: info.id }, player)) { + return; + } + + // place the held block against the face + const held_slot = player.inventory.get_slot(player.selected_slot); + const held = EverythingRegistry.get("items", held_slot.type_id ?? ""); + if (!held?.block_id || ty < 0 || ty >= CHUNK_HEIGHT || !this.#replaceable(tx, ty, tz)) { + this.#correct(player, tx, ty, tz); + return; + } + + this.set_block(tx, ty, tz, held.block_id, player); + held_slot.amount = held_slot.amount! - 1; + } + + #click(player: ServerPlayer, key: unknown, index: unknown, button: unknown) { + if (!is_int(index) || !is_int(button)) { + return; + } + const container = this.#get_container(player, key as ContainerKey); + if (!container || index < 0 || index >= container.size) { + return; + } + + if (key === "crafting" && index === CRAFTING_RESULT_SLOT) { + const result = container.get_item(CRAFTING_RESULT_SLOT); + if (result && take_output(result, player.cursor)) { + consume_recipe_items(container); + } + } else if (key === "screen" && player.screen?.layout.slots.find((s) => s.index === index)?.output) { + const item = container.get_item(index); + if (item && take_output(item, player.cursor)) { + container.set_item(index, undefined); + } + } else { + click_slot(container, index, player.cursor, button); + } + + if (key === "crafting") { + update_crafting_result(container); + } + } + + #chat(player: ServerPlayer, raw: unknown) { + if (typeof raw !== "string") { + return; + } + const text = raw.trim().slice(0, MAX_CHAT_LENGTH); + if (text.length === 0) { + return; + } + if (text.startsWith("/")) { + this.#command(player, text); + return; + } + console.log(`<${player.name}> ${text}`); + this.#broadcast({ type: "chat", from: player.name, text }); + } + + #command(player: ServerPlayer, text: string) { + const [command, ...args] = text.slice(1).split(/\s+/); + if (command === "give") { + // TODO: only let operators do this + let [item_id, count] = args; + if (!item_id) { + this.#send(player, { type: "chat", text: "Usage: /give [count]" }); + return; + } + if (!item_id.includes(":")) { + item_id = `bworld:${item_id}`; + } + const amount = count === undefined ? 1 : Number(count); + if (!EverythingRegistry.get("items", item_id)) { + this.#send(player, { type: "chat", text: `Unknown item ${item_id}` }); + return; + } + if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GIVE) { + this.#send(player, { type: "chat", text: `Count must be between 1 and ${MAX_GIVE}` }); + return; + } + // split into stacks so max stack sizes are respected + let left = amount; + while (left > 0) { + const stack = new ItemStack(item_id); + stack.amount = Math.min(left, stack.max_amount); + left -= stack.amount; + player.give(stack); + } + return; + } + this.#send(player, { type: "chat", text: `Unknown command /${command}` }); + } + + #close_screen(player: ServerPlayer) { + player.screen = undefined; + player.sent.delete("screen"); + player.sent.delete("properties"); + + // the crafting grid and whatever the cursor holds go back into the inventory + for (let i = 0; i < 9; i++) { + const item = player.crafting.get_item(i); + if (item) { + player.give(item); + player.crafting.set_item(i, undefined); + } + } + update_crafting_result(player.crafting); + if (player.cursor.item) { + player.give(player.cursor.item); + player.cursor.item = undefined; + } + } + + // helpers + + #get_container(player: ServerPlayer, key: ContainerKey): Container | undefined { + switch (key) { + case "inventory": + return player.inventory; + case "crafting": + return player.crafting; + case "screen": + return player.screen?.container; + } + } + + // sends whatever changed since last time + #sync(player: ServerPlayer) { + const sync = (key: string, value: unknown, message: () => ServerMessage) => { + const json = JSON.stringify(value); + if (player.sent.get(key) !== json) { + player.sent.set(key, json); + this.#send(player, message()); + } + }; + + const inventory = player.inventory.to_data(); + sync("inventory", inventory, () => ({ type: "container", container: "inventory", items: inventory })); + const crafting = player.crafting.to_data(); + sync("crafting", crafting, () => ({ type: "container", container: "crafting", items: crafting })); + const cursor = player.cursor.item?.to_data() ?? null; + sync("cursor", cursor, () => ({ type: "cursor", item: cursor })); + + if (player.screen) { + const items = player.screen.container.to_data(); + sync("screen", items, () => ({ type: "container", container: "screen", items })); + const properties = player.screen.properties(); + sync("properties", properties, () => ({ type: "screen_properties", properties })); + } + } + + // tell the player what's really at a position, undoing anything their client guessed + #correct(player: ServerPlayer, x: number, y: number, z: number) { + if (y < 0 || y >= CHUNK_HEIGHT) { + return; + } + this.#send(player, { type: "set_block", x, y, z, id: this.world.get_block_id(x, y, z) }); + } + + #replaceable(x: number, y: number, z: number) { + const nid = this.world.get_block_nid(x, y, z); + return nid === AIR || EverythingRegistry.get_by_id("blocks", nid)?.id === "bworld:water"; + } + + #in_reach(player: ServerPlayer, x: number, y: number, z: number) { + const dx = x + 0.5 - player.x; + const dy = y + 0.5 - (player.y + EYE_HEIGHT); + const dz = z + 0.5 - player.z; + return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH; + } + + #near_any_player(x: number, z: number) { + const chunk_x = Math.floor(x / CHUNK_SIZE); + const chunk_z = Math.floor(z / CHUNK_SIZE); + for (const player of this.#players.values()) { + const px = Math.floor(player.x / CHUNK_SIZE); + const pz = Math.floor(player.z / CHUNK_SIZE); + if (Math.max(Math.abs(px - chunk_x), Math.abs(pz - chunk_z)) <= SIMULATION_DISTANCE) { + return true; + } + } + return false; + } + + #unique_name(name: unknown): string { + const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : ""; + const base = cleaned || `player${Math.floor(Math.random() * 10000)}`; + + const taken = new Set([...this.#players.values()].map((p) => p.name)); + let final = base; + let i = 2; + while (taken.has(final)) { + final = `${base}${i}`; + i += 1; + } + return final; + } + + #send(player: ServerPlayer, message: ServerMessage) { + this.#host.send(player.conn, JSON.stringify(message)); + } + + #broadcast(message: ServerMessage, except?: ServerPlayer) { + const data = JSON.stringify(message); + for (const player of this.#players.values()) { + if (player !== except) { + this.#host.send(player.conn, data); + } + } + } +} + +// ticks happen at a fixed rate +export const TICK_MS = TICK_DELTA * 1000; + +function is_number(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function is_int(value: unknown): value is number { + return Number.isInteger(value); +} + +// messages are parsed json, so the types in ClientMessage are only what a well behaved client sends +function is_block_position(x: unknown, y: unknown, z: unknown): boolean { + return is_int(x) && is_int(y) && is_int(z) && y >= 0 && y < CHUNK_HEIGHT && + Math.abs(x) < 30000 * CHUNK_SIZE && Math.abs(z) < 30000 * CHUNK_SIZE; +} diff --git a/server/game/host_protocol.ts b/server/game/host_protocol.ts new file mode 100644 index 0000000..24f7a40 --- /dev/null +++ b/server/game/host_protocol.ts @@ -0,0 +1,15 @@ +// messages between server/main.ts (the host) and the game server worker + +export type HostToGame = + | { type: "init"; save: string | undefined; default_seed: string } + | { type: "connect"; conn: number } + | { type: "message"; conn: number; data: string } + | { type: "disconnect"; conn: number } + // save now and say when it's done, for shutting down + | { type: "shutdown" }; + +export type GameToHost = + | { type: "ready"; seed: string } + | { type: "send"; conn: number; data: string } + | { type: "close"; conn: number } + | { type: "save"; data: string; final: boolean }; diff --git a/server/game/lru.ts b/server/game/lru.ts new file mode 100644 index 0000000..62dd8f2 --- /dev/null +++ b/server/game/lru.ts @@ -0,0 +1,35 @@ +// a Map that forgets the least recently used entries past max_size +export class LruMap { + #map = new Map(); + readonly max_size: number; + + constructor(max_size: number) { + this.max_size = max_size; + } + + get(key: K): V | undefined { + const value = this.#map.get(key); + if (value !== undefined) { + // maps keep insertion order, so re-inserting moves it to the back + this.#map.delete(key); + this.#map.set(key, value); + } + return value; + } + + set(key: K, value: V) { + this.#map.delete(key); + this.#map.set(key, value); + if (this.#map.size > this.max_size) { + this.#map.delete(this.#map.keys().next().value!); + } + } + + delete(key: K) { + this.#map.delete(key); + } + + get size() { + return this.#map.size; + } +} diff --git a/server/game/player.ts b/server/game/player.ts new file mode 100644 index 0000000..154ae93 --- /dev/null +++ b/server/game/player.ts @@ -0,0 +1,86 @@ +import { Container, Cursor, ItemData, ItemStack } from "$/common/inventory.ts"; +import { PlayerInfo, ScreenLayout } from "$/common/protocol.ts"; +import { Tile } from "./world.ts"; + +export const INVENTORY_SIZE = 9 * 4; +export const CRAFTING_SIZE = 10; + +// a screen the server opened for this player, showing a tile's container +export interface OpenScreen { + tile: Tile; + container: Container; + layout: ScreenLayout; + properties(): Record; +} + +// what's saved about a player between sessions, by name +export interface SavedPlayer { + x: number; + y: number; + z: number; + yaw: number; + pitch: number; + selected_slot: number; + inventory: (ItemData | null)[]; +} + +export class ServerPlayer { + readonly conn: number; + readonly id = crypto.randomUUID(); + readonly name: string; + + x = 0; + y = 100; + z = 0; + yaw = 0; + pitch = 0; + + inventory = new Container(INVENTORY_SIZE); + crafting = new Container(CRAFTING_SIZE); + cursor: Cursor = { item: undefined }; + selected_slot = 0; + screen: OpenScreen | undefined; + + // last json sent for each synced thing, so only changes get sent + sent = new Map(); + + constructor(conn: number, name: string) { + this.conn = conn; + this.name = name; + } + + get held_item(): ItemStack | undefined { + return this.inventory.get_item(this.selected_slot); + } + + give(item: ItemStack) { + // TODO: drop what doesn't fit once items can be on the ground + this.inventory.add_item(item); + } + + info(): PlayerInfo { + return { id: this.id, name: this.name, x: this.x, y: this.y, z: this.z, yaw: this.yaw, pitch: this.pitch }; + } + + save(): SavedPlayer { + return { + x: this.x, + y: this.y, + z: this.z, + yaw: this.yaw, + pitch: this.pitch, + selected_slot: this.selected_slot, + inventory: this.inventory.to_data(), + }; + } + + load(saved: SavedPlayer) { + this.x = saved.x; + this.y = saved.y; + this.z = saved.z; + this.yaw = saved.yaw; + this.pitch = saved.pitch; + this.selected_slot = saved.selected_slot; + this.inventory.load(saved.inventory); + } +} diff --git a/server/game/worker.ts b/server/game/worker.ts new file mode 100644 index 0000000..edc3fca --- /dev/null +++ b/server/game/worker.ts @@ -0,0 +1,57 @@ +/// + +// runs the game in a worker without any permissions, the host does files and networking +import { GameServer, TICK_MS } from "./game_server.ts"; +import { GameToHost, HostToGame } from "./host_protocol.ts"; + +const SAVE_INTERVAL_MS = 30_000; + +let game: GameServer | undefined; + +function post(message: GameToHost) { + self.postMessage(message); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + + if (message.type === "init") { + game = new GameServer( + { + send: (conn, data) => post({ type: "send", conn, data }), + close: (conn) => post({ type: "close", conn }), + }, + message.save, + message.default_seed, + ); + + setInterval(() => game!.tick(), TICK_MS); + setInterval(() => { + if (game!.world.dirty) { + post({ type: "save", data: game!.save(), final: false }); + } + }, SAVE_INTERVAL_MS); + + post({ type: "ready", seed: game.world.seed }); + return; + } + + if (!game) { + return; + } + + switch (message.type) { + case "connect": + game.on_connect(message.conn); + break; + case "message": + game.on_message(message.conn, message.data); + break; + case "disconnect": + game.on_disconnect(message.conn); + break; + case "shutdown": + post({ type: "save", data: game.save(), final: true }); + break; + } +}; diff --git a/server/game/world.ts b/server/game/world.ts new file mode 100644 index 0000000..7913203 --- /dev/null +++ b/server/game/world.ts @@ -0,0 +1,186 @@ +import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, ID_MASK } from "$/common/constants.ts"; +import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; +import { generate_raw_chunk, RawChunk } from "$/common/generation.ts"; +import { Container } from "$/common/inventory.ts"; +import { AIR_ID, BlockChange } from "$/common/protocol.ts"; +import { chunk_key } from "$/common/utils.ts"; +import { LruMap } from "./lru.ts"; + +// generation only, rebuilt when needed. 128 KB each +const RAW_CACHE_SIZE = 256; +const CHUNK_CACHE_SIZE = 512; + +// a block with state the server keeps, like a chest's items. never sent to clients as is +export interface Tile { + id: string; + x: number; + y: number; + z: number; + data: Record; + containers: Record; +} + +export function position_key(x: number, y: number, z: number) { + return `${x},${y},${z}`; +} + +// the authoritative world: generated terrain plus everything players changed +export class ServerWorld { + readonly seed: string; + readonly block_ids: Record = {}; + + // what generation made, and the final blocks with neighbors' leaves and player changes applied + #raw = new LruMap(RAW_CACHE_SIZE); + #chunks = new LruMap(CHUNK_CACHE_SIZE); + + // changes from generated terrain, per chunk. these and the tiles are what gets saved + #changes = new Map>(); + tiles = new Map(); + + // set when anything that gets saved changes + dirty = false; + + on_block_change?: (x: number, y: number, z: number, id: string) => void; + + constructor(seed: string) { + this.seed = seed; + EverythingRegistry.get_registry("blocks").forEach((block, nid) => { + this.block_ids[block.id] = nid; + }); + } + + get_block_id(x: number, y: number, z: number): string { + const nid = this.get_block_nid(x, y, z); + return nid === AIR ? AIR_ID : EverythingRegistry.get_by_id("blocks", nid)?.id ?? AIR_ID; + } + + get_block_nid(x: number, y: number, z: number): number { + if (y < 0 || y >= CHUNK_HEIGHT) { + return AIR; + } + const chunk_x = Math.floor(x / CHUNK_SIZE); + const chunk_z = Math.floor(z / CHUNK_SIZE); + const blocks = this.#get_chunk(chunk_x, chunk_z); + return blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] & ID_MASK; + } + + get_block_info(x: number, y: number, z: number): BlockRegistry | undefined { + const nid = this.get_block_nid(x, y, z); + return nid === AIR ? undefined : EverythingRegistry.get_by_id("blocks", nid); + } + + // only changes the block, the game server runs behaviors and tiles + set_block(x: number, y: number, z: number, id: string) { + if (y < 0 || y >= CHUNK_HEIGHT) { + return; + } + const nid = id === AIR_ID ? AIR : this.block_ids[id]; + if (nid === undefined) { + throw new Error(`Unknown block ${id}`); + } + + const chunk_x = Math.floor(x / CHUNK_SIZE); + const chunk_z = Math.floor(z / CHUNK_SIZE); + const blocks = this.#get_chunk(chunk_x, chunk_z); + blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = nid; + + this.#record_change(x, y, z, id); + this.dirty = true; + this.on_block_change?.(x, y, z, id); + } + + all_changes(): BlockChange[] { + const all: BlockChange[] = []; + for (const chunk_changes of this.#changes.values()) { + all.push(...chunk_changes.values()); + } + return all; + } + + load_changes(changes: BlockChange[]) { + for (const [x, y, z, id] of changes) { + this.#record_change(x, y, z, id); + } + } + + get_tile(x: number, y: number, z: number) { + return this.tiles.get(position_key(x, y, z)); + } + + add_tile(tile: Tile) { + this.tiles.set(position_key(tile.x, tile.y, tile.z), tile); + this.dirty = true; + } + + remove_tile(x: number, y: number, z: number) { + this.tiles.delete(position_key(x, y, z)); + this.dirty = true; + } + + #record_change(x: number, y: number, z: number, id: string) { + const key = chunk_key(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)); + let chunk_changes = this.#changes.get(key); + if (!chunk_changes) { + chunk_changes = new Map(); + this.#changes.set(key, chunk_changes); + } + chunk_changes.set(position_key(x, y, z), [x, y, z, id]); + } + + #get_raw(chunk_x: number, chunk_z: number) { + const key = chunk_key(chunk_x, chunk_z); + let raw = this.#raw.get(key); + if (!raw) { + raw = generate_raw_chunk(chunk_x, chunk_z, this.seed, this.block_ids); + this.#raw.set(key, raw); + } + return raw; + } + + #get_chunk(chunk_x: number, chunk_z: number) { + const key = chunk_key(chunk_x, chunk_z); + let blocks = this.#chunks.get(key); + if (!blocks) { + blocks = this.#build_chunk(chunk_x, chunk_z); + this.#chunks.set(key, blocks); + } + return blocks; + } + + // same rules as the client: own blocks, then neighbors' leaves only into air, then player changes + #build_chunk(chunk_x: number, chunk_z: number) { + const blocks = this.#get_raw(chunk_x, chunk_z).blocks.slice(); + + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + if (dx === 0 && dz === 0) { + continue; + } + const spills = this.#get_raw(chunk_x + dx, chunk_z + dz).spills; + for (let i = 0; i < spills.length; i += 4) { + const [x, y, z, nid] = [spills[i], spills[i + 1], spills[i + 2], spills[i + 3]]; + if (Math.floor(x / CHUNK_SIZE) !== chunk_x || Math.floor(z / CHUNK_SIZE) !== chunk_z) { + continue; + } + const index = index_in_chunk(x, y, z, chunk_x, chunk_z); + if (blocks[index] === AIR) { + blocks[index] = nid; + } + } + } + } + + for (const [x, y, z, id] of this.#changes.get(chunk_key(chunk_x, chunk_z))?.values() ?? []) { + const nid = id === AIR_ID ? AIR : this.block_ids[id]; + if (nid !== undefined) { + blocks[index_in_chunk(x, y, z, chunk_x, chunk_z)] = nid; + } + } + + return blocks; + } +} + +function index_in_chunk(x: number, y: number, z: number, chunk_x: number, chunk_z: number) { + return y * CHUNK_AREA + (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE); +} diff --git a/server/main.ts b/server/main.ts index 032cd23..55aadbd 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,235 +1,105 @@ import { serveDir } from "@std/http/file-server"; -import { CHUNK_HEIGHT } from "$/common/constants.ts"; -import { - BlockChange, - ClientMessage, - MAX_CHAT_LENGTH, - MAX_NAME_LENGTH, - PlayerInfo, - ServerMessage, -} from "$/common/protocol.ts"; +import { GameToHost, HostToGame } from "./game/host_protocol.ts"; const PORT = Number(Deno.env.get("PORT") ?? 8000); const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; const STATIC_ROOT = "build"; -const SAVE_INTERVAL_MS = 30_000; const MAX_MESSAGE_SIZE = 4096; -// how far from a player a block can be changed, a bit more than the client's reach -const MAX_REACH = 8; +const SHUTDOWN_TIMEOUT_MS = 5000; -const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; +// the host only does files and networking, the game runs in a worker with no permissions +const game = new Worker(new URL("./game/worker.ts", import.meta.url), { + type: "module", + deno: { permissions: "none" }, +} as WorkerOptions); -interface SavedWorld { - seed: string; - changes: BlockChange[]; +const sockets = new Map(); +let next_conn = 1; + +function post(message: HostToGame) { + game.postMessage(message); } -interface Client { - socket: WebSocket; - player?: PlayerInfo; -} - -// the server doesnt generate terrain, clients do that from the seed -// we only keep track of what players changed on top of it -class ServerWorld { - seed: string; - changes = new Map(); - dirty = false; - - constructor(seed: string) { - this.seed = seed; - } - - set_block(x: number, y: number, z: number, id: string) { - this.changes.set(`${x},${y},${z}`, [x, y, z, id]); - this.dirty = true; - } - - static load(path: string): ServerWorld { - try { - const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path)); - const world = new ServerWorld(saved.seed); - for (const [x, y, z, id] of saved.changes) { - world.changes.set(`${x},${y},${z}`, [x, y, z, id]); - } - console.log(`Loaded ${path} (${world.changes.size} block changes)`); - return world; - } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) { - throw e; - } - const world = new ServerWorld(Deno.env.get("SEED") ?? crypto.randomUUID()); - world.dirty = true; - console.log(`Created new world with seed ${world.seed}`); - return world; +function read_world(): string | undefined { + try { + return Deno.readTextFileSync(WORLD_FILE); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + return undefined; } - } - - save(path: string) { - if (!this.dirty) { - return; - } - const saved: SavedWorld = { seed: this.seed, changes: [...this.changes.values()] }; - // write then rename so a crash mid write doesnt eat the world - Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved)); - Deno.renameSync(`${path}.tmp`, path); - this.dirty = false; + throw e; } } -const world = ServerWorld.load(WORLD_FILE); -const clients = new Set(); - -function send(client: Client, message: ServerMessage) { - if (client.socket.readyState === WebSocket.OPEN) { - client.socket.send(JSON.stringify(message)); - } +function write_world(data: string) { + // write then rename so a crash mid write doesnt eat the world + Deno.writeTextFileSync(`${WORLD_FILE}.tmp`, data); + Deno.renameSync(`${WORLD_FILE}.tmp`, WORLD_FILE); } -function broadcast(message: ServerMessage, except?: Client) { - const data = JSON.stringify(message); - for (const client of clients) { - if (client !== except && client.player && client.socket.readyState === WebSocket.OPEN) { - client.socket.send(data); - } - } -} - -function is_number(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function is_int(value: unknown): value is number { - return Number.isInteger(value); -} - -function clean_name(name: unknown): string { - const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : ""; - const base = cleaned || `player${Math.floor(Math.random() * 10000)}`; - - // make it unique - const taken = new Set([...clients].map((c) => c.player?.name)); - let final = base; - let i = 2; - while (taken.has(final)) { - final = `${base}${i}`; - i += 1; - } - return final; -} - -function handle_message(client: Client, message: ClientMessage) { - if (!client.player) { - if (message.type !== "hello") { - return; - } - const player: PlayerInfo = { - id: crypto.randomUUID(), - name: clean_name(message.name), - x: 0, - y: 100, - z: 0, - yaw: 0, - pitch: 0, - }; - send(client, { - type: "welcome", - id: player.id, - seed: world.seed, - players: [...clients].flatMap((c) => c.player ? [c.player] : []), - changes: [...world.changes.values()], - }); - client.player = player; - broadcast({ type: "player_join", player }, client); - broadcast({ type: "chat", text: `${player.name} joined` }); - console.log(`${player.name} joined (${clients.size} online)`); - return; - } - - const player = client.player; - +game.onmessage = (event: MessageEvent) => { + const message = event.data; switch (message.type) { - case "move": { - const { x, y, z, yaw, pitch } = message; - if (![x, y, z, yaw, pitch].every(is_number)) { - return; + case "ready": + console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`); + break; + case "send": { + const socket = sockets.get(message.conn); + if (socket?.readyState === WebSocket.OPEN) { + socket.send(message.data); } - Object.assign(player, { x, y, z, yaw, pitch }); - broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client); break; } - case "set_block": { - const { x, y, z, id } = message; - if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) { - return; - } - if (typeof id !== "string" || !BLOCK_ID_PATTERN.test(id)) { - return; - } - const dx = x + 0.5 - player.x; - const dy = y + 0.5 - (player.y + 1.69); - const dz = z + 0.5 - player.z; - if (dx * dx + dy * dy + dz * dz > MAX_REACH * MAX_REACH) { - return; - } - world.set_block(x, y, z, id); - broadcast({ type: "set_block", x, y, z, id }, client); + case "close": + sockets.get(message.conn)?.close(); break; - } - case "chat": { - if (typeof message.text !== "string") { - return; + case "save": + write_world(message.data); + if (message.final) { + console.log("World saved"); + Deno.exit(0); } - const text = message.text.trim().slice(0, MAX_CHAT_LENGTH); - if (text.length === 0) { - return; - } - console.log(`<${player.name}> ${text}`); - broadcast({ type: "chat", from: player.name, text }); break; - } } -} +}; + +game.onerror = (event) => { + console.error("Game server crashed:", event.message); + Deno.exit(1); +}; + +const save = read_world(); +post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() }); function handle_socket(socket: WebSocket) { - const client: Client = { socket }; + const conn = next_conn++; socket.addEventListener("open", () => { - clients.add(client); + sockets.set(conn, socket); + post({ type: "connect", conn }); }); socket.addEventListener("message", (event) => { if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { return; } - let message: ClientMessage; - try { - message = JSON.parse(event.data); - } catch { - return; - } - if (typeof message !== "object" || message === null) { - return; - } - handle_message(client, message); + post({ type: "message", conn, data: event.data }); }); socket.addEventListener("close", () => { - clients.delete(client); - if (client.player) { - broadcast({ type: "player_leave", id: client.player.id }); - broadcast({ type: "chat", text: `${client.player.name} left` }); - console.log(`${client.player.name} left (${clients.size} online)`); + if (sockets.delete(conn)) { + post({ type: "disconnect", conn }); } }); } -setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS); - function shutdown() { console.log("Saving world..."); - world.save(WORLD_FILE); - Deno.exit(0); + post({ type: "shutdown" }); + setTimeout(() => { + console.error("Game server didn't save in time"); + Deno.exit(1); + }, SHUTDOWN_TIMEOUT_MS); } Deno.addSignalListener("SIGINT", shutdown); if (Deno.build.os !== "windows") {