This commit is contained in:
2026-09-24 18:34:16 -03:00
parent a758037f20
commit a37ffd9127
20 changed files with 799 additions and 20 deletions
+1
View File
@@ -17,6 +17,7 @@ const block = EverythingRegistry.register<BlockRegistry>("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;
+1
View File
@@ -17,6 +17,7 @@ EverythingRegistry.register<BlockRegistry>("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;
+21 -1
View File
@@ -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();
}
}
}
+66 -6
View File
@@ -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<string, Map<string, BlockChange>>();
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<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) {
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) {
+4 -1
View File
@@ -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);
+6 -4
View File
@@ -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;
}
}
+12 -2
View File
@@ -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 = "";
+10 -1
View File
@@ -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();
+125
View File
@@ -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<string, RemotePlayer>();
// 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<Connection | undefined> {
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") ?? "";
}
+66
View File
@@ -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);
}
+77
View File
@@ -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,
});
}
}
}
+2
View File
@@ -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;
}
}
+9 -2
View File
@@ -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);
}
}
+53
View File
@@ -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;
}
}