Server authority

This commit is contained in:
2026-09-24 19:34:07 -03:00
parent 1bef94ce0c
commit 79faa556de
75 changed files with 2247 additions and 1632 deletions
+8 -23
View File
@@ -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 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 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 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 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) - [Mod channels](#mod-channels)
- [World generation](#world-generation) - [World generation](#world-generation)
- [Delivery to clients](#delivery-to-clients) - [Delivery to clients](#delivery-to-clients)
- [Single player](#single-player)
- [Security](#security) - [Security](#security)
- [Example mod](#example-mod) - [Example mod](#example-mod)
- [Implementation plan](#implementation-plan) - [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 Server scripts run in the game server worker with **no Deno permissions** (see [Security](#security)). They get the mod
APIs**. That lets the same script run on a dedicated server and in the browser for [single player](#single-player). They API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`.
have no file or network access; persistent state goes through `ctx.storage`.
```ts ```ts
interface ServerContext { 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. - 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. - 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 ## Security
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on **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 ## 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) 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 and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server
environment-free module behind a transport and storage interface. Run it in a Deno worker from `server/main.ts`. 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`, 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 `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 corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced
containers. 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, 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:<file>`), bundle scripts per side, and write build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
hashed output to `build/mods/` and `server_mods/`. hashed output to `build/mods/` and `server_mods/`.
-44
View File
@@ -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<BlockRegistry>("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<TileChestData>(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);
-27
View File
@@ -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<BlockRegistry>("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);
-200
View File
@@ -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<string, number> = {
"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<BlockRegistry>("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<TileChestData>(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<TileChestData>(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);
-25
View File
@@ -1,25 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { PlayerComponent } from "../player.ts";
EverythingRegistry.register<BlockRegistry>("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;
},
});
+2 -5
View File
@@ -8,7 +8,6 @@ import { UIRenderSystem } from "$/client/systems/ui_render_system.ts";
import { create_main_menu } from "./main_menu.ts"; import { create_main_menu } from "./main_menu.ts";
import { start_game } from "./game.ts"; import { start_game } from "./game.ts";
import { canvas, resize_canvas } from "./renderer/mod.ts"; import { canvas, resize_canvas } from "./renderer/mod.ts";
import { DimensionLogicSystem } from "./systems/dimension_logic.ts";
import { Dimension } from "./components/dimension.ts"; import { Dimension } from "./components/dimension.ts";
import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts"; import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts";
import { WorldGenerationSystem } from "./systems/world_generation_system.ts"; import { WorldGenerationSystem } from "./systems/world_generation_system.ts";
@@ -27,11 +26,10 @@ export class ClientWorld extends World {
dimension!: Dimension; dimension!: Dimension;
// undefined when playing single player connection: Connection;
connection?: Connection;
chat_log: ChatLine[] = []; chat_log: ChatLine[] = [];
constructor(connection?: Connection) { constructor(connection: Connection) {
super("game"); super("game");
this.connection = connection; this.connection = connection;
@@ -55,7 +53,6 @@ export class ClientWorld extends World {
this.add_system(new GuiTickSystem(), "game"); this.add_system(new GuiTickSystem(), "game");
this.add_system(new PlayerControlsSystem(), "game"); this.add_system(new PlayerControlsSystem(), "game");
this.add_system(new WorldGenerationSystem(), "game"); this.add_system(new WorldGenerationSystem(), "game");
this.add_system(new DimensionLogicSystem(), "game");
this.add_system(new CollisionSystem(), "game"); this.add_system(new CollisionSystem(), "game");
this.add_system(new MovementSystem(), "game"); this.add_system(new MovementSystem(), "game");
+18 -74
View File
@@ -1,4 +1,5 @@
import { Component } from "$/common/ecs/mod.ts"; import { Component } from "$/common/ecs/mod.ts";
import { chunk_key } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.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"; 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 { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.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 { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts"; import { Camera } from "./camera.ts";
export interface BlockData<T = unknown> {
id: string;
x: number;
y: number;
z: number;
data: T;
}
export interface Block { export interface Block {
id: string; id: string;
x: number; x: number;
@@ -32,7 +23,6 @@ export interface Chunk {
x: number; x: number;
z: number; z: number;
blocks: Uint32Array; blocks: Uint32Array;
blocks_data: BlockData[];
generated: boolean; generated: boolean;
dirty: boolean; dirty: boolean;
// bumped on every mesh request so late results from older requests get ignored // bumped on every mesh request so late results from older requests get ignored
@@ -43,10 +33,7 @@ export interface Chunk {
transparent_vertex_count?: number; transparent_vertex_count?: number;
} }
// numeric so looking chunks up doesnt allocate a string every time, fine for |x|, |z| < 32768 export { chunk_key };
export function chunk_key(x: number, z: number) {
return (x + 32768) * 65536 + (z + 32768);
}
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const; const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
@@ -99,7 +86,6 @@ export class Dimension extends Component {
x, x,
z, z,
blocks, blocks,
blocks_data: [],
dirty: true, dirty: true,
generated: false, generated: false,
mesh_version: 0, mesh_version: 0,
@@ -120,7 +106,7 @@ export class Dimension extends Component {
chunk = this.add_chunk(block_chunk_x, block_chunk_z); chunk = this.add_chunk(block_chunk_x, block_chunk_z);
} }
const [nid, block_info] = EverythingRegistry.get_full<BlockRegistry>("blocks", block.id)!; const nid = EverythingRegistry.get_id("blocks", block.id)!;
const lx = block.x - block_chunk_x * CHUNK_SIZE; const lx = block.x - block_chunk_x * CHUNK_SIZE;
const lz = block.z - block_chunk_z * 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.blocks[index] = nid;
chunk.dirty = true; chunk.dirty = true;
if (block_info?.on_create) { this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
block_info?.on_create(this, block);
}
} }
get_block(x: number, y: number, z: number) { get_block(x: number, y: number, z: number) {
@@ -154,33 +138,8 @@ export class Dimension extends Component {
return chunk.blocks[index] & ID_MASK; return chunk.blocks[index] & ID_MASK;
} }
add_block_data(block_data: BlockData) { // only changes what this client shows, drops and everything else happen on the server
const block_chunk_x = Math.floor(block_data.x / CHUNK_SIZE); break_block(x: number, y: number, z: number) {
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<T>(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<T>;
}
break_block(x: number, y: number, z: number, drop_item: boolean = true) {
const block_chunk_x = Math.floor(x / CHUNK_SIZE); const block_chunk_x = Math.floor(x / CHUNK_SIZE);
const block_chunk_z = Math.floor(z / CHUNK_SIZE); const block_chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(block_chunk_x, block_chunk_z); const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
@@ -188,14 +147,6 @@ export class Dimension extends Component {
return; return;
} }
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("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 lx = x - block_chunk_x * CHUNK_SIZE;
const lz = z - block_chunk_z * CHUNK_SIZE; const lz = z - block_chunk_z * CHUNK_SIZE;
const ly = y; const ly = y;
@@ -203,7 +154,11 @@ export class Dimension extends Component {
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx; const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = AIR; chunk.blocks[index] = AIR;
chunk.dirty = true; 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) { if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z); const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) { if (n) {
@@ -226,10 +181,6 @@ export class Dimension extends Component {
n.dirty = true; 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) { index_to_xyz(index: number) {
@@ -252,18 +203,7 @@ export class Dimension extends Component {
chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]); 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 // set a block the server told us about
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<BlockRegistry>("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
apply_change(x: number, y: number, z: number, id: string) { 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)); const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
if (!chunk || !chunk.generated) { if (!chunk || !chunk.generated) {
@@ -273,7 +213,7 @@ export class Dimension extends Component {
const current = this.get_block(x, y, z); const current = this.get_block(x, y, z);
if (id === AIR_ID) { if (id === AIR_ID) {
if (current !== AIR) { if (current !== AIR) {
this.break_block(x, y, z, false); this.break_block(x, y, z);
} }
return; return;
} }
@@ -423,7 +363,8 @@ export class Dimension extends Component {
chunk.transparent_vertex_count = message.transparent_count / 9; 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) { #set_block_raw(x: number, y: number, z: number, nid: number) {
if (y < 0 || y >= CHUNK_HEIGHT) { if (y < 0 || y >= CHUNK_HEIGHT) {
return; return;
@@ -433,9 +374,12 @@ export class Dimension extends Component {
const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z); const chunk = this.get_chunk(chunk_x, chunk_z) ?? this.add_chunk(chunk_x, chunk_z);
const lx = x - chunk_x * CHUNK_SIZE; const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE; const lz = z - chunk_z * CHUNK_SIZE;
chunk.blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid; const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
if (chunk.blocks[index] === AIR) {
chunk.blocks[index] = nid;
chunk.dirty = true; chunk.dirty = true;
} }
}
delete_chunk_mesh(chunk: Chunk) { delete_chunk_mesh(chunk: Chunk) {
if (chunk.opaque_vertex_buffer) { if (chunk.opaque_vertex_buffer) {
+26
View File
@@ -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;
}
+2 -2
View File
@@ -13,8 +13,8 @@ export function start_game(world: ClientWorld) {
const dimension = new Entity("dimension"); const dimension = new Entity("dimension");
world.dimension?.dispose(); world.dimension?.dispose();
world.dimension = new Dimension(world, world.connection?.seed); world.dimension = new Dimension(world, world.connection.seed);
for (const [x, y, z, id] of world.connection?.initial_changes ?? []) { for (const [x, y, z, id] of world.connection.initial_changes) {
world.dimension.record_change(x, y, z, id); world.dimension.record_change(x, y, z, id);
} }
dimension.add(world.dimension); dimension.add(world.dimension);
+2 -27
View File
@@ -3,7 +3,6 @@ import { canvas, draw_rect, draw_text, measure_text } from "$/client/renderer/mo
import { InputManager } from "../input_manager.ts"; import { InputManager } from "../input_manager.ts";
import { ClientWorld } from "../client_world.ts"; import { ClientWorld } from "../client_world.ts";
import { PlayerComponent } from "../player.ts"; import { PlayerComponent } from "../player.ts";
import { ItemStack } from "../inventory.ts";
import { MAX_CHAT_LENGTH } from "$/common/protocol.ts"; import { MAX_CHAT_LENGTH } from "$/common/protocol.ts";
import { render_chat_log } from "../systems/rendering/network.ts"; import { render_chat_log } from "../systems/rendering/network.ts";
@@ -102,14 +101,9 @@ export class GuiChat extends GuiScreen {
override on_close(): void {} override on_close(): void {}
submit() { submit() {
if (this.text_typed.startsWith("/")) { // commands like /give run on the server too
this.command(); if (this.text_typed.trim().length > 0) {
} else if (this.text_typed.trim().length > 0) {
if (this.world.connection) {
this.world.connection.send({ type: "chat", text: this.text_typed }); this.world.connection.send({ type: "chat", text: this.text_typed });
} else {
this.world.add_chat(this.text_typed);
}
} }
this.text_typed = ""; this.text_typed = "";
@@ -119,23 +113,4 @@ export class GuiChat extends GuiScreen {
player_component.pop_screen(); 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)));
}
}
}
} }
-60
View File
@@ -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<Texture>("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();
}
}
+119
View File
@@ -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<string, number>;
constructor(
inventories: ClientInventories,
send: (message: ClientMessage) => void,
layout: ScreenLayout,
properties: Record<string, number>,
) {
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<Texture>("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<Texture>("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,
);
}
}
}
}
-211
View File
@@ -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<Inventory, TileChestData> {
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<Texture>("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<Texture>("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<Texture>("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,
);
}
}
+11 -219
View File
@@ -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 { add_player_hotbar, add_player_inventory, GuiInventoryScreen, Slot } from "./gui_screen.ts";
import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts"; import { canvas, draw_rect, Texture } from "$/client/renderer/mod.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { SLOT_SIZE } from "../../common/constants.ts"; import { SLOT_SIZE } from "../../common/constants.ts";
import { draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_nine_slice } from "../systems/rendering/render_utils.ts";
import { ClientInventories } from "../inventory.ts";
export interface CraftingRecipe { import { ClientMessage, CRAFTING_RESULT_SLOT } from "$/common/protocol.ts";
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),
},
];
const PADDING = 10; const PADDING = 10;
// the recipes live on the server, which fills in the result slot
export class GuiPlayerInventory extends GuiInventoryScreen { export class GuiPlayerInventory extends GuiInventoryScreen {
override inventory_width = PADDING * 2 + SLOT_SIZE * 9; override inventory_width = PADDING * 2 + SLOT_SIZE * 9;
override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING; override inventory_height = PADDING * 2 + SLOT_SIZE * 4 + PADDING + SLOT_SIZE * 3 + PADDING;
constructor(player_inventory: PlayerInventory) { constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
super(new Inventory(new Container(10)), player_inventory, undefined); super(inventories, send);
add_player_hotbar(this, player_inventory, PADDING, PADDING); add_player_hotbar(this, PADDING, PADDING);
add_player_inventory(this, player_inventory, PADDING, PADDING * 2 + SLOT_SIZE); add_player_inventory(this, PADDING, PADDING * 2 + SLOT_SIZE);
const crafting_x = PADDING; const crafting_x = PADDING;
const crafting_y = PADDING * 3 + SLOT_SIZE * 4; 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 i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) { for (let j = 0; j < 3; j += 1) {
this.slots.push( 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)); this.slots.push(
} new Slot("crafting", CRAFTING_RESULT_SLOT, crafting_x + 5 * SLOT_SIZE, crafting_y + 1 * SLOT_SIZE, true),
);
override on_tick(delta: number): void {
super.on_tick(delta);
this.update_crafting_result();
} }
override on_render(): void { override on_render(): void {
@@ -134,136 +58,4 @@ export class GuiPlayerInventory extends GuiInventoryScreen {
super.on_render(); 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();
}
}
} }
+73 -183
View File
@@ -1,27 +1,28 @@
import { SLOT_SIZE } from "$/common/constants.ts"; 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 { point_inside_rec } from "../../common/utils.ts";
import { AssetManager } from "../assets.ts"; import { AssetManager } from "../assets.ts";
import { InputManager } from "../input_manager.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 { canvas, Texture } from "../renderer/mod.ts";
import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts"; import { draw_item, draw_nine_slice } from "../systems/rendering/render_utils.ts";
export class Slot { export class Slot {
inventory: Inventory; container: ContainerKey;
index: number; index: number;
// relative to screen top left // relative to screen top left
x: number; x: number;
y: 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) { constructor(container: ContainerKey, index: number, x: number, y: number, output = false) {
this.inventory = inventory; this.container = container;
this.index = index; this.index = index;
this.x = x; this.x = x;
this.y = y; this.y = y;
} this.output = output;
get item(): ItemStack | undefined {
return this.inventory.container.get_item(this.index);
} }
} }
@@ -34,25 +35,32 @@ export abstract class GuiScreen {
abstract on_close(): void; abstract on_close(): void;
} }
export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties = Record<string, unknown> | undefined> // a screen of slots. the server owns the contents: clicks are sent to it and guessed locally
extends GuiScreen { // so they feel instant, then whatever the server syncs back wins
inventory: Inv; export class GuiInventoryScreen extends GuiScreen {
player_inventory: PlayerInventory; inventories: ClientInventories;
properties: Properties; send: (message: ClientMessage) => void;
slots: Slot[] = []; slots: Slot[] = [];
hovering: Slot | undefined;
inventory_width: number = 500; inventory_width: number = 500;
inventory_height: number = 500; inventory_height: number = 500;
constructor( constructor(inventories: ClientInventories, send: (message: ClientMessage) => void) {
inventory: Inv,
player_inventory: PlayerInventory,
properties: Properties,
) {
super(); super();
this.inventory = inventory; this.inventories = inventories;
this.player_inventory = player_inventory; this.send = send;
this.properties = properties; }
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 { on_tick(_delta: number): void {
@@ -60,20 +68,17 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
this.y = canvas.height / 2 - (this.inventory_height / 2); this.y = canvas.height / 2 - (this.inventory_height / 2);
this.handle_interaction(); this.handle_interaction();
if (this.player_inventory.holding_item?.amount === 0) {
this.player_inventory.holding_item = undefined;
}
} }
on_render(): void { on_render(): void {
const ui = AssetManager.instance.get<Texture>("bworld:ui"); const ui = AssetManager.instance.get<Texture>("bworld:ui");
for (const slot of this.slots) { for (const slot of this.slots) {
const hovered = slot === this.hovering;
draw_nine_slice( draw_nine_slice(
ui, ui,
slot.inventory.hovering_slot === slot.index ? 19 * 16 : 160 + 32, hovered ? 19 * 16 : 160 + 32,
slot.inventory.hovering_slot === slot.index ? 16 : 0, hovered ? 16 : 0,
16, 16,
16, 16,
4, 4,
@@ -88,198 +93,83 @@ export class GuiInventoryScreen<Inv extends Inventory = Inventory, Properties =
} }
for (const slot of this.slots) { for (const slot of this.slots) {
const item = slot.item; const item = this.get_container(slot.container)?.get_item(slot.index);
if (item) { if (item) {
draw_item(item, this.x + slot.x, this.y + slot.y); draw_item(item, this.x + slot.x, this.y + slot.y);
} }
} }
if (this.player_inventory.holding_item) { if (this.inventories.cursor.item) {
const mouse = InputManager.get_mouse_position(); const mouse = InputManager.get_mouse_position();
draw_item(this.player_inventory.holding_item, mouse.x, mouse.y); draw_item(this.inventories.cursor.item, mouse.x, mouse.y);
} }
} }
on_close(): void {} on_close(): void {
this.send({ type: "close_screen" });
// the server puts these back in the inventory, show it right away
const { inventory, crafting, cursor } = this.inventories;
for (let i = 0; i < CRAFTING_RESULT_SLOT; i++) {
const item = crafting.get_item(i);
if (item) {
inventory.add_item(item);
crafting.set_item(i, undefined);
}
}
crafting.set_item(CRAFTING_RESULT_SLOT, undefined);
if (cursor.item) {
inventory.add_item(cursor.item);
cursor.item = undefined;
}
}
handle_interaction() { handle_interaction() {
this.player_inventory.hovering_slot = -1; this.hovering = undefined;
this.inventory.hovering_slot = -1;
for (const slot of this.slots) {
const mouse = InputManager.get_mouse_position(); const mouse = InputManager.get_mouse_position();
const slot_x = slot.x + this.x;
const slot_y = slot.y + this.y;
const hovering = point_inside_rec(mouse.x, mouse.y, slot_x, slot_y, SLOT_SIZE, SLOT_SIZE);
if (hovering) {
slot.inventory.hovering_slot = slot.index;
if (InputManager.is_mouse_pressed(0)) { for (const slot of this.slots) {
InputManager.consume_mouse(0); const hovering = point_inside_rec(mouse.x, mouse.y, this.x + slot.x, this.y + slot.y, SLOT_SIZE, SLOT_SIZE);
this.handle_left_click(slot); if (!hovering) {
return; continue;
} }
this.hovering = slot;
if (InputManager.is_mouse_pressed(2)) { for (const button of [0, 2]) {
InputManager.consume_mouse(2); if (InputManager.is_mouse_pressed(button)) {
this.handle_right_click(slot); InputManager.consume_mouse(button);
this.click(slot, button);
return; return;
} }
} }
} }
} }
switch_item_with_holding(inventory: Inventory, index: number) { click(slot: Slot, button: number) {
const container_slot = inventory.container.get_slot(index); this.send({ type: "click", container: slot.container, index: slot.index, button });
const original_holding_item = this.player_inventory.holding_item;
this.player_inventory.holding_item = container_slot.get_item();
container_slot.set_item(original_holding_item);
}
handle_left_click(slot: Slot) { // output slots depend on server side things like recipes, just wait for the server
const holding = this.player_inventory.holding_item; const container = this.get_container(slot.container);
const inventory_slot = slot.inventory.container.get_slot(slot.index); if (container && !slot.output) {
const slot_item = inventory_slot.get_item(); click_slot(container, slot.index, this.inventories.cursor, button);
// if you aren't holding anything
// "swap" with nothing on your hand (pick it up)
if (!holding) {
this.switch_item_with_holding(slot.inventory, slot.index);
return;
}
// if you are holding something and slot type equals holding type
// try to add to stack
if (slot_item && inventory_slot.type_id === holding.type_id) {
const space_left = inventory_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
this.switch_item_with_holding(slot.inventory, slot.index);
}
handle_right_click(slot: Slot) {
const holding = this.player_inventory.holding_item;
const inventory_slot = slot.inventory.container.get_slot(slot.index);
const slot_item = inventory_slot.get_item();
// if holding something
if (holding) {
// and slot type equals holding type
// add 1 to matching stack
if (slot_item && inventory_slot.type_id === holding.type_id) {
if (slot_item.amount < slot_item.max_amount!) {
slot_item.amount += 1;
holding.amount -= 1;
}
return;
}
// (actually the same as the last one but creates a new one techinically)
// place 1 into empty slot
if (!slot_item) {
const new_item = holding.clone();
new_item.amount = 1;
inventory_slot.set_item(new_item);
holding.amount -= 1;
return;
}
// if something on hand but not the same
// swap
this.switch_item_with_holding(slot.inventory, slot.index);
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);
slot_item.amount = half;
const picked_up = slot_item.clone();
picked_up.amount = original_amount - half;
this.player_inventory.holding_item = picked_up;
if (slot_item.amount === 0) {
inventory_slot.set_item(undefined);
} }
} }
/*handle_hotbar(world: ClientWorld, player_inventory: PlayerInventory) {
const [player, player_hand] = world.get_tag("player")!;
const player_position = player.get(Position);
const player_sprite = player.get(AnimatedSprite);
const hand_position = player_hand.get(Position);
const hand_sprite = player_hand.get(Sprite);
if (!player_position || !hand_position || !hand_sprite || !player_sprite) {
return;
}
// sync position
hand_position.x = player_position.x + (player_sprite.flip_x ? 34 : 4);
hand_position.y = player_position.y + 34;
hand_sprite.flip_x = player_sprite.flip_x;
const maybe_item = player_inventory.container.get_item(player_inventory.hotbar_selected);
if (maybe_item) {
const region = get_sprite_region(maybe_item.type_id);
hand_sprite.source_x = region.x * 16;
hand_sprite.source_y = region.y * 16;
hand_sprite.source_width = 16;
hand_sprite.source_height = 16;
hand_sprite.width = 32;
hand_sprite.height = 32;
} else {
hand_sprite.width = 0;
hand_sprite.height = 0;
}
}*/
} }
// generic methods that is often needed // generic methods that is often needed
export function add_player_inventory( export function add_player_inventory(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
handler: GuiInventoryScreen,
player_inventory: PlayerInventory,
offset_x: number,
offset_y: number,
) {
for (let i = 0; i < 3; ++i) { for (let i = 0; i < 3; ++i) {
for (let l = 0; l < 9; ++l) { for (let l = 0; l < 9; ++l) {
handler.slots.push( handler.slots.push(
new Slot( new Slot("inventory", l + i * 9 + 9, offset_x + l * SLOT_SIZE, offset_y + i * SLOT_SIZE),
player_inventory,
l + i * 9 + 9,
offset_x + l * SLOT_SIZE,
offset_y + i * SLOT_SIZE,
),
); );
} }
} }
} }
export function add_player_hotbar( export function add_player_hotbar(handler: GuiInventoryScreen, offset_x: number, offset_y: number) {
handler: GuiInventoryScreen,
player_inventory: PlayerInventory,
offset_x: number,
offset_y: number,
) {
for (let i = 0; i < 9; ++i) { for (let i = 0; i < 9; ++i) {
handler.slots.push(new Slot(player_inventory, i, offset_x + i * SLOT_SIZE, offset_y)); handler.slots.push(new Slot("inventory", i, offset_x + i * SLOT_SIZE, offset_y));
} }
} }
+9 -136
View File
@@ -1,138 +1,11 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts"; import { Container, Cursor } from "$/common/inventory.ts";
export class ItemStack<T = unknown | undefined> { // the local copies of the containers the server syncs to this player
type_id: string; export class ClientInventories {
amount: number; inventory = new Container(9 * 4);
max_amount: number; crafting = new Container(10);
data?: T; // the open server screen's container
screen: Container | undefined;
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) { cursor: Cursor = { item: undefined };
this.type_id = type_id; hotbar_selected = 0;
this.amount = amount;
this.max_amount = max_amount;
this.data = undefined;
const item_info = EverythingRegistry.get<ItemRegistry>("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;
}
} }
+9 -7
View File
@@ -3,9 +3,10 @@ import { ClientWorld } from "./client_world.ts";
import { InputManager } from "./input_manager.ts"; import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name, get_server_url } from "./network.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 { 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("$/common/blocks/mod.ts");
await import("./items/mod.ts"); await import("$/common/items/mod.ts");
export class ClientLoop { export class ClientLoop {
running = false; running = false;
@@ -39,7 +40,7 @@ export class ClientLoop {
} }
loop(time: number) { loop(time: number) {
if (!this.running) { if (!this.running || is_stopped()) {
return; return;
} }
@@ -91,17 +92,18 @@ await AssetManager.instance.load_all();
init_font(); 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()); const connection = await Connection.open(get_server_url(), get_player_name());
if (connection) { if (connection) {
console.log(`Connected to ${get_server_url()}`); console.log(`Connected to ${get_server_url()}`);
} else {
console.log("Couldn't reach a server, playing single player");
}
const client_world = new ClientWorld(connection); const client_world = new ClientWorld(connection);
client_world.add_chat(connection ? "Connected to the server" : "Playing single player"); client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world); const loop = new ClientLoop(client_world);
loop.start(); loop.start();
console.log("Game started"); console.log("Game started");
} else {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
}
+10 -6
View File
@@ -15,18 +15,22 @@ export class Connection {
id: string; id: string;
seed: string; seed: string;
initial_changes: BlockChange[]; initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
players = new Map<string, RemotePlayer>(); players = new Map<string, RemotePlayer>();
// handled by the network system inside the game loop, not whenever the socket feels like it // handled by the network system inside the game loop, not whenever the socket feels like it
incoming: ServerMessage[] = []; incoming: ServerMessage[] = [];
closed = false; closed = false;
constructor(socket: WebSocket, id: string, seed: string, players: PlayerInfo[], changes: BlockChange[]) { constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket; this.socket = socket;
this.id = id; this.id = welcome.id;
this.seed = seed; this.seed = welcome.seed;
this.initial_changes = changes; this.initial_changes = welcome.changes;
for (const player of players) { this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot;
for (const player of welcome.players) {
this.add_player(player); this.add_player(player);
} }
@@ -77,7 +81,7 @@ export class Connection {
const message: ServerMessage = JSON.parse(event.data); const message: ServerMessage = JSON.parse(event.data);
if (message.type === "welcome") { if (message.type === "welcome") {
clearTimeout(timeout); clearTimeout(timeout);
resolve(new Connection(socket, message.id, message.seed, message.players, message.changes)); resolve(new Connection(socket, message));
} }
}, { once: true }); }, { once: true });
+9 -5
View File
@@ -2,14 +2,14 @@ import { Position } from "$/common/components/position.ts";
import { Velocity } from "$/common/components/velocity.ts"; import { Velocity } from "$/common/components/velocity.ts";
import { Component, Entity } from "$/common/ecs/mod.ts"; import { Component, Entity } from "$/common/ecs/mod.ts";
import { Camera } from "$/client/components/camera.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 { PlayerControls } from "$/client/components/player_controls.ts";
import { ClientWorld } from "./client_world.ts"; import { ClientWorld } from "./client_world.ts";
import { GuiScreen } from "./gui/gui_screen.ts"; import { GuiScreen } from "./gui/gui_screen.ts";
import { CollisionCuboid } from "./components/collision.ts"; import { CollisionCuboid } from "./components/collision.ts";
export class PlayerComponent extends Component { export class PlayerComponent extends Component {
player_inventory = new PlayerInventory(""); inventories = new ClientInventories();
screens: GuiScreen[] = []; screens: GuiScreen[] = [];
render_distance = 6; render_distance = 6;
@@ -27,12 +27,16 @@ export class PlayerComponent extends Component {
export function create_player(world: ClientWorld) { export function create_player(world: ClientWorld) {
const player = new Entity("player"); 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 Velocity(0, 0, 0));
player.add(new PlayerControls()); player.add(new PlayerControls());
player.add(new PlayerComponent()); const player_component = player.add(new PlayerComponent());
player.add(new Camera()); 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)); player.add(new CollisionCuboid(0.55, 1.79, 0.55));
world.add_entity(player); world.add_entity(player);
+9
View File
@@ -0,0 +1,9 @@
import { SpriteRegion } from "$/common/constants.ts";
import { AssetManager } from "./assets.ts";
type TexturesInfo = Record<string, SpriteRegion>;
export function get_sprite_region(id: string): SpriteRegion {
const textures_info = AssetManager.instance.get<TexturesInfo>("bworld:textures_info");
return textures_info?.[id] ?? { x: 0, y: 0 };
}
-44
View File
@@ -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<BlockRegistry>("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<BlockRegistry>("blocks", tickable.id);
if (tile_info && tile_info.on_tick) {
tile_info.on_tick(world.dimension, tickable, dimension.tick_timer);
}
}
}
dimension.tick_timer = 0;
}
}
+43 -5
View File
@@ -2,6 +2,10 @@ import { System } from "$/common/ecs/mod.ts";
import { Position } from "$/common/components/position.ts"; import { Position } from "$/common/components/position.ts";
import { ClientWorld } from "../client_world.ts"; import { ClientWorld } from "../client_world.ts";
import { Camera } from "../components/camera.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 MOVE_SEND_INTERVAL = 1 / 10;
const REMOTE_PLAYER_SMOOTHING = 12; const REMOTE_PLAYER_SMOOTHING = 12;
@@ -11,9 +15,9 @@ export class NetworkSystem extends System {
update(world: ClientWorld, delta: number): void { update(world: ClientWorld, delta: number): void {
const connection = world.connection; const connection = world.connection;
if (!connection) { const [local_player] = world.get_tag("player")!;
return; const player_component = local_player.get(PlayerComponent)!;
} const inventories = player_component.inventories;
for (const message of connection.incoming) { for (const message of connection.incoming) {
switch (message.type) { switch (message.type) {
@@ -41,13 +45,47 @@ export class NetworkSystem extends System {
case "chat": case "chat":
world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text); world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text);
break; 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; connection.incoming.length = 0;
if (connection.closed) { if (connection.closed) {
world.add_chat("Lost connection to the server"); show_fatal_error("Lost connection to the server");
world.connection = undefined;
return; return;
} }
+41 -62
View File
@@ -9,6 +9,8 @@ import { PlayerComponent } from "../player.ts";
import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts"; import { GuiPlayerInventory } from "../gui/gui_player_inventory.ts";
import { CollisionCuboid } from "../components/collision.ts"; import { CollisionCuboid } from "../components/collision.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.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 { GuiChat } from "../gui/gui_chat.ts";
import { GuiInventoryScreen } from "../gui/gui_screen.ts"; import { GuiInventoryScreen } from "../gui/gui_screen.ts";
@@ -24,6 +26,7 @@ export class PlayerControlsSystem extends System {
const player_component = player.get(PlayerComponent)!; const player_component = player.get(PlayerComponent)!;
const position = player.get(Position)!; const position = player.get(Position)!;
const camera = player.get(Camera)!; const camera = player.get(Camera)!;
const send = (message: ClientMessage) => world.connection.send(message);
if (player_component.screens.length === 0) { if (player_component.screens.length === 0) {
let input_x = 0; let input_x = 0;
@@ -71,7 +74,7 @@ export class PlayerControlsSystem extends System {
if (InputManager.is_key_pressed(controls.open_inventory)) { if (InputManager.is_key_pressed(controls.open_inventory)) {
if (player_component.screens.length === 0) { 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) { } else if (player_component.screens.at(-1) instanceof GuiInventoryScreen) {
player_component.pop_screen(); player_component.pop_screen();
} }
@@ -119,10 +122,9 @@ export class PlayerControlsSystem extends System {
} }
const block = world.dimension.get_looked_block(world.dimension, camera); const block = world.dimension.get_looked_block(world.dimension, camera);
const holding_item = player_component.player_inventory.container.get_item( const inventories = player_component.inventories;
player_component.player_inventory.hotbar_selected, const hotbar_slot = inventories.inventory.get_slot(inventories.hotbar_selected);
); const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", hotbar_slot.type_id ?? "");
const holding_item_info = EverythingRegistry.get<ItemRegistry>("items", holding_item?.type_id ?? "");
if (block && player_component.screens.length === 0) { if (block && player_component.screens.length === 0) {
const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!; const block_info = EverythingRegistry.get_by_id<BlockRegistry>("blocks", block.block)!;
@@ -135,47 +137,24 @@ export class PlayerControlsSystem extends System {
} }
player_component.break_progress += delta * multiplier; player_component.break_progress += delta * multiplier;
if (player_component.break_progress >= player_component.break_progress_max) { if (player_component.break_progress >= player_component.break_progress_max) {
const drop_item = !block_info.requires_tool || // show it right away, the server decides drops and corrects us if it disagrees
holding_item_info?.tool_type === block_info.tool_to_break; world.dimension.break_block(block.x, block.y, block.z);
world.dimension.break_block(block.x, block.y, block.z, drop_item); send({ type: "break_block", x: block.x, y: block.y, z: block.z });
world.dimension.sync_block(block.x, block.y, block.z);
player_component.break_progress_max = 0; player_component.break_progress_max = 0;
player_component.break_progress = 0; player_component.break_progress = 0;
} }
} else if (InputManager.is_mouse_pressed(2)) { } else if (InputManager.is_mouse_pressed(2)) {
// interact if possible send({ type: "use_block", x: block.x, y: block.y, z: block.z, face: block.face });
const interacted = block_info?.on_interact?.(world.dimension, {
x: block.x, // guess that it places the held block, unless the block does something when used
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<ItemRegistry>("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]; const offset = FACE_OFFSETS[block.face];
world.dimension.add_block({ const target = { x: block.x + offset.x, y: block.y + offset.y, z: block.z + offset.z };
x: block.x + offset.x, const target_id = world.dimension.get_block(target.x, target.y, target.z);
y: block.y + offset.y, const replaceable = target_id === AIR ||
z: block.z + offset.z, EverythingRegistry.get_by_id<BlockRegistry>("blocks", target_id)?.id === "bworld:water";
id: item_info.block_id, if (!block_info.interactive && holding_item_info?.block_id && replaceable) {
}); world.dimension.add_block({ ...target, id: holding_item_info.block_id });
world.dimension.sync_block(block.x + offset.x, block.y + offset.y, block.z + offset.z); hotbar_slot.amount = hotbar_slot.amount! - 1;
hotbar_slot.amount! -= 1;
}
}
} }
} }
} else { } else {
@@ -185,32 +164,32 @@ export class PlayerControlsSystem extends System {
} }
if (player_component.screens.length === 0) { if (player_component.screens.length === 0) {
const player_inventory = player_component.player_inventory; const previous = inventories.hotbar_selected;
const scroll = InputManager.get_wheel_delta(); const scroll = InputManager.get_wheel_delta();
if (scroll > 0) { 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) { } 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)) { const hotbar_keys = [
player_inventory.hotbar_selected = 0; controls.hotbar_1,
} else if (InputManager.is_key_pressed(controls.hotbar_2)) { controls.hotbar_2,
player_inventory.hotbar_selected = 1; controls.hotbar_3,
} else if (InputManager.is_key_pressed(controls.hotbar_3)) { controls.hotbar_4,
player_inventory.hotbar_selected = 2; controls.hotbar_5,
} else if (InputManager.is_key_pressed(controls.hotbar_4)) { controls.hotbar_6,
player_inventory.hotbar_selected = 3; controls.hotbar_7,
} else if (InputManager.is_key_pressed(controls.hotbar_5)) { controls.hotbar_8,
player_inventory.hotbar_selected = 4; controls.hotbar_9,
} else if (InputManager.is_key_pressed(controls.hotbar_6)) { ];
player_inventory.hotbar_selected = 5; const pressed = hotbar_keys.findIndex((key) => InputManager.is_key_pressed(key));
} else if (InputManager.is_key_pressed(controls.hotbar_7)) { if (pressed !== -1) {
player_inventory.hotbar_selected = 6; inventories.hotbar_selected = pressed;
} else if (InputManager.is_key_pressed(controls.hotbar_8)) { }
player_inventory.hotbar_selected = 7;
} else if (InputManager.is_key_pressed(controls.hotbar_9)) { if (inventories.hotbar_selected !== previous) {
player_inventory.hotbar_selected = 8; send({ type: "select_slot", slot: inventories.hotbar_selected });
} }
} }
} }
+1 -1
View File
@@ -61,7 +61,7 @@ export class RenderSystem extends System {
const player_component = entity.get(PlayerComponent); const player_component = entity.get(PlayerComponent);
if (player_component) { if (player_component) {
render_player_hotbar(player_component.player_inventory); render_player_hotbar(player_component.inventories);
render_player_crosshair(); render_player_crosshair();
} }
} }
+6 -6
View File
@@ -1,6 +1,6 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts";
import { AssetManager } from "$/client/assets.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 { draw_item, draw_nine_slice } from "./render_utils.ts";
import { import {
canvas, canvas,
@@ -14,11 +14,11 @@ import {
Texture, Texture,
} from "$/client/renderer/mod.ts"; } from "$/client/renderer/mod.ts";
import { PlayerComponent } from "../../player.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; const PADDING = 10;
export function render_player_hotbar(player_inventory: PlayerInventory) { export function render_player_hotbar(inventories: ClientInventories) {
const ui = AssetManager.instance.get<Texture>("bworld:ui"); const ui = AssetManager.instance.get<Texture>("bworld:ui");
const hotbar_width = PADDING * 2 + SLOT_SIZE * 9; 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) { for (let index = 0; index < 9; index += 1) {
draw_nine_slice( draw_nine_slice(
ui, ui,
player_inventory.hotbar_selected === index ? 19 * 16 : 160 + 32, inventories.hotbar_selected === index ? 19 * 16 : 160 + 32,
player_inventory.hotbar_selected === index ? 16 : 0, inventories.hotbar_selected === index ? 16 : 0,
16, 16,
16, 16,
4, 4,
@@ -62,7 +62,7 @@ export function render_player_hotbar(player_inventory: PlayerInventory) {
} }
for (let index = 0; index < 9; index += 1) { 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) { if (item) {
draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING); draw_item(item, x + PADDING + index * SLOT_SIZE, y + PADDING);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { SLOT_SIZE, TEXTURE_SIZE } from "$/common/constants.ts"; 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 { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
import { AssetManager } from "$/client/assets.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"; import { draw_text, draw_texture_region, draw_texture_region_skewed, Texture } from "$/client/renderer/mod.ts";
export function draw_nine_slice( export function draw_nine_slice(
+3 -32
View File
@@ -4,7 +4,7 @@ import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts"; import type { SpriteRegion } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts"; import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.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; const pad = 0.5;
@@ -311,37 +311,8 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
} }
function generate(chunk_x: number, chunk_z: number, seed: string) { function generate(chunk_x: number, chunk_z: number, seed: string) {
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids);
const spills: number[] = []; post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
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,
]);
} }
function make_chunk_mesh( function make_chunk_mesh(
+16
View File
@@ -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<BlockRegistry>("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);
@@ -1,6 +1,4 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; import { EverythingRegistry } from "$/common/everything_registry.ts";
import { ItemStack } from "../inventory.ts";
import { PlayerComponent } from "../player.ts";
interface CropsRegistry { interface CropsRegistry {
total_stages: number; total_stages: number;
+15
View File
@@ -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<BlockRegistry>("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);
+16
View File
@@ -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<BlockRegistry>("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);
+12
View File
@@ -0,0 +1,12 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
// hoeing it is handled in server/game/blocks.ts
EverythingRegistry.register<BlockRegistry>("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",
});
+10
View File
@@ -22,3 +22,13 @@ export const STATE_SHIFT = 16;
export const CHUNK_SIZE = 16; export const CHUNK_SIZE = 16;
export const CHUNK_HEIGHT = 128; export const CHUNK_HEIGHT = 128;
export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE;
// where a block placed against each face of another block goes
export const FACE_OFFSETS: Record<Faces, { x: number; y: number; z: number }> = {
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 },
};
+4 -10
View File
@@ -1,5 +1,4 @@
import { Block, Dimension } from "$/client/components/dimension.ts"; import type { ItemStack } from "./inventory.ts";
import { ItemStack } from "$/client/inventory.ts";
export class EverythingRegistry { export class EverythingRegistry {
static #key_to_id = new Map<string, Map<string, number>>(); static #key_to_id = new Map<string, Map<string, number>>();
@@ -94,12 +93,9 @@ export interface BlockRegistry {
states?: BlockStateDefinition[]; states?: BlockStateDefinition[];
variants?: Record<string, BlockStateVariant>; variants?: Record<string, BlockStateVariant>;
on_create?(dimension: Dimension, block: Block): void; // right clicking it does something instead of placing a block, clients don't predict placing against it.
on_break?(dimension: Dimension, block: Block): void; // behavior runs on the server, see server/game/blocks.ts
on_click?(dimension: Dimension, block: Block): void; interactive?: boolean;
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;
compiled_states?: CompiledStateDefinition[]; compiled_states?: CompiledStateDefinition[];
} }
@@ -109,8 +105,6 @@ export interface ItemRegistry<T = unknown | undefined> {
block_id?: string; block_id?: string;
tool_type?: string; tool_type?: string;
place?(dimension: Dimension, block: Block): void;
on_create?(item: ItemStack<T>): void; on_create?(item: ItemStack<T>): void;
get_lore?(item: ItemStack<T>): string; get_lore?(item: ItemStack<T>): string;
+45 -1
View File
@@ -1,5 +1,5 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng"; 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 // generation runs in a worker now, so it only needs somewhere to put blocks
export interface BlockSink { 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<string, number>,
): 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) };
}
+270
View File
@@ -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<T = unknown | undefined> {
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<ItemRegistry>("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;
}
+50 -3
View File
@@ -1,4 +1,6 @@
// messages sent between the client and the server, as json over a websocket // 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"; export const AIR_ID = "bworld:air";
@@ -15,19 +17,64 @@ export interface PlayerInfo {
// x, y, z, block id // x, y, z, block id
export type BlockChange = [number, number, number, string]; 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 = export type ClientMessage =
| { type: "hello"; name: string } | { type: "hello"; name: string }
| { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number } | { 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 }; | { type: "chat"; text: string };
export type ServerMessage = 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_join"; player: PlayerInfo }
| { type: "player_leave"; id: string } | { type: "player_leave"; id: string }
| { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number } | { 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: "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<string, number> }
| { type: "screen_properties"; properties: Record<string, number> }
| { type: "close_screen" };
export const MAX_NAME_LENGTH = 16; export const MAX_NAME_LENGTH = 16;
export const MAX_CHAT_LENGTH = 256; export const MAX_CHAT_LENGTH = 256;
+6 -9
View File
@@ -1,5 +1,4 @@
import { AssetManager } from "../client/assets.ts"; import { ID_MASK, STATE_SHIFT } from "./constants.ts";
import { ID_MASK, SpriteRegion, STATE_SHIFT } from "./constants.ts";
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts"; import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
export function point_inside_rec( export function point_inside_rec(
@@ -16,13 +15,6 @@ export function point_inside_rec(
point_y < rec_y + rec_h; point_y < rec_y + rec_h;
} }
type TexturesInfo = Record<string, SpriteRegion>;
export function get_sprite_region(id: string): SpriteRegion {
const textures_info = AssetManager.instance.get<TexturesInfo>("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) { export function distance_point_rectangle(px: number, py: number, sqx: number, sqy: number, sqw: number, sqh: number) {
const x0 = sqx; const x0 = sqx;
const y0 = sqy; const y0 = sqy;
@@ -106,3 +98,8 @@ function compile_block_states(block_info: BlockRegistry) {
return { name: s.name, mask, shift }; 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);
}
+1 -1
View File
@@ -2,7 +2,7 @@
"tasks": { "tasks": {
"build": "deno run -A build.ts", "build": "deno run -A build.ts",
"serve:client": "deno run --allow-net --allow-read jsr:@std/http/file-server build", "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": { "compilerOptions": {
"lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"] "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable", "deno.webgpu"]
+278
View File
@@ -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<string, BlockBehavior> = {};
// 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<string, number> = {
"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);
}
}
},
};
+131
View File
@@ -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;
}
}
}
+541
View File
@@ -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<string, unknown>;
containers: Record<string, (ItemData | null)[]>;
}[];
players: Record<string, SavedPlayer>;
}
export class GameServer {
world: ServerWorld;
#host: GameHost;
#players = new Map<number, ServerPlayer>();
#saved_players: Record<string, SavedPlayer> = {};
#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<SavedWorld> | 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<string, Container> = {};
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<ItemRegistry>("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<ItemRegistry>("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 <item> [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<BlockRegistry>("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;
}
+15
View File
@@ -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 };
+35
View File
@@ -0,0 +1,35 @@
// a Map that forgets the least recently used entries past max_size
export class LruMap<K, V> {
#map = new Map<K, V>();
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;
}
}
+86
View File
@@ -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<string, number>;
}
// 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<string, string>();
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);
}
}
+57
View File
@@ -0,0 +1,57 @@
/// <reference lib="deno.worker" />
// 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<HostToGame>) => {
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;
}
};
+186
View File
@@ -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<string, unknown>;
containers: Record<string, Container>;
}
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<string, number> = {};
// what generation made, and the final blocks with neighbors' leaves and player changes applied
#raw = new LruMap<number, RawChunk>(RAW_CACHE_SIZE);
#chunks = new LruMap<number, Uint32Array>(CHUNK_CACHE_SIZE);
// changes from generated terrain, per chunk. these and the tiles are what gets saved
#changes = new Map<number, Map<string, BlockChange>>();
tiles = new Map<string, Tile>();
// 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<BlockRegistry>("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<BlockRegistry>("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<BlockRegistry>("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);
}
+57 -187
View File
@@ -1,235 +1,105 @@
import { serveDir } from "@std/http/file-server"; import { serveDir } from "@std/http/file-server";
import { CHUNK_HEIGHT } from "$/common/constants.ts"; import { GameToHost, HostToGame } from "./game/host_protocol.ts";
import {
BlockChange,
ClientMessage,
MAX_CHAT_LENGTH,
MAX_NAME_LENGTH,
PlayerInfo,
ServerMessage,
} from "$/common/protocol.ts";
const PORT = Number(Deno.env.get("PORT") ?? 8000); const PORT = Number(Deno.env.get("PORT") ?? 8000);
const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json";
const STATIC_ROOT = "build"; const STATIC_ROOT = "build";
const SAVE_INTERVAL_MS = 30_000;
const MAX_MESSAGE_SIZE = 4096; const MAX_MESSAGE_SIZE = 4096;
// how far from a player a block can be changed, a bit more than the client's reach const SHUTDOWN_TIMEOUT_MS = 5000;
const MAX_REACH = 8;
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 { const sockets = new Map<number, WebSocket>();
seed: string; let next_conn = 1;
changes: BlockChange[];
function post(message: HostToGame) {
game.postMessage(message);
} }
interface Client { function read_world(): string | undefined {
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<string, BlockChange>();
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 { try {
const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path)); return Deno.readTextFileSync(WORLD_FILE);
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) { } catch (e) {
if (!(e instanceof Deno.errors.NotFound)) { if (e instanceof Deno.errors.NotFound) {
return undefined;
}
throw e; 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;
}
} }
save(path: string) { function write_world(data: 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 // write then rename so a crash mid write doesnt eat the world
Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved)); Deno.writeTextFileSync(`${WORLD_FILE}.tmp`, data);
Deno.renameSync(`${path}.tmp`, path); Deno.renameSync(`${WORLD_FILE}.tmp`, WORLD_FILE);
this.dirty = false;
}
} }
const world = ServerWorld.load(WORLD_FILE); game.onmessage = (event: MessageEvent<GameToHost>) => {
const clients = new Set<Client>(); const message = event.data;
function send(client: Client, message: ServerMessage) {
if (client.socket.readyState === WebSocket.OPEN) {
client.socket.send(JSON.stringify(message));
}
}
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;
switch (message.type) { switch (message.type) {
case "move": { case "ready":
const { x, y, z, yaw, pitch } = message; console.log(`World ${WORLD_FILE} ready, seed ${message.seed}`);
if (![x, y, z, yaw, pitch].every(is_number)) { break;
return; 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; break;
} }
case "set_block": { case "close":
const { x, y, z, id } = message; sockets.get(message.conn)?.close();
if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) { break;
return; case "save":
write_world(message.data);
if (message.final) {
console.log("World saved");
Deno.exit(0);
} }
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);
break; break;
} }
case "chat": { };
if (typeof message.text !== "string") {
return; game.onerror = (event) => {
} console.error("Game server crashed:", event.message);
const text = message.text.trim().slice(0, MAX_CHAT_LENGTH); Deno.exit(1);
if (text.length === 0) { };
return;
} const save = read_world();
console.log(`<${player.name}> ${text}`); post({ type: "init", save, default_seed: Deno.env.get("SEED") ?? crypto.randomUUID() });
broadcast({ type: "chat", from: player.name, text });
break;
}
}
}
function handle_socket(socket: WebSocket) { function handle_socket(socket: WebSocket) {
const client: Client = { socket }; const conn = next_conn++;
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
clients.add(client); sockets.set(conn, socket);
post({ type: "connect", conn });
}); });
socket.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) {
return; return;
} }
let message: ClientMessage; post({ type: "message", conn, data: event.data });
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (typeof message !== "object" || message === null) {
return;
}
handle_message(client, message);
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
clients.delete(client); if (sockets.delete(conn)) {
if (client.player) { post({ type: "disconnect", conn });
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)`);
} }
}); });
} }
setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS);
function shutdown() { function shutdown() {
console.log("Saving world..."); console.log("Saving world...");
world.save(WORLD_FILE); post({ type: "shutdown" });
Deno.exit(0); setTimeout(() => {
console.error("Game server didn't save in time");
Deno.exit(1);
}, SHUTDOWN_TIMEOUT_MS);
} }
Deno.addSignalListener("SIGINT", shutdown); Deno.addSignalListener("SIGINT", shutdown);
if (Deno.build.os !== "windows") { if (Deno.build.os !== "windows") {