Files
bworld/client/components/dimension.ts
T
2026-09-24 23:49:34 -03:00

525 lines
14 KiB
TypeScript

import { Component } from "$/common/ecs/mod.ts";
import { chunk_key } from "$/common/utils.ts";
import { AIR_ID, BlockChange } from "$/common/protocol.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from "../../common/constants.ts";
import { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts";
export interface Block {
id: string;
x: number;
y: number;
z: number;
}
export { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE };
export interface Chunk {
x: number;
z: number;
blocks: Uint32Array;
generated: boolean;
dirty: boolean;
// bumped on every mesh request so late results from older requests get ignored
mesh_version: number;
opaque_vertex_buffer?: GPUBuffer;
opaque_vertex_count?: number;
transparent_vertex_buffer?: GPUBuffer;
transparent_vertex_count?: number;
}
export { chunk_key };
const NEIGHBOR_OFFSETS = [[-1, 0], [1, 0], [0, -1], [0, 1]] as const;
export class Dimension extends Component {
world: ClientWorld;
image: Texture = AssetManager.instance.get("bworld:textures");
chunks = new Map<number, Chunk>();
second_timer = 0;
tick_timer = 0;
seed: string;
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
changes = new Map<string, Map<string, BlockChange>>();
workers: ChunkWorkerPool;
// chunks being generated by a worker, by chunk key
pending_generation = new Map<number, { x: number; z: number }>();
constructor(world: ClientWorld, seed = "seed") {
super();
this.world = world;
this.seed = seed;
this.workers = new ChunkWorkerPool((message) => this.#on_worker_message(message));
const blocks_registry = EverythingRegistry.get_registry<BlockRegistry>("blocks");
const block_ids: Record<string, number> = {};
blocks_registry.forEach((block, nid) => block_ids[block.id] = nid);
// sent once instead of with every mesh request
this.workers.broadcast({
type: "init",
blocks_registry: strip_functions(blocks_registry),
block_ids,
textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts,
ores: worldgen_mods.ores,
});
}
dispose() {
this.workers.terminate();
for (const chunk of this.chunks.values()) {
this.delete_chunk_mesh(chunk);
}
this.chunks.clear();
this.pending_generation.clear();
}
add_chunk(x: number, z: number, blocks: Uint32Array = new Uint32Array(CHUNK_AREA * CHUNK_HEIGHT)) {
const chunk: Chunk = {
x,
z,
blocks,
dirty: true,
generated: false,
mesh_version: 0,
};
this.chunks.set(chunk_key(x, z), chunk);
return chunk;
}
get_chunk(x: number, z: number) {
return this.chunks.get(chunk_key(x, z));
}
add_block(block: Block) {
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
const block_chunk_z = Math.floor(block.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);
}
const nid = EverythingRegistry.get_id("blocks", block.id)!;
const lx = block.x - block_chunk_x * CHUNK_SIZE;
const lz = block.z - block_chunk_z * CHUNK_SIZE;
const ly = block.y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = nid;
chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
}
get_block(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 VOID;
}
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
return chunk.blocks[index] & ID_MASK;
}
// only changes what this client shows, drops and everything else happen on the server
break_block(x: number, y: number, z: number) {
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
const chunk = this.get_chunk(block_chunk_x, block_chunk_z);
if (!chunk) {
return;
}
const lx = x - block_chunk_x * CHUNK_SIZE;
const lz = z - block_chunk_z * CHUNK_SIZE;
const ly = y;
const index = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
chunk.blocks[index] = AIR;
chunk.dirty = true;
this.#mark_border_neighbors_dirty(block_chunk_x, block_chunk_z, lx, lz);
}
// a block on a chunk's edge changes which faces its neighbor shows
#mark_border_neighbors_dirty(block_chunk_x: number, block_chunk_z: number, lx: number, lz: number) {
if (lx === 0) {
const n = this.get_chunk(block_chunk_x - 1, block_chunk_z);
if (n) {
n.dirty = true;
}
} else if (lx === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x + 1, block_chunk_z);
if (n) {
n.dirty = true;
}
}
if (lz === 0) {
const n = this.get_chunk(block_chunk_x, block_chunk_z - 1);
if (n) {
n.dirty = true;
}
} else if (lz === CHUNK_SIZE - 1) {
const n = this.get_chunk(block_chunk_x, block_chunk_z + 1);
if (n) {
n.dirty = true;
}
}
}
index_to_xyz(index: number) {
const y = Math.floor(index / CHUNK_AREA);
const rem = index % CHUNK_AREA;
const z = Math.floor(rem / CHUNK_SIZE);
const x = rem % CHUNK_SIZE;
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]);
}
// set a block the server told us about
apply_change(x: number, y: number, z: number, id: string) {
const chunk = this.get_chunk(Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
if (!chunk || !chunk.generated) {
return;
}
const current = this.get_block(x, y, z);
if (id === AIR_ID) {
if (current !== AIR) {
this.break_block(x, y, z);
}
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);
}
}
is_generated(cx: number, cz: number) {
return this.get_chunk(cx, cz)?.generated ?? false;
}
is_generating(cx: number, cz: number) {
return this.pending_generation.has(chunk_key(cx, cz));
}
// generation happens in a worker, the chunk shows up in #on_generated
request_chunk(cx: number, cz: number) {
const key = chunk_key(cx, cz);
if (this.pending_generation.has(key) || this.chunks.get(key)?.generated) {
return;
}
this.pending_generation.set(key, { x: cx, z: cz });
this.workers.post({ type: "generate", chunk_x: cx, chunk_z: cz, seed: this.seed });
}
cancel_chunk_request(cx: number, cz: number) {
this.pending_generation.delete(chunk_key(cx, cz));
}
unload_chunk(cx: number, cz: number) {
const key = chunk_key(cx, cz);
const chunk = this.chunks.get(key);
if (!chunk) {
return;
}
this.delete_chunk_mesh(chunk);
this.chunks.delete(key);
}
// a chunk is only meshed once all its neighbors exist, otherwise its border faces would be wrong
// and it would have to be meshed again as each neighbor loads
can_mesh(chunk: Chunk) {
if (!chunk.generated) {
return false;
}
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
if (!this.is_generated(chunk.x + dx, chunk.z + dz)) {
return false;
}
}
return true;
}
// sends every dirty chunk that can be meshed to the workers
request_meshes() {
for (const chunk of this.chunks.values()) {
if (!chunk.dirty || !this.can_mesh(chunk)) {
continue;
}
chunk.dirty = false;
chunk.mesh_version += 1;
const padded_chunk = this.create_padded_chunk(chunk);
this.workers.post({
type: "mesh",
chunk_x: chunk.x,
chunk_z: chunk.z,
version: chunk.mesh_version,
padded_chunk,
}, [padded_chunk.buffer]);
}
}
#on_worker_message(message: FromChunkWorker) {
if (message.type === "generated") {
this.#on_generated(message.chunk_x, message.chunk_z, message.blocks, message.spills);
} else {
this.#on_meshed(message);
}
}
#on_generated(cx: number, cz: number, blocks: Uint32Array, spills: Int32Array) {
const key = chunk_key(cx, cz);
// unloaded or cancelled while the worker was busy
if (!this.pending_generation.delete(key)) {
return;
}
let chunk = this.chunks.get(key);
if (chunk) {
// a placeholder made by a neighbor's tree, keep its blocks where generation left air
const existing = chunk.blocks;
for (let i = 0; i < blocks.length; i++) {
if (blocks[i] !== AIR) {
existing[i] = blocks[i];
}
}
} else {
chunk = this.add_chunk(cx, cz, blocks);
}
chunk.generated = true;
chunk.dirty = true;
for (let i = 0; i < spills.length; i += 4) {
this.#set_block_raw(spills[i], spills[i + 1], spills[i + 2], spills[i + 3]);
}
for (const [dx, dz] of NEIGHBOR_OFFSETS) {
const neighbor = this.get_chunk(cx + dx, cz + dz);
if (neighbor && neighbor.generated) {
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);
}
}
}
#on_meshed(message: Extract<FromChunkWorker, { type: "meshed" }>) {
const chunk = this.get_chunk(message.chunk_x, message.chunk_z);
// unloaded, or remeshed again since this was requested
if (!chunk || chunk.mesh_version !== message.version) {
return;
}
this.delete_chunk_mesh(chunk);
chunk.opaque_vertex_buffer = create_vertex_buffer(message.opaque_vertices.subarray(0, message.opaque_count));
chunk.opaque_vertex_count = message.opaque_count / 9;
chunk.transparent_vertex_buffer = create_vertex_buffer(
message.transparent_vertices.subarray(0, message.transparent_count),
);
chunk.transparent_vertex_count = message.transparent_count / 9;
}
// a neighbor's leaves, only fill air so the result doesn't depend on which chunk loaded first.
// the server builds chunks the same way (server/game/world.ts)
#set_block_raw(x: number, y: number, z: number, nid: number) {
if (y < 0 || y >= CHUNK_HEIGHT) {
return;
}
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) ?? this.add_chunk(chunk_x, chunk_z);
const lx = x - chunk_x * CHUNK_SIZE;
const lz = z - chunk_z * CHUNK_SIZE;
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
if (chunk.blocks[index] === AIR) {
chunk.blocks[index] = nid;
chunk.dirty = true;
}
}
delete_chunk_mesh(chunk: Chunk) {
if (chunk.opaque_vertex_buffer) {
destroy_vertex_buffer(chunk.opaque_vertex_buffer);
chunk.opaque_vertex_buffer = undefined;
}
if (chunk.transparent_vertex_buffer) {
destroy_vertex_buffer(chunk.transparent_vertex_buffer);
chunk.transparent_vertex_buffer = undefined;
}
}
get_looked_block(
dimension: Dimension,
camera: Camera,
max_distance = 6,
step = 0.05,
): { x: number; y: number; z: number; block: number; face: Faces } | undefined {
const yaw = camera.yaw;
const pitch = camera.pitch;
const cos_pitch = Math.cos(pitch);
const dx = -Math.sin(yaw) * cos_pitch;
const dy = Math.sin(pitch);
const dz = -Math.cos(yaw) * cos_pitch;
let x = camera.x;
let y = camera.y;
let z = camera.z;
let prev_bx = Math.floor(x);
let prev_by = Math.floor(y);
let prev_bz = Math.floor(z);
let dist = 0;
while (dist <= max_distance) {
x += dx * step;
y += dy * step;
z += dz * step;
dist += step;
const bx = Math.floor(x);
const by = Math.floor(y);
const bz = Math.floor(z);
if (bx === prev_bx && by === prev_by && bz === prev_bz) {
continue;
}
const block = dimension.get_block(bx, by, bz);
if (block && block !== AIR && block !== VOID) {
let face: Faces;
if (bx > prev_bx) {
face = "west";
} else if (bx < prev_bx) {
face = "east";
} else if (by > prev_by) {
face = "bottom";
} else if (by < prev_by) {
face = "top";
} else if (bz > prev_bz) {
face = "north";
} else {
face = "south";
}
return { x: bx, y: by, z: bz, face, block };
}
prev_bx = bx;
prev_by = by;
prev_bz = bz;
}
return undefined;
}
create_padded_chunk(chunk: Chunk) {
const size = CHUNK_SIZE + 2;
const layer = size * size;
const padded = new Uint32Array(layer * CHUNK_HEIGHT);
// look the 3x3 chunks up once instead of once per border block
const around: (Uint32Array | undefined)[] = [];
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
around.push(this.get_chunk(chunk.x + dx, chunk.z + dz)?.blocks);
}
}
for (let z = -1; z <= CHUNK_SIZE; z++) {
const dz = z < 0 ? -1 : z >= CHUNK_SIZE ? 1 : 0;
const lz = z - dz * CHUNK_SIZE;
for (let x = -1; x <= CHUNK_SIZE; x++) {
const dx = x < 0 ? -1 : x >= CHUNK_SIZE ? 1 : 0;
const lx = x - dx * CHUNK_SIZE;
const source = around[(dz + 1) * 3 + (dx + 1)];
const source_index = lz * CHUNK_SIZE + lx;
const padded_index = (z + 1) * size + (x + 1);
if (!source) {
for (let y = 0; y < CHUNK_HEIGHT; y++) {
padded[y * layer + padded_index] = VOID;
}
continue;
}
for (let y = 0; y < CHUNK_HEIGHT; y++) {
padded[y * layer + padded_index] = source[y * CHUNK_AREA + source_index] & ID_MASK;
}
}
}
return padded;
}
}
// functions cant be sent to workers
// deno-lint-ignore no-explicit-any
function strip_functions<T extends Record<string, any>>(obj: T): T {
// deno-lint-ignore no-explicit-any
const out: any = Array.isArray(obj) ? [] : {};
for (const k in obj) {
const v = obj[k];
if (typeof v === "function") continue;
if (v && typeof v === "object") {
out[k] = strip_functions(v);
} else {
out[k] = v;
}
}
return out;
}