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>
235 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|