// 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(); // 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); } } }