TURNCRAFT/src/engine/VoxelWorld.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

235 lines
9.2 KiB
TypeScript

// TURNCRAFT — Lane A. Chunked voxel storage implementing IVoxelWorld.
//
// One Uint8Array per 32^3 chunk, allocated lazily (an unallocated chunk reads
// as all-AIR). Coordinates are Y-up, 1 voxel = 1 unit; voxel (x,y,z) occupies
// [x,x+1)x[y,y+1)x[z,z+1). All world sizes come in as multiples of CHUNK.
import { CHUNK } from '../core/constants';
import { AIR, isSolidId, type BlockId } from '../core/blocks';
import type { IVoxelWorld } from '../core/types';
// Out-of-bounds below y=0 reads as a solid sentinel so the player never falls
// out of the world and the floor's underside is culled. Value is arbitrary as
// long as it is a solid, opaque block id (never rendered — nothing meshes y<0).
const OOB_SOLID: BlockId = 4; // steel_grey (solid, opaque)
const CH = CHUNK;
const CH2 = CH * CH;
const CH3 = CH * CH * CH;
const P = CH + 2; // padded chunk edge (1-voxel border)
const P3 = P * P * P;
/** local voxel index within a chunk array: y outer, z middle, x inner. */
function localIdx(lx: number, ly: number, lz: number): number {
return (ly * CH + lz) * CH + lx;
}
/** padded index for coords in [-1, CH]; matches localIdx ordering (+1 shift). */
function padIdx(px: number, py: number, pz: number): number {
return ((py + 1) * P + (pz + 1)) * P + (px + 1);
}
export class VoxelWorld implements IVoxelWorld {
readonly sizeX: number;
readonly sizeY: number;
readonly sizeZ: number;
readonly numChunksX: number;
readonly numChunksY: number;
readonly numChunksZ: number;
private readonly chunks: (Uint8Array | undefined)[];
private readonly dirty = new Set<number>();
// fillBox chunk-resolve cache (fillBox is worldgen's hot path).
private _lastCx = -1;
private _lastCy = -1;
private _lastCz = -1;
private _lastArr: Uint8Array | null = null;
constructor(sizeX: number, sizeY: number, sizeZ: number) {
if (sizeX % CH || sizeY % CH || sizeZ % CH) {
throw new Error(`world size must be a multiple of CHUNK=${CH}`);
}
this.sizeX = sizeX;
this.sizeY = sizeY;
this.sizeZ = sizeZ;
this.numChunksX = sizeX / CH;
this.numChunksY = sizeY / CH;
this.numChunksZ = sizeZ / CH;
this.chunks = new Array(this.numChunksX * this.numChunksY * this.numChunksZ);
}
private chunkIndex(cx: number, cy: number, cz: number): number {
return (cy * this.numChunksZ + cz) * this.numChunksX + cx;
}
private chunkAt(cx: number, cy: number, cz: number): Uint8Array | undefined {
return this.chunks[this.chunkIndex(cx, cy, cz)];
}
getBlock(x: number, y: number, z: number): BlockId {
if (y < 0) return OOB_SOLID;
if (x < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) {
return AIR;
}
const cx = (x / CH) | 0, cy = (y / CH) | 0, cz = (z / CH) | 0;
const arr = this.chunks[this.chunkIndex(cx, cy, cz)];
if (!arr) return AIR;
return arr[localIdx(x - cx * CH, y - cy * CH, z - cz * CH)];
}
isSolid(x: number, y: number, z: number): boolean {
return isSolidId(this.getBlock(x, y, z));
}
setBlock(x: number, y: number, z: number, id: BlockId): void {
if (x < 0 || y < 0 || z < 0 || x >= this.sizeX || y >= this.sizeY || z >= this.sizeZ) {
return;
}
const cx = (x / CH) | 0, cy = (y / CH) | 0, cz = (z / CH) | 0;
const ci = this.chunkIndex(cx, cy, cz);
let arr = this.chunks[ci];
if (!arr) {
if (id === AIR) return; // writing air into empty chunk: nothing to do
arr = this.chunks[ci] = new Uint8Array(CH3);
}
const lx = x - cx * CH, ly = y - cy * CH, lz = z - cz * CH;
const li = localIdx(lx, ly, lz);
if (arr[li] === id) return; // no change → no remesh
arr[li] = id;
this.markDirty(cx, cy, cz);
// Neighbouring chunk faces go stale when the edited voxel borders them.
if (lx === 0) this.markDirty(cx - 1, cy, cz);
else if (lx === CH - 1) this.markDirty(cx + 1, cy, cz);
if (ly === 0) this.markDirty(cx, cy - 1, cz);
else if (ly === CH - 1) this.markDirty(cx, cy + 1, cz);
if (lz === 0) this.markDirty(cx, cy, cz - 1);
else if (lz === CH - 1) this.markDirty(cx, cy, cz + 1);
}
/**
* Bulk fill [x0..x1] x [y0..y1] x [z0..z1] (inclusive) with `id`, writing raw
* into chunk arrays without per-voxel dirty bookkeeping. Dirties every touched
* chunk plus a 1-chunk margin so cross-chunk border faces re-cull correctly.
* Worldgen (Lane C) paints millions of voxels through this path.
*/
fillBox(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, id: BlockId): void {
let ax0 = Math.min(x0, x1), ax1 = Math.max(x0, x1);
let ay0 = Math.min(y0, y1), ay1 = Math.max(y0, y1);
let az0 = Math.min(z0, z1), az1 = Math.max(z0, z1);
ax0 = Math.max(0, ax0); ay0 = Math.max(0, ay0); az0 = Math.max(0, az0);
ax1 = Math.min(this.sizeX - 1, ax1);
ay1 = Math.min(this.sizeY - 1, ay1);
az1 = Math.min(this.sizeZ - 1, az1);
if (ax0 > ax1 || ay0 > ay1 || az0 > az1) return;
for (let y = ay0; y <= ay1; y++) {
const cy = (y / CH) | 0, ly = y - cy * CH;
for (let z = az0; z <= az1; z++) {
const cz = (z / CH) | 0, lz = z - cz * CH;
for (let x = ax0; x <= ax1; x++) {
const cx = (x / CH) | 0;
let arr = this._lastArr;
if (cx !== this._lastCx || cy !== this._lastCy || cz !== this._lastCz || !arr) {
const ci = this.chunkIndex(cx, cy, cz);
arr = this.chunks[ci] ?? null;
if (!arr) {
if (id === AIR) { // don't allocate a chunk just to write air
this._lastCx = cx; this._lastCy = cy; this._lastCz = cz;
this._lastArr = null;
continue;
}
arr = this.chunks[ci] = new Uint8Array(CH3);
}
this._lastCx = cx; this._lastCy = cy; this._lastCz = cz;
this._lastArr = arr;
}
arr[localIdx(x - cx * CH, ly, lz)] = id;
}
}
}
this._lastArr = null; // invalidate cache (arrays may be re-fetched next call)
// Dirty the touched chunk range, expanded by one chunk for border re-cull.
const cx0 = Math.max(0, ((ax0 / CH) | 0) - 1);
const cy0 = Math.max(0, ((ay0 / CH) | 0) - 1);
const cz0 = Math.max(0, ((az0 / CH) | 0) - 1);
const cx1 = Math.min(this.numChunksX - 1, ((ax1 / CH) | 0) + 1);
const cy1 = Math.min(this.numChunksY - 1, ((ay1 / CH) | 0) + 1);
const cz1 = Math.min(this.numChunksZ - 1, ((az1 / CH) | 0) + 1);
for (let cy = cy0; cy <= cy1; cy++)
for (let cz = cz0; cz <= cz1; cz++)
for (let cx = cx0; cx <= cx1; cx++)
this.dirty.add(this.chunkIndex(cx, cy, cz));
}
// --- dirty tracking (consumed by ChunkManager, both Lane A) ---
private markDirty(cx: number, cy: number, cz: number): void {
if (cx < 0 || cy < 0 || cz < 0 ||
cx >= this.numChunksX || cy >= this.numChunksY || cz >= this.numChunksZ) return;
this.dirty.add(this.chunkIndex(cx, cy, cz));
}
forEachDirtyChunk(cb: (cx: number, cy: number, cz: number) => void): void {
// Snapshot: the callback may clearDirty as it goes.
const nx = this.numChunksX, nz = this.numChunksZ;
for (const ci of this.dirty) {
const cx = ci % nx;
const cy = (ci / (nx * nz)) | 0;
const cz = ((ci - cy * nx * nz) / nx) | 0;
cb(cx, cy, cz);
}
}
clearDirty(cx: number, cy: number, cz: number): void {
this.dirty.delete(this.chunkIndex(cx, cy, cz));
}
get dirtyCount(): number { return this.dirty.size; }
isChunkEmpty(cx: number, cy: number, cz: number): boolean {
return this.chunkAt(cx, cy, cz) === undefined;
}
/**
* Fill `out` (length (CHUNK+2)^3) with the block ids of chunk (cx,cy,cz) plus a
* 1-voxel border read from neighbouring chunks / world bounds. Interior is a
* fast direct array copy; only the ~6*(CHUNK+2)^2 border cells go through
* getBlock. The mesher indexes this with padIdx() (coords -1..CHUNK).
*/
readChunkPadded(cx: number, cy: number, cz: number, out: Uint8Array): void {
if (out.length !== P3) throw new Error('readChunkPadded: bad buffer size');
const arr = this.chunkAt(cx, cy, cz);
// interior [0..CH-1]^3
if (arr) {
for (let ly = 0; ly < CH; ly++)
for (let lz = 0; lz < CH; lz++) {
let src = (ly * CH + lz) * CH;
let dst = ((ly + 1) * P + (lz + 1)) * P + 1;
for (let lx = 0; lx < CH; lx++) out[dst + lx] = arr[src + lx];
}
} else {
out.fill(0); // whole chunk (incl. interior) starts as AIR
}
const bx = cx * CH, by = cy * CH, bz = cz * CH;
// x = -1 and x = CH planes
for (let py = -1; py <= CH; py++)
for (let pz = -1; pz <= CH; pz++) {
out[padIdx(-1, py, pz)] = this.getBlock(bx - 1, by + py, bz + pz);
out[padIdx(CH, py, pz)] = this.getBlock(bx + CH, by + py, bz + pz);
}
// y = -1 and y = CH planes
for (let px = -1; px <= CH; px++)
for (let pz = -1; pz <= CH; pz++) {
out[padIdx(px, -1, pz)] = this.getBlock(bx + px, by - 1, bz + pz);
out[padIdx(px, CH, pz)] = this.getBlock(bx + px, by + CH, bz + pz);
}
// z = -1 and z = CH planes
for (let px = -1; px <= CH; px++)
for (let py = -1; py <= CH; py++) {
out[padIdx(px, py, -1)] = this.getBlock(bx + px, by + py, bz - 1);
out[padIdx(px, py, CH)] = this.getBlock(bx + px, by + py, bz + CH);
}
}
}