Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
58 changes: 54 additions & 4 deletions src/game/BlockRaycaster.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
}
126 changes: 126 additions & 0 deletions src/game/Blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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] {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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…)
}
44 changes: 44 additions & 0 deletions src/game/Chunk.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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). */
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
Loading
Loading