diff --git a/.gitignore b/.gitignore index d163863..9e857c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -build/ \ No newline at end of file +build/ +world.json +world.json.tmp diff --git a/client/blocks/dirt.ts b/client/blocks/dirt.ts index 75938ab..7a61e3e 100644 --- a/client/blocks/dirt.ts +++ b/client/blocks/dirt.ts @@ -17,6 +17,7 @@ const block = EverythingRegistry.register("blocks", "bworld:dirt" 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; diff --git a/client/blocks/grass.ts b/client/blocks/grass.ts index 158c5e7..20b0b7c 100644 --- a/client/blocks/grass.ts +++ b/client/blocks/grass.ts @@ -17,6 +17,7 @@ EverythingRegistry.register("blocks", "bworld:grass", { if (item?.type_id === "bworld:hoe") { block.id = "bworld:hoed_dirt"; dimension.add_block(block); + dimension.sync_block(block.x, block.y, block.z); return true; } return false; diff --git a/client/client_world.ts b/client/client_world.ts index 7841217..c201129 100644 --- a/client/client_world.ts +++ b/client/client_world.ts @@ -13,6 +13,13 @@ import { Dimension } from "./components/dimension.ts"; import { GuiRenderSystem, GuiTickSystem } from "./gui/gui_systems.ts"; import { WorldGenerationSystem } from "./systems/world_generation_system.ts"; import { CollisionSystem } from "./systems/collision_system.ts"; +import { NetworkSystem } from "./systems/network_system.ts"; +import { Connection } from "./network.ts"; + +export interface ChatLine { + text: string; + time: number; +} export class ClientWorld extends World { paused = false; @@ -20,8 +27,13 @@ export class ClientWorld extends World { dimension!: Dimension; - constructor() { + // undefined when playing single player + connection?: Connection; + chat_log: ChatLine[] = []; + + constructor(connection?: Connection) { super("game"); + this.connection = connection; this.add_state("main_menu"); this.add_state("paused"); @@ -39,6 +51,7 @@ export class ClientWorld extends World { // Logic systems this.add_system(new UIInteractionSystem(), "main_menu"); this.add_system(new UIInteractionSystem(), "paused"); + this.add_system(new NetworkSystem(), "game"); this.add_system(new GuiTickSystem(), "game"); this.add_system(new PlayerControlsSystem(), "game"); this.add_system(new WorldGenerationSystem(), "game"); @@ -53,4 +66,11 @@ export class ClientWorld extends World { this.add_system(new UIRenderSystem(), "main_menu"); this.add_system(new UIRenderSystem(), "paused"); } + + add_chat(text: string) { + this.chat_log.push({ text, time: performance.now() }); + if (this.chat_log.length > 100) { + this.chat_log.shift(); + } + } } diff --git a/client/components/dimension.ts b/client/components/dimension.ts index 07cf1bb..57bf093 100644 --- a/client/components/dimension.ts +++ b/client/components/dimension.ts @@ -1,6 +1,7 @@ import { Component } from "$/common/ecs/mod.ts"; +import { AIR_ID, BlockChange } from "$/common/protocol.ts"; import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts"; -import { AIR, Faces, ID_MASK, VOID } from "../../common/constants.ts"; +import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts"; import { AssetManager } from "../assets.ts"; import { ClientWorld } from "../client_world.ts"; import { generate_chunk } from "../generation.ts"; @@ -24,9 +25,7 @@ export interface Block { z: number; } -export const CHUNK_SIZE = 16; -export const CHUNK_HEIGHT = 128; -export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; +export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE }; export interface Chunk { x: number; @@ -47,10 +46,15 @@ export class Dimension extends Component { chunks: Chunk[] = []; second_timer = 0; tick_timer = 0; + seed: string; - constructor(world: ClientWorld) { + // blocks players changed from the generated terrain, per chunk, so they survive reloading chunks + changes = new Map>(); + + constructor(world: ClientWorld, seed = "seed") { super(); this.world = world; + this.seed = seed; } add_chunk(x: number, z: number) { @@ -200,8 +204,57 @@ export class Dimension extends Component { return [x, y, z]; } + record_change(x: number, y: number, z: number, id: string) { + const chunk_key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`; + let chunk_changes = this.changes.get(chunk_key); + if (!chunk_changes) { + chunk_changes = new Map(); + this.changes.set(chunk_key, chunk_changes); + } + chunk_changes.set(`${x},${y},${z}`, [x, y, z, id]); + } + + // call after the player changes a block so it gets saved and sent to the server + sync_block(x: number, y: number, z: number) { + const numeric_id = this.get_block(x, y, z); + const id = numeric_id === AIR ? AIR_ID : EverythingRegistry.get_by_id("blocks", numeric_id)?.id; + if (!id) { + return; + } + this.record_change(x, y, z, id); + this.world.connection?.send({ type: "set_block", x, y, z, id }); + } + + // set a block without drops or syncing, for changes that came from somewhere else + apply_change(x: number, y: number, z: number, id: string) { + const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)); + if (!chunk || !chunk.generated) { + return; + } + + const current = this.get_block(x, y, z); + if (id === AIR_ID) { + if (current !== AIR) { + this.break_block(x, y, z, false); + } + return; + } + + const nid = EverythingRegistry.get_id("blocks", id); + if (nid === undefined || nid === current) { + return; + } + this.add_block({ x, y, z, id }); + } + + apply_chunk_changes(cx: number, cz: number) { + for (const [x, y, z, id] of this.changes.get(`${cx},${cz}`)?.values() ?? []) { + this.apply_change(x, y, z, id); + } + } + load_chunk(cx: number, cz: number) { - generate_chunk(this, cx, cz); + generate_chunk(this, cx, cz, this.seed); const chunk = this.get_chunk(cx, cz); if (chunk) { chunk.generated = true; @@ -221,6 +274,13 @@ export class Dimension extends Component { neighbor.dirty = true; } } + + // trees spill into neighboring chunks, so their changes need reapplying too + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + this.apply_chunk_changes(cx + dx, cz + dz); + } + } } unload_chunk(cx: number, cz: number) { diff --git a/client/game.ts b/client/game.ts index ff09e3d..91e7de5 100644 --- a/client/game.ts +++ b/client/game.ts @@ -12,7 +12,10 @@ export function start_game(world: ClientWorld) { world.clear_entities(); const dimension = new Entity("dimension"); - world.dimension = new Dimension(world); + world.dimension = new Dimension(world, world.connection?.seed); + for (const [x, y, z, id] of world.connection?.initial_changes ?? []) { + world.dimension.record_change(x, y, z, id); + } dimension.add(world.dimension); world.add_entity(dimension); diff --git a/client/generation.ts b/client/generation.ts index 04f0ec5..5f0f71b 100644 --- a/client/generation.ts +++ b/client/generation.ts @@ -129,8 +129,8 @@ function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) return true; } -function place_tree(dimension: Dimension, x: number, y: number, z: number, biome: Biome) { - const height = Math.floor(Math.random() * 3) + (biome === "jungle" ? 8 : 4); +function place_tree(dimension: Dimension, rng: Alea, x: number, y: number, z: number, biome: Biome) { + const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4); const trunk_block = "bworld:log"; const leaves_block = "bworld:leaves"; @@ -177,6 +177,8 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see const moisture_noise = create_noise_2d(new Alea(seed + "_moisture")); const feature_noise = create_noise_2d(new Alea(seed + "_feature")); const ore_noises = ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))); + // seeded per chunk so every client generates the exact same terrain + const rng = new Alea(`${seed}_chunk_${cx}_${cz}`); const biome_scale = 0.003; const terrain_scale = 0.01; @@ -226,7 +228,7 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see block = "bworld:dirt"; } - if (biome === "swamp" && y === height && Math.random() < 0.2) { + if (biome === "swamp" && y === height && rng.next() < 0.2) { block = "bworld:water"; } @@ -234,7 +236,7 @@ export function generate_chunk(dimension: Dimension, cx: number, cz: number, see } if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) { - place_tree(dimension, wx, height + 1, wz, biome); + place_tree(dimension, rng, wx, height + 1, wz, biome); tree_map[x][z] = true; } } diff --git a/client/gui/gui_chat.ts b/client/gui/gui_chat.ts index dd5f1dd..b52c84b 100644 --- a/client/gui/gui_chat.ts +++ b/client/gui/gui_chat.ts @@ -4,6 +4,8 @@ import { InputManager } from "../input_manager.ts"; import { ClientWorld } from "../client_world.ts"; import { PlayerComponent } from "../player.ts"; import { ItemStack } from "../inventory.ts"; +import { MAX_CHAT_LENGTH } from "$/common/protocol.ts"; +import { render_chat_log } from "../systems/rendering/network.ts"; export class GuiChat extends GuiScreen { world: ClientWorld; @@ -22,6 +24,7 @@ export class GuiChat extends GuiScreen { draw_rect(0, 0, canvas.width, canvas.height, [0, 0, 0, 0.8]); const y = canvas.height - 32; + render_chat_log(this.world, true, y - 8); draw_rect(0, y, canvas.width, canvas.height, [0, 0, 0, 0.4]); draw_text(this.text_typed, 0, y, 2, [1, 1, 1]); @@ -85,6 +88,9 @@ export class GuiChat extends GuiScreen { const typed = InputManager.get_typed_characters(); for (const char of typed) { + if (this.text_typed.length >= MAX_CHAT_LENGTH) { + break; + } this.text_typed = this.text_typed.slice(0, this.caret) + char + this.text_typed.slice(this.caret); this.caret += 1; } @@ -98,8 +104,12 @@ export class GuiChat extends GuiScreen { submit() { if (this.text_typed.startsWith("/")) { this.command(); - } else { - // we dont have multiplayer lol? + } else if (this.text_typed.trim().length > 0) { + if (this.world.connection) { + this.world.connection.send({ type: "chat", text: this.text_typed }); + } else { + this.world.add_chat(this.text_typed); + } } this.text_typed = ""; diff --git a/client/main.ts b/client/main.ts index 3a3d5aa..a519611 100644 --- a/client/main.ts +++ b/client/main.ts @@ -1,6 +1,7 @@ import { AssetManager } from "./assets.ts"; import { ClientWorld } from "./client_world.ts"; import { InputManager } from "./input_manager.ts"; +import { Connection, get_player_name, get_server_url } from "./network.ts"; import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts"; await import("./blocks/mod.ts"); @@ -90,7 +91,15 @@ await AssetManager.instance.load_all(); init_font(); -const client_world = new ClientWorld(); +const connection = await Connection.open(get_server_url(), get_player_name()); +if (connection) { + console.log(`Connected to ${get_server_url()}`); +} else { + console.log("Couldn't reach a server, playing single player"); +} + +const client_world = new ClientWorld(connection); +client_world.add_chat(connection ? "Connected to the server" : "Playing single player"); const loop = new ClientLoop(client_world); loop.start(); diff --git a/client/network.ts b/client/network.ts new file mode 100644 index 0000000..eed4e52 --- /dev/null +++ b/client/network.ts @@ -0,0 +1,125 @@ +import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts"; + +const CONNECT_TIMEOUT_MS = 3000; + +export interface RemotePlayer extends PlayerInfo { + // where we draw them, eased towards x/y/z so movement isnt choppy + display_x: number; + display_y: number; + display_z: number; + color: [number, number, number]; +} + +export class Connection { + socket: WebSocket; + id: string; + seed: string; + initial_changes: BlockChange[]; + players = new Map(); + + // handled by the network system inside the game loop, not whenever the socket feels like it + incoming: ServerMessage[] = []; + closed = false; + + constructor(socket: WebSocket, id: string, seed: string, players: PlayerInfo[], changes: BlockChange[]) { + this.socket = socket; + this.id = id; + this.seed = seed; + this.initial_changes = changes; + for (const player of players) { + this.add_player(player); + } + + socket.addEventListener("message", (event) => { + this.incoming.push(JSON.parse(event.data)); + }); + socket.addEventListener("close", () => { + this.closed = true; + }); + } + + send(message: ClientMessage) { + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)); + } + } + + add_player(player: PlayerInfo) { + this.players.set(player.id, { + ...player, + display_x: player.x, + display_y: player.y, + display_z: player.z, + color: color_from_name(player.name), + }); + } + + static open(url: string, name: string): Promise { + return new Promise((resolve) => { + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch { + resolve(undefined); + return; + } + + const timeout = setTimeout(() => { + socket.close(); + resolve(undefined); + }, CONNECT_TIMEOUT_MS); + + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ type: "hello", name } satisfies ClientMessage)); + }); + + socket.addEventListener("message", (event) => { + const message: ServerMessage = JSON.parse(event.data); + if (message.type === "welcome") { + clearTimeout(timeout); + resolve(new Connection(socket, message.id, message.seed, message.players, message.changes)); + } + }, { once: true }); + + socket.addEventListener("error", () => { + clearTimeout(timeout); + resolve(undefined); + }); + }); + } +} + +function color_from_name(name: string): [number, number, number] { + let hash = 0; + for (const ch of name) { + hash = (hash * 31 + ch.charCodeAt(0)) | 0; + } + const hue = ((hash % 360) + 360) % 360; + // hsl with s=0.6 l=0.6 to rgb + const c = 0.48; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = 0.36; + const [r, g, b] = hue < 60 + ? [c, x, 0] + : hue < 120 + ? [x, c, 0] + : hue < 180 + ? [0, c, x] + : hue < 240 + ? [0, x, c] + : hue < 300 + ? [x, 0, c] + : [c, 0, x]; + return [r + m, g + m, b + m]; +} + +export function get_server_url(): string { + const params = new URLSearchParams(location.search); + const server = params.get("server"); + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${server ?? location.host}/ws`; +} + +export function get_player_name(): string { + return new URLSearchParams(location.search).get("name") ?? ""; +} diff --git a/client/renderer/models.ts b/client/renderer/models.ts index ed5f647..9b17faa 100644 --- a/client/renderer/models.ts +++ b/client/renderer/models.ts @@ -191,3 +191,69 @@ export function push_bottom_face( push_vertex(x2, y, z2, u1, v0, r, g, b, a); push_vertex(x, y, z2, u0, v0, r, g, b, a); } + +// solid colored box, draw it with white_tex as the current texture +export function push_box( + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + r = 1, + g = 1, + b = 1, + a = 1, +) { + const x2 = x + width; + const y2 = y + height; + const z2 = z + depth; + + // front + push_vertex(x, y, z2, 0, 1, r, g, b, a); + push_vertex(x2, y, z2, 1, 1, r, g, b, a); + push_vertex(x2, y2, z2, 1, 0, r, g, b, a); + push_vertex(x, y, z2, 0, 1, r, g, b, a); + push_vertex(x2, y2, z2, 1, 0, r, g, b, a); + push_vertex(x, y2, z2, 0, 0, r, g, b, a); + + // back + push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a); + push_vertex(x, y, z, 1, 1, r * 0.8, g * 0.8, b * 0.8, a); + push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a); + push_vertex(x2, y, z, 0, 1, r * 0.8, g * 0.8, b * 0.8, a); + push_vertex(x, y2, z, 1, 0, r * 0.8, g * 0.8, b * 0.8, a); + push_vertex(x2, y2, z, 0, 0, r * 0.8, g * 0.8, b * 0.8, a); + + // left + push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x, y, z2, 1, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x, y, z, 0, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x, y2, z2, 1, 0, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x, y2, z, 0, 0, r * 0.9, g * 0.9, b * 0.9, a); + + // right + push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x2, y, z, 1, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x2, y, z2, 0, 1, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x2, y2, z, 1, 0, r * 0.9, g * 0.9, b * 0.9, a); + push_vertex(x2, y2, z2, 0, 0, r * 0.9, g * 0.9, b * 0.9, a); + + // top + push_vertex(x, y2, z2, 0, 1, r, g, b, a); + push_vertex(x2, y2, z2, 1, 1, r, g, b, a); + push_vertex(x2, y2, z, 1, 0, r, g, b, a); + push_vertex(x, y2, z2, 0, 1, r, g, b, a); + push_vertex(x2, y2, z, 1, 0, r, g, b, a); + push_vertex(x, y2, z, 0, 0, r, g, b, a); + + // bottom + push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a); + push_vertex(x2, y, z, 1, 1, r * 0.6, g * 0.6, b * 0.6, a); + push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a); + push_vertex(x, y, z, 0, 1, r * 0.6, g * 0.6, b * 0.6, a); + push_vertex(x2, y, z2, 1, 0, r * 0.6, g * 0.6, b * 0.6, a); + push_vertex(x, y, z2, 0, 0, r * 0.6, g * 0.6, b * 0.6, a); +} diff --git a/client/systems/network_system.ts b/client/systems/network_system.ts new file mode 100644 index 0000000..90a5dff --- /dev/null +++ b/client/systems/network_system.ts @@ -0,0 +1,77 @@ +import { System } from "$/common/ecs/mod.ts"; +import { Position } from "$/common/components/position.ts"; +import { ClientWorld } from "../client_world.ts"; +import { Camera } from "../components/camera.ts"; + +const MOVE_SEND_INTERVAL = 1 / 10; +const REMOTE_PLAYER_SMOOTHING = 12; + +export class NetworkSystem extends System { + move_timer = 0; + + update(world: ClientWorld, delta: number): void { + const connection = world.connection; + if (!connection) { + return; + } + + for (const message of connection.incoming) { + switch (message.type) { + case "player_join": + connection.add_player(message.player); + break; + case "player_leave": + connection.players.delete(message.id); + break; + case "player_move": { + const player = connection.players.get(message.id); + if (player) { + player.x = message.x; + player.y = message.y; + player.z = message.z; + player.yaw = message.yaw; + player.pitch = message.pitch; + } + break; + } + case "set_block": + world.dimension.record_change(message.x, message.y, message.z, message.id); + world.dimension.apply_change(message.x, message.y, message.z, message.id); + break; + case "chat": + world.add_chat(message.from ? `<${message.from}> ${message.text}` : message.text); + break; + } + } + connection.incoming.length = 0; + + if (connection.closed) { + world.add_chat("Lost connection to the server"); + world.connection = undefined; + return; + } + + const t = Math.min(1, delta * REMOTE_PLAYER_SMOOTHING); + for (const player of connection.players.values()) { + player.display_x += (player.x - player.display_x) * t; + player.display_y += (player.y - player.display_y) * t; + player.display_z += (player.z - player.display_z) * t; + } + + this.move_timer += delta; + if (this.move_timer >= MOVE_SEND_INTERVAL) { + this.move_timer = 0; + const [player] = world.get_tag("player")!; + const position = player.get(Position)!; + const camera = player.get(Camera)!; + connection.send({ + type: "move", + x: position.x, + y: position.y, + z: position.z, + yaw: camera.yaw, + pitch: camera.pitch, + }); + } + } +} diff --git a/client/systems/player_controls.ts b/client/systems/player_controls.ts index 5fc6469..d69d14a 100644 --- a/client/systems/player_controls.ts +++ b/client/systems/player_controls.ts @@ -138,6 +138,7 @@ export class PlayerControlsSystem extends System { const drop_item = !block_info.requires_tool || holding_item_info?.tool_type === block_info.tool_to_break; world.dimension.break_block(block.x, block.y, block.z, drop_item); + world.dimension.sync_block(block.x, block.y, block.z); player_component.break_progress_max = 0; player_component.break_progress = 0; } @@ -171,6 +172,7 @@ export class PlayerControlsSystem extends System { z: block.z + offset.z, id: item_info.block_id, }); + world.dimension.sync_block(block.x + offset.x, block.y + offset.y, block.z + offset.z); hotbar_slot.amount! -= 1; } } diff --git a/client/systems/render_system.ts b/client/systems/render_system.ts index 233e418..4bb0c76 100644 --- a/client/systems/render_system.ts +++ b/client/systems/render_system.ts @@ -1,5 +1,4 @@ import { System } from "$/common/ecs/mod.ts"; -import { World } from "$/common/ecs/world.ts"; import { Position } from "$/common/components/position.ts"; import { AnimatedSprite, Sprite } from "$/client/components/sprite.ts"; import { Dimension } from "../components/dimension.ts"; @@ -10,13 +9,15 @@ import { render_dimension } from "./rendering/dimension.ts"; import { render_player_breaking, render_player_crosshair, render_player_hotbar } from "./rendering/player.ts"; import { PlayerComponent } from "../player.ts"; import { begin_mode_3d, end_mode_3d } from "../renderer/core.ts"; +import { ClientWorld } from "../client_world.ts"; +import { render_chat_log, render_remote_players } from "./rendering/network.ts"; export class RenderSystem extends System { constructor() { super(); } - update(world: World, _delta: number): void { + update(world: ClientWorld, _delta: number): void { const camera_entity = world.get_entities().values().find((e) => e.get(Camera)); const camera = camera_entity?.get(Camera); @@ -39,6 +40,10 @@ export class RenderSystem extends System { } } + if (world.connection) { + render_remote_players(world.connection); + } + end_mode_3d(); for (const entity of world.get_entities()) { @@ -60,5 +65,7 @@ export class RenderSystem extends System { render_player_crosshair(); } } + + render_chat_log(world, false); } } diff --git a/client/systems/rendering/network.ts b/client/systems/rendering/network.ts new file mode 100644 index 0000000..f624d06 --- /dev/null +++ b/client/systems/rendering/network.ts @@ -0,0 +1,53 @@ +import { ClientWorld } from "$/client/client_world.ts"; +import { Connection } from "$/client/network.ts"; +import { + canvas, + draw_rect, + draw_text, + flush_batch, + push_box, + set_current_texture, + white_tex, +} from "$/client/renderer/mod.ts"; + +const CHAT_LINE_HEIGHT = 24; +const CHAT_VISIBLE_SECONDS = 10; +const CHAT_MAX_LINES = 10; + +export function render_remote_players(connection: Connection) { + if (connection.players.size === 0) { + return; + } + + flush_batch(); + set_current_texture(white_tex!); + + for (const player of connection.players.values()) { + const [r, g, b] = player.color; + const x = player.display_x; + const y = player.display_y; + const z = player.display_z; + + // body + push_box(x - 0.3, y, z - 0.15, 0.6, 1.3, 0.3, r, g, b); + // head + push_box(x - 0.25, y + 1.3, z - 0.25, 0.5, 0.5, 0.5, 0.95, 0.8, 0.65); + } + + flush_batch(); +} + +// draws the chat log above the bottom left corner, `all` shows old messages too (when the chat is open) +export function render_chat_log(world: ClientWorld, all: boolean, bottom = canvas.height - 100) { + const now = performance.now(); + const lines = world.chat_log + .filter((line) => all || now - line.time < CHAT_VISIBLE_SECONDS * 1000) + .slice(-CHAT_MAX_LINES); + + let y = bottom - lines.length * CHAT_LINE_HEIGHT; + for (const line of lines) { + draw_rect(0, y, 600, CHAT_LINE_HEIGHT, [0, 0, 0, 0.4]); + draw_text(line.text, 4, y, 2, [1, 1, 1, 1]); + y += CHAT_LINE_HEIGHT; + } +} diff --git a/common/constants.ts b/common/constants.ts index d3732e4..39e3c57 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -18,3 +18,7 @@ export const VOID = 0xFFFFFFFF; export const ID_MASK = 0xFFFF; export const STATE_SHIFT = 16; + +export const CHUNK_SIZE = 16; +export const CHUNK_HEIGHT = 128; +export const CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; diff --git a/common/protocol.ts b/common/protocol.ts new file mode 100644 index 0000000..21d1fce --- /dev/null +++ b/common/protocol.ts @@ -0,0 +1,33 @@ +// messages sent between the client and the server, as json over a websocket + +export const AIR_ID = "bworld:air"; + +export interface PlayerInfo { + id: string; + name: string; + x: number; + y: number; + z: number; + yaw: number; + pitch: number; +} + +// x, y, z, block id +export type BlockChange = [number, number, number, string]; + +export type ClientMessage = + | { type: "hello"; name: string } + | { type: "move"; x: number; y: number; z: number; yaw: number; pitch: number } + | { type: "set_block"; x: number; y: number; z: number; id: string } + | { type: "chat"; text: string }; + +export type ServerMessage = + | { type: "welcome"; id: string; seed: string; players: PlayerInfo[]; changes: BlockChange[] } + | { type: "player_join"; player: PlayerInfo } + | { type: "player_leave"; id: string } + | { type: "player_move"; id: string; x: number; y: number; z: number; yaw: number; pitch: number } + | { type: "set_block"; x: number; y: number; z: number; id: string } + | { type: "chat"; from?: string; text: string }; + +export const MAX_NAME_LENGTH = 16; +export const MAX_CHAT_LENGTH = 256; diff --git a/deno.json b/deno.json index 0ab7a5f..2fe818d 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,8 @@ { "tasks": { "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" }, "compilerOptions": { "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns", "deno.unstable"] @@ -18,6 +19,7 @@ "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", "@paulaboks/rng": "jsr:@paulaboks/rng@^0.0.3", "@std/fs": "jsr:@std/fs@^1.0.23", + "@std/http": "jsr:@std/http@^1.0.23", "gl-matrix": "npm:gl-matrix@^3.4.4", "marked": "npm:marked@^17.0.3" } diff --git a/deno.lock b/deno.lock index 70ab4b2..6020ca8 100644 --- a/deno.lock +++ b/deno.lock @@ -3,10 +3,20 @@ "specifiers": { "jsr:@gfx/canvas-wasm@~0.4.2": "0.4.2", "jsr:@paulaboks/rng@^0.0.3": "0.0.3", + "jsr:@std/cli@^1.0.27": "1.0.27", "jsr:@std/encoding@1.0.5": "1.0.5", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.9": "1.0.10", + "jsr:@std/fs@^1.0.22": "1.0.23", "jsr:@std/fs@^1.0.23": "1.0.23", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@*": "1.0.24", + "jsr:@std/http@^1.0.23": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/streams@^1.0.17": "1.1.2", "npm:gl-matrix@^3.4.4": "3.4.4", "npm:marked@^17.0.3": "17.0.3" }, @@ -14,15 +24,24 @@ "@gfx/canvas-wasm@0.4.2": { "integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a", "dependencies": [ - "jsr:@std/encoding" + "jsr:@std/encoding@1.0.5" ] }, "@paulaboks/rng@0.0.3": { "integrity": "8d2571f9f406dab2674178f4034825cc654c5a52aa7c2a176a25f8b713eee202" }, + "@std/cli@1.0.27": { + "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de" + }, "@std/encoding@1.0.5": { "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, "@std/fs@1.0.23": { "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ @@ -30,14 +49,40 @@ "jsr:@std/path" ] }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.24": { + "integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fmt", + "jsr:@std/fs@^1.0.22", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams" + ] + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + }, "@std/path@1.1.4": { "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "dependencies": [ "jsr:@std/internal" ] + }, + "@std/streams@1.1.2": { + "integrity": "0249bf9b78a999f57032ca4d8e7ccaa57579090242640b35ddc948fbb745d3af" } }, "npm": { @@ -54,6 +99,7 @@ "jsr:@gfx/canvas-wasm@~0.4.2", "jsr:@paulaboks/rng@^0.0.3", "jsr:@std/fs@^1.0.23", + "jsr:@std/http@^1.0.23", "npm:gl-matrix@^3.4.4", "npm:marked@^17.0.3" ] diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..032cd23 --- /dev/null +++ b/server/main.ts @@ -0,0 +1,256 @@ +import { serveDir } from "@std/http/file-server"; +import { CHUNK_HEIGHT } from "$/common/constants.ts"; +import { + BlockChange, + ClientMessage, + MAX_CHAT_LENGTH, + MAX_NAME_LENGTH, + PlayerInfo, + ServerMessage, +} from "$/common/protocol.ts"; + +const PORT = Number(Deno.env.get("PORT") ?? 8000); +const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; +const STATIC_ROOT = "build"; +const SAVE_INTERVAL_MS = 30_000; +const MAX_MESSAGE_SIZE = 4096; +// how far from a player a block can be changed, a bit more than the client's reach +const MAX_REACH = 8; + +const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; + +interface SavedWorld { + seed: string; + changes: BlockChange[]; +} + +interface Client { + socket: WebSocket; + player?: PlayerInfo; +} + +// the server doesnt generate terrain, clients do that from the seed +// we only keep track of what players changed on top of it +class ServerWorld { + seed: string; + changes = new Map(); + dirty = false; + + constructor(seed: string) { + this.seed = seed; + } + + set_block(x: number, y: number, z: number, id: string) { + this.changes.set(`${x},${y},${z}`, [x, y, z, id]); + this.dirty = true; + } + + static load(path: string): ServerWorld { + try { + const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path)); + const world = new ServerWorld(saved.seed); + for (const [x, y, z, id] of saved.changes) { + world.changes.set(`${x},${y},${z}`, [x, y, z, id]); + } + console.log(`Loaded ${path} (${world.changes.size} block changes)`); + return world; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) { + throw e; + } + const world = new ServerWorld(Deno.env.get("SEED") ?? crypto.randomUUID()); + world.dirty = true; + console.log(`Created new world with seed ${world.seed}`); + return world; + } + } + + save(path: string) { + if (!this.dirty) { + return; + } + const saved: SavedWorld = { seed: this.seed, changes: [...this.changes.values()] }; + // write then rename so a crash mid write doesnt eat the world + Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved)); + Deno.renameSync(`${path}.tmp`, path); + this.dirty = false; + } +} + +const world = ServerWorld.load(WORLD_FILE); +const clients = new Set(); + +function send(client: Client, message: ServerMessage) { + if (client.socket.readyState === WebSocket.OPEN) { + client.socket.send(JSON.stringify(message)); + } +} + +function 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) { + 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 }); + broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client); + break; + } + case "set_block": { + const { x, y, z, id } = message; + if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) { + return; + } + if (typeof id !== "string" || !BLOCK_ID_PATTERN.test(id)) { + return; + } + const dx = x + 0.5 - player.x; + const dy = y + 0.5 - (player.y + 1.69); + const dz = z + 0.5 - player.z; + if (dx * dx + dy * dy + dz * dz > MAX_REACH * MAX_REACH) { + return; + } + world.set_block(x, y, z, id); + broadcast({ type: "set_block", x, y, z, id }, client); + break; + } + case "chat": { + if (typeof message.text !== "string") { + return; + } + const text = message.text.trim().slice(0, MAX_CHAT_LENGTH); + if (text.length === 0) { + return; + } + console.log(`<${player.name}> ${text}`); + broadcast({ type: "chat", from: player.name, text }); + break; + } + } +} + +function handle_socket(socket: WebSocket) { + const client: Client = { socket }; + + socket.addEventListener("open", () => { + clients.add(client); + }); + + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { + return; + } + let message: ClientMessage; + try { + message = JSON.parse(event.data); + } catch { + return; + } + if (typeof message !== "object" || message === null) { + return; + } + handle_message(client, message); + }); + + socket.addEventListener("close", () => { + clients.delete(client); + if (client.player) { + broadcast({ type: "player_leave", id: client.player.id }); + broadcast({ type: "chat", text: `${client.player.name} left` }); + console.log(`${client.player.name} left (${clients.size} online)`); + } + }); +} + +setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS); + +function shutdown() { + console.log("Saving world..."); + world.save(WORLD_FILE); + Deno.exit(0); +} +Deno.addSignalListener("SIGINT", shutdown); +if (Deno.build.os !== "windows") { + Deno.addSignalListener("SIGTERM", shutdown); +} + +Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => { + const url = new URL(req.url); + + if (url.pathname === "/ws") { + if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected a websocket", { status: 426 }); + } + const { socket, response } = Deno.upgradeWebSocket(req); + handle_socket(socket); + return response; + } + + if (url.pathname === "/") { + return Response.redirect(new URL("/client/", url), 302); + } + + return serveDir(req, { fsRoot: STATIC_ROOT, quiet: true }); +});