diff --git a/package.json b/package.json index af9d298..7ba2057 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "site:preview": "cd website && bun run preview", "typecheck": "tsc --noEmit", "check": "bun run typecheck", + "test": "bun tests/liquid.test.ts && bun tests/raycast.test.ts && bun tests/responsiveness.test.ts", "test:light": "bun scripts/lighttest.ts", "screenshot": "bun scripts/screenshot.ts" }, diff --git a/src/game/BlockRaycaster.ts b/src/game/BlockRaycaster.ts index b5d88a8..58e7c58 100644 --- a/src/game/BlockRaycaster.ts +++ b/src/game/BlockRaycaster.ts @@ -1,12 +1,28 @@ -import type { RaycastHit } from "../types"; +import type { RaycastHit, LiquidPassHit } from "../types"; import type { World } from "../game/World"; +import { isLiquid } from "./Blocks"; const EPS = 1e-9; +/** Raycast targeting options (Luanti-style `liquids` / `pointabilities`). */ +export interface RaycastOptions { + /** + * When true, liquid cells are treated as passable: the ray continues through + * them to the first SOLID block, recording the first liquid it crossed in + * `firstLiquid` / `passedThroughLiquid`. This is the default mining/building + * behaviour (Luanti `core.raycast(..., liquids = false)` — liquids are not + * pointable by default). When false, the ray stops at the first non-air cell, + * including liquids (bucket-style liquid selection). + */ + ignoreLiquid?: boolean; +} + /** * Amanatides & Woo voxel raycast. Steps through the integer grid from `origin` - * along normalized `dir`, returning the first solid (non-air) block hit within - * `maxDist`. The adjacent empty cell (for placement) is also returned. + * along normalized `dir`, returning the first hit block within `maxDist` (solid + * always; liquid only unless `ignoreLiquid`). The adjacent empty cell (for + * placement) is also returned, along with whether the ray crossed any liquid + * and the first such liquid cell (for the liquid-targeting mode + debug). */ export function raycastVoxel( world: World, @@ -17,7 +33,10 @@ export function raycastVoxel( dy: number, dz: number, maxDist: number, + opts?: RaycastOptions, ): RaycastHit | null { + const ignoreLiquid = opts?.ignoreLiquid ?? false; + let x = Math.floor(ox); let y = Math.floor(oy); let z = Math.floor(oz); @@ -39,19 +58,44 @@ export function raycastVoxel( let tMaxY = stepY !== 0 ? tDeltaY * (fracY === 0 ? 1 : fracY) : Infinity; let tMaxZ = stepZ !== 0 ? tDeltaZ * (fracZ === 0 ? 1 : fracZ) : Infinity; + // (px,py,pz) tracks the last passable cell the ray was in before the current + // one — the placement cell adjacent to whichever face we eventually hit. We + // update it whenever we step INTO a new cell, using the cell we just left. let px = x; let py = y; let pz = z; let t = 0; + let passedThroughLiquid = false; + let firstLiquid: LiquidPassHit | undefined; + // (lpx,lpy,lpz): the passable cell immediately before the first liquid, used + // as that liquid's placement coordinate if it becomes the active target. + let lpx = x; + let lpy = y; + let lpz = z; + while (t <= maxDist) { const block = world.getBlock(x, y, z); if (block !== 0) { - return { x, y, z, px, py, pz, block, distance: t }; + if (ignoreLiquid && isLiquid(block)) { + // Pass through the liquid; remember the first one for targeting/debug. + if (!firstLiquid) { + firstLiquid = { x, y, z, px: lpx, py: lpy, pz: lpz, block, distance: t }; + } + passedThroughLiquid = true; + } else { + return { x, y, z, px, py, pz, block, distance: t, passedThroughLiquid, firstLiquid }; + } } + // Remember the cell we're leaving as the placement origin for the NEXT cell. px = x; py = y; pz = z; + if (!firstLiquid) { + lpx = x; + lpy = y; + lpz = z; + } if (tMaxX < tMaxY) { if (tMaxX < tMaxZ) { x += stepX; @@ -75,5 +119,11 @@ export function raycastVoxel( } if (t > maxDist + EPS) break; } + // No solid hit. If we passed a liquid and are ignoring liquids, expose it so + // the caller can fall back to selecting the water surface (Luanti-style). + if (firstLiquid) { + const f = firstLiquid; + return { x: f.x, y: f.y, z: f.z, px: f.px, py: f.py, pz: f.pz, block: f.block, distance: f.distance, passedThroughLiquid: true, firstLiquid }; + } return null; } diff --git a/src/game/Blocks.ts b/src/game/Blocks.ts index 9298b01..ddd5609 100644 --- a/src/game/Blocks.ts +++ b/src/game/Blocks.ts @@ -89,6 +89,54 @@ export interface BlockLightDefinition { receivesShadows?: boolean; } +/** + * Minetest/Luanti-style liquid behaviour for a block. Attached to BOTH the + * source and the flowing member of a liquid pair (see `liquidType`). The pair + * shares the same `LiquidDef`; only `liquidType` differs. + * + * Level/depth is NOT stored here — it is per-voxel (see `Chunk.levels` and + * `liquidHeight()`). Source cells are implicitly "full"; flowing cells carry a + * 1..MAX_LEVEL value that decays one step per horizontal spread. + */ +export interface LiquidDef { + /** Logical liquid id ("water", "lava", …). Source + flowing share this. */ + id: string; + /** Max horizontal distance flowing spreads from a source on flat ground. */ + range: number; + /** Flow speed tier (0 fastest → 7 slowest). Slows the update rate. */ + viscosity: number; + /** Whether 2+ adjacent sources may renew a new source (infinite water). */ + renewable: boolean; + /** Applies swim physics (buoyancy/drag) to the player. */ + swimmable: boolean; + /** Drowning damage per second when the player's head is submerged (0 = none). */ + drowning: number; + /** Screen tint (hex) applied when the camera is submerged in this liquid. */ + fogColor: string; + /** Fog-distance multiplier when submerged (<1 = murkier). */ + fogDensity: number; +} + +/** Liquid role of a block (Minetest `liquidtype`). */ +export type LiquidType = "none" | "source" | "flowing"; + +/** Shared liquid definition for water (source + flowing pair). Declared here, + * before the block table, so the Water / Flowing Water entries can reference + * it without a temporal-dead-zone error. */ +export const WATER_LIQUID_DEF: LiquidDef = { + id: "water", + range: 7, + viscosity: 1, + renewable: true, + swimmable: true, + drowning: 0, // breath/drowning handled by PlayerState; scaffold value + fogColor: "#1f6fb0", + fogDensity: 0.45, +}; + +/** Maximum flowing-liquid level (full flowing just under a source). */ +export const MAX_LIQUID_LEVEL = 7; + export interface BlockDef { id: BlockId; name: string; @@ -108,6 +156,10 @@ export interface BlockDef { shape?: "plantlike"; /** Voxel lighting behaviour. Omitted fields resolve to documented defaults. */ light?: BlockLightDefinition; + /** Liquid role (source/flowing/none). `liquid` must be true when non-"none". */ + liquidType?: LiquidType; + /** Liquid definition (range/viscosity/renewable/swim/drown/fog). */ + liquidDef?: LiquidDef; } function uniform(tile: number): readonly [number, number, number, number, number, number] { @@ -201,6 +253,8 @@ export const BLOCKS: readonly BlockDef[] = [ opaque: false, transparent: true, liquid: true, + liquidType: "source", + liquidDef: WATER_LIQUID_DEF, // Light spreads through water but sunlight does not pass unattenuated, so // light decays with depth (deep water is dark). light: { lightPassesThrough: true, sunlightPassesThrough: false }, @@ -423,10 +477,26 @@ export const BLOCKS: readonly BlockDef[] = [ // propagator can be observed (e.g. a lit radius inside a dark cave). light: { lightEmission: 15 }, }, + { + id: 29, + name: "Flowing Water", + tiles: uniform(T.WATER), + color: "#366ec4", + solid: false, + opaque: false, + transparent: true, + liquid: true, + liquidType: "flowing", + liquidDef: WATER_LIQUID_DEF, + // Same lighting behaviour as the source: light spreads through, sunlight + // does not pass unattenuated (deep water darkens). + light: { lightPassesThrough: true, sunlightPassesThrough: false }, + }, ]; export const AIR_BLOCK = 0; export const WATER_BLOCK = 7; +export const WATER_FLOWING_BLOCK = 29; export const CACTUS_BLOCK = 19; export const MUSHROOM_BLOCK = 23; @@ -483,3 +553,59 @@ export function clampLight(v: number): number { } export { MAX_LIGHT }; + +// --------------------------------------------------------------------------- +// Liquid accessors (Minetest/Luanti-style). Level/depth is per-voxel: a source +// is implicitly full (height = MAX_LIQUID_LEVEL + 1), a flowing node carries a +// 1..MAX_LIQUID_LEVEL value, and non-liquids have height 0. +// --------------------------------------------------------------------------- + +/** True if the block id is any liquid (source or flowing). */ +export function isLiquid(id: BlockId): boolean { + return getBlock(id).liquid; +} + +/** True if the block id is a liquid source. */ +export function isLiquidSource(id: BlockId): boolean { + return getBlock(id).liquidType === "source"; +} + +/** True if the block id is a flowing liquid. */ +export function isLiquidFlowing(id: BlockId): boolean { + return getBlock(id).liquidType === "flowing"; +} + +/** + * "Head" of a liquid cell — a monotonic measure of how much liquid is present, + * used by the flow simulator to decide spread direction and decay. + * + * source → MAX_LIQUID_LEVEL + 1 (i.e. 8, "full") + * flowing (level L) → L (1..7) + * non-liquid → 0 + * + * Flowing water spreads to neighbours whose head is lower; each horizontal + * step decays by one. This mirrors Minetest's `LiquidData::level` semantics. + */ +export function liquidHeight(id: BlockId, level: number): number { + const def = getBlock(id); + if (def.liquidType === "source") return MAX_LIQUID_LEVEL + 1; + if (def.liquidType === "flowing") return level > 0 ? (level > MAX_LIQUID_LEVEL ? MAX_LIQUID_LEVEL : level) : 0; + return 0; +} + +/** The shared `LiquidDef` for a liquid block id, or null for non-liquids. */ +export function liquidDefOf(id: BlockId): LiquidDef | null { + return getBlock(id).liquidDef ?? null; +} + +/** + * Whether a liquid may flow INTO this block (Minetest `floodable`). Air and + * non-solid plantlike decorations are floodable; opaque solids and other + * liquids are not. Liquids never displace solid terrain. + */ +export function isFloodable(id: BlockId): boolean { + if (id === AIR_BLOCK) return true; + const def = getBlock(id); + if (def.liquid) return false; // don't displace other liquids + return !def.solid; // air-like or passable decoration (tall grass, flowers…) +} diff --git a/src/game/Chunk.ts b/src/game/Chunk.ts index b45cb83..e5305b2 100644 --- a/src/game/Chunk.ts +++ b/src/game/Chunk.ts @@ -1,5 +1,6 @@ import { CHUNK_SIZE, CHUNK_HEIGHT, CHUNK_VOLUME } from "../constants"; import type { BlockId, ChunkCoord } from "../types"; +import { WATER_FLOWING_BLOCK as FLOWING_WATER_ID } from "./Blocks"; /** Convert local block coords to a flat array index. */ export function blockIndex(x: number, y: number, z: number): number { @@ -9,11 +10,22 @@ export function blockIndex(x: number, y: number, z: number): number { /** * A single chunk: a flat Uint8Array of block ids plus metadata used for * streaming and remeshing. Chunk meshes live on the World/scene side. + * + * In addition to block ids, a chunk carries an optional per-voxel **liquid + * level** array (mirrors the lighting `LightMap` design). Only flowing-liquid + * cells use it (1..MAX_LIQUID_LEVEL); sources are implicitly full and + * non-liquids read 0. Kept as a separate array so the id-driven mesher and + * collision code stay untouched. */ export class Chunk implements ChunkCoord { readonly cx: number; readonly cz: number; readonly blocks: Uint8Array; + /** + * Per-voxel liquid level (0..MAX_LIQUID_LEVEL). Only meaningful for cells + * whose block id is a flowing liquid; sources ignore it. + */ + readonly levels: Uint8Array; /** World-space x origin of this chunk (block coords). */ readonly originX: number; /** World-space z origin of this chunk (block coords). */ @@ -30,6 +42,7 @@ export class Chunk implements ChunkCoord { this.cx = cx; this.cz = cz; this.blocks = new Uint8Array(CHUNK_VOLUME); + this.levels = new Uint8Array(CHUNK_VOLUME); this.originX = cx * CHUNK_SIZE; this.originZ = cz * CHUNK_SIZE; } @@ -48,6 +61,37 @@ export class Chunk implements ChunkCoord { const idx = blockIndex(x, y, z); if (this.blocks[idx] === id) return false; this.blocks[idx] = id; + // Clear stale liquid metadata whenever the cell is no longer a flowing + // liquid (placing terrain into water, drying a cell, etc.). Sources carry + // no level, so only flowing ids retain it. + if (id !== FLOWING_WATER_ID) this.levels[idx] = 0; + this.version++; + this.dirty = true; + return true; + } + + /** Liquid level at local coords (0 for non-flowing / out of range). */ + getLocalLevel(x: number, y: number, z: number): number { + if (x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE) { + return 0; + } + return this.levels[blockIndex(x, y, z)]; + } + + /** + * Set both the block id and the liquid level atomically. Use this for any + * liquid edit so the level array never goes stale relative to the id array. + * Returns true if anything changed. + */ + setLocalWithLevel(x: number, y: number, z: number, id: BlockId, level: number): boolean { + if (x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE) { + return false; + } + const idx = blockIndex(x, y, z); + const lv = level < 0 ? 0 : level; + if (this.blocks[idx] === id && this.levels[idx] === lv) return false; + this.blocks[idx] = id; + this.levels[idx] = id === 0 ? 0 : lv; this.version++; this.dirty = true; return true; diff --git a/src/game/ChunkMesher.ts b/src/game/ChunkMesher.ts index 417919d..8de8d05 100644 --- a/src/game/ChunkMesher.ts +++ b/src/game/ChunkMesher.ts @@ -1,7 +1,7 @@ import { VertexData } from "@babylonjs/core"; import { CHUNK_SIZE, CHUNK_HEIGHT } from "../constants"; import type { BlockId, FaceDef } from "../types"; -import { getBlock } from "./Blocks"; +import { getBlock, MAX_LIQUID_LEVEL, FACE, type BlockDef } from "./Blocks"; import { tileUV } from "../engine/Textures"; import type { Chunk } from "./Chunk"; import { FACE_SHADE, PLANT_SHADE } from "./lighting/LightingConfig"; @@ -29,6 +29,9 @@ export interface BrightnessSample { export type BrightnessSampler = (wx: number, wy: number, wz: number, shade: number) => BrightnessSample; +/** Per-voxel liquid level accessor (world coords; 0 for non-flowing). */ +export type LevelSampler = (wx: number, wy: number, wz: number) => number; + // The six cube faces. Corner order + UVs are tuned so that triangles // (0,1,2, 2,1,3) produce correctly-wound front faces. Order matches the // FACE index in Blocks.ts: [PX, NX, PY, NY, PZ, NZ]. @@ -137,10 +140,34 @@ function shouldRenderFace(self: BlockId, neighbor: BlockId): boolean { if (neighbor === 0) return true; // air always shows the face const nb = getBlock(neighbor); if (nb.opaque) return false; // hidden by opaque neighbor - // Transparent neighbor: render unless it's the same type (water-water, etc.) + // Transparent neighbor: render unless it's the same liquid family + // (water-source / water-flowing share a face → cull). Other transparent + // blocks (e.g. a plant next to water) still show the face. + if (nb.liquid) return !isSameLiquid(self, neighbor); return getBlock(self).id !== nb.id; } +/** + * True if two blocks belong to the same liquid family (e.g. water source + + * flowing water). Used to cull faces between adjacent water cells and to draw + * "steps" between differing flowing levels instead of leaving gaps. + */ +function isSameLiquid(a: BlockId, b: BlockId): boolean { + if (a === b) return true; + const da = getBlock(a); + const db = getBlock(b); + if (!da.liquid || !db.liquid) return false; + return da.liquidDef?.id === db.liquidDef?.id && da.liquidDef?.id !== undefined; +} + +/** Render height (fraction of a block) for a liquid cell. */ +function liquidTopFrac(def: BlockDef, level: number): number { + if (def.liquidType === "source") return 0.9; // matches the pre-overfall surface dip + const f = (level <= 0 ? 0 : level > MAX_LIQUID_LEVEL ? MAX_LIQUID_LEVEL : level) / (MAX_LIQUID_LEVEL + 1); + // Keep a visible minimum so level-1 trickles still render. + return f < 0.12 ? 0.12 : f; +} + export interface MeshResult { opaque: VertexData | null; cutout: VertexData | null; @@ -205,6 +232,129 @@ function pushFace( b.vertexCount += 4; } +/** + * Push a liquid face whose vertical extent is mapped to a custom [bottom,top] + * block fraction (0..1) rather than the full cube. This is what lets flowing + * water render at partial height and lets "steps" between differing flowing + * levels draw as exposed vertical strips. + * + * UVs are WORLD-SPACE (derived from each corner's world position + the face + * normal), NOT atlas-tile UVs. This is critical for water: the shared surface + * texture then maps continuously across the whole body (no per-block tiling + * grid), and scrolling uOffset/vOffset on the material animates the entire + * surface as one. The texture's uScale/vScale (+ WRAP) handle the repeat rate. + */ +function pushScaledFace( + b: BufferBuilder, + faceIndex: number, + x: number, + y: number, + z: number, + sample: BrightnessSample, + bottomFrac: number, + topFrac: number, +): void { + const face = FACES[faceIndex]; + const nx = face.normal[0]; + const ny = face.normal[1]; + const base = b.vertexCount; + // Water colours are stripped by World before upload (StandardMaterial + // supplies a uniform tint + texture), but keep baking them so the buffer + // shape is consistent with the terrain pass. + const cr = sample.sunBright; + const cg = sample.blockBright; + const cb = sample.sunLevel; + const ca = sample.blockLevel; + for (let c = 0; c < 4; c++) { + const corner = face.corners[c]; + // corner[1] is 0 (bottom) or 1 (top); map to the requested Y band. + const fy = corner[1] === 1 ? topFrac : bottomFrac; + const wx = x + corner[0]; + const wy = y + fy; + const wz = z + corner[2]; + b.positions.push(wx, wy, wz); + b.normals.push(nx, ny, face.normal[2]); + // Pick the two in-plane world axes from the face normal so the surface + // texture is continuous: Y-faces → (X,Z); X-faces → (Z,Y); Z-faces → (X,Y). + let u: number; + let v: number; + if (ny !== 0) { u = wx; v = wz; } + else if (nx !== 0) { u = wz; v = wy; } + else { u = wx; v = wy; } + b.uvs.push(u, v); + b.colors.push(cr, cg, cb, ca); + } + b.indices.push(base, base + 1, base + 2, base + 2, base + 1, base + 3); + b.vertexCount += 4; +} + +/** + * Build the water geometry for a single liquid cell (source or flowing). + * + * • Top (+Y): drawn at the cell's surface height when the cell above is not + * the same liquid (air/solid lid → exposed surface). This is the primary + * visible surface. + * • Sides: drawn against non-water neighbours; between two water cells of + * differing level, only the exposed vertical strip (neighbourTop..top) is + * drawn so shorelines and waterfalls show clean steps with no gaps. + * + * Internal faces (between two cells of the SAME liquid + same level) are NOT + * drawn — that is what makes a lake read as one continuous body instead of a + * grid of glass cubes. Bottom faces are never drawn: they're always coplanar + * with the terrain below (invisible + a z-fight source). + * + * Source cells render at a near-full height (0.9); flowing cells at level/(MAX+1). + */ +function pushLiquidCell( + b: BufferBuilder, + getBlockWorld: (x: number, y: number, z: number) => BlockId, + getLevelWorld: (x: number, y: number, z: number) => number, + x: number, + y: number, + z: number, + def: BlockDef, + level: number, + sampleBrightness: BrightnessSampler, + renderSides: boolean, +): void { + const top = liquidTopFrac(def, level); + const above = getBlockWorld(x, y + 1, z); + + // +Y top surface (only when not submerged under the same liquid). This is the + // only face rendered for an interior source cell of a flat lake. + if (!isSameLiquid(def.id, above)) { + pushScaledFace(b, FACE.PY, x, y, z, sampleBrightness(x, y + 1, z, FACE_BRIGHTNESS[FACE.PY]), top, top); + } + + if (!renderSides) return; + + // Horizontal faces: step against lower-level water, full against non-water. + // Between equal-level same-liquid neighbours NOTHING is drawn (culled), which + // is what removes the internal grid. Only shore/exposed/step faces render. + const horiz: Array<[number, number, number, number]> = [ + [FACE.PX, x + 1, y, z], + [FACE.NX, x - 1, y, z], + [FACE.PZ, x, y, z + 1], + [FACE.NZ, x, y, z - 1], + ]; + for (const [fi, nx, ny, nz] of horiz) { + const nid = getBlockWorld(nx, ny, nz); + if (isSameLiquid(def.id, nid)) { + const ndef = getBlock(nid); + const nTop = liquidTopFrac(ndef, ndef.liquidType === "flowing" ? getLevelWorld(nx, ny, nz) : 0); + if (nTop < top - 1e-3) { + // Exposed step: strip from the neighbour's surface up to ours. + pushScaledFace(b, fi, x, y, z, sampleBrightness(nx, ny, nz, FACE_BRIGHTNESS[fi]), nTop, top); + } + } else if (nid !== 0 && getBlock(nid).opaque) { + // Hidden by opaque terrain — cull. + } else { + // Air / plant / different transparent — full side. + pushScaledFace(b, fi, x, y, z, sampleBrightness(nx, ny, nz, FACE_BRIGHTNESS[fi]), 0, top); + } + } +} + // Two diagonal quads forming an "X" — the classic plantlike cross used for // grass tufts, flowers and mushrooms. Rendered in the cutout pass. function pushCross(b: BufferBuilder, x: number, y: number, z: number, tile: number, sample: BrightnessSample): void { @@ -257,18 +407,29 @@ function toVertexData(b: BufferBuilder): VertexData | null { return vd; } +/** Options for {@link buildChunkGeometry} (debug toggles threaded from World). */ +export interface MeshOptions { + /** When false, skip water side faces (top surface only) — debug isolation. */ + waterSides?: boolean; +} + /** * Build opaque + transparent geometry for a chunk. `getBlockWorld` returns the * block id at world coordinates (0 = air for unloaded/out-of-range-above, - * opaque for below the world floor). `sampleBrightness` returns the final - * vertex brightness (0..1) for the cell a face looks into, given the face's - * directional shade — it encodes voxel light + day/night + debug mode. + * opaque for below the world floor). `getLevelWorld` returns the per-voxel + * liquid level (used for partial-height flowing water). `sampleBrightness` + * returns the final vertex brightness (0..1) for the cell a face looks into, + * given the face's directional shade — it encodes voxel light + day/night + + * debug mode. */ export function buildChunkGeometry( chunk: Chunk, getBlockWorld: (x: number, y: number, z: number) => BlockId, + getLevelWorld: (x: number, y: number, z: number) => number, sampleBrightness: BrightnessSampler, + opts?: MeshOptions, ): MeshResult { + const waterSides = opts?.waterSides ?? true; const opaque = newBuilder(); const cutout = newBuilder(); const transparent = newBuilder(); @@ -291,9 +452,14 @@ export function buildChunkGeometry( pushCross(cutout, wx, wy, wz, def.tiles[2], br); continue; } - // Only water (liquids) uses the transparent pass/material. Leaves are - // opaque-textured and render in the opaque pass for correct depth. - const builder = def.liquid ? transparent : opaque; + // Liquids (water source + flowing) use the transparent pass with + // partial-height geometry and stepped shorelines. + if (def.liquid) { + const level = chunk.getLocalLevel(x, y, z); + pushLiquidCell(transparent, getBlockWorld, getLevelWorld, wx, wy, wz, def, level, sampleBrightness, waterSides); + continue; + } + // Opaque cubes (terrain, leaves, ores, …). for (let f = 0; f < 6; f++) { const n = FACES[f].neighbor; const nwx = wx + n[0]; @@ -304,10 +470,7 @@ export function buildChunkGeometry( // Face brightness comes from the light of the cell the face is // exposed to (the neighbour air/space), combined with face shade. const sample = sampleBrightness(nwx, nwy, nwz, FACE_BRIGHTNESS[f]); - const isWaterTop = def.liquid && n[1] === 1; - // All faces bake four-channel light colours; the water pass discards - // them (World strips the colour kind) since it uses a StandardMaterial. - pushFace(builder, f, wx, wy, wz, def.tiles[f], sample, true, isWaterTop); + pushFace(opaque, f, wx, wy, wz, def.tiles[f], sample, true, false); } } } diff --git a/src/game/Game.ts b/src/game/Game.ts index db4821e..8d94c2b 100644 --- a/src/game/Game.ts +++ b/src/game/Game.ts @@ -46,6 +46,8 @@ import { GraphicsController, MAX_RENDER_DISTANCE, MIN_RENDER_DISTANCE, presetRen import { graphicsFromPreset, type GraphicsPreset, type GraphicsSettings } from "./graphics/GraphicsSettings"; import { PerfOverlay, type PerfSnapshot } from "../ui/PerfOverlay"; import { ChunkBorderOverlay } from "../ui/ChunkBorderOverlay"; +import { UnderwaterRenderer } from "./UnderwaterRenderer"; +import { isLiquid, liquidDefOf, WATER_BLOCK, WATER_FLOWING_BLOCK } from "./Blocks"; import { dbg, dbgWarn } from "../state/Debug"; const SPAWN_PREGEN_RADIUS = 2; @@ -85,6 +87,7 @@ export class Game { private readonly graphics: GraphicsController; private readonly perf: PerfOverlay; private readonly chunkBorders: ChunkBorderOverlay; + private readonly underwater: UnderwaterRenderer; private selectedIndex = 0; private last = performance.now(); @@ -149,6 +152,7 @@ export class Game { this.graphics = new GraphicsController(this.renderer.engine, scene, this.sky, this.player.camera); this.perf = new PerfOverlay(); this.chunkBorders = new ChunkBorderOverlay(scene); + this.underwater = new UnderwaterRenderer(scene); this.graphics.apply(this.settings.graphics); this.highlight = makeHighlight(scene); @@ -205,6 +209,13 @@ export class Game { const on = this.chunkBorders.toggle(); this.hud.showToast(on ? "Chunk borders: on" : "Chunk borders: off"); } + if (code === "F4") { + // Toggle liquid targeting (Luanti `liquids` pointability). Default + // (off) = mine/build through water; on = point at the water surface to + // remove/place water sources. + const on = this.player.toggleTargetLiquids(); + this.hud.showToast(on ? "Targeting: liquids (water)" : "Targeting: solids through water"); + } if (code === "KeyE" && (this.state === "playing" || this.inventoryOpen)) this.toggleInventory(); if (code === "Escape") { if (this.inventoryOpen) this.closeInventory(); @@ -477,6 +488,18 @@ export class Game { this.player.position.y, this.player.position.z, ); + // Animate the water surface (subtle shimmer; no-op on Low). + this.world.waterShader.animate(dt); + } + // Underwater tint + fog override. Runs every frame (even with no world) so + // the overlay reliably fades out on quit-to-menu; when submerged it pulls + // the fog in for the camera and restores it automatically when surfaced. + { + const eyeLiquidId = this.world ? this.player.liquidAtEye(this.world) : 0; + const eyeLiquid = eyeLiquidId ? liquidDefOf(eyeLiquidId) : null; + const submerged = this.world ? this.player.headSubmerged(this.world) : false; + const dayFactor = this.lighting?.dayNight.dayFactor ?? 1; + this.underwater.update(dt, submerged, eyeLiquid, dayFactor); } this.sky.update(dt, this.player.camera.position); @@ -671,10 +694,26 @@ export class Game { private breakBlock(x: number, y: number, z: number): void { const world = this.world!; const id = world.getBlock(x, y, z); - dbg("breakBlock", JSON.stringify({ x, y, z, id, breakable: isBreakable(id) })); + dbg("breakBlock", JSON.stringify({ x, y, z, id, breakable: isBreakable(id), liquid: isLiquid(id) })); + // Liquid-mode removal: the raycast only returns a liquid as the target + // when liquid targeting is on, OR as the fallback when no solid is in + // reach. Only honour removal in liquid mode; otherwise hint the player. + if (isLiquid(id)) { + if (!this.player.targetLiquids) { + this.hud.showToast("Switch to liquid targeting (F4) to remove water"); + return; + } + if (world.setLiquid(x, y, z, 0, 0)) { + world.queueLiquidUpdate(x, y, z); + this.hud.showToast(`Removed ${getBlock(id).name}`); + } + return; + } if (!isBreakable(id)) return; const changed = world.setBlock(x, y, z, 0); dbg(" setBlock -> changed=" + changed); + // setBlock already wakes the liquid simulator around the edit, so water + // flows into the newly opened space / recedes correctly. if (this.settings.mode === "survival") { const drop = dropForBlock(id); if (drop !== null) { @@ -850,6 +889,9 @@ export class Game { this.settings.graphics.preset === "low" ? 0.42 : 0.5; this.scene.fogEnd = far; this.scene.fogStart = far * startFrac; + // Tell the underwater renderer the new above-water baseline so its blend + // target stays correct when the view distance / preset changes. + this.underwater.setSurfaceFog(this.scene.fogStart, this.scene.fogEnd, this.scene.fogColor); } private updateFps(dt: number): void { @@ -886,6 +928,20 @@ export class Game { const dn = this.lighting?.dayNight; // JS heap is Chrome-only; report null elsewhere rather than misleading 0. const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; + // Liquid diagnostics. + const liquid = this.world?.liquidDebug(); + const target = this.player.getTarget(); + let targetLiquidType = "none"; + let targetLiquidLevel = 0; + if (target) { + const tdef = getBlock(target.block); + if (tdef.liquidType && tdef.liquidType !== "none") { + targetLiquidType = tdef.liquidType; + if (tdef.liquidType === "flowing" && this.world) { + targetLiquidLevel = this.world.getLevel(target.x, target.y, target.z); + } + } + } return { fps: this.fpsEma, frameMs: this.frameMsEma, @@ -902,6 +958,7 @@ export class Game { shadowCasters: casters, shadowsEnabled, waterMeshes: this.world?.waterMeshCount ?? 0, + waterVertices: this.world?.waterVertexCount ?? 0, preset: g.preset, viewDistance: this.settings.viewDistance, renderScale: g.renderScale, @@ -919,6 +976,23 @@ export class Game { waterAlpha: this.world?.waterShader.currentAlpha ?? 1, waterQuality: this.world?.waterShader.currentQuality ?? "—", antiAliasing: g.antiAliasing, + inWater: this.player.inWater, + underwater: this.underwater.isUnderwater, + liquidQueue: liquid?.queueSize ?? 0, + liquidPriorityQueue: liquid?.priorityQueueSize ?? 0, + liquidProcessed: liquid?.processedLastTick ?? 0, + liquidBudget: liquid?.budget ?? 0, + liquidWrites: liquid?.totalWrites ?? 0, + liquidMsSinceTick: liquid?.msSinceLastTick ?? 0, + targetLiquidType, + targetLiquidLevel, + targetMode: this.player.targetLiquids ? "liquids" : "solids", + rayThroughLiquid: !!target?.passedThroughLiquid, + firstLiquid: target?.firstLiquid + ? { x: target.firstLiquid.x, y: target.firstLiquid.y, z: target.firstLiquid.z } + : null, + waterSidesOn: this.world?.waterSidesOn ?? true, + waterAnimOn: this.world?.waterShader.animationOn ?? true, }; } @@ -944,6 +1018,7 @@ export class Game { window.removeEventListener("resize", this.handleResize); this.input.dispose(); this.graphics.dispose(); + this.underwater.dispose(); this.lighting?.dispose(); this.world?.dispose(); this.sky.dispose(); @@ -1118,6 +1193,38 @@ export class Game { this.hud.showToast(on ? "Water: opaque debug" : "Water: normal"); } + /** Debug: enable/disable water side faces (top surface only when off). */ + _setWaterSides(on: boolean): void { + this.world?.setWaterSides(on); + this.hud.showToast(on ? "Water sides: on" : "Water sides: off (surface only)"); + } + + /** Debug: enable/disable water surface scroll + shimmer animation. */ + _setWaterAnim(on: boolean): void { + this.world?.waterShader.setAnimationEnabled(on); + this.hud.showToast(on ? "Water animation: on" : "Water animation: off"); + } + + /** Debug: toggle water depth-write (isolate transparency/depth artifacts). */ + _setWaterDepth(on: boolean): void { + this.world?.waterShader.setDepthWrite(on); + this.hud.showToast(on ? "Water depth-write: ON (may show artifacts)" : "Water depth-write: off (default)"); + } + + /** Debug: swap water to a plain untextured material (isolate texture issues). */ + _setWaterSimple(on: boolean): void { + this.world?.waterShader.setSimpleMaterial(on); + this.hud.showToast(on ? "Water: simple untextured" : "Water: normal textured"); + } + + /** Debug: toggle liquid targeting (ray stops at water vs passes through). */ + _setTargetLiquids(on?: boolean): boolean { + if (on === undefined) this.player.targetLiquids = !this.player.targetLiquids; + else this.player.targetLiquids = on; + this.hud.showToast(this.player.targetLiquids ? "Targeting: liquids" : "Targeting: solids through water"); + return this.player.targetLiquids; + } + /** Debug: toggle distance fog. */ _setFog(on: boolean): void { this.applySettings({ graphics: { ...this.settings.graphics, fog: on } }); @@ -1161,6 +1268,78 @@ export class Game { return s; } + /** + * Liquid simulator audit for the console (`__voxl.liquid()`): queue size, + * budget, total writes, and the targeted block's liquid state. On-demand. + */ + _liquidDebug(): unknown { + if (!this.world) return { error: "no world" }; + const d = this.world.liquidDebug(); + const target = this.player.getTarget(); + let t: Record | null = null; + if (target) { + const def = getBlock(target.block); + t = { + x: target.x, y: target.y, z: target.z, + block: target.block, + name: def.name, + liquidType: def.liquidType ?? "none", + level: def.liquidType === "flowing" ? this.world.getLevel(target.x, target.y, target.z) : 0, + }; + } + const info = { ...d, inWater: this.player.inWater, underwater: this.underwater.isUnderwater, target: t }; + // eslint-disable-next-line no-console + console.log("[liquid]", info); + return info; + } + + /** + * Debug: place a water SOURCE at the targeted block's adjacent cell (or a + * given xyz) and wake the liquid simulator. Lets you test flow from the + * console without a bucket item. Usage: `__voxl.placeWater()` or + * `__voxl.placeWater(x,y,z)`. + */ + _placeWater(x?: number, y?: number, z?: number): void { + if (!this.world) return; + let wx: number, wy: number, wz: number; + if (x !== undefined && y !== undefined && z !== undefined) { + wx = x; wy = y; wz = z; + } else { + const t = this.player.getTarget(); + if (!t) { this.hud.showToast("Aim at a block first"); return; } + wx = t.px; wy = t.py; wz = t.pz; + } + this.world.setLiquid(wx, wy, wz, WATER_BLOCK, 0); + this.world.queueLiquidUpdate(wx, wy, wz); + this.hud.showToast(`Water source at ${wx},${wy},${wz}`); + } + + /** + * Debug: remove liquid at the targeted block (or xyz). Equivalent to placing + * air; wakes the simulator so neighbours recompute. + */ + _removeWater(x?: number, y?: number, z?: number): void { + if (!this.world) return; + let wx: number, wy: number, wz: number; + if (x !== undefined && y !== undefined && z !== undefined) { + wx = x; wy = y; wz = z; + } else { + const t = this.player.getTarget(); + if (!t) { this.hud.showToast("Aim at a block first"); return; } + wx = t.x; wy = t.y; wz = t.z; + } + this.world.setLiquid(wx, wy, wz, 0, 0); + this.world.queueLiquidUpdate(wx, wy, wz); + this.hud.showToast(`Removed liquid at ${wx},${wy},${wz}`); + } + + /** Debug: set the liquid simulator per-tick cell budget. */ + _liquidBudget(n: number): void { + if (!this.world) return; + this.world.liquid.setBudget(n); + this.hud.showToast(`Liquid budget: ${n}/tick`); + } + /** TEMP debug: inspect interaction state. */ _debugInfo(): Record { const t = this.player.getTarget(); diff --git a/src/game/Items.ts b/src/game/Items.ts index 994691d..2b17f4a 100644 --- a/src/game/Items.ts +++ b/src/game/Items.ts @@ -1,5 +1,5 @@ import type { BlockId } from "../types"; -import { BLOCKS, WATER_BLOCK, MUSHROOM_BLOCK } from "./Blocks"; +import { BLOCKS, WATER_BLOCK, WATER_FLOWING_BLOCK, MUSHROOM_BLOCK } from "./Blocks"; /** * Items generalize blocks: every non-air block becomes a placeable block item @@ -129,6 +129,7 @@ type Hardness = "instant" | "soft" | "medium" | "unbreakable"; const HARDNESS: Record = { 8: "unbreakable", // bedrock [WATER_BLOCK]: "unbreakable", // fluids can't be punched away + [WATER_FLOWING_BLOCK]: "unbreakable", // flowing water likewise 20: "instant", 21: "instant", 22: "instant", [MUSHROOM_BLOCK]: "instant", // plantlike }; diff --git a/src/game/Player.ts b/src/game/Player.ts index 9e8e8f9..facb1ed 100644 --- a/src/game/Player.ts +++ b/src/game/Player.ts @@ -13,7 +13,7 @@ import { REACH, } from "../constants"; import type { Settings } from "../types"; -import { getBlock, WATER_BLOCK, CACTUS_BLOCK } from "./Blocks"; +import { getBlock, isLiquid, CACTUS_BLOCK } from "./Blocks"; import type { World } from "./World"; import type { Input } from "../engine/Input"; import { raycastVoxel } from "./BlockRaycaster"; @@ -40,6 +40,13 @@ export class Player { flying = false; onGround = false; inWater = false; + /** + * Liquid-targeting mode (Luanti-style). When false (default) the ray passes + * THROUGH water to target the solid terrain behind/under it — the normal + * mining/building behaviour. When true the ray stops at the first liquid + * (bucket-style), so the player can select/remove water sources themselves. + */ + targetLiquids = false; /** Whether double-tap-Space may toggle flight (creative only). */ canFly = true; @@ -107,11 +114,15 @@ export class Player { } private blockAtFeetIsWater(world: World): boolean { - return world.getBlock( - Math.floor(this.position.x), - Math.floor(this.position.y + 0.5), - Math.floor(this.position.z), - ) === WATER_BLOCK; + // Any liquid at feet counts as "in water" — flowing water swims the same + // as a source. This is the player's swim/drag/buoyancy trigger. + return isLiquid( + world.getBlock( + Math.floor(this.position.x), + Math.floor(this.position.y + 0.5), + Math.floor(this.position.z), + ), + ); } update(dt: number, world: World, input: Input, settings: Settings): void { @@ -156,14 +167,28 @@ export class Player { if (input.isDown("ShiftLeft") || input.isDown("ShiftRight")) vy -= 1; this.velocity.y = vy * speed; } else { - const speed = sprinting ? SPRINT_SPEED : WALK_SPEED; + // Horizontal speed is reduced in water (swim drag). Sprinting is ignored + // underwater — you can't sprint-swim. + const baseSpeed = sprinting ? SPRINT_SPEED : WALK_SPEED; + const speed = this.inWater ? baseSpeed * 0.55 : baseSpeed; this.velocity.x = wish.x * speed; this.velocity.z = wish.z * speed; - const g = this.inWater ? GRAVITY * 0.35 : GRAVITY; + // Buoyancy: much weaker gravity underwater so the player floats/sinks + // gently instead of plummeting. + const g = this.inWater ? GRAVITY * 0.22 : GRAVITY; this.velocity.y -= g * dt; if (this.inWater) { - this.velocity.y = Math.max(this.velocity.y, -4); // slow sink - if (input.isDown("Space")) this.velocity.y = 4; // swim up + // Clamp sink speed so the player drifts down slowly; space gives a + // steady upward swim impulse (held = sustained climb). + this.velocity.y = Math.max(this.velocity.y, -2.6); + if (input.isDown("Space")) this.velocity.y = 5.2; + // Gentle horizontal drag so the player coasts to a stop in water. + // dt-aware exponential decay so the feel is identical at 30 / 60 / 144 + // FPS (0.86 is the per-60Hz-frame factor; pow scales it to the actual + // frame delta). + const drag = Math.pow(0.86, dt * 60); + this.velocity.x *= drag; + this.velocity.z *= drag; } else if (input.isDown("Space") && this.onGround) { this.velocity.y = JUMP_SPEED; this.onGround = false; @@ -226,29 +251,55 @@ export class Player { // --- Targeting raycast --- // Forward = Ry(yaw) * Rx(pitch) * (0, 0, -1) — same as three.js YXZ. + // Targeting mode (Luanti-style): default passes through liquids so the + // player can mine/build underwater; liquid mode stops at the water surface. const eye = this.camera.position; - if (input.locked) { - const cp = Math.cos(this.pitch); - const fx = -Math.sin(this.yaw) * cp; - const fy = Math.sin(this.pitch); - const fz = -Math.cos(this.yaw) * cp; - this.target = raycastVoxel(world, eye.x, eye.y, eye.z, fx, fy, fz, REACH); - } else { - // Pointer lock unavailable: aim via the cursor position instead so the - // game stays fully playable (build/mine) without mouse-look. - const sc = this.camera.getScene() as Scene; - const ray = sc.createPickingRay(sc.pointerX, sc.pointerY, Matrix.Identity(), this.camera); - this.target = raycastVoxel( - world, - ray.origin.x, - ray.origin.y, - ray.origin.z, - ray.direction.x, - ray.direction.y, - ray.direction.z, - REACH, - ); + const aimRay = input.locked + ? (() => { + const cp = Math.cos(this.pitch); + return { + x: eye.x, y: eye.y, z: eye.z, + dx: -Math.sin(this.yaw) * cp, + dy: Math.sin(this.pitch), + dz: -Math.cos(this.yaw) * cp, + }; + })() + : (() => { + // Pointer lock unavailable: aim via the cursor so the game stays + // playable (build/mine) without mouse-look. + const sc = this.camera.getScene() as Scene; + const ray = sc.createPickingRay(sc.pointerX, sc.pointerY, Matrix.Identity(), this.camera); + return { x: ray.origin.x, y: ray.origin.y, z: ray.origin.z, dx: ray.direction.x, dy: ray.direction.y, dz: ray.direction.z }; + })(); + this.target = this.computeTarget(world, aimRay.x, aimRay.y, aimRay.z, aimRay.dx, aimRay.dy, aimRay.dz); + } + + /** + * Resolve the active targeted block from the current aim ray + targeting + * mode. In solid mode the ray ignores liquids (stops at the first solid), + * falling back to the first liquid if no solid is reached (so open water is + * still selectable). In liquid mode the ray stops at the first non-air cell, + * letting the player point at the water surface itself. + */ + private computeTarget( + world: World, + ox: number, oy: number, oz: number, + dx: number, dy: number, dz: number, + ): RaycastHit | null { + if (this.targetLiquids) { + // Stop at the first non-air cell (water surface or solid). + return raycastVoxel(world, ox, oy, oz, dx, dy, dz, REACH, { ignoreLiquid: false }); } + // Solid mode: pass through liquids to reach terrain. + const hit = raycastVoxel(world, ox, oy, oz, dx, dy, dz, REACH, { ignoreLiquid: true }); + if (hit) return hit; + return null; + } + + /** Toggle liquid-targeting mode (Luanti `liquids` pointability flip). */ + toggleTargetLiquids(): boolean { + this.targetLiquids = !this.targetLiquids; + return this.targetLiquids; } /** The block the camera is currently looking at (for break/place). */ @@ -282,14 +333,29 @@ export class Player { this.wasOnGround = this.onGround; } - /** Head (eye) submerged in water — used for the drowning breath meter. */ + /** Head (eye) submerged in a liquid — used for the drowning breath meter + * and the underwater screen tint/fog. Any liquid counts (source or flowing). */ headSubmerged(world: World): boolean { const eyeY = this.position.y + PLAYER_EYE_HEIGHT; - return world.getBlock( + return isLiquid( + world.getBlock( + Math.floor(this.position.x), + Math.floor(eyeY), + Math.floor(this.position.z), + ), + ); + } + + /** The liquid block id at the player's eye, or 0 if eyes are in air. Used by + * the underwater renderer to pick the right fog colour/density per liquid. */ + liquidAtEye(world: World): number { + const eyeY = this.position.y + PLAYER_EYE_HEIGHT; + const id = world.getBlock( Math.floor(this.position.x), Math.floor(eyeY), Math.floor(this.position.z), - ) === WATER_BLOCK; + ); + return isLiquid(id) ? id : 0; } /** Touching a cactus block anywhere in the player's AABB. */ diff --git a/src/game/UnderwaterRenderer.ts b/src/game/UnderwaterRenderer.ts new file mode 100644 index 0000000..0bb7d00 --- /dev/null +++ b/src/game/UnderwaterRenderer.ts @@ -0,0 +1,125 @@ +import { Color3, Scene } from "@babylonjs/core"; +import type { LiquidDef } from "./Blocks"; + +/** + * Underwater presentation: when the player's eye is submerged, this (a) pulls + * the scene fog in close and tints it the liquid's colour, and (b) fades in a + * full-screen DOM tint so the world reads as "underwater" without a costly + * full-screen post-process. Above the surface everything is left untouched. + * + * Stability: a 0..1 `submerge` factor is lerped each frame, so crossing the + * surface is a smooth fade rather than a flicker even though "eye in water" is + * a discrete per-frame boolean. The surface fog baseline is supplied by the + * game (`setSurfaceFog`); this class only overrides `scene.fog*` while the + * factor is non-zero, and writes the baseline back when fully surfaced. + */ +export class UnderwaterRenderer { + private readonly scene: Scene; + private readonly overlay: HTMLElement; + /** Current 0..1 submerge blend (lerped; 0 = fully above, 1 = fully under). */ + private factor = 0; + /** Liquid the eye is currently in (null = air). */ + private liquid: LiquidDef | null = null; + /** Cached surface (above-water) fog, supplied by the game each change. */ + private surfaceStart = 60; + private surfaceEnd = 220; + private surfaceColor = new Color3(0.75, 0.89, 1); + + constructor(scene: Scene) { + this.scene = scene; + this.overlay = this.createOverlay(); + } + + private createOverlay(): HTMLElement { + let el = document.getElementById("underwater-overlay"); + if (!el) { + el = document.createElement("div"); + el.id = "underwater-overlay"; + el.style.position = "fixed"; + el.style.inset = "0"; + el.style.pointerEvents = "none"; + el.style.zIndex = "5"; + el.style.mixBlendMode = "normal"; + el.style.opacity = "0"; + el.style.transition = "background-color 0.2s linear"; + document.getElementById("app")?.appendChild(el) ?? document.body.appendChild(el); + } + el.style.background = "rgba(31,111,176,0.0)"; + return el; + } + + /** Record the above-water fog baseline (called whenever the game changes it). */ + setSurfaceFog(start: number, end: number, color: Color3): void { + this.surfaceStart = start; + this.surfaceEnd = end; + this.surfaceColor.copyFrom(color); + } + + get isUnderwater(): boolean { + return this.factor > 0.5; + } + + get submergeFactor(): number { + return this.factor; + } + + /** + * Advance the submerge blend and apply fog + tint. + * + * @param dt frame delta (seconds) + * @param submerged true when the player's eye is inside a liquid this frame + * @param liquid the liquid at the eye (null if air) + * @param dayFactor 0..1 daylight factor (underwater is darker at night) + */ + update(dt: number, submerged: boolean, liquid: LiquidDef | null, dayFactor: number): void { + // Lerp toward the target (1 underwater, 0 above). Fast enough to feel + // responsive, slow enough to avoid single-frame flicker at the surface. + const target = submerged ? 1 : 0; + const speed = submerged ? 9 : 6; // 1/s + const f = this.factor + (target - this.factor) * Math.min(1, speed * dt); + this.factor = f > 0.999 ? 1 : f < 0.001 ? 0 : f; + + if (f <= 0.001) { + // Fully above: restore the surface fog baseline exactly. + this.scene.fogStart = this.surfaceStart; + this.scene.fogEnd = this.surfaceEnd; + this.scene.fogColor.copyFrom(this.surfaceColor); + this.overlay.style.opacity = "0"; + return; + } + + const ldef = liquid ?? this.liquid ?? null; + this.liquid = ldef; + // Default water-ish tint if we somehow have no def. + const tintHex = ldef?.fogColor ?? "#1f6fb0"; + const density = ldef?.fogDensity ?? 0.45; + const tint = Color3.FromHexString(tintHex); + + // Pull the fog in: underwater end = surface end × density (murkier). + const underEnd = this.surfaceEnd * density; + const underStart = 0; + this.scene.fogStart = lerp(this.surfaceStart, underStart, f); + this.scene.fogEnd = lerp(this.surfaceEnd, underEnd, f); + // Blend the fog colour toward the liquid tint. + this.scene.fogColor.r = lerp(this.surfaceColor.r, tint.r, f); + this.scene.fogColor.g = lerp(this.surfaceColor.g, tint.g, f); + this.scene.fogColor.b = lerp(this.surfaceColor.b, tint.b, f); + + // DOM tint overlay. Stronger in daylight (so you can still see at night we + // don't over-darken); opacity scales with submerge factor. + const alpha = 0.42 * f * (0.55 + 0.45 * dayFactor); + const r = Math.round(tint.r * 255); + const g = Math.round(tint.g * 255); + const b = Math.round(tint.b * 255); + this.overlay.style.background = `rgba(${r},${g},${b},${alpha.toFixed(3)})`; + this.overlay.style.opacity = "1"; + } + + dispose(): void { + this.overlay.remove(); + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} diff --git a/src/game/World.ts b/src/game/World.ts index 769371b..8a630df 100644 --- a/src/game/World.ts +++ b/src/game/World.ts @@ -25,7 +25,9 @@ import { } from "./lighting/LightingConfig"; import type { FoliageDensity, WaterQuality } from "./graphics/GraphicsSettings"; import { dbg } from "../state/Debug"; -import { WATER_BLOCK } from "./Blocks"; +import { getBlock, WATER_BLOCK } from "./Blocks"; +import { LiquidSimulator, DEFAULT_LIQUID_BUDGET, LIQUID_TICK_RATE_HZ, LIQUID_IMMEDIATE_BURST } from "./liquid/LiquidSimulator"; +import type { LiquidAccess, LiquidDebugSnapshot } from "./liquid/LiquidTypes"; function key(cx: number, cz: number): string { return `${cx},${cz}`; @@ -79,6 +81,24 @@ export class World { /** Active light debug overlay (applied as a material uniform — no remesh). */ private lightDebugMode: LightDebugMode = "off"; + /** + * Voxel liquid flow simulator + its bounded update queue. Owns the flow + * logic; reads/writes through {@link liquidAccess} (→ this world). Ticked + * from {@link update} at a fixed rate with a per-tick cell budget so water + * never runs every render frame and never re-simulates the whole world. + */ + readonly liquid = new LiquidSimulator(); + private readonly liquidAccess: LiquidAccess = { + getBlock: (x, y, z) => this.getBlock(x, y, z), + getLevel: (x, y, z) => this.getLevel(x, y, z), + setLiquid: (x, y, z, id, level) => this.setLiquid(x, y, z, id, level), + isChunkLoaded: (x, z) => this.isChunkLoaded(x, z), + }; + /** Accumulator for the fixed-rate liquid tick. */ + private liquidAccumulator = 0; + /** Last liquid tick's cell budget (reported by the debug overlay). */ + private liquidTickBudget = DEFAULT_LIQUID_BUDGET; + /** * Debug: when false, all water (transparent) meshes are hidden. Use to * confirm whether a suspect patch is the water layer. Read at mesh creation; @@ -86,6 +106,13 @@ export class World { */ private waterEnabled = true; + /** + * Debug: when false, water renders its top surface only (no side faces). + * Read at mesh rebuild; flip + request a remesh to compare. Useful to + * isolate whether a visual artifact comes from water sides vs the surface. + */ + private waterSidesEnabled = true; + /** * Debug: when true, all chunk meshes get `alwaysSelectAsActiveMesh = true` so * Babylon never frustum-culls them — useful to confirm whether missing terrain @@ -192,6 +219,60 @@ export class World { return chunk.getLocal(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE); } + /** Per-voxel liquid level at world coords (0 for non-flowing / unloaded). */ + getLevel(wx: number, wy: number, wz: number): number { + if (wy < 0 || wy >= CHUNK_HEIGHT) return 0; + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const chunk = this.chunks.get(key(cx, cz)); + if (!chunk) return 0; + return chunk.getLocalLevel(wx - cx * CHUNK_SIZE, wy, wz - cz * CHUNK_SIZE); + } + + /** Whether the chunk owning these world coords is generated/loaded. */ + isChunkLoaded(wx: number, wz: number): boolean { + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const chunk = this.chunks.get(key(cx, cz)); + return !!chunk && chunk.generated; + } + + /** + * Write a liquid cell (block id + level). Used by the liquid simulator and by + * creative water placement. Performs the same lighting + dirty + neighbour + * bookkeeping as {@link setBlock}, but DOES NOT rebuild the mesh immediately + * — the streaming pass rebuilds affected chunks in budget. Returns true if + * the cell changed. + */ + setLiquid(wx: number, wy: number, wz: number, id: BlockId, level: number): boolean { + if (wy < 0 || wy >= CHUNK_HEIGHT) return false; + const cx = Math.floor(wx / CHUNK_SIZE); + const cz = Math.floor(wz / CHUNK_SIZE); + const chunk = this.chunks.get(key(cx, cz)); + if (!chunk || !chunk.generated) return false; + const lx = wx - cx * CHUNK_SIZE; + const lz = wz - cz * CHUNK_SIZE; + const changed = chunk.setLocalWithLevel(lx, wy, lz, id, level); + if (!changed) return false; + // Liquids affect light (flowing water is transparent + light-conducting), + // so recompute lighting exactly like a terrain edit. + this.relightChunkNow(chunk, false); + // A border edit may change a neighbour's border faces/light too. Liquids are + // transparent + light-conducting, so a lighting change is NOT guaranteed + // (unlike opaque edits); mark the neighbour dirty explicitly so its border + // water side/step faces remesh even when light is unchanged. + if (lx === 0) { this.queueNeighbourLight(cx - 1, cz); this.markDirty(cx - 1, cz); } + if (lx === CHUNK_SIZE - 1) { this.queueNeighbourLight(cx + 1, cz); this.markDirty(cx + 1, cz); } + if (lz === 0) { this.queueNeighbourLight(cx, cz - 1); this.markDirty(cx, cz - 1); } + if (lz === CHUNK_SIZE - 1) { this.queueNeighbourLight(cx, cz + 1); this.markDirty(cx, cz + 1); } + return true; + } + + /** Queue a cell for the liquid simulator (external triggers: edits, gen). */ + queueLiquidUpdate(wx: number, wy: number, wz: number): void { + this.liquid.enqueue(wx, wy, wz); + } + /** Edit a block. Returns true if the world changed. */ setBlock(wx: number, wy: number, wz: number, id: BlockId): boolean { if (wy < 0 || wy >= CHUNK_HEIGHT) return false; @@ -215,6 +296,19 @@ export class World { if (lz === 0) this.queueNeighbourLight(cx, cz - 1); if (lz === CHUNK_SIZE - 1) this.queueNeighbourLight(cx, cz + 1); this.rebuildMesh(chunk); + // Wake the liquid simulator around the edit on the PRIORITY lane so water + // reacts immediately (not behind the ocean-seeding backlog). Then fire an + // immediate burst so the FIRST flow step happens this frame — the player + // sees water start filling the gap within ~1 frame instead of seconds. The + // burst's writes mark affected chunks dirty; the streaming mesh pass + // (closest-first, next frame) remeshes them, keeping the visual in lockstep. + this.liquid.enqueueAround(wx, wy, wz); + const burst = this.liquid.tickPriority(this.liquidAccess, LIQUID_IMMEDIATE_BURST); + dbg( + "Block edit at", wx, wy, wz, "->", + "queued 7 priority positions; immediate burst processed", burst, + "cells; queue now", this.liquid.queueSize, "(priority", this.liquid.priorityQueueSize + ")", + ); return true; } @@ -223,6 +317,88 @@ export class World { if (chunk && chunk.generated) chunk.dirty = true; } + /** + * Wake the liquid simulator for every LIQUID cell along one edge of chunk + * (cx,cz). Used when a neighbouring chunk unloads: the liquid that was fed + * across that border must recompute (it may now dry up). `edge` selects the + * shared border — "xHigh"/"xLow" scan a constant local X column, "zHigh"/ + * "zLow" a constant local Z row. Only liquid cells are enqueued, so the cost + * is bounded by how much water actually sits on the border (usually little). + */ + private wakeBorderLiquid(cx: number, cz: number, edge: "xHigh" | "xLow" | "zHigh" | "zLow"): void { + const chunk = this.chunks.get(key(cx, cz)); + if (!chunk || !chunk.generated) return; + const ox = chunk.originX; + const oz = chunk.originZ; + const b = chunk.blocks; + if (edge === "xHigh" || edge === "xLow") { + const lx = edge === "xHigh" ? CHUNK_SIZE - 1 : 0; + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let lz = 0; lz < CHUNK_SIZE; lz++) { + const id = b[(y * CHUNK_SIZE + lz) * CHUNK_SIZE + lx]; + if (getBlock(id).liquid) this.liquid.enqueue(ox + lx, y, oz + lz); + } + } + } else { + const lz = edge === "zHigh" ? CHUNK_SIZE - 1 : 0; + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let lx = 0; lx < CHUNK_SIZE; lx++) { + const id = b[(y * CHUNK_SIZE + lz) * CHUNK_SIZE + lx]; + if (getBlock(id).liquid) this.liquid.enqueue(ox + lx, y, oz + lz); + } + } + } + } + + /** + * After a chunk generates, wake the liquid simulator for every water cell + * that touches a floodable neighbour. This seeds cross-chunk flow at lake / + * ocean shores and waterfalls without re-simulating the (mostly solid) bulk + * of the chunk. Bounded: at most a few hundred enqueues per freshly streamed + * chunk, processed over several ticks by the budget. + */ + private seedLiquidForChunk(chunk: Chunk): void { + const ox = chunk.originX; + const oz = chunk.originZ; + const b = chunk.blocks; + for (let y = 0; y < CHUNK_HEIGHT; y++) { + for (let z = 0; z < CHUNK_SIZE; z++) { + for (let x = 0; x < CHUNK_SIZE; x++) { + const id = b[(y * CHUNK_SIZE + z) * CHUNK_SIZE + x]; + if (id !== WATER_BLOCK) continue; + // Only seed sources that have at least one floodable neighbour + // (the others are fully embedded in a source lake and have nothing + // to do until something nearby changes). + const wx = ox + x; + const wz = oz + z; + // A source only does work if it can pour DOWN or spread SIDEWAYS into + // a floodable cell. Air ABOVE never receives flow (water doesn't flow + // up), so we don't check +Y — this keeps a stable ocean's interior + // sources out of the queue entirely (only shore/waterfall sites wake). + if ( + this.isFloodableAt(wx + 1, y, wz) || + this.isFloodableAt(wx - 1, y, wz) || + this.isFloodableAt(wx, y, wz + 1) || + this.isFloodableAt(wx, y, wz - 1) || + this.isFloodableAt(wx, y - 1, wz) + ) { + this.liquid.enqueue(wx, y, wz); + } + } + } + } + } + + private isFloodableAt(wx: number, wy: number, wz: number): boolean { + if (wy < 0) return false; + if (wy >= CHUNK_HEIGHT) return true; // above world = air = floodable + const id = this.getBlock(wx, wy, wz); + if (id === 0) return true; + const def = getBlock(id); + if (def.liquid) return false; + return !def.solid; + } + // --------------------------------------------------------- lighting --- /** @@ -389,6 +565,9 @@ export class World { this.markDirty(cx + 1, cz); this.markDirty(cx, cz - 1); this.markDirty(cx, cz + 1); + // Wake the liquid simulator at shore/waterfall sites in this chunk so + // cross-chunk flow initializes as chunks stream in. + this.seedLiquidForChunk(chunk); } return chunk; } @@ -436,6 +615,9 @@ export class World { this.markDirty(cx + 1, cz); this.markDirty(cx, cz - 1); this.markDirty(cx, cz + 1); + // Seed liquid flow at shore/waterfall sites so cross-chunk water + // updates begin as soon as a chunk streams in. + this.seedLiquidForChunk(chunk); genBudget--; } } @@ -444,6 +626,19 @@ export class World { // meshes always read fresh light values. this.processLightDirty(lightBudget); + // Run the liquid flow simulator at a fixed rate (decoupled from fps) with a + // frame-aware budget. Water therefore never runs every render frame, and on + // a stuttering frame the budget shrinks so flow can't compound a spike. + this.liquidAccumulator += frameMs / 1000; + const tickInterval = 1 / LIQUID_TICK_RATE_HZ; + if (this.liquidAccumulator >= tickInterval) { + this.liquidAccumulator = 0; // fixed cadence (skip accrued slack) + const liquidBudget = Math.max(16, Math.round(DEFAULT_LIQUID_BUDGET * f)); + this.liquidTickBudget = liquidBudget; + this.liquid.setBudget(liquidBudget); + this.liquid.tick(this.liquidAccess); + } + // Mesh dirty chunks (closest first), respecting the budget. for (const off of order) { if (meshBudget <= 0) break; @@ -467,6 +662,21 @@ export class World { this.chunks.delete(k); this.lighting.removeLight(chunk.cx, chunk.cz); this.lightDirty.delete(k); + // When a chunk vanishes, its border cells change from "loaded" to + // "void", which affects the liquid simulator: water that was flowing + // FROM the unloaded chunk into a neighbour loses its feeder and must + // dry up. markDirty only flags a remesh — it does NOT recompute liquid + // state — so also wake the simulator for the liquid cells along each + // neighbour's shared border. Otherwise stale flowing water hangs in the + // air at the new world edge. + this.markDirty(chunk.cx - 1, chunk.cz); + this.markDirty(chunk.cx + 1, chunk.cz); + this.markDirty(chunk.cx, chunk.cz - 1); + this.markDirty(chunk.cx, chunk.cz + 1); + this.wakeBorderLiquid(chunk.cx - 1, chunk.cz, "xHigh"); + this.wakeBorderLiquid(chunk.cx + 1, chunk.cz, "xLow"); + this.wakeBorderLiquid(chunk.cx, chunk.cz - 1, "zHigh"); + this.wakeBorderLiquid(chunk.cx, chunk.cz + 1, "zLow"); } } @@ -496,7 +706,9 @@ export class World { const result = buildChunkGeometry( chunk, (x, y, z) => this.getBlock(x, y, z), + (x, y, z) => this.getLevel(x, y, z), this.sampleBrightness, + { waterSides: this.waterSidesEnabled }, ); const k = key(chunk.cx, chunk.cz); const existing = this.meshes.get(k); @@ -617,6 +829,21 @@ export class World { } } + /** + * Debug: toggle water side faces (shore/waterfall walls). When off, only the + * top surface renders — useful to isolate whether an artifact comes from the + * sides. Marks every loaded chunk dirty so the change is picked up on the + * next mesh-budget pass. + */ + setWaterSides(on: boolean): void { + this.waterSidesEnabled = on; + for (const chunk of this.chunks.values()) if (chunk.generated) chunk.dirty = true; + } + + get waterSidesOn(): boolean { + return this.waterSidesEnabled; + } + /** * Debug: dump every chunk mesh's name, material name, and triangle count to * the console — for correlating a visible patch with the mesh/material that @@ -655,6 +882,18 @@ export class World { return n; } + /** Total vertex count across all water (transparent) meshes (debug overlay). */ + get waterVertexCount(): number { + let n = 0; + for (const entry of this.meshes.values()) { + const m = entry.transparent; + if (!m) continue; + const vd = m.getVerticesData?.("position"); + if (vd) n += vd.length / 3; + } + return n; + } + /** * Full water audit (for the console `__voxl.waterStats()` only — iterates * every loaded block, so do not call per-frame). Reports how many loaded @@ -673,10 +912,24 @@ export class World { return { chunksWithWater, waterBlocks, loaded: this.chunks.size }; } + /** Liquid simulator snapshot for the debug overlay (queue/budget/writes). */ + liquidDebug(): LiquidDebugSnapshot { + const snap = this.liquid.debug; + return { ...snap, dirtyChunks: this.dirtyChunkCount }; + } + + /** Number of chunks currently flagged for a remesh (liquid + terrain). */ + private get dirtyChunkCount(): number { + let n = 0; + for (const chunk of this.chunks.values()) if (chunk.dirty) n++; + return n; + } + dispose(): void { for (const k of [...this.meshes.keys()]) this.disposeMeshes(k); this.chunks.clear(); this.lightDirty.clear(); + this.liquid.reset(); this.lighting.dispose(); this.terrainOpaque.dispose(); this.terrainCutout.dispose(); diff --git a/src/game/lighting/WaterMaterial.ts b/src/game/lighting/WaterMaterial.ts index 8c11f53..b616931 100644 --- a/src/game/lighting/WaterMaterial.ts +++ b/src/game/lighting/WaterMaterial.ts @@ -1,60 +1,180 @@ -import { Color3, Material, Scene, StandardMaterial, Texture, Vector3 } from "@babylonjs/core"; +import { Color3, DynamicTexture, Material, Scene, StandardMaterial, Texture, Vector3 } from "@babylonjs/core"; import type { WaterQuality } from "../graphics/GraphicsSettings"; /** - * Water surface material. + * Water surface material — a stylized, Minetest/Luanti-inspired voxel water. * - * IMPORTANT HISTORY: this was previously a custom GLSL `ShaderMaterial`. Despite - * setting `transparencyMode = MATERIAL_ALPHABLEND` and pushing alpha all the way - * to 0.92, the water stayed nearly invisible — a `ShaderMaterial` transparency - * edge case (alpha combining against an unbound texture alpha) defeated every - * tuning attempt. To make water RELIABLY visible, we render it with a plain - * `StandardMaterial`, which is what made water visible originally and which - * Babylon handles transparently (pun intended) with zero ambiguity. + * VISUAL DESIGN (clean terrain-behind-water + no grid): + * • **Depth write is OFF** (transparent best-practice). The opaque terrain is + * drawn first and writes depth, so it ALWAYS shows through the water's alpha + * cleanly — no "black gaps" or missing patches behind the surface. (An + * earlier attempt turned depth-write ON to kill a grid; that instead caused + * transparent water to occlude itself under Babylon's per-mesh transparent + * sort and fight depth precision at the water/terrain interface.) + * • The "stacked-glass grid" is prevented at the MESH level instead: the + * mesher no longer emits water bottom faces (the only overlapping-transparent + * source for a flat body), so a lake renders just its tiling top surface — + * nothing stacks, no grid, even with depth-write off. + * • A subtle procedural surface texture (soft caustic noise) is scrolled over + * time for gentle movement. The mesher emits WORLD-SPACE UVs for water so + * the texture is continuous across the whole body (no per-block tiling grid). + * • Tint is a clear blue with a small emissive floor (readable at night) and + * a soft specular sun glint. Quality tiers scale alpha/animation/specular. * - * Properties: - * - solid blue diffuse + a small emissive floor (readable even in shadow/night) - * - subtle specular → a sun glint that reads as "water" - * - lit by the scene's Babylon lights (so it dims at night) and fogged by the - * scene fog (so it blends with terrain at distance — no more vanishing lakes) - * - alpha-blended, double-sided, no depth write - * - no atlas texture → no tiling repetition - * - * Quality tiers only change alpha (lower tiers slightly more opaque = clearer). - * The mesh's baked vertex colours are disabled (see World.applyMesh) so they - * can't darken the surface — the StandardMaterial supplies a uniform tint. + * This stays a plain `StandardMaterial` on purpose: an earlier custom + * `ShaderMaterial` made water invisible (alpha-combining against an unbound + * texture alpha). StandardMaterial transparency is unambiguous and reliable. */ export interface WaterMaterialOptions { - /** Unused (kept for API compatibility). Water is a solid colour, not textured. */ + /** Unused (kept for API compatibility). The water texture is generated. */ texture?: Texture; } +/** World-space UV repeat (texels per block) — keeps the surface pattern coarse. */ +const WATER_UV_SCALE = 0.18; +/** Pixel size of the procedural water surface texture. */ +const WATER_TEX_PX = 64; + export class WaterMaterial { readonly material: StandardMaterial; + /** Animated surface texture (scrolled via uOffset/vOffset — cheap uniform). */ + private readonly waterTexture: DynamicTexture; private quality: WaterQuality = "medium"; - private alpha = 0.78; + private alpha = 0.82; + /** Base diffuse/emissive colours (the subtle animation oscillates around these). */ + private readonly baseDiffuse = Color3.FromHexString("#1f86d8"); + private readonly baseEmissive = Color3.FromHexString("#0b3a6b"); + private readonly shimmerColor = Color3.FromHexString("#2aa0e8"); + private animTime = 0; + private animationEnabled = true; constructor(scene: Scene, _options?: WaterMaterialOptions) { void _options; const mat = new StandardMaterial("voxel-water", scene); - mat.diffuseColor = Color3.FromHexString("#1f86d8"); // clear blue - mat.emissiveColor = Color3.FromHexString("#0b3a6b"); // subtle blue glow floor + this.waterTexture = this.createSurfaceTexture(scene); + // Texture modulates diffuse colour only — its alpha stays solid so the + // material's `alpha` controls transparency (avoids the invisible-water bug). + this.waterTexture.hasAlpha = false; + this.waterTexture.wrapU = Texture.WRAP_ADDRESSMODE; + this.waterTexture.wrapV = Texture.WRAP_ADDRESSMODE; + this.waterTexture.uScale = WATER_UV_SCALE; + this.waterTexture.vScale = WATER_UV_SCALE; + this.waterTexture.coordinatesMode = Texture.EXPLICIT_MODE; + mat.diffuseTexture = this.waterTexture; + mat.diffuseColor = this.baseDiffuse.clone(); + mat.emissiveColor = this.baseEmissive.clone(); mat.specularColor = new Color3(0.35, 0.4, 0.5); // sun glint mat.specularPower = 96; - mat.backFaceCulling = false; // see the surface from below - mat.disableDepthWrite = true; // don't occlude submerged terrain + mat.backFaceCulling = false; // see the surface from below when underwater + // Transparent best-practice: do NOT write depth. The opaque terrain is + // rendered first (and writes depth), so it always shows through the water's + // alpha cleanly. Writing depth here caused "black gaps" behind the water: + // under Babylon's per-MESH (not per-triangle) transparent sort, a + // depth-writing water surface occluded other water surfaces / fought depth + // precision at the water-terrain interface. The earlier "stacked-glass grid" + // is now prevented a different way — the mesher no longer emits water bottom + // faces (the only overlapping-transparent source for a flat lake), so a lake + // renders just its tiling top surface with nothing to stack. + mat.disableDepthWrite = true; mat.transparencyMode = Material.MATERIAL_ALPHABLEND; mat.fogEnabled = true; // blend into the scene fog at distance (matches terrain) this.material = mat; this.setQuality(this.quality); } + /** + * Procedural water surface texture: a soft, low-contrast caustic-ish noise on + * blue, roughly tileable. Drawn once to a 64×64 canvas; animation comes from + * scrolling the texture (uOffset/vOffset), not redrawing — so it costs ~0 + * per frame. Bilinear+trilinear filtering keeps it smooth at distance (no + * NEAREST shimmer); mipmaps are safe here because this is a standalone + * texture, not the shared 8×8 atlas. + */ + private createSurfaceTexture(scene: Scene): DynamicTexture { + const tex = new DynamicTexture( + "voxel-water-surface", + { width: WATER_TEX_PX, height: WATER_TEX_PX }, + scene, + true, // generateMipMaps → trilinear, smooth at distance + Texture.TRILINEAR_SAMPLINGMODE, + ); + const ctx = tex.getContext() as CanvasRenderingContext2D; + // Base translucent blue (alpha is ignored — material.alpha drives it). + ctx.fillStyle = "rgb(40,108,184)"; + ctx.fillRect(0, 0, WATER_TEX_PX, WATER_TEX_PX); + // Soft lighter "ripple" blobs (low contrast → subtle, no harsh pattern). + const rand = mulberry32(20240614); + for (let i = 0; i < 26; i++) { + const x = rand() * WATER_TEX_PX; + const y = rand() * WATER_TEX_PX; + const r = 4 + rand() * 9; + const lift = 28 + rand() * 26; + const grad = ctx.createRadialGradient(x, y, 0, x, y, r); + grad.addColorStop(0, `rgba(${96 + lift | 0},${158 + lift | 0},${214 + lift | 0},0.55)`); + grad.addColorStop(1, "rgba(40,108,184,0)"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + // A few darker streaks for depth variation. + for (let i = 0; i < 10; i++) { + const x = rand() * WATER_TEX_PX; + const y = rand() * WATER_TEX_PX; + const w = 6 + rand() * 10; + ctx.fillStyle = "rgba(20,72,128,0.35)"; + ctx.fillRect(x, y, w, 1); + } + tex.update(false); + return tex; + } + setQuality(quality: WaterQuality): void { this.quality = quality; - // All tiers stay clearly readable as water. Higher tiers are slightly more - // transparent (livelier) but never so much that the surface disappears. - this.alpha = quality === "low" ? 0.85 : quality === "high" ? 0.72 : 0.78; + // All tiers stay clearly readable as water. Slightly higher alpha than + // before for a cleaner, less "glassy" surface read. + this.alpha = quality === "low" ? 0.88 : quality === "high" ? 0.76 : 0.82; this.material.alpha = this.alpha; + this.material.specularPower = quality === "high" ? 128 : quality === "low" ? 64 : 96; + this.material.diffuseColor = this.baseDiffuse.clone(); + this.material.emissiveColor = this.baseEmissive.clone(); + } + + /** + * Per-frame animation: scroll the surface texture for gentle movement, plus a + * tiny two-sine tint shimmer. Both are cheap uniform writes on a shared + * material — no per-vertex CPU work, no geometry rebuild. Disabled on Low or + * when the debug "disable animation" toggle is off. + */ + animate(dt: number): void { + if (this.quality === "low" || !this.animationEnabled) return; + this.animTime += dt; + const tex = this.waterTexture; + // Slow diagonal scroll + a subtle sideways sway for an organic drift. + tex.uOffset = this.animTime * 0.018; + tex.vOffset = this.animTime * 0.012 + Math.sin(this.animTime * 0.3) * 0.01; + // Faint global tint shimmer (amplitude tiny so the hue is stable). + const amp = this.quality === "high" ? 0.05 : 0.03; + const w = Math.sin(this.animTime * 0.9) * 0.5 + Math.sin(this.animTime * 0.37 + 1.3) * 0.5; + const m = this.material; + m.diffuseColor.r = this.baseDiffuse.r + (this.shimmerColor.r - this.baseDiffuse.r) * amp * w; + m.diffuseColor.g = this.baseDiffuse.g + (this.shimmerColor.g - this.baseDiffuse.g) * amp * w; + m.diffuseColor.b = this.baseDiffuse.b + (this.shimmerColor.b - this.baseDiffuse.b) * amp * w; + m.emissiveColor.r = this.baseEmissive.r * (1 + amp * 0.5 * w); + m.emissiveColor.g = this.baseEmissive.g * (1 + amp * 0.5 * w); + m.emissiveColor.b = this.baseEmissive.b * (1 + amp * 0.5 * w); + } + + /** Debug: enable/disable the surface scroll + shimmer (texture stays put). */ + setAnimationEnabled(on: boolean): void { + this.animationEnabled = on; + if (!on) { + this.waterTexture.uOffset = 0; + this.waterTexture.vOffset = 0; + } + } + get animationOn(): boolean { + return this.animationEnabled; } get currentQuality(): WaterQuality { @@ -68,23 +188,56 @@ export class WaterMaterial { /** * Debug: force the water fully opaque + flat bright blue, bypassing lighting * and transparency. Use `__voxl.waterOpaque()` to confirm whether a suspect - * patch is the water layer (it turns into a solid blue slab) vs terrain. + * patch is the water layer (it turns into a solid blue slab) vs terrain. If + * the "holes behind water" disappear with this on, the cause is transparency + * / depth sorting (the current default), NOT terrain generation. */ setDebugOpaque(on: boolean): void { if (on) { this.material.alpha = 1.0; + this.material.diffuseTexture = null; // flat colour, no scrolling this.material.emissiveColor = Color3.FromHexString("#1f9fe0"); this.material.diffuseColor = Color3.FromHexString("#1f9fe0"); } else { - this.material.diffuseColor = Color3.FromHexString("#1f86d8"); - this.material.emissiveColor = Color3.FromHexString("#0b3a6b"); + this.material.diffuseTexture = this.waterTexture; + this.material.diffuseColor = this.baseDiffuse.clone(); + this.material.emissiveColor = this.baseEmissive.clone(); + this.setQuality(this.quality); + } + } + + /** + * Debug: toggle water depth-write on/off to isolate transparency/depth + * artifacts. Default is OFF (correct for transparent water — terrain behind + * shows through cleanly). Turning it ON may reintroduce occlusion-style + * artifacts but can help confirm the cause of a rendering bug. + */ + setDepthWrite(on: boolean): void { + this.material.disableDepthWrite = !on; + } + get depthWriteOn(): boolean { + return !this.material.disableDepthWrite; + } + + /** + * Debug: swap to a plain untextured blue StandardMaterial (no procedural + * surface texture, no shimmer). If an artifact disappears with this on, the + * procedural texture/animation was the cause, not depth/sorting. + */ + setSimpleMaterial(on: boolean): void { + if (on) { + this.material.diffuseTexture = null; + this.material.specularColor = new Color3(0.2, 0.25, 0.3); + this.setAnimationEnabled(false); + } else { + this.material.diffuseTexture = this.waterTexture; + this.material.specularColor = new Color3(0.35, 0.4, 0.5); this.setQuality(this.quality); } } // Day/night + fog are handled by the StandardMaterial (scene lights + scene - // fog), so these are kept as no-ops for API compatibility. (A setTime/animation - // hook is intentionally omitted until water animation is actually implemented.) + // fog), so these are kept as no-ops for API compatibility. setDayNight(_dayFactor: number, _moonFloor: number): void { void _dayFactor; void _moonFloor; @@ -98,5 +251,18 @@ export class WaterMaterial { dispose(): void { this.material.dispose(); + this.waterTexture.dispose(); } } + +/** Small deterministic PRNG so the surface texture is identical every run. */ +function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/src/game/liquid/LiquidSimulator.ts b/src/game/liquid/LiquidSimulator.ts new file mode 100644 index 0000000..b9a5a9e --- /dev/null +++ b/src/game/liquid/LiquidSimulator.ts @@ -0,0 +1,492 @@ +// Voxel liquid flow simulator, modelled on Luanti/Minetest's +// `transforming_liquid` system but adapted to this engine's id-driven storage. +// +// Design (see PLAN in the PR description): +// • Source cells are infinite emitters. They pour DOWN into floodable cells +// (creating full-level flowing waterfalls) and wake their horizontal +// neighbours so flow spawns beside them. +// • Air / flowing cells RECOMPUTE their desired state from their feeders +// (the cell above + the four horizontal neighbours). Each horizontal step +// decays one level; a cell with a floodable cell below is treated as +// "falling" and clamps to full level. With no feeder and no fall, a +// flowing cell dries to air. +// • Renewable sources: a flowing/air cell with ≥2 source neighbours AND a +// solid/source support below becomes a source (the classic infinite-water +// rule). Bounded and conservative — it only fires inside genuine pools. +// +// Convergence: a cell only changes when its computed target differs from its +// current state; equal writes are no-ops and never re-enqueue. Flowing water +// at level L can only create neighbours at level ≤ L−1, so levels strictly +// decay outward and the system always settles. + +import { + AIR_BLOCK, + WATER_BLOCK, + WATER_FLOWING_BLOCK, + MAX_LIQUID_LEVEL, + getBlock, + isFloodable, + liquidDefOf, + liquidHeight, + type LiquidDef, +} from "../Blocks"; +import type { BlockId } from "../../types"; +import { LiquidUpdateQueue, type QueuedCell } from "./LiquidUpdateQueue"; +import type { LiquidAccess, LiquidDebugSnapshot } from "./LiquidTypes"; + +/** Default cells processed per scheduled tick. Tuned for ~40fps baseline hardware. */ +export const DEFAULT_LIQUID_BUDGET = 128; +/** + * The simulator ticks at most this many times per second (decoupled from fps). + * ~18 Hz gives a ~55ms cadence so ongoing flow propagation feels responsive + * while staying cheap. The FIRST response to a player edit doesn't wait for + * this — `World.setBlock` fires an immediate priority burst on the same frame. + */ +export const LIQUID_TICK_RATE_HZ = 18; +/** + * Maximum cells processed in the immediate post-edit priority burst. Small so a + * big edit can't stall the frame, but enough to flow water into the opened gap + * on the very first frame (7 enqueued neighbours + a few propagation wakes). + */ +export const LIQUID_IMMEDIATE_BURST = 48; + +// Horizontal neighbour offsets (E, W, N, S). +const HORIZ = [ + [1, 0, 0], + [-1, 0, 0], + [0, 0, 1], + [0, 0, -1], +] as const; + +export class LiquidSimulator { + private readonly queue = new LiquidUpdateQueue(); + private budget = DEFAULT_LIQUID_BUDGET; + private processedLastTick = 0; + private totalWrites = 0; + /** Performance.now() of the last scheduled tick (for the "time since tick" debug). */ + private lastTickMs = performance.now(); + /** Total cells processed via the immediate priority burst since world start. */ + private priorityProcessed = 0; + + /** Add a position for the next tick (deduplicated). */ + enqueue(x: number, y: number, z: number): void { + this.queue.enqueue(x, y, z); + } + + /** + * Wake a cell and its 6 neighbours on the PRIORITY lane — used after player + * terrain edits. Priority cells are drained before the normal (seeding + + * propagation) backlog, so removing a block beside water reacts within a + * frame instead of waiting behind thousands of ocean-seeded cells. + */ + enqueueAround(x: number, y: number, z: number): void { + this.queue.enqueuePriority(x, y, z); + this.queue.enqueuePriority(x + 1, y, z); + this.queue.enqueuePriority(x - 1, y, z); + this.queue.enqueuePriority(x, y + 1, z); + this.queue.enqueuePriority(x, y - 1, z); + this.queue.enqueuePriority(x, y, z + 1); + this.queue.enqueuePriority(x, y, z - 1); + } + + /** + * Immediately process up to `budget` PRIORITY cells right now (called by + * World right after a player edit). This makes the first flow step happen on + * the SAME frame as the edit — water visibly starts moving before the next + * scheduled liquid tick. Bounded so a big edit can't stall the frame. + */ + tickPriority(access: LiquidAccess, budget: number): number { + let processed = 0; + while (processed < budget) { + const cell = this.queue.pullPriority(); + if (!cell) break; + this.flowCell(access, cell.x, cell.y, cell.z); + processed++; + } + this.priorityProcessed += processed; + return processed; + } + + /** Per-tick cell budget (debug-configurable). */ + setBudget(budget: number): void { + this.budget = budget > 0 ? Math.floor(budget) : DEFAULT_LIQUID_BUDGET; + } + get currentBudget(): number { + return this.budget; + } + + get queueSize(): number { + return this.queue.size; + } + get priorityQueueSize(): number { + return this.queue.prioritySize; + } + get peakQueueSize(): number { + return this.queue.peakSize; + } + + /** Drain the queue (on world unload). */ + reset(): void { + this.queue.clear(); + this.processedLastTick = 0; + this.lastTickMs = performance.now(); + } + + /** Debug: snapshot of currently-queued positions (do not mutate). */ + peekQueue(): readonly QueuedCell[] { + return this.queue.snapshot(); + } + + /** Milliseconds since the last scheduled tick (debug overlay). */ + get msSinceLastTick(): number { + return performance.now() - this.lastTickMs; + } + /** Total cells processed via the immediate priority burst (debug). */ + get priorityProcessedTotal(): number { + return this.priorityProcessed; + } + + /** + * Process up to `budget` queued cells against `access` (PRIORITY lane first, + * then the normal backlog). Each cell is recomputed and, if it changes, its + * neighbours are woken so the flow ripples outward over successive ticks. + * Safe to call every frame. + */ + tick(access: LiquidAccess): LiquidDebugSnapshot { + this.lastTickMs = performance.now(); + let processed = 0; + let remaining = this.budget; + while (remaining > 0) { + const cell = this.queue.dequeue(); + if (!cell) break; + this.flowCell(access, cell.x, cell.y, cell.z); + remaining--; + processed++; + } + this.processedLastTick = processed; + return { + queueSize: this.queue.size, + priorityQueueSize: this.queue.prioritySize, + processedLastTick: processed, + budget: this.budget, + totalWrites: this.totalWrites, + priorityProcessed: this.priorityProcessed, + msSinceLastTick: 0, + dirtyChunks: 0, // filled in by the World wrapper + }; + } + + get debug(): LiquidDebugSnapshot { + return { + queueSize: this.queue.size, + priorityQueueSize: this.queue.prioritySize, + processedLastTick: this.processedLastTick, + budget: this.budget, + totalWrites: this.totalWrites, + priorityProcessed: this.priorityProcessed, + msSinceLastTick: this.msSinceLastTick, + dirtyChunks: 0, + }; + } + + // --------------------------------------------------------------- flow --- + + private flowCell(access: LiquidAccess, x: number, y: number, z: number): void { + const id = access.getBlock(x, y, z); + if (id === AIR_BLOCK) { + this.trySpawnIntoAir(access, x, y, z); + return; + } + const def = getBlock(id); + if (def.liquidType === "source") { + this.flowSource(access, x, y, z, def.liquidDef ?? null); + return; + } + if (def.liquidType === "flowing") { + this.flowFlowing(access, x, y, z, def.liquidDef ?? null, access.getLevel(x, y, z)); + return; + } + // A solid / plant neighbour was enqueued (e.g. terrain placed next to + // water): wake its liquid neighbours so they recompute. + this.wakeLiquidNeighbours(access, x, y, z); + } + + /** A source emits downward + sideways indefinitely. */ + private flowSource(access: LiquidAccess, x: number, y: number, z: number, ldef: LiquidDef | null): void { + // Pour down: create a full-level flowing waterfall below. + const below = access.getBlock(x, y - 1, z); + if (isFloodable(below)) { + const level = ldef ? ldef.range : MAX_LIQUID_LEVEL; + if (this.write(access, x, y - 1, z, WATER_FLOWING_BLOCK, level)) { + this.queue.enqueue(x, y - 1, z); + } + } + // Wake only neighbours that could actually receive flow — floodable cells + // (air) beside the source, or existing flowing cells (which may need to + // rise to full). We deliberately do NOT wake other sources: a stable ocean + // of sources would otherwise re-enqueue itself forever (the endless-update + // trap). Solids never receive flow either. + for (const o of HORIZ) { + const nx = x + o[0]; + const nz = z + o[2]; + const nid = access.getBlock(nx, y, nz); + if (nid === AIR_BLOCK || getBlock(nid).liquidType === "flowing") { + this.queue.enqueue(nx, y, nz); + } + } + } + + /** + * Recompute a flowing cell: it may rise (new feeder), dry (feeder gone), or + * hold its level and spread. Falling cells clamp to full level. + * + * Feeding is ACYCLIC to guarantee convergence: a horizontal neighbour only + * feeds this cell if its head is STRICTLY GREATER (so equal-level cells on a + * broad front can never pull each other up/down — the oscillation trap). The + * cell directly ABOVE may feed at equal head to sustain a waterfall column. + */ + private flowFlowing( + access: LiquidAccess, + x: number, + y: number, + z: number, + ldef: LiquidDef | null, + level: number, + ): void { + const ownHead = level; // flowing head == level (1..MAX) + const f = this.bestFeeder(access, x, y, z, ownHead, true); + const below = access.getBlock(x, y - 1, z); + const belowFloodable = isFloodable(below); + const columnFed = f.above > 0; // liquid above at head ≥ ours sustains us + const horizFed = f.horiz > ownHead; + + // No feeder at all → dry (a drying waterfall tail drains too). + if (!columnFed && !horizFed) { + if (this.write(access, x, y, z, AIR_BLOCK, 0)) this.wakeAround(x, y, z); + return; + } + + const range = ldef ? ldef.range : MAX_LIQUID_LEVEL; + let target: number; + if (belowFloodable) { + // Falling + fed → full-level waterfall. + target = range; + } else { + // Landed: water transfers DOWN from above without decay, and spreads + // SIDEWAYS with a one-level decay. Take whichever is higher. + let t = 0; + if (columnFed) t = f.above > range ? range : f.above; // down-transfer (clamp to range) + if (horizFed && f.horiz - 1 > t) t = f.horiz - 1; + target = t; + } + if (target < 1) { + if (this.write(access, x, y, z, AIR_BLOCK, 0)) this.wakeAround(x, y, z); + return; + } + + // Renewable source creation: ≥2 source neighbours + solid/source support. + if (ldef?.renewable) { + const sources = this.countSourceNeighbours(access, x, y, z); + const supported = getBlock(below).solid || below === WATER_BLOCK; + if (sources >= 2 && supported) { + if (this.write(access, x, y, z, WATER_BLOCK, 0)) this.wakeAround(x, y, z); + return; + } + } + + if (target !== level) { + if (this.write(access, x, y, z, WATER_FLOWING_BLOCK, target)) this.wakeAround(x, y, z); + return; + } + + // Level unchanged: propagate. Falling water ONLY pours down (no sideways + // spread — a falling stream that spawned a full ring at every level would + // grow into a diverging 3D plume). Landed water spreads sideways. + if (belowFloodable) { + if (this.write(access, x, y - 1, z, WATER_FLOWING_BLOCK, range)) { + this.queue.enqueue(x, y - 1, z); + } + } else { + for (const o of HORIZ) { + const nx = x + o[0]; + const nz = z + o[2]; + const nid = access.getBlock(nx, y, nz); + if (nid === AIR_BLOCK) { + this.queue.enqueue(nx, y, nz); + } else if (getBlock(nid).liquidType === "flowing") { + const nl = access.getLevel(nx, y, nz); + if (nl < level) this.queue.enqueue(nx, y, nz); + } + } + } + } + + /** Air cell: spawn flowing water here if a feeder reaches it (horizontal or + * falling-from-above; water never flows UP, so the cell above is ignored). */ + private trySpawnIntoAir(access: LiquidAccess, x: number, y: number, z: number): void { + const f = this.bestFeeder(access, x, y, z, 0, false); + if (f.horiz === 0) return; // no horizontal feeder can reach; stay air + const ldef = f.ldef ?? liquidDefOf(WATER_BLOCK); + if (!ldef) return; + + const below = access.getBlock(x, y - 1, z); + const belowFloodable = isFloodable(below); + const range = ldef.range; + let target: number; + if (belowFloodable) { + target = range; // pouring into an open shaft → full + } else if (f.horiz >= range + 1) { + target = range; // fed directly by a source → full + } else { + target = f.horiz - 1; + } + if (target < 1) return; + + // Renewable into air (classic 2-source pool fill). + if (ldef.renewable) { + const sources = this.countSourceNeighbours(access, x, y, z); + const supported = getBlock(below).solid || below === WATER_BLOCK; + if (sources >= 2 && supported) { + if (this.write(access, x, y, z, WATER_BLOCK, 0)) this.wakeAround(x, y, z); + return; + } + } + + if (this.write(access, x, y, z, WATER_FLOWING_BLOCK, target)) this.wakeAround(x, y, z); + } + + /** + * Wake the 6 neighbours of a CHANGED cell on the PRIORITY lane so active flow + * ripples outward responsively. This is safe re: the endless-loop trap + * because only cells that ACTUALLY changed reach here — the stable ocean + * backlog (no-change no-op cells) never wakes anything, so the priority lane + * stays small (just the live flow front) and drains each tick. Convergence is + * unchanged (priority/normal only differ in ordering, not in flow logic). + */ + private wakeAround(x: number, y: number, z: number): void { + this.queue.enqueuePriority(x + 1, y, z); + this.queue.enqueuePriority(x - 1, y, z); + this.queue.enqueuePriority(x, y + 1, z); + this.queue.enqueuePriority(x, y - 1, z); + this.queue.enqueuePriority(x, y, z + 1); + this.queue.enqueuePriority(x, y, z - 1); + } + + /** Wake only the liquid-valued neighbours of a solid/plant cell. */ + private wakeLiquidNeighbours(access: LiquidAccess, x: number, y: number, z: number): void { + const neigh = [ + [x + 1, y, z], + [x - 1, y, z], + [x, y + 1, z], + [x, y - 1, z], + [x, y, z + 1], + [x, y, z - 1], + ]; + for (const n of neigh) { + const id = access.getBlock(n[0], n[1], n[2]); + if (getBlock(id).liquidType && getBlock(id).liquidType !== "none") { + this.queue.enqueue(n[0], n[1], n[2]); + } + } + } + + // ----------------------------------------------------------- helpers --- + + /** + * Feeder analysis for cell (x,y,z), split into the cell directly ABOVE and + * the four HORIZONTAL neighbours. They feed under different rules: + * + * • ABOVE: a liquid cell above transfers its level DOWNWARD WITHOUT DECAY + * (a waterfall column stays full). Only counts when its head is ≥ + * `ownHead` (so a draining column doesn't falsely sustain the cell below). + * Ignored entirely for air cells (`allowAboveColumn=false`) — water never + * flows up into air. + * • HORIZONTAL: a strictly-higher, SUPPORTED (landed/source) neighbour + * feeds this cell with a one-level decay. The strict-greater + supported + * rules make feeding acyclic and keep waterfalls as narrow columns. + * + * Downward neighbours never feed (down is a drain). + */ + private bestFeeder( + access: LiquidAccess, + x: number, + y: number, + z: number, + ownHead: number, + allowAboveColumn: boolean, + ): { above: number; horiz: number; ldef: LiquidDef | null } { + let above = 0; + let horiz = 0; + let ldef: LiquidDef | null = null; + + if (allowAboveColumn) { + const id = access.getBlock(x, y + 1, z); + const ld = liquidDefOf(id); + if (ld) { + const lv = id === WATER_FLOWING_BLOCK ? access.getLevel(x, y + 1, z) : 0; + const h = liquidHeight(id, lv); + if (h >= ownHead && h > 0) above = h; + if (ld) ldef = ld; + } + } + + const considerHoriz = (nx: number, nz: number): void => { + if (!access.isChunkLoaded(nx, nz)) return; + const id = access.getBlock(nx, y, nz); + const ld = liquidDefOf(id); + if (!ld) return; + if (!this.isSupported(access, nx, y, nz)) return; // falling cells don't spread sideways + const lv = id === WATER_FLOWING_BLOCK ? access.getLevel(nx, y, nz) : 0; + const h = liquidHeight(id, lv); + if (h > ownHead && h > horiz) { + horiz = h; + ldef = ld; + } + }; + considerHoriz(x + 1, z); + considerHoriz(x - 1, z); + considerHoriz(x, z + 1); + considerHoriz(x, z - 1); + return { above, horiz, ldef }; + } + + /** + * True if the liquid cell at (x,y,z) may spread SIDEWARDS — i.e. it is a + * source, or a flowing cell resting on SOLID terrain. A flowing cell with + * water/air below is part of a suspended/falling column and must NOT spread + * sideways (otherwise a waterfall grows into a diverging 3D plume). Only the + * cell that actually lands on the floor spreads, which keeps flow bounded. + */ + private isSupported(access: LiquidAccess, x: number, y: number, z: number): boolean { + const id = access.getBlock(x, y, z); + const def = getBlock(id); + if (def.liquidType === "source") return true; + if (def.liquidType !== "flowing") return false; + return getBlock(access.getBlock(x, y - 1, z)).solid; + } + + /** Count source-liquid neighbours in all 6 directions. */ + private countSourceNeighbours(access: LiquidAccess, x: number, y: number, z: number): number { + let n = 0; + const check = (nx: number, ny: number, nz: number): void => { + if (!access.isChunkLoaded(nx, nz)) return; + if (getBlock(access.getBlock(nx, ny, nz)).liquidType === "source") n++; + }; + check(x + 1, y, z); + check(x - 1, y, z); + check(x, y, z + 1); + check(x, y, z - 1); + check(x, y + 1, z); + return n; + } + + /** + * Write a cell via the access. Counts writes for the debug overlay. Returns + * the access result (true if the cell changed). + */ + private write(access: LiquidAccess, x: number, y: number, z: number, id: BlockId, level: number): boolean { + const changed = access.setLiquid(x, y, z, id, level); + if (changed) this.totalWrites++; + return changed; + } +} diff --git a/src/game/liquid/LiquidTypes.ts b/src/game/liquid/LiquidTypes.ts new file mode 100644 index 0000000..f141e93 --- /dev/null +++ b/src/game/liquid/LiquidTypes.ts @@ -0,0 +1,47 @@ +// Liquid system shared types. The flow simulator talks to the world through a +// narrow {@link LiquidAccess} interface (mirroring the lighting system's +// `LightAccess`), keeping it decoupled from chunk storage and Babylon. + +import type { BlockId } from "../../types"; + +/** + * Read/write surface the {@link LiquidSimulator} uses. Implemented by the + * {@link World}; the indirection keeps the simulator free of storage/Babylon + * details and makes it unit-testable in isolation. + */ +export interface LiquidAccess { + /** Block id at world coordinates (0 = air for unloaded/above-world). */ + getBlock(wx: number, wy: number, wz: number): BlockId; + /** Per-voxel liquid level at world coordinates (0 for non-flowing). */ + getLevel(wx: number, wy: number, wz: number): number; + /** + * Write a block id + liquid level atomically. Handles chunk dirty-marking, + * neighbour-mesh invalidation and lighting recompute. Returns true if the + * cell actually changed (so the simulator can decide whether to wake + * neighbours). MUST NOT re-enqueue liquid updates itself (the simulator owns + * the queue) — it only performs the storage + render-side bookkeeping. + */ + setLiquid(wx: number, wy: number, wz: number, id: BlockId, level: number): boolean; + /** Whether the (cx,cz) chunk for these world coords is generated/loaded. */ + isChunkLoaded(wx: number, wz: number): boolean; +} + +/** Debug snapshot consumed by the perf/liquid overlay. */ +export interface LiquidDebugSnapshot { + /** Positions currently waiting in the update queue (both lanes). */ + queueSize: number; + /** Positions in the priority lane (player edits) — should stay small. */ + priorityQueueSize: number; + /** Cells processed during the most recent scheduled tick. */ + processedLastTick: number; + /** Configured per-tick cell budget. */ + budget: number; + /** Total liquid cells written since the world started (monotonic). */ + totalWrites: number; + /** Total cells processed via immediate post-edit bursts (monotonic). */ + priorityProcessed: number; + /** Milliseconds since the last scheduled liquid tick (responsiveness signal). */ + msSinceLastTick: number; + /** Number of chunks marked dirty for a remesh (live). */ + dirtyChunks: number; +} diff --git a/src/game/liquid/LiquidUpdateQueue.ts b/src/game/liquid/LiquidUpdateQueue.ts new file mode 100644 index 0000000..d651ce9 --- /dev/null +++ b/src/game/liquid/LiquidUpdateQueue.ts @@ -0,0 +1,127 @@ +// Bounded, deduplicating queue of voxel positions awaiting a liquid-flow +// recompute. Mirrors Luanti/Minetest's `transforming_liquid` queue: positions +// are added when a liquid cell (or a neighbour of one) changes, and a limited +// number are drained per tick so the whole world is never re-simulated. +// +// Two lanes: +// • NORMAL — simulator self-wakes (flow propagation) + chunk-gen seeding. +// This is the bulk lane and can carry a backlog (a freshly streamed ocean +// seeds thousands of shore cells). +// • PRIORITY — player block edits. These MUST jump ahead of any backlog so +// removing a block beside water reacts within a frame, not seconds. +// +// `dequeue` always serves PRIORITY first. The two lanes have independent dedup +// sets, so a cell present in both is processed once per lane — the second visit +// is a cheap no-op (state unchanged → no re-enqueue), which is harmless and +// avoids the O(n) cost of upgrading a normal entry to priority. + +export interface QueuedCell { + x: number; + y: number; + z: number; +} + +export class LiquidUpdateQueue { + /** Normal lane (flow propagation + seeding). */ + private readonly pending = new Set(); + private fifo: QueuedCell[] = []; + /** Priority lane (player edits) — drained before the normal lane. */ + private readonly priorityPending = new Set(); + private priorityFifo: QueuedCell[] = []; + /** High-water mark (both lanes) since creation, for the debug overlay. */ + peakSize = 0; + + get size(): number { + return this.pending.size + this.priorityPending.size; + } + + /** Count in the priority lane only (debug). */ + get prioritySize(): number { + return this.priorityPending.size; + } + + /** Pack integer coords into a dedup key. */ + private key(x: number, y: number, z: number): string { + return x + "|" + y + "|" + z; + } + + private bumpPeak(): void { + const s = this.pending.size + this.priorityPending.size; + if (s > this.peakSize) this.peakSize = s; + } + + /** Add a cell to the NORMAL lane (no-op if already pending there). */ + enqueue(x: number, y: number, z: number): void { + const k = this.key(x, y, z); + if (this.pending.has(k)) return; + this.pending.add(k); + this.fifo.push({ x, y, z }); + this.bumpPeak(); + } + + /** + * Add a cell to the PRIORITY lane (player edits). Drained before the normal + * lane so terrain changes react immediately even when a large background + * backlog exists. No-op if already pending in the priority lane. + */ + enqueuePriority(x: number, y: number, z: number): void { + const k = this.key(x, y, z); + if (this.priorityPending.has(k)) return; + this.priorityPending.add(k); + this.priorityFifo.push({ x, y, z }); + this.bumpPeak(); + } + + /** True if the cell is pending in either lane. */ + has(x: number, y: number, z: number): boolean { + const k = this.key(x, y, z); + return this.pending.has(k) || this.priorityPending.has(k); + } + + /** + * Pop the oldest PENDING cell, preferring the priority lane. Skips stale + * entries (the Set is the source of truth; the FIFO is only for ordering). + */ + dequeue(): QueuedCell | null { + // Priority first. + while (this.priorityFifo.length > 0) { + const cell = this.priorityFifo.shift()!; + const k = this.key(cell.x, cell.y, cell.z); + if (this.priorityPending.delete(k)) return cell; + } + // Then the normal backlog. + while (this.fifo.length > 0) { + const cell = this.fifo.shift()!; + const k = this.key(cell.x, cell.y, cell.z); + if (this.pending.delete(k)) return cell; + } + return null; + } + + /** + * Pull one PRIORITY cell (used by the immediate post-edit burst so the first + * flow step happens on the same frame as a player edit, before the next + * scheduled tick). Returns null when the priority lane is empty. + */ + pullPriority(): QueuedCell | null { + while (this.priorityFifo.length > 0) { + const cell = this.priorityFifo.shift()!; + const k = this.key(cell.x, cell.y, cell.z); + if (this.priorityPending.delete(k)) return cell; + } + return null; + } + + /** Empty both lanes (on world unload / sim reset). */ + clear(): void { + this.pending.clear(); + this.fifo.length = 0; + this.priorityPending.clear(); + this.priorityFifo.length = 0; + } + + /** Debug snapshot of queued positions (read-only; does not mutate). */ + snapshot(): readonly QueuedCell[] { + return this.fifo; + } +} diff --git a/src/main.ts b/src/main.ts index 3f7261a..22ea8e0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,17 @@ function boot(): void { post?: (on?: boolean) => void; shadows?: (on?: boolean) => void; dumpMaterials?: () => void; + // Liquid system debug surface (see LiquidSimulator). + liquid?: () => unknown; + placeWater?: (x?: number, y?: number, z?: number) => void; + removeWater?: (x?: number, y?: number, z?: number) => void; + liquidBudget?: (n: number) => void; + // Water rendering / targeting debug toggles. + waterSides?: (on?: boolean) => void; + waterAnim?: (on?: boolean) => void; + waterDepth?: (on?: boolean) => void; + waterSimple?: (on?: boolean) => void; + targetLiquids?: (on?: boolean) => boolean; } (window as unknown as { __voxl?: VoxlAutomation }).__voxl = { beginPlay: () => game.beginPlay(), @@ -62,6 +73,15 @@ function boot(): void { post: (on) => game._setPost(on ?? true), shadows: (on) => game._setShadows(on ?? true), dumpMaterials: () => game._dumpMaterials(), + liquid: () => game._liquidDebug(), + placeWater: (x, y, z) => game._placeWater(x, y, z), + removeWater: (x, y, z) => game._removeWater(x, y, z), + liquidBudget: (n) => game._liquidBudget(n), + waterSides: (on) => game._setWaterSides(on ?? true), + waterAnim: (on) => game._setWaterAnim(on ?? true), + waterDepth: (on) => game._setWaterDepth(on ?? true), + waterSimple: (on) => game._setWaterSimple(on ?? true), + targetLiquids: (on) => game._setTargetLiquids(on), }; } diff --git a/src/types.ts b/src/types.ts index 8613048..fceaa8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,19 @@ export type { GraphicsSettings, }; +/** A liquid cell the ray passed through (for targeting/debug), with the empty + * cell just before it (for placement against terrain through water). */ +export interface LiquidPassHit { + x: number; + y: number; + z: number; + px: number; + py: number; + pz: number; + block: BlockId; + distance: number; +} + /** Result of a voxel DDA raycast. */ export interface RaycastHit { /** Integer coords of the hit block. */ @@ -73,4 +86,9 @@ export interface RaycastHit { block: BlockId; /** Distance from ray origin. */ distance: number; + /** True if the ray crossed at least one liquid cell before this hit. */ + passedThroughLiquid: boolean; + /** The first liquid cell the ray passed through (when ignoring liquids), + * so a "liquid targeting" mode can select it without a second raycast. */ + firstLiquid?: LiquidPassHit; } diff --git a/src/ui/PerfOverlay.ts b/src/ui/PerfOverlay.ts index 896e654..710c6a5 100644 --- a/src/ui/PerfOverlay.ts +++ b/src/ui/PerfOverlay.ts @@ -18,6 +18,8 @@ export interface PerfSnapshot { shadowCasters: number; shadowsEnabled: boolean; waterMeshes: number; + /** Total vertices across all water (transparent) meshes — fill/overdraw signal. */ + waterVertices: number; preset: string; viewDistance: number; renderScale: number; @@ -36,6 +38,23 @@ export interface PerfSnapshot { waterAlpha: number; waterQuality: string; antiAliasing: boolean; + // Liquid / swimming state. + inWater: boolean; + underwater: boolean; + liquidQueue: number; + liquidPriorityQueue: number; + liquidProcessed: number; + liquidBudget: number; + liquidWrites: number; + liquidMsSinceTick: number; + targetLiquidType: string; + targetLiquidLevel: number; + // Block targeting through water (Luanti-style pointability). + targetMode: "solids" | "liquids"; + rayThroughLiquid: boolean; + firstLiquid: { x: number; y: number; z: number } | null; + waterSidesOn: boolean; + waterAnimOn: boolean; } function $(id: string): HTMLElement { @@ -61,6 +80,8 @@ export class PerfOverlay { private readonly lightEl: HTMLElement; private readonly fogEl: HTMLElement; private readonly waterEl: HTMLElement; + private readonly liquidEl: HTMLElement; + private readonly targetEl: HTMLElement; private readonly memEl: HTMLElement; private visible = false; @@ -95,6 +116,8 @@ export class PerfOverlay { this.lightEl = mkline(grid, "light"); this.fogEl = mkline(grid, "fog"); this.waterEl = mkline(grid, "water"); + this.liquidEl = mkline(grid, "liquid"); + this.targetEl = mkline(grid, "target"); this.memEl = mkline(grid, "mem"); root.appendChild(grid); @@ -138,7 +161,20 @@ export class PerfOverlay { `amb ${s.ambientIntensity.toFixed(2)} · sun ${s.sunIntensity.toFixed(2)} · day ${s.dayFactor.toFixed(2)}`, ); setLine(this.fogEl, `start ${Math.round(s.fogStart)} · end ${Math.round(s.fogEnd)}`); - setLine(this.waterEl, `${s.waterQuality} · alpha ${s.waterAlpha.toFixed(2)} · ${s.waterMeshes} water meshes`); + setLine(this.waterEl, `${s.waterQuality} · alpha ${s.waterAlpha.toFixed(2)} · ${s.waterMeshes} meshes · ${formatK(s.waterVertices)} verts`); + const liquidState = + (s.underwater ? "under" : s.inWater ? "in-water" : "dry") + + ` · tgt ${s.targetLiquidType}${s.targetLiquidType === "flowing" ? ` L${s.targetLiquidLevel}` : ""}`; + setLine( + this.liquidEl, + `q ${s.liquidQueue} (pri ${s.liquidPriorityQueue}) · ${s.liquidProcessed}/${s.liquidBudget}/tick · ${s.liquidMsSinceTick.toFixed(0)}ms · ${formatK(s.liquidWrites)} writes · ${liquidState}`, + s.liquidMsSinceTick > 300 ? "var(--danger)" : s.liquidMsSinceTick > 150 ? "var(--warm)" : "", + ); + const fl = s.firstLiquid; + setLine( + this.targetEl, + `${s.targetMode} · ${s.rayThroughLiquid ? "through water" : "no water in ray"}${fl ? ` · 1st liq ${fl.x},${fl.y},${fl.z}` : ""} · sides ${s.waterSidesOn ? "on" : "off"} · anim ${s.waterAnimOn ? "on" : "off"}`, + ); const tod = `t ${Math.floor(s.timeOfDay * 24).toString().padStart(2, "0")}:${Math.floor(((s.timeOfDay * 24) % 1) * 60).toString().padStart(2, "0")}`; if (s.heapUsedMB !== null) { setLine(this.memEl, `${s.heapUsedMB.toFixed(0)} MB JS · ${tod}${s.gpuRenderer ? " · " + s.gpuRenderer : ""}`); diff --git a/tests/liquid.test.ts b/tests/liquid.test.ts new file mode 100644 index 0000000..778f730 --- /dev/null +++ b/tests/liquid.test.ts @@ -0,0 +1,186 @@ +// Liquid flow simulator tests (Babylon-free logic). Run: bun tests/liquid.test.ts +// Verifies the Minetest-inspired flow rules converge and behave correctly: +// waterfalls, horizontal decay, drying, stable oceans (no endless loop), +// void-edge safety, renewable sources, solid walls, waterfall landings. + +import { + AIR_BLOCK, + WATER_BLOCK, + WATER_FLOWING_BLOCK, + MAX_LIQUID_LEVEL, +} from "../src/game/Blocks"; +import { LiquidSimulator } from "../src/game/liquid/LiquidSimulator"; +import type { LiquidAccess } from "../src/game/liquid/LiquidTypes"; +import type { BlockId } from "../src/types"; + +// In-memory world mirroring the real World's semantics: +// • getBlock below y=0 → stone (the world floor; stops infinite waterfalls) +// • setLiquid to an UNLOADED chunk → rejected (no write), like World.setLiquid +class FakeWorld implements LiquidAccess { + cells = new Map(); + loaded: (x: number, z: number) => boolean = () => true; + isChunkLoaded(x: number, z: number): boolean { return this.loaded(x, z); } + private k(x: number, y: number, z: number): string { return x + "|" + y + "|" + z; } + getBlock(x: number, y: number, z: number): BlockId { + if (y < 0) return 3; // world floor = stone (matches real World.getBlock) + return this.cells.get(this.k(x, y, z))?.id ?? AIR_BLOCK; + } + getLevel(x: number, y: number, z: number): number { return this.cells.get(this.k(x, y, z))?.level ?? 0; } + setLiquid(x: number, y: number, z: number, id: BlockId, level: number): boolean { + if (!this.loaded(x, z)) return false; // unloaded chunk → reject (mirrors World) + const key = this.k(x, y, z); + const cur = this.cells.get(key); + if (cur && cur.id === id && cur.level === level) return false; + if (id === AIR_BLOCK) this.cells.delete(key); + else this.cells.set(key, { id, level }); + return true; + } + set(x: number, y: number, z: number, id: BlockId, level = 0): void { this.setLiquid(x, y, z, id, level); } + floor(xMin: number, xMax: number, zMin: number, zMax: number, y: number): void { + for (let x = xMin; x <= xMax; x++) for (let z = zMin; z <= zMax; z++) this.set(x, y, z, 3); + } +} + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { console.error(" FAIL:", msg); failures++; } + else console.log(" ok:", msg); +} +function settle(sim: LiquidSimulator, world: FakeWorld, maxTicks = 400): void { + for (let i = 0; i < maxTicks; i++) { + sim.tick(world); + if (sim.queueSize === 0) return; + } + console.error(" DID NOT SETTLE within", maxTicks, "ticks (queue=", sim.queueSize, ")"); +} + +console.log("\n[Test 1] Source above air pours straight down (waterfall), stops on floor."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + w.set(0, 5, 0, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(0, 5, 0); + settle(sim, w); + assert(w.getBlock(0, 5, 0) === WATER_BLOCK, "source stays at y=5"); + assert(w.getBlock(0, 4, 0) === WATER_FLOWING_BLOCK, "flowing at y=4"); + assert(w.getBlock(0, 3, 0) === WATER_FLOWING_BLOCK, "flowing at y=3"); + assert(w.getLevel(0, 4, 0) === 7 && w.getLevel(0, 1, 0) === 7, "falling column is full level (7)"); + assert(w.getBlock(0, 0, 0) === 3, "floor intact"); + assert(sim.queueSize === 0, "queue drained (no endless loop)"); +} + +console.log("\n[Test 2] Source on a solid floor spreads horizontally and stops at range."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + w.set(0, 1, 0, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(0, 1, 0); + settle(sim, w); + let ok = true, maxDist = 0; + for (let d = 1; d <= 9; d++) { + const id = w.getBlock(d, 1, 0); + if (id === WATER_FLOWING_BLOCK) { + maxDist = d; + const expected = MAX_LIQUID_LEVEL - d + 1; + if (w.getLevel(d, 1, 0) !== expected) { ok = false; console.error(` d=${d} level=${w.getLevel(d, 1, 0)} expected=${expected}`); } + } + } + assert(ok, "horizontal levels decay by 1 per step from the source"); + assert(maxDist === 7, `spread reached exactly range (7) blocks, got ${maxDist}`); + assert(w.getBlock(8, 1, 0) === AIR_BLOCK, "no water beyond range (air at d=8)"); + assert(sim.queueSize === 0, "queue drained"); +} + +console.log("\n[Test 3] Removing a source dries up its flowing tail."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + w.set(0, 1, 0, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(0, 1, 0); + settle(sim, w); + assert(w.getBlock(3, 1, 0) === WATER_FLOWING_BLOCK, "flowing exists at d=3 before removal"); + w.set(0, 1, 0, AIR_BLOCK); + sim.enqueueAround(0, 1, 0); + settle(sim, w); + assert(w.getBlock(0, 1, 0) === AIR_BLOCK, "source cell is air"); + assert(w.getBlock(1, 1, 0) === AIR_BLOCK, "neighbour dried"); + assert(w.getBlock(3, 1, 0) === AIR_BLOCK, "far flowing dried too"); + assert(sim.queueSize === 0, "queue drained after drying"); +} + +console.log("\n[Test 4] Stable ocean (walled basin) does NOT churn forever."); +{ + const w = new FakeWorld(); + w.floor(0, 20, 0, 20, 5); + for (let x = 0; x <= 20; x++) { w.set(x, 5, -1, 3); w.set(x, 5, 21, 3); w.set(x, 6, -1, 3); w.set(x, 6, 21, 3); } + for (let z = 0; z <= 20; z++) { w.set(-1, 5, z, 3); w.set(21, 5, z, 3); w.set(-1, 6, z, 3); w.set(21, 6, z, 3); } + for (let x = 0; x <= 20; x++) for (let z = 0; z <= 20; z++) w.set(x, 6, z, WATER_BLOCK); + const sim = new LiquidSimulator(); + for (let x = 0; x <= 20; x++) for (let z = 0; z <= 20; z++) sim.enqueue(x, 6, z); + settle(sim, w, 80); + assert(sim.queueSize === 0, "ocean queue fully drains (no endless update loop)"); + let allSources = true; + for (let x = 0; x <= 20; x++) for (let z = 0; z <= 20; z++) if (w.getBlock(x, 6, z) !== WATER_BLOCK) allSources = false; + assert(allSources, "flat ocean surface unchanged (still all sources)"); +} + +console.log("\n[Test 5] Water does not flow into the void (unloaded chunk)."); +{ + const w = new FakeWorld(); + w.floor(0, 14, -14, 14, 0); + w.set(0, 1, 0, WATER_BLOCK); + w.loaded = (x) => x >= 0; + const sim = new LiquidSimulator(); + sim.enqueue(0, 1, 0); + settle(sim, w); + assert(w.getBlock(-1, 1, 0) === AIR_BLOCK, "no water drained into the void (x=-1 stays air)"); + assert(w.getBlock(2, 1, 0) === WATER_FLOWING_BLOCK, "spreads into loaded land (+x)"); + assert(sim.queueSize === 0, "queue drained"); +} + +console.log("\n[Test 6] Renewable: 2 sources + solid floor → air between becomes a source."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + w.set(1, 1, 1, WATER_BLOCK); + w.set(3, 1, 1, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(2, 1, 1); + settle(sim, w); + assert(w.getBlock(2, 1, 1) === WATER_BLOCK, "air between 2 sources became a source (renewal)"); + assert(sim.queueSize === 0, "queue drained"); +} + +console.log("\n[Test 7] Water stops at a solid wall."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + for (let y = 0; y <= 5; y++) w.set(4, y, 0, 3); + w.set(0, 1, 0, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(0, 1, 0); + settle(sim, w); + assert(w.getBlock(4, 1, 0) === 3, "wall intact (water did not replace solid)"); + assert(w.getBlock(3, 1, 0) === WATER_FLOWING_BLOCK, "water spreads up to the wall"); + assert(sim.queueSize === 0, "queue drained"); +} + +console.log("\n[Test 8] Waterfall spreads outward when it lands on a floor."); +{ + const w = new FakeWorld(); + w.floor(-14, 14, -14, 14, 0); + w.set(0, 6, 0, WATER_BLOCK); + const sim = new LiquidSimulator(); + sim.enqueue(0, 6, 0); + settle(sim, w); + assert(w.getBlock(0, 1, 0) === WATER_FLOWING_BLOCK, "waterfall reached the floor"); + assert(w.getLevel(0, 1, 0) === 7, "landing cell is full level"); + assert(w.getBlock(2, 1, 0) === WATER_FLOWING_BLOCK, "spread outward from the landing"); + assert(sim.queueSize === 0, "queue drained"); +} + +if (failures === 0) console.log("\nALL LIQUID TESTS PASSED\n"); +else { console.error(`\n${failures} LIQUID TEST(S) FAILED\n`); process.exit(1); } diff --git a/tests/raycast.test.ts b/tests/raycast.test.ts new file mode 100644 index 0000000..ee20f4a --- /dev/null +++ b/tests/raycast.test.ts @@ -0,0 +1,72 @@ +// Raycast-through-water tests (Babylon-free logic). Run: bun tests/raycast.test.ts +// Verifies the Luanti-style pointability: default ray passes THROUGH water to +// hit solid terrain; liquid mode stops at the water surface; fallback + seabed. + +import { WATER_BLOCK, WATER_FLOWING_BLOCK } from "../src/game/Blocks"; +import { raycastVoxel } from "../src/game/BlockRaycaster"; + +// Minimal world stub: the raycaster only calls world.getBlock(x,y,z). +function makeWorld(map: Record) { + return { getBlock: (x: number, y: number, z: number) => map[`${x}|${y}|${z}`] ?? 0 } as never; +} + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { console.error(" FAIL:", msg); failures++; } + else console.log(" ok:", msg); +} + +// Layout (looking +X from origin): air at x=0,1; WATER source at x=2,3; STONE at x=4. +const world = makeWorld({ "2|0|0": WATER_BLOCK, "3|0|0": WATER_BLOCK, "4|0|0": 3 }); + +console.log("\n[Test 1] Solid mode (ignoreLiquid=true): ray passes through water, hits stone."); +{ + const hit = raycastVoxel(world, 0.5, 0.5, 0.5, 1, 0, 0, 12, { ignoreLiquid: true }); + assert(hit !== null, "hit is not null"); + assert(hit!.block === 3, `hit stone (block 3), got ${hit?.block}`); + assert(hit!.x === 4, `hit at x=4, got x=${hit?.x}`); + assert(hit!.passedThroughLiquid === true, "ray passed through liquid"); + assert(hit!.firstLiquid !== undefined, "firstLiquid recorded"); + assert(hit!.firstLiquid!.x === 2 && hit!.firstLiquid!.block === WATER_BLOCK, `first liquid is the source at x=2, got x=${hit?.firstLiquid?.x}`); + assert(hit!.px === 3, `placement cell x=3 (adjacent water), got px=${hit?.px}`); +} + +console.log("\n[Test 2] Liquid mode (ignoreLiquid=false): ray stops at the water surface."); +{ + const hit = raycastVoxel(world, 0.5, 0.5, 0.5, 1, 0, 0, 12, { ignoreLiquid: false }); + assert(hit !== null, "hit is not null"); + assert(hit!.block === WATER_BLOCK, `hit water, got ${hit?.block}`); + assert(hit!.x === 2, `hit at x=2 (first water), got x=${hit?.x}`); + assert(hit!.passedThroughLiquid === false, "did not pass through (stopped at liquid)"); +} + +console.log("\n[Test 3] Solid mode, no solid behind water within reach → fallback to first liquid."); +{ + const world2 = makeWorld({ "2|0|0": WATER_FLOWING_BLOCK, "3|0|0": WATER_FLOWING_BLOCK }); + const hit = raycastVoxel(world2, 0.5, 0.5, 0.5, 1, 0, 0, 4, { ignoreLiquid: true }); + assert(hit !== null, "fallback hit is not null"); + assert(hit!.block === WATER_FLOWING_BLOCK, `fell back to flowing water, got ${hit?.block}`); + assert(hit!.passedThroughLiquid === true, "marked as passed-through"); +} + +console.log("\n[Test 4] No water at all: solid mode behaves like the classic raycast."); +{ + const world3 = makeWorld({ "2|0|0": 3 }); + const hit = raycastVoxel(world3, 0.5, 0.5, 0.5, 1, 0, 0, 8, { ignoreLiquid: true }); + assert(hit !== null && hit!.block === 3, "hits stone"); + assert(hit!.passedThroughLiquid === false, "no liquid crossed"); + assert(hit!.firstLiquid === undefined, "no firstLiquid"); +} + +console.log("\n[Test 5] Water UNDER the ray (mining the seabed from above)."); +{ + const world4 = makeWorld({ "0|0|0": 4 /*sand*/, "0|1|0": WATER_BLOCK }); + const hit = raycastVoxel(world4, 0.5, 4.5, 0.5, 0, -1, 0, 8, { ignoreLiquid: true }); + assert(hit !== null && hit!.block === 4, `hits sand through water, got ${hit?.block}`); + assert(hit!.y === 0, `sand at y=0, got y=${hit?.y}`); + assert(hit!.py === 1, `placement cell y=1 (where water was), got py=${hit?.py}`); + assert(hit!.passedThroughLiquid === true, "passed through the water column"); +} + +if (failures === 0) console.log("\nALL RAYCAST TESTS PASSED\n"); +else { console.error(`\n${failures} RAYCAST TEST(S) FAILED\n`); process.exit(1); } diff --git a/tests/responsiveness.test.ts b/tests/responsiveness.test.ts new file mode 100644 index 0000000..292b4c3 --- /dev/null +++ b/tests/responsiveness.test.ts @@ -0,0 +1,77 @@ +// Responsiveness test: a player edit (priority burst) must fill a gap +// IMMEDIATELY even when the normal queue carries a huge ocean-seeding backlog. +// Run: bun tests/responsiveness.test.ts + +import { AIR_BLOCK, WATER_BLOCK, WATER_FLOWING_BLOCK } from "../src/game/Blocks"; +import { LiquidSimulator, LIQUID_IMMEDIATE_BURST } from "../src/game/liquid/LiquidSimulator"; +import type { LiquidAccess } from "../src/game/liquid/LiquidTypes"; +import type { BlockId } from "../src/types"; + +class W implements LiquidAccess { + cells = new Map(); + isChunkLoaded() { return true; } + k(x: number, y: number, z: number) { return x + "|" + y + "|" + z; } + getBlock(x: number, y: number, z: number) { if (y < 0) return 3; return this.cells.get(this.k(x, y, z))?.id ?? AIR_BLOCK; } + getLevel(x: number, y: number, z: number) { return this.cells.get(this.k(x, y, z))?.level ?? 0; } + setLiquid(x: number, y: number, z: number, id: BlockId, level: number) { + const key = this.k(x, y, z); const c = this.cells.get(key); + if (c && c.id === id && c.level === level) return false; + if (id === AIR_BLOCK) this.cells.delete(key); else this.cells.set(key, { id, level }); + return true; + } +} + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { console.error(" FAIL:", msg); failures++; } + else console.log(" ok:", msg); +} + +console.log("\n[Test] Edit beside water reacts IMMEDIATELY despite a large normal backlog."); +{ + const w = new W(); + for (let x = -14; x <= 14; x++) for (let z = -14; z <= 14; z++) w.setLiquid(x, 0, z, 3, 0); + w.setLiquid(0, 1, 0, WATER_BLOCK, 0); + w.setLiquid(1, 1, 0, 3, 0); + w.setLiquid(2, 1, 0, 3, 0); + + const sim = new LiquidSimulator(); + // Huge ocean-seeding backlog in the NORMAL lane (thousands of unrelated cells). + for (let i = 0; i < 4000; i++) sim.enqueue(100 + i, 50, 100); + sim.enqueue(0, 1, 0); + sim.tick(w); + + // Player edit: remove sand at (1,1,0); World does enqueueAround [PRIORITY] + tickPriority. + w.setLiquid(1, 1, 0, AIR_BLOCK, 0); + sim.enqueueAround(1, 1, 0); + const processed = sim.tickPriority(w, LIQUID_IMMEDIATE_BURST); + + const gap = w.getBlock(1, 1, 0); + assert(processed > 0, `immediate burst processed cells (${processed})`); + assert(gap === WATER_FLOWING_BLOCK || gap === WATER_BLOCK, `gap filled with water immediately (got id=${gap}), NOT waiting behind the backlog`); + assert(sim.priorityQueueSize < 200, `priority lane holds only the live flow front, not the backlog (${sim.priorityQueueSize} << ${sim.queueSize})`); + assert(sim.queueSize > 3000, `normal backlog still largely intact (${sim.queueSize}) — only priority was drained`); +} + +console.log("\n[Test] Without the burst (old behaviour), the gap would NOT fill until the backlog drains."); +{ + const w = new W(); + for (let x = -14; x <= 14; x++) for (let z = -14; z <= 14; z++) w.setLiquid(x, 0, z, 3, 0); + w.setLiquid(0, 1, 0, WATER_BLOCK, 0); + w.setLiquid(1, 1, 0, 3, 0); + const sim = new LiquidSimulator(); + for (let i = 0; i < 4000; i++) sim.enqueue(100 + i, 50, 100); + sim.enqueue(0, 1, 0); + sim.tick(w); + w.setLiquid(1, 1, 0, AIR_BLOCK, 0); + // OLD path: enqueue to normal (back of the line). + sim.enqueue(1, 1, 0); sim.enqueue(0, 1, 0); sim.enqueue(2, 1, 0); + sim.enqueue(1, 2, 0); sim.enqueue(1, 0, 0); sim.enqueue(1, 1, 1); sim.enqueue(1, 1, -1); + sim.setBudget(128); + sim.tick(w); + const gap = w.getBlock(1, 1, 0); + assert(gap === AIR_BLOCK, `old behaviour: gap still empty after one tick (got id=${gap}) — confirms the backlog was the cause`); +} + +if (failures === 0) console.log("\nALL RESPONSIVENESS TESTS PASSED\n"); +else { console.error(`\n${failures} RESPONSIVENESS TEST(S) FAILED\n`); process.exit(1); }