From 581e5892b00d69b295e871652648b0a92948faea Mon Sep 17 00:00:00 2001 From: Errilaz Date: Fri, 7 Aug 2026 19:11:43 +0200 Subject: [PATCH] feat: actors stage 2 --- AGENTS.md | 34 ++-- app/level.ts | 98 +++++----- app/main.ts | 21 +-- app/render-worker.ts | 3 +- app/renderScene.ts | 6 +- app/renderer.ts | 3 +- engine/scene/Actor.ts | 26 +++ engine/scene/Mob.ts | 328 ++++------------------------------ engine/scene/Tree.ts | 261 +++++---------------------- engine/scene/mobs/Bee.ts | 40 +++++ engine/scene/mobs/Frog.ts | 57 ++++++ engine/scene/mobs/Robin.ts | 78 ++++++++ engine/scene/mobs/mobkit.ts | 120 +++++++++++++ engine/scene/trees/Birch.ts | 52 ++++++ engine/scene/trees/Oak.ts | 53 ++++++ engine/scene/trees/Spruce.ts | 32 ++++ engine/scene/trees/treekit.ts | 95 ++++++++++ tests/mobs.test.ts | 21 +++ tests/trees.test.ts | 18 ++ 19 files changed, 752 insertions(+), 594 deletions(-) create mode 100644 engine/scene/Actor.ts create mode 100644 engine/scene/mobs/Bee.ts create mode 100644 engine/scene/mobs/Frog.ts create mode 100644 engine/scene/mobs/Robin.ts create mode 100644 engine/scene/mobs/mobkit.ts create mode 100644 engine/scene/trees/Birch.ts create mode 100644 engine/scene/trees/Oak.ts create mode 100644 engine/scene/trees/Spruce.ts create mode 100644 engine/scene/trees/treekit.ts create mode 100644 tests/mobs.test.ts create mode 100644 tests/trees.test.ts diff --git a/AGENTS.md b/AGENTS.md index 794409c..37d075e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,9 @@ rules live in `.agents/rules/*.md`. Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer` (optional backface cull per draw), `Frustum` (6 planes from the viewProj + AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no - mipmaps), `Sky` (gradient + sun + procedural clouds; renders at 1/`step` res). + mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one, + so the renderer draws by list, not by named texture), `Sky` (gradient + sun + + procedural clouds; renders at 1/`step` res). - `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks), `Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex, no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite` @@ -71,9 +73,11 @@ rules live in `.agents/rules/*.md`. heightfield around the room: flat clearing in the center, rolling hills, tall edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called per chunk, aligned so patches weld crack-free, with a hole for the room; - `Terrain.height` is the shared ground-height sampler for the player), `Tree` - (procedural low-poly oak/spruce/birch geometry, sapling..full via a `growth` knob; - `Tree.build` appends into shared trunk + foliage meshes), `Boulder` + `Terrain.height` is the shared ground-height sampler for the player), `Actor` + (the `Entity` interface — geometry + behavior + bounds — that each mob kind + implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a + `growth` knob; each species a `TreeSpecies` in `trees/.ts`, assembled by a + registry — see the Trees section), `Boulder` (procedural low-poly rock: a squashed, jittered, part-buried sphere; `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; @@ -273,10 +277,13 @@ Both are exported presets in `app/level.ts`; the active one is set in branching in the cloud shader. Cost scales with sky resolution — fine at `standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky). -## Trees (`engine/scene/Tree.ts`) +## Trees (`engine/scene/Tree.ts` + `engine/scene/trees/`) -Procedural low-poly geometry, faceted flat-shaded like everything else. Three -`kind`s carry the species read purely by silhouette: +Procedural low-poly geometry, faceted flat-shaded like everything else. Each species +is a `TreeSpecies` definition in its own `trees/.ts` module (geometry + +which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them +into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in +`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette: - **`oak`** — short tapered trunk, a couple of branches, a broad cluster of lumpy canopy `blob`s (wider than tall, bushy). - **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing @@ -288,11 +295,14 @@ Procedural low-poly geometry, faceted flat-shaded like everything else. Three `growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble. -`Tree.build` appends into caller-chosen trunk + foliage meshes, so a forest still -batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch -leaf foliage, spruce needles). `app/level.ts` `placeTrees` seeds the forest and -rolls the species; add one by extending the union + a builder (a new bark/leaf -look also needs its own texture + per-chunk mesh channel — see `birchBark`). +A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white +`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one +mesh per material key and routes each tree via `Tree.species(kind)` — so a forest +still batches into a few draw calls and the baker names no texture. `app/level.ts` +`placeTrees` seeds the forest and rolls the species. **Add a species** = add a +`trees/.ts` module (its geometry + material keys) + one entry in the `Tree` +registry; only a genuinely new material also needs a `Material` in `buildLevel` + +its key in `MAT_ORDER`. **Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build` appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it diff --git a/app/level.ts b/app/level.ts index aa563f6..5092fdb 100644 --- a/app/level.ts +++ b/app/level.ts @@ -55,6 +55,14 @@ type ChunkMaterials = { flower: Material } +/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */ +type MatKey = keyof ChunkMaterials + +/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided + * -- last), so the per-chunk draw sequence is deterministic and matches the pre- + * registry order. Every material key must appear here. */ +const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"] + /** The playground: a flat-floored room dropped into the center of a big open * landscape. The room (floor/walls/crate) is small and always drawn; the * outdoor world is split into `chunks` that are frustum-culled per frame. */ @@ -249,74 +257,43 @@ function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bush for (let cj = 0; cj < CHUNK_GRID; cj++) { const z0 = -TERRAIN.outer + cj * cell const z1 = z0 + cell - const grass = mesh() - const bark = mesh() - const birchBark = mesh() - const leaf = mesh() - const needle = mesh() - const rock = mesh() - const flowerMesh = mesh() - const barkFar = mesh() - const birchBarkFar = mesh() - const leafFar = mesh() - const needleFar = mesh() - const rockFar = mesh() + // Accumulate geometry into one mesh per material key, for the near (full) and + // far (impostor) LOD sets. Props declare which material(s) they write, so the + // baker never names a texture -- adding a species/material touches no code here. + const near = new Map() + const far = new Map() + const grass = matMesh(near, "grass") + far.set("grass", grass) // the ground is drawn in both LOD sets Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) for (const tree of trees) { if (inCell(tree.position, x0, z0, x1, z1)) { - // Birch trunks go to their own white-bark mesh; oak + birch share the oak - // leaf foliage, spruce keeps its needles. - const trunk = tree.kind === "birch" ? birchBark : bark - const trunkFar = tree.kind === "birch" ? birchBarkFar : barkFar - const foliage = tree.kind === "spruce" ? needle : leaf - const foliageFar = tree.kind === "spruce" ? needleFar : leafFar - Tree.build(tree, trunk, foliage) - Tree.build(tree, trunkFar, foliageFar, "impostor") + const s = Tree.species(tree.kind) + Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage)) + Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor") } } for (const boulder of boulders) { if (inCell(boulder.position, x0, z0, x1, z1)) { - Boulder.build(boulder, rock) - Boulder.build(boulder, rockFar, "impostor") + Boulder.build(boulder, matMesh(near, "rock")) + Boulder.build(boulder, matMesh(far, "rock"), "impostor") } } - // Bushes share the near leaf mesh; they just drop out past lodDistance. + // Bushes fold into the near leaf mesh; they just drop out past lodDistance. for (const bush of bushes) { if (inCell(bush.position, x0, z0, x1, z1)) { - Bush.build(bush, leaf) + Bush.build(bush, matMesh(near, "leaf")) } } for (const flower of flowers) { if (inCell(flower.position, x0, z0, x1, z1)) { - Flower.build(flower, flowerMesh) + Flower.build(flower, matMesh(near, "flower")) } } - const b = bounds([grass, bark, birchBark, leaf, needle, rock, flowerMesh]) + const b = bounds([...near.values()]) if (b === null) { continue } - // Same draws as before, just described as data. Order is preserved (it - // matches the old fixed sequence): grass, then the solid props, then the - // double-sided flowers. Empty meshes are pruned so a chunk only carries the - // groups it actually has. - const near = drawGroups([ - [grass, m.grass], - [rock, m.rock], - [bark, m.bark], - [birchBark, m.birch], - [leaf, m.leaf], - [needle, m.needle], - [flowerMesh, m.flower], - ]) - const far = drawGroups([ - [grass, m.grass], - [rockFar, m.rock], - [barkFar, m.bark], - [birchBarkFar, m.birch], - [leafFar, m.leaf], - [needleFar, m.needle], - ]) - chunks.push({ ...b, near, far }) + chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) }) } } return chunks @@ -326,13 +303,26 @@ function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1 } -/** Pair meshes with their materials into a draw-group list, dropping any mesh - * that ended up empty (a cell rarely holds every prop kind). */ -function drawGroups(pairs: [Mesh, Material][]): DrawGroup[] { +/** Lazily get (creating on first use) the accumulation mesh for a material key in a + * chunk's near/far map. Props write into these by key, so the baker stays generic. */ +function matMesh(map: Map, key: string): Mesh { + let m = map.get(key) + if (m === undefined) { + m = mesh() + map.set(key, m) + } + return m +} + +/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material + * order (so the draw sequence is deterministic across bakes) and dropping any that + * ended up empty (a cell rarely holds every prop kind). */ +function toGroups(map: Map, materials: ChunkMaterials): DrawGroup[] { const out: DrawGroup[] = [] - for (const [m, material] of pairs) { - if (m.indices.length > 0) { - out.push({ mesh: m, material }) + for (const key of MAT_ORDER) { + const m = map.get(key) + if (m !== undefined && m.indices.length > 0) { + out.push({ mesh: m, material: materials[key] }) } } return out diff --git a/app/main.ts b/app/main.ts index 985a6b9..0ded1be 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,7 +1,7 @@ import { RenderConfig } from "../engine/render/RenderConfig" import { Camera } from "../engine/scene/Camera" import type { Mesh } from "../engine/scene/Mesh" -import { Mob } from "../engine/scene/Mob" +import { Mob, MOB_KINDS, type MobKind } from "../engine/scene/Mob" import type { Vec3 } from "../engine/math/Vec3" import { loadTextures } from "./assets" import { buildLevel, type Level } from "./level" @@ -48,21 +48,22 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me async function main(): Promise { const textures = await loadTextures() const level = buildLevel(textures) - // Two canonical mob meshes, built once and shared by every instance (the sim - // supplies each mob's per-frame transform). - const frogMesh: Mesh = { verts: [], indices: [] } - const beeMesh: Mesh = { verts: [], indices: [] } - const robinMesh: Mesh = { verts: [], indices: [] } - Mob.build("frog", frogMesh) - Mob.build("bee", beeMesh) - Mob.build("robin", robinMesh) + // Build each kind's canonical mesh once, shared by every instance (the sim + // supplies each mob's per-frame transform). Registry-driven -- a new kind needs + // no change here. + const mobMesh = {} as Record + for (const kind of MOB_KINDS) { + const m: Mesh = { verts: [], indices: [] } + Mob.build(kind, m) + mobMesh[kind] = m + } const scene: Scene = { chunks: level.chunks, floor: level.floor, walls: level.walls, crate: level.crate, npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } }, - mobMesh: { frog: frogMesh, bee: beeMesh, robin: robinMesh }, + mobMesh, mobCount: level.mobs.length, sky: level.sky, textures, diff --git a/app/render-worker.ts b/app/render-worker.ts index 0a490db..50773c4 100644 --- a/app/render-worker.ts +++ b/app/render-worker.ts @@ -1,6 +1,7 @@ import type { Framebuffer } from "../engine/render/Framebuffer" import type { RenderConfig } from "../engine/render/RenderConfig" -import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene" +import { MOB_KINDS } from "../engine/scene/Mob" +import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene" /** One-time setup: shared framebuffer + control/param buffers, the (cloned) * scene, this worker's row band, and its index into the per-worker times array. */ diff --git a/app/renderScene.ts b/app/renderScene.ts index b4f9209..e7cde7d 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -36,11 +36,7 @@ export type Scene = { * `renderBand` (single-thread) or packed into the shared `mobState` buffer and * rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */ export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number } -export const MOB_FLOATS = 6 // kind index, x, y, z, heading, scale -/** Canonical kind order -- the index packed into the shared `mobState` buffer - * (main packs `indexOf`, each worker reads it back). Keep frog/bee first so the - * existing indices don't shift. */ -export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"] +export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale /** Chunk indices whose bounding box is inside the view frustum. Computed once on * the main thread and shared with every worker (so they don't each re-cull). */ diff --git a/app/renderer.ts b/app/renderer.ts index fdc2c9b..08b4af1 100644 --- a/app/renderer.ts +++ b/app/renderer.ts @@ -2,7 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer" import type { RenderConfig } from "../engine/render/RenderConfig" import type { Mat4 } from "../engine/math/Mat4" import type { Camera } from "../engine/scene/Camera" -import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene" +import { MOB_KINDS } from "../engine/scene/Mob" +import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene" /** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */ const SKY_STEP = 2 diff --git a/engine/scene/Actor.ts b/engine/scene/Actor.ts new file mode 100644 index 0000000..3318786 --- /dev/null +++ b/engine/scene/Actor.ts @@ -0,0 +1,26 @@ +import type { Mesh } from "./Mesh" + +/** Definition of an **Entity** actor kind: something that lives in the world with + * its own behavior and a live transform (mobs, and later the npc / powerups) -- as + * opposed to a baked, static `Prop`. `State` is the per-instance runtime record the + * behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`). + * + * Every field is plain data or a module function, so a definition is **imported + * into each context** (main thread + each render worker) rather than structured- + * cloned across the wire -- the per-kind polymorphism is code, not serialized + * state. That's what lets a registry of these stay compatible with the worker + * renderer (only plain instance data ever crosses; behavior is loaded per side). */ +export type Entity = { + /** Stable tag for the kind (also the texture key today). The registry's order, + * not this string, is what becomes the id packed into the mob SAB. */ + name: string + /** Build the canonical local-space mesh once; every instance shares it, differing + * only by its per-frame model matrix. */ + build: (mesh: Mesh) => void + /** Advance one instance by `dt` seconds. */ + update: (state: State, dt: number, world: World) => void + /** Local bounding radius (pre-scale) for the per-frame cull AABB. */ + boundingRadius: number + /** Local body height (pre-scale) for the top of the stand-on collider. */ + bodyHeight: number +} diff --git a/engine/scene/Mob.ts b/engine/scene/Mob.ts index db7acd4..4b75469 100644 --- a/engine/scene/Mob.ts +++ b/engine/scene/Mob.ts @@ -1,22 +1,23 @@ -import { Terrain } from "./Terrain" +import type { Terrain } from "./Terrain" import type { Vec3 } from "../math/Vec3" -import { STRIDE, type Mesh } from "./Mesh" - -const TAU = Math.PI * 2 +import type { Mesh } from "./Mesh" +import type { Entity } from "./Actor" +import { frog } from "./mobs/Frog" +import { bee } from "./mobs/Bee" +import { robin } from "./mobs/Robin" /** A roaming creature drawn as a moving low-poly mesh (unlike the static baked - * world). Three kinds, told apart by silhouette + motion: - * frog -- squat, ground-bound, sits then springs a ballistic hop. - * bee -- small, hovers and darts through the air, wings out. - * robin -- round red-breasted bird; mostly hops like a frog, but now and then - * takes off on a short powered flight to a new perch. + * world). Each kind is an `Entity` definition (geometry + behavior + bounds) living + * in its own module under `mobs/`; this file just assembles them into a registry + * and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/.ts` + + * one entry in `MOB_KINDS`/`DEFS`. * - * Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry - * is a **canonical local-space mesh** built once per kind (front = +Z, frog/robin - * feet / bee body at the origin); the live `position`/`heading`/`scale` are turned - * into a per-frame model matrix by the renderer. All wander state lives here so - * `update` is a pure stepping function of the mob + dt (deterministic via the - * evolving `seed`), which keeps the sim on the main thread and cloneable-free. */ + * A mob's geometry is a **canonical local-space mesh** built once per kind (front = + * +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/ + * `scale` are turned into a per-frame model matrix by the renderer. All wander + * state lives on the instance so `update` is a pure stepping function of the mob + + * dt (deterministic via the evolving `seed`), which keeps the sim on the main + * thread and cloneable-free. */ export type MobKind = "frog" | "bee" | "robin" export type Mob = { @@ -45,294 +46,39 @@ export type Mob = { grounded: boolean } -// --- Behavior tuning ------------------------------------------------------ -const FROG_LEASH = 5 -const FROG_REST_MIN = 0.7 -const FROG_REST_SPAN = 1.8 -const FROG_HOP_SPEED = 1.6 -const FROG_HOP_IMPULSE = 3.2 -const FROG_GRAVITY = 14 -const BEE_LEASH = 6 -const BEE_SPEED = 1.7 -const BEE_TURN_MIN = 0.4 -const BEE_TURN_SPAN = 1 -const BEE_HOVER = 1.1 -const BEE_BOB_AMP = 0.18 -const BEE_BOB_FREQ = 3 -const ROBIN_LEASH = 6 -const ROBIN_REST_MIN = 0.5 -const ROBIN_REST_SPAN = 1.3 -const ROBIN_HOP_SPEED = 1.4 -const ROBIN_HOP_IMPULSE = 2.6 -/** Fraction of a robin's moves that are a flight rather than a ground hop. */ -const ROBIN_FLY_CHANCE = 0.35 -const ROBIN_FLY_SPEED = 4.5 -const ROBIN_FLY_IMPULSE = 3.5 -const ROBIN_CRUISE = 0.8 -const ROBIN_GRAVITY = 14 +/** Canonical kind order. **The index is the id packed into the mob SAB** (see + * renderer/worker), so this order must be identical in every context and must not + * change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */ +export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"] + +/** The per-kind `Entity` definitions, one module each. Imported (not cloned) into + * whatever context uses it, so it works the same on the main thread and in workers. */ +const DEFS: Record> = { frog, bee, robin } export namespace Mob { - /** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */ - export function update(mob: Mob, dt: number, terrain: Terrain): void { - if (mob.kind === "frog") { - frog(mob, dt, terrain) - } else if (mob.kind === "bee") { - bee(mob, dt, terrain) - } else { - robin(mob, dt, terrain) - } + /** The definition for a kind (geometry, behavior, bounds). */ + export function def(kind: MobKind): Entity { + return DEFS[kind] } - /** Append the canonical local-space mesh for `kind` into `mesh` (called once - * per kind at load; every instance shares it, differing only by transform). */ + /** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */ + export function update(mob: Mob, dt: number, terrain: Terrain): void { + DEFS[mob.kind].update(mob, dt, terrain) + } + + /** Append the canonical local-space mesh for `kind` into `mesh` (once per kind at + * load; every instance shares it, differing only by transform). */ export function build(kind: MobKind, mesh: Mesh): void { - if (kind === "frog") { - buildFrog(mesh) - } else if (kind === "bee") { - buildBee(mesh) - } else { - buildRobin(mesh) - } + DEFS[kind].build(mesh) } /** Local bounding radius (pre-scale), for building the per-frame cull AABB. */ export function boundingRadius(kind: MobKind): number { - return kind === "frog" ? 0.7 : kind === "robin" ? 0.45 : 0.5 + return DEFS[kind].boundingRadius } /** Local body height (pre-scale), for the top of the stand-on collider. */ export function bodyHeight(kind: MobKind): number { - return kind === "frog" ? 0.6 : kind === "robin" ? 0.55 : 0.5 - } - - // --- Simulation --------------------------------------------------------- - - function frog(mob: Mob, dt: number, terrain: Terrain): void { - if (mob.grounded) { - mob.timer -= dt - mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) - if (mob.timer > 0) { - return - } - // Launch a hop: pick a heading (pulled homeward past the leash), then - // convert it into a forward+upward ballistic velocity. - mob.heading = wanderHeading(mob, FROG_LEASH, 0.9) - mob.vx = Math.sin(mob.heading) * FROG_HOP_SPEED - mob.vz = Math.cos(mob.heading) * FROG_HOP_SPEED - mob.vy = FROG_HOP_IMPULSE - mob.grounded = false - return - } - mob.vy -= FROG_GRAVITY * dt - mob.position.x += mob.vx * dt - mob.position.y += mob.vy * dt - mob.position.z += mob.vz * dt - const ground = Terrain.height(terrain, mob.position.x, mob.position.z) - if (mob.position.y <= ground && mob.vy < 0) { - mob.position.y = ground - mob.vx = 0 - mob.vy = 0 - mob.vz = 0 - mob.grounded = true - mob.timer = FROG_REST_MIN + nextRand(mob) * FROG_REST_SPAN - } - } - - function bee(mob: Mob, dt: number, terrain: Terrain): void { - mob.phase += dt - mob.timer -= dt - if (mob.timer <= 0) { - mob.heading = wanderHeading(mob, BEE_LEASH, 1.4) - mob.timer = BEE_TURN_MIN + nextRand(mob) * BEE_TURN_SPAN - } - mob.position.x += Math.sin(mob.heading) * BEE_SPEED * dt - mob.position.z += Math.cos(mob.heading) * BEE_SPEED * dt - const ground = Terrain.height(terrain, mob.position.x, mob.position.z) - mob.position.y = ground + BEE_HOVER + Math.sin(mob.phase * BEE_BOB_FREQ) * BEE_BOB_AMP - } - - function robin(mob: Mob, dt: number, terrain: Terrain): void { - if (mob.grounded) { - mob.timer -= dt - mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) - if (mob.timer > 0) { - return - } - // Decide the next move: usually a short ground hop, sometimes a longer - // powered flight -- higher + faster off the mark, then a flat glide (see - // the cruise branch below) before settling onto a new perch. - mob.heading = wanderHeading(mob, ROBIN_LEASH, 1) - const fly = nextRand(mob) < ROBIN_FLY_CHANCE - const speed = fly ? ROBIN_FLY_SPEED : ROBIN_HOP_SPEED - mob.vx = Math.sin(mob.heading) * speed - mob.vz = Math.cos(mob.heading) * speed - mob.vy = fly ? ROBIN_FLY_IMPULSE : ROBIN_HOP_IMPULSE - mob.phase = fly ? ROBIN_CRUISE : 0 - mob.grounded = false - return - } - if (mob.phase > 0) { - // In flight: bleed vertical speed toward level so it glides roughly flat - // (a bird crossing the clearing), not a lob; gravity resumes once cruise ends. - mob.phase -= dt - mob.vy += (0 - mob.vy) * Math.min(1, dt * 6) - } else { - mob.vy -= ROBIN_GRAVITY * dt - } - mob.position.x += mob.vx * dt - mob.position.y += mob.vy * dt - mob.position.z += mob.vz * dt - const ground = Terrain.height(terrain, mob.position.x, mob.position.z) - if (mob.position.y <= ground && mob.vy < 0) { - mob.position.y = ground - mob.vx = 0 - mob.vy = 0 - mob.vz = 0 - mob.phase = 0 - mob.grounded = true - mob.timer = ROBIN_REST_MIN + nextRand(mob) * ROBIN_REST_SPAN - } - } - - /** A new heading: free wander when inside the leash, else biased back toward - * home so the mob never drifts off into the peaks (`jitter` = the random cone - * half-width in radians layered on top of the homeward bearing). */ - function wanderHeading(mob: Mob, leash: number, jitter: number): number { - const dx = mob.home.x - mob.position.x - const dz = mob.home.z - mob.position.z - if (dx * dx + dz * dz > leash * leash) { - return Math.atan2(dx, dz) + (nextRand(mob) - 0.5) * jitter - } - return nextRand(mob) * TAU - } - - /** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is - * deterministic and needs no external RNG object to clone. */ - function nextRand(mob: Mob): number { - const a = (mob.seed + 0x6D2B79F5) | 0 - mob.seed = a - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } - - // --- Geometry ----------------------------------------------------------- - // Mobs are drawn double-sided (see renderScene), so winding is not load-bearing - // here -- these builders only need to place faceted, flat-shaded surfaces. - - function buildFrog(mesh: Mesh): void { - // Wide squat body, two eye bumps on the top-front, two hind haunches. UVs: - // the frog texture is green skin on the left, a dark eye tone on the right. - ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1) - ellipsoid(mesh, 0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1) - ellipsoid(mesh, -0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1) - ellipsoid(mesh, 0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1) - ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1) - } - - function buildBee(mesh: Mesh): void { - // Fore-aft ovoid body striped along its length, a dark head at the front, two - // pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing- - // pale (right); the body maps v along z so the stripes band across it. - ovoidZ(mesh, -0.4, 0.4, 0.24, 7, 5, 0, 0.54, 0, 1) - ellipsoid(mesh, 0, 0.02, 0.44, 0.16, 0.16, 0.16, 5, 4, 0.6, 0.79, 0, 1) - wing(mesh, 1, 0.83, 0.99, 0, 1) - wing(mesh, -1, 0.83, 0.99, 0, 1) - } - - function buildRobin(mesh: Mesh): void { - // Round European robin: plump brown body, an orange-red breast bulging on the - // front, a round brown head with two dark eyes + a small dark beak, short tail. - // UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right). - ellipsoid(mesh, 0, 0.26, 0, 0.26, 0.26, 0.3, 6, 4, 0, 0.38, 0, 1) // body (brown) - ellipsoid(mesh, 0, 0.18, 0.17, 0.22, 0.22, 0.16, 5, 4, 0.42, 0.68, 0, 1) // breast (orange) - ellipsoid(mesh, 0, 0.48, 0.14, 0.18, 0.18, 0.18, 5, 4, 0, 0.38, 0, 1) // head (brown) - ellipsoid(mesh, 0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye - ellipsoid(mesh, -0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye - ellipsoid(mesh, 0, 0.47, 0.35, 0.03, 0.025, 0.09, 3, 2, 0.85, 0.99, 0, 1) // beak (dark) - ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown) - } - - /** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */ - function ellipsoid( - mesh: Mesh, - cx: number, - cy: number, - cz: number, - rx: number, - ry: number, - rz: number, - seg: number, - rings: number, - u0: number, - u1: number, - v0: number, - v1: number, - ): void { - const start = mesh.verts.length / STRIDE - for (let ir = 0; ir <= rings; ir++) { - const phi = (ir / rings) * Math.PI - const cyv = Math.cos(phi) - const crv = Math.sin(phi) - const v = v0 + (v1 - v0) * (ir / rings) - for (let is = 0; is <= seg; is++) { - const theta = (is / seg) * TAU - const u = u0 + (u1 - u0) * (is / seg) - mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v) - } - } - quadGrid(mesh, start, seg, rings) - } - - /** An ovoid whose pole axis is Z (rings step along z, tapering at both ends), - * so the mapped `v` runs down the body's length -- used for the bee's stripes. */ - function ovoidZ( - mesh: Mesh, - z0: number, - z1: number, - r: number, - seg: number, - rings: number, - u0: number, - u1: number, - v0: number, - v1: number, - ): void { - const start = mesh.verts.length / STRIDE - for (let ir = 0; ir <= rings; ir++) { - const t = ir / rings - const z = z0 + (z1 - z0) * t - const rr = r * (0.15 + 0.85 * Math.sin(t * Math.PI)) - const v = v0 + (v1 - v0) * t - for (let is = 0; is <= seg; is++) { - const theta = (is / seg) * TAU - const u = u0 + (u1 - u0) * (is / seg) - mesh.verts.push(Math.cos(theta) * rr, Math.sin(theta) * rr, z, u, v) - } - } - quadGrid(mesh, start, seg, rings) - } - - /** Index a (seg x rings) vertex grid (row = seg+1) into two tris per cell. */ - function quadGrid(mesh: Mesh, start: number, seg: number, rings: number): void { - const row = seg + 1 - for (let ir = 0; ir < rings; ir++) { - for (let is = 0; is < seg; is++) { - const p = start + ir * row + is - mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) - } - } - } - - /** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */ - function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void { - const base = mesh.verts.length / STRIDE - mesh.verts.push( - side * 0.06, 0.12, 0.14, u0, v0, - side * 0.42, 0.24, 0.1, u1, v0, - side * 0.42, 0.24, -0.12, u1, v1, - side * 0.06, 0.12, -0.1, u0, v1, - ) - mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) + return DEFS[kind].bodyHeight } } diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts index 7eab2cc..d509d5a 100644 --- a/engine/scene/Tree.ts +++ b/engine/scene/Tree.ts @@ -1,233 +1,54 @@ -import { Vec3 } from "../math/Vec3" -import { STRIDE, type Mesh } from "./Mesh" +import type { Vec3 } from "../math/Vec3" +import type { Mesh } from "./Mesh" +import { oak } from "./trees/Oak" +import { spruce } from "./trees/Spruce" +import { birch } from "./trees/Birch" -const TAU = Math.PI * 2 +export type TreeKind = "oak" | "spruce" | "birch" -/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it - * scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed` - * drives the per-tree random wobble so a forest doesn't look cloned. */ +/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales + * height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random + * wobble so a forest doesn't look cloned. */ export type Tree = { - kind: "oak" | "spruce" | "birch" + kind: TreeKind /** Trunk base, sitting on the ground. */ position: Vec3 growth: number seed: number } -/** - * Low-poly tree geometry, in the same faceted flat-shaded style as the rest of - * the world. Two silhouettes carry the species read: - * oak -- short tapered trunk, a couple of branches, a broad cluster of - * rounded canopy blobs (bushy, wider than tall). - * spruce -- tall thin trunk under stacked cones that narrow to a point - * (tiered, taller than wide). - * `build` appends into caller-owned meshes so a whole forest batches into a few - * draw calls: all trunks share one bark mesh, foliage splits oak vs spruce so - * each can carry its own leaf/needle texture. - */ +/** Definition of a tree species: which chunk materials its trunk + foliage bake + * into, plus how to append its geometry. Each lives in its own `trees/.ts` + * module (silhouette carries the species read); this file just assembles them. + * `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`): + * oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak + * `leaf` or spruce `needle`. */ +export type TreeSpecies = { + kind: TreeKind + trunk: string + foliage: string + build: (tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor") => void +} + +/** All tree species (also the placement roll's palette). Trees are baked at load, + * not shipped per frame, so this order isn't an id contract like `MOB_KINDS` -- but + * keeping it lets placement + tests stay registry-driven. */ +export const TREE_KINDS: TreeKind[] = ["oak", "spruce", "birch"] + +/** The per-species definitions, one module each. Imported (not cloned) wherever + * used, so it works the same on the main thread and in workers. */ +const SPECIES: Record = { oak, spruce, birch } + export namespace Tree { - /** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh - * for its kind (oak leaf vs spruce needle). */ - /** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures + - * faceted look, same height/position) for far chunks; "full" is up close. */ + /** The species definition for a kind (its trunk/foliage materials + geometry). */ + export function species(kind: TreeKind): TreeSpecies { + return SPECIES[kind] + } + + /** Append one tree into the caller-provided `trunk` + `foliage` meshes (which the + * caller selects from the species' `trunk`/`foliage` material keys). `lod` + * "impostor" bakes a much cheaper stand-in for far chunks; "full" is up close. */ export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void { - const rand = rng(tree.seed) - if (tree.kind === "oak") { - oak(tree.position, tree.growth, rand, trunk, foliage, lod) - } else if (tree.kind === "spruce") { - spruce(tree.position, tree.growth, rand, trunk, foliage, lod) - } else { - birch(tree.position, tree.growth, rand, trunk, foliage, lod) - } - } - - function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { - const h = lerp(0.8, 7, g) - const rTrunk = lerp(0.04, 0.32, g) - const forkY = base.y + h * 0.5 - const canopyY = base.y + h * 0.72 - const blobR = h * 0.3 - if (lod === "impostor") { - // One low-poly blob on a stubby trunk -- reads as an oak at distance. - limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3) - blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2) - return - } - limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5) - - const spread = h * 0.32 - // Central blob plus, as it grows, a couple offset ones -> broad bushy crown. - const blobs = 1 + Math.round(g * 2) - for (let i = 0; i < blobs; i++) { - const angle = rand() * TAU - const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5) - const center = { - x: base.x + Math.cos(angle) * rad, - y: canopyY + (rand() - 0.4) * spread, - z: base.z + Math.sin(angle) * rad, - } - blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) - } - // Grown oaks throw out a few branches, each tipped with a leaf tuft. - if (g > 0.55) { - const branches = 2 + Math.round(rand()) - for (let i = 0; i < branches; i++) { - const angle = rand() * TAU - const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) }) - const start = { x: base.x, y: base.y + h * 0.42, z: base.z } - const end = Vec3.add(start, Vec3.scale(dir, h * 0.3)) - limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4) - blob(leaves, end, blobR * 0.6, rand) - } - } - } - - function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { - const h = lerp(0.6, 9, g) - const rTrunk = lerp(0.03, 0.2, g) - const impostor = lod === "impostor" - limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5) - - // Stacked cones: widest low, shrinking to a point up top -> conical tiers. - // The impostor keeps the first two tiers at low sides (same seed => aligned). - const tiers = impostor ? 2 : 2 + Math.round(g * 3) - const sides = impostor ? 4 : 6 - const bottom = base.y + h * 0.1 - const span = h * 0.9 - for (let i = 0; i < tiers; i++) { - const t = i / tiers - const y = bottom + t * span * 0.82 - const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) - const coneH = (span / tiers) * 1.9 - cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides) - } - } - - function birch(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { - // Silver birch: tall, slender, near-straight white trunk under an airy, high, - // slightly drooping canopy of small leaf tufts -- a lean silhouette between the - // broad oak and the conical spruce (the white bark texture does the rest). - const h = lerp(1, 8.5, g) - const rTrunk = lerp(0.03, 0.16, g) - const canopyY = base.y + h * 0.75 - const blobR = h * 0.22 - if (lod === "impostor") { - limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3) - blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2) - return - } - limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5) - - const spread = h * 0.22 - // Sparse small blobs clustered high, biased downward so the crown droops. - const blobs = 2 + Math.round(g * 2) - for (let i = 0; i < blobs; i++) { - const angle = rand() * TAU - const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5) - const center = { - x: base.x + Math.cos(angle) * rad, - y: canopyY + (rand() - 0.6) * spread, - z: base.z + Math.sin(angle) * rad, - } - blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) - } - // Grown birches trail a few thin, near-horizontal drooping twigs. - if (g > 0.5) { - const branches = 2 + Math.round(rand()) - for (let i = 0; i < branches; i++) { - const angle = rand() * TAU - const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) }) - const start = { x: base.x, y: base.y + h * 0.7, z: base.z } - const end = Vec3.add(start, Vec3.scale(dir, h * 0.22)) - limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4) - blob(leaves, end, blobR * 0.55, rand) - } - } - } - - /** A tapered tube between two points (trunk or branch), `sides`-gonal. */ - function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void { - const axis = Vec3.normalize(Vec3.sub(b, a)) - const [u, v] = basis(axis) - const len = Vec3.length(Vec3.sub(b, a)) - const start = mesh.verts.length / STRIDE - for (let i = 0; i <= sides; i++) { - const angle = (i / sides) * TAU - const dx = u.x * Math.cos(angle) + v.x * Math.sin(angle) - const dy = u.y * Math.cos(angle) + v.y * Math.sin(angle) - const dz = u.z * Math.cos(angle) + v.z * Math.sin(angle) - const s = i / sides - mesh.verts.push(a.x + dx * ra, a.y + dy * ra, a.z + dz * ra, s * 1.5, 0) - mesh.verts.push(b.x + dx * rb, b.y + dy * rb, b.z + dz * rb, s * 1.5, len * 0.5) - } - for (let i = 0; i < sides; i++) { - const p = start + i * 2 - mesh.indices.push(p, p + 2, p + 3, p, p + 3, p + 1) - } - } - - /** A cone standing on a base ring, apex `height` above it (one spruce tier). */ - function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void { - const start = mesh.verts.length / STRIDE - mesh.verts.push(base.x, base.y + height, base.z, 0.5, 0) - for (let i = 0; i <= sides; i++) { - const angle = (i / sides) * TAU - mesh.verts.push(base.x + Math.cos(angle) * radius, base.y, base.z + Math.sin(angle) * radius, (i / sides) * 2, 1) - } - for (let i = 0; i < sides; i++) { - // Wound so the outer surface faces out, matching the backface-cull sign. - mesh.indices.push(start, start + 2 + i, start + 1 + i) - } - } - - /** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps - * it organic without cracking the longitude seam. */ - function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void { - const start = mesh.verts.length / STRIDE - for (let r = 0; r <= rings; r++) { - const phi = (r / rings) * Math.PI - const cy = Math.cos(phi) - const cr = Math.sin(phi) - const scale = radius * (0.85 + rand() * 0.3) - for (let s = 0; s <= seg; s++) { - const theta = (s / seg) * TAU - mesh.verts.push( - center.x + cr * Math.cos(theta) * scale, - center.y + cy * scale, - center.z + cr * Math.sin(theta) * scale, - (s / seg) * 2, - (r / rings) * 2, - ) - } - } - const row = seg + 1 - for (let r = 0; r < rings; r++) { - for (let s = 0; s < seg; s++) { - const p = start + r * row + s - mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) - } - } - } - - /** Two unit vectors spanning the plane perpendicular to `axis`. */ - function basis(axis: Vec3): [Vec3, Vec3] { - const ref = Math.abs(axis.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 } - const u = Vec3.normalize(Vec3.cross(ref, axis)) - return [u, Vec3.cross(axis, u)] - } - - function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t - } - - /** Deterministic 0..1 generator (mulberry32) seeded per tree. */ - function rng(seed: number): () => number { - let a = seed >>> 0 - return () => { - a = (a + 0x6D2B79F5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } + SPECIES[tree.kind].build(tree, trunk, foliage, lod) } } diff --git a/engine/scene/mobs/Bee.ts b/engine/scene/mobs/Bee.ts new file mode 100644 index 0000000..0e1f236 --- /dev/null +++ b/engine/scene/mobs/Bee.ts @@ -0,0 +1,40 @@ +import { Terrain } from "../Terrain" +import type { Mesh } from "../Mesh" +import type { Mob } from "../Mob" +import type { Entity } from "../Actor" +import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit" + +// Everything about the bee: small, hovers and darts through the air, wings out. + +const LEASH = 6 +const SPEED = 1.7 +const TURN_MIN = 0.4 +const TURN_SPAN = 1 +const HOVER = 1.1 +const BOB_AMP = 0.18 +const BOB_FREQ = 3 + +function build(mesh: Mesh): void { + // Fore-aft ovoid body striped along its length, a dark head at the front, two + // pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing-pale + // (right); the body maps v along z so the stripes band across it. + ovoidZ(mesh, -0.4, 0.4, 0.24, 7, 5, 0, 0.54, 0, 1) + ellipsoid(mesh, 0, 0.02, 0.44, 0.16, 0.16, 0.16, 5, 4, 0.6, 0.79, 0, 1) + wing(mesh, 1, 0.83, 0.99, 0, 1) + wing(mesh, -1, 0.83, 0.99, 0, 1) +} + +function update(mob: Mob, dt: number, terrain: Terrain): void { + mob.phase += dt + mob.timer -= dt + if (mob.timer <= 0) { + mob.heading = wanderHeading(mob, LEASH, 1.4) + mob.timer = TURN_MIN + nextRand(mob) * TURN_SPAN + } + mob.position.x += Math.sin(mob.heading) * SPEED * dt + mob.position.z += Math.cos(mob.heading) * SPEED * dt + const ground = Terrain.height(terrain, mob.position.x, mob.position.z) + mob.position.y = ground + HOVER + Math.sin(mob.phase * BOB_FREQ) * BOB_AMP +} + +export const bee: Entity = { name: "bee", build, update, boundingRadius: 0.5, bodyHeight: 0.5 } diff --git a/engine/scene/mobs/Frog.ts b/engine/scene/mobs/Frog.ts new file mode 100644 index 0000000..c144c01 --- /dev/null +++ b/engine/scene/mobs/Frog.ts @@ -0,0 +1,57 @@ +import { Terrain } from "../Terrain" +import type { Mesh } from "../Mesh" +import type { Mob } from "../Mob" +import type { Entity } from "../Actor" +import { ellipsoid, nextRand, wanderHeading } from "./mobkit" + +// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop. + +const LEASH = 5 +const REST_MIN = 0.7 +const REST_SPAN = 1.8 +const HOP_SPEED = 1.6 +const HOP_IMPULSE = 3.2 +const GRAVITY = 14 + +function build(mesh: Mesh): void { + // Wide squat body, two eye bumps on the top-front, two hind haunches. UVs: + // the frog texture is green skin on the left, a dark eye tone on the right. + ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1) + ellipsoid(mesh, 0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1) + ellipsoid(mesh, -0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1) + ellipsoid(mesh, 0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1) + ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1) +} + +function update(mob: Mob, dt: number, terrain: Terrain): void { + if (mob.grounded) { + mob.timer -= dt + mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.timer > 0) { + return + } + // Launch a hop: pick a heading (pulled homeward past the leash), then convert + // it into a forward+upward ballistic velocity. + mob.heading = wanderHeading(mob, LEASH, 0.9) + mob.vx = Math.sin(mob.heading) * HOP_SPEED + mob.vz = Math.cos(mob.heading) * HOP_SPEED + mob.vy = HOP_IMPULSE + mob.grounded = false + return + } + mob.vy -= GRAVITY * dt + mob.position.x += mob.vx * dt + mob.position.y += mob.vy * dt + mob.position.z += mob.vz * dt + const ground = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.position.y <= ground && mob.vy < 0) { + mob.position.y = ground + mob.vx = 0 + mob.vy = 0 + mob.vz = 0 + mob.grounded = true + mob.timer = REST_MIN + nextRand(mob) * REST_SPAN + } +} + +export const frog: Entity = { name: "frog", build, update, boundingRadius: 0.7, bodyHeight: 0.6 } diff --git a/engine/scene/mobs/Robin.ts b/engine/scene/mobs/Robin.ts new file mode 100644 index 0000000..e7e6846 --- /dev/null +++ b/engine/scene/mobs/Robin.ts @@ -0,0 +1,78 @@ +import { Terrain } from "../Terrain" +import type { Mesh } from "../Mesh" +import type { Mob } from "../Mob" +import type { Entity } from "../Actor" +import { ellipsoid, nextRand, wanderHeading } from "./mobkit" + +// Everything about the robin: round red-breasted bird that mostly hops like a frog +// but now and then takes a short powered flight to a new perch. + +const LEASH = 6 +const REST_MIN = 0.5 +const REST_SPAN = 1.3 +const HOP_SPEED = 1.4 +const HOP_IMPULSE = 2.6 +/** Fraction of a robin's moves that are a flight rather than a ground hop. */ +const FLY_CHANCE = 0.35 +const FLY_SPEED = 4.5 +const FLY_IMPULSE = 3.5 +const CRUISE = 0.8 +const GRAVITY = 14 + +function build(mesh: Mesh): void { + // Round European robin: plump brown body, an orange-red breast bulging on the + // front, a round brown head with two dark eyes + a small dark beak, short tail. + // UVs: robin texture is brown (left), orange breast (mid), dark eye/beak (right). + ellipsoid(mesh, 0, 0.26, 0, 0.26, 0.26, 0.3, 6, 4, 0, 0.38, 0, 1) // body (brown) + ellipsoid(mesh, 0, 0.18, 0.17, 0.22, 0.22, 0.16, 5, 4, 0.42, 0.68, 0, 1) // breast (orange) + ellipsoid(mesh, 0, 0.48, 0.14, 0.18, 0.18, 0.18, 5, 4, 0, 0.38, 0, 1) // head (brown) + ellipsoid(mesh, 0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye + ellipsoid(mesh, -0.09, 0.52, 0.26, 0.03, 0.03, 0.03, 3, 2, 0.85, 0.99, 0, 1) // eye + ellipsoid(mesh, 0, 0.47, 0.35, 0.03, 0.025, 0.09, 3, 2, 0.85, 0.99, 0, 1) // beak (dark) + ellipsoid(mesh, 0, 0.26, -0.32, 0.09, 0.05, 0.16, 4, 2, 0, 0.38, 0, 1) // tail (brown) +} + +function update(mob: Mob, dt: number, terrain: Terrain): void { + if (mob.grounded) { + mob.timer -= dt + mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.timer > 0) { + return + } + // Decide the next move: usually a short ground hop, sometimes a longer powered + // flight -- higher + faster off the mark, then a flat glide (see the cruise + // branch below) before settling onto a new perch. + mob.heading = wanderHeading(mob, LEASH, 1) + const fly = nextRand(mob) < FLY_CHANCE + const speed = fly ? FLY_SPEED : HOP_SPEED + mob.vx = Math.sin(mob.heading) * speed + mob.vz = Math.cos(mob.heading) * speed + mob.vy = fly ? FLY_IMPULSE : HOP_IMPULSE + mob.phase = fly ? CRUISE : 0 + mob.grounded = false + return + } + if (mob.phase > 0) { + // In flight: bleed vertical speed toward level so it glides roughly flat (a bird + // crossing the clearing), not a lob; gravity resumes once the cruise ends. + mob.phase -= dt + mob.vy += (0 - mob.vy) * Math.min(1, dt * 6) + } else { + mob.vy -= GRAVITY * dt + } + mob.position.x += mob.vx * dt + mob.position.y += mob.vy * dt + mob.position.z += mob.vz * dt + const ground = Terrain.height(terrain, mob.position.x, mob.position.z) + if (mob.position.y <= ground && mob.vy < 0) { + mob.position.y = ground + mob.vx = 0 + mob.vy = 0 + mob.vz = 0 + mob.phase = 0 + mob.grounded = true + mob.timer = REST_MIN + nextRand(mob) * REST_SPAN + } +} + +export const robin: Entity = { name: "robin", build, update, boundingRadius: 0.45, bodyHeight: 0.55 } diff --git a/engine/scene/mobs/mobkit.ts b/engine/scene/mobs/mobkit.ts new file mode 100644 index 0000000..6a1deac --- /dev/null +++ b/engine/scene/mobs/mobkit.ts @@ -0,0 +1,120 @@ +import type { Mob } from "../Mob" +import { STRIDE, type Mesh } from "../Mesh" + +// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the +// faceted geometry primitives and the deterministic wander helpers. Kept in its own +// module (no runtime import of `Mob`, only its type) so the per-kind files and the +// `Mob` registry don't form an import cycle. + +export const TAU = Math.PI * 2 + +// --- Wander helpers ------------------------------------------------------- + +/** A new heading: free wander when inside the leash, else biased back toward home + * so the mob never drifts off into the peaks (`jitter` = the random cone half-width + * in radians layered on top of the homeward bearing). */ +export function wanderHeading(mob: Mob, leash: number, jitter: number): number { + const dx = mob.home.x - mob.position.x + const dz = mob.home.z - mob.position.z + if (dx * dx + dz * dz > leash * leash) { + return Math.atan2(dx, dz) + (nextRand(mob) - 0.5) * jitter + } + return nextRand(mob) * TAU +} + +/** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is + * deterministic and needs no external RNG object to clone. */ +export function nextRand(mob: Mob): number { + const a = (mob.seed + 0x6D2B79F5) | 0 + mob.seed = a + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 +} + +// --- Geometry primitives -------------------------------------------------- +// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing -- +// these only need to place faceted, flat-shaded surfaces. + +/** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */ +export function ellipsoid( + mesh: Mesh, + cx: number, + cy: number, + cz: number, + rx: number, + ry: number, + rz: number, + seg: number, + rings: number, + u0: number, + u1: number, + v0: number, + v1: number, +): void { + const start = mesh.verts.length / STRIDE + for (let ir = 0; ir <= rings; ir++) { + const phi = (ir / rings) * Math.PI + const cyv = Math.cos(phi) + const crv = Math.sin(phi) + const v = v0 + (v1 - v0) * (ir / rings) + for (let is = 0; is <= seg; is++) { + const theta = (is / seg) * TAU + const u = u0 + (u1 - u0) * (is / seg) + mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v) + } + } + quadGrid(mesh, start, seg, rings) +} + +/** An ovoid whose pole axis is Z (rings step along z, tapering at both ends), so + * the mapped `v` runs down the body's length -- used for the bee's stripes. */ +export function ovoidZ( + mesh: Mesh, + z0: number, + z1: number, + r: number, + seg: number, + rings: number, + u0: number, + u1: number, + v0: number, + v1: number, +): void { + const start = mesh.verts.length / STRIDE + for (let ir = 0; ir <= rings; ir++) { + const t = ir / rings + const z = z0 + (z1 - z0) * t + const rr = r * (0.15 + 0.85 * Math.sin(t * Math.PI)) + const v = v0 + (v1 - v0) * t + for (let is = 0; is <= seg; is++) { + const theta = (is / seg) * TAU + const u = u0 + (u1 - u0) * (is / seg) + mesh.verts.push(Math.cos(theta) * rr, Math.sin(theta) * rr, z, u, v) + } + } + quadGrid(mesh, start, seg, rings) +} + +/** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */ +export function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void { + const base = mesh.verts.length / STRIDE + mesh.verts.push( + side * 0.06, 0.12, 0.14, u0, v0, + side * 0.42, 0.24, 0.1, u1, v0, + side * 0.42, 0.24, -0.12, u1, v1, + side * 0.06, 0.12, -0.1, u0, v1, + ) + mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) +} + +/** Index a (seg x rings) vertex grid (row = seg+1) into two tris per cell. */ +function quadGrid(mesh: Mesh, start: number, seg: number, rings: number): void { + const row = seg + 1 + for (let ir = 0; ir < rings; ir++) { + for (let is = 0; is < seg; is++) { + const p = start + ir * row + is + mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) + } + } +} diff --git a/engine/scene/trees/Birch.ts b/engine/scene/trees/Birch.ts new file mode 100644 index 0000000..6032d4e --- /dev/null +++ b/engine/scene/trees/Birch.ts @@ -0,0 +1,52 @@ +import { Vec3 } from "../../math/Vec3" +import type { Mesh } from "../Mesh" +import type { Tree, TreeSpecies } from "../Tree" +import { blob, lerp, limb, TAU, rng } from "./treekit" + +// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly +// drooping canopy -- a lean silhouette between the broad oak and conical spruce. +// Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read). + +function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { + const base = tree.position + const g = tree.growth + const rand = rng(tree.seed) + const h = lerp(1, 8.5, g) + const rTrunk = lerp(0.03, 0.16, g) + const canopyY = base.y + h * 0.75 + const blobR = h * 0.22 + if (lod === "impostor") { + limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3) + blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2) + return + } + limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5) + + const spread = h * 0.22 + // Sparse small blobs clustered high, biased downward so the crown droops. + const blobs = 2 + Math.round(g * 2) + for (let i = 0; i < blobs; i++) { + const angle = rand() * TAU + const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5) + const center = { + x: base.x + Math.cos(angle) * rad, + y: canopyY + (rand() - 0.6) * spread, + z: base.z + Math.sin(angle) * rad, + } + blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) + } + // Grown birches trail a few thin, near-horizontal drooping twigs. + if (g > 0.5) { + const branches = 2 + Math.round(rand()) + for (let i = 0; i < branches; i++) { + const angle = rand() * TAU + const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) }) + const start = { x: base.x, y: base.y + h * 0.7, z: base.z } + const end = Vec3.add(start, Vec3.scale(dir, h * 0.22)) + limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4) + blob(leaves, end, blobR * 0.55, rand) + } + } +} + +export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build } diff --git a/engine/scene/trees/Oak.ts b/engine/scene/trees/Oak.ts new file mode 100644 index 0000000..c57fdfd --- /dev/null +++ b/engine/scene/trees/Oak.ts @@ -0,0 +1,53 @@ +import { Vec3 } from "../../math/Vec3" +import type { Mesh } from "../Mesh" +import type { Tree, TreeSpecies } from "../Tree" +import { blob, lerp, limb, TAU, rng } from "./treekit" + +// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy +// blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf. + +function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { + const base = tree.position + const g = tree.growth + const rand = rng(tree.seed) + const h = lerp(0.8, 7, g) + const rTrunk = lerp(0.04, 0.32, g) + const forkY = base.y + h * 0.5 + const canopyY = base.y + h * 0.72 + const blobR = h * 0.3 + if (lod === "impostor") { + // One low-poly blob on a stubby trunk -- reads as an oak at distance. + limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3) + blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2) + return + } + limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5) + + const spread = h * 0.32 + // Central blob plus, as it grows, a couple offset ones -> broad bushy crown. + const blobs = 1 + Math.round(g * 2) + for (let i = 0; i < blobs; i++) { + const angle = rand() * TAU + const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5) + const center = { + x: base.x + Math.cos(angle) * rad, + y: canopyY + (rand() - 0.4) * spread, + z: base.z + Math.sin(angle) * rad, + } + blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand) + } + // Grown oaks throw out a few branches, each tipped with a leaf tuft. + if (g > 0.55) { + const branches = 2 + Math.round(rand()) + for (let i = 0; i < branches; i++) { + const angle = rand() * TAU + const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) }) + const start = { x: base.x, y: base.y + h * 0.42, z: base.z } + const end = Vec3.add(start, Vec3.scale(dir, h * 0.3)) + limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4) + blob(leaves, end, blobR * 0.6, rand) + } + } +} + +export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build } diff --git a/engine/scene/trees/Spruce.ts b/engine/scene/trees/Spruce.ts new file mode 100644 index 0000000..57fac37 --- /dev/null +++ b/engine/scene/trees/Spruce.ts @@ -0,0 +1,32 @@ +import type { Mesh } from "../Mesh" +import type { Tree, TreeSpecies } from "../Tree" +import { cone, lerp, limb, rng } from "./treekit" + +// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller +// than wide). Trunk = brown bark, foliage = spruce needle. + +function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { + const base = tree.position + const g = tree.growth + const rand = rng(tree.seed) + const h = lerp(0.6, 9, g) + const rTrunk = lerp(0.03, 0.2, g) + const impostor = lod === "impostor" + limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5) + + // Stacked cones: widest low, shrinking to a point up top -> conical tiers. The + // impostor keeps the first two tiers at low sides (same seed => aligned). + const tiers = impostor ? 2 : 2 + Math.round(g * 3) + const sides = impostor ? 4 : 6 + const bottom = base.y + h * 0.1 + const span = h * 0.9 + for (let i = 0; i < tiers; i++) { + const t = i / tiers + const y = bottom + t * span * 0.82 + const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2) + const coneH = (span / tiers) * 1.9 + cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides) + } +} + +export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build } diff --git a/engine/scene/trees/treekit.ts b/engine/scene/trees/treekit.ts new file mode 100644 index 0000000..3d62674 --- /dev/null +++ b/engine/scene/trees/treekit.ts @@ -0,0 +1,95 @@ +import { Vec3 } from "../../math/Vec3" +import { STRIDE, type Mesh } from "../Mesh" + +// Shared faceted-geometry primitives + the per-tree RNG, used by the species +// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry +// don't form an import cycle. + +export const TAU = Math.PI * 2 + +/** A tapered tube between two points (trunk or branch), `sides`-gonal. */ +export function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void { + const axis = Vec3.normalize(Vec3.sub(b, a)) + const [u, v] = basis(axis) + const len = Vec3.length(Vec3.sub(b, a)) + const start = mesh.verts.length / STRIDE + for (let i = 0; i <= sides; i++) { + const angle = (i / sides) * TAU + const dx = u.x * Math.cos(angle) + v.x * Math.sin(angle) + const dy = u.y * Math.cos(angle) + v.y * Math.sin(angle) + const dz = u.z * Math.cos(angle) + v.z * Math.sin(angle) + const s = i / sides + mesh.verts.push(a.x + dx * ra, a.y + dy * ra, a.z + dz * ra, s * 1.5, 0) + mesh.verts.push(b.x + dx * rb, b.y + dy * rb, b.z + dz * rb, s * 1.5, len * 0.5) + } + for (let i = 0; i < sides; i++) { + const p = start + i * 2 + mesh.indices.push(p, p + 2, p + 3, p, p + 3, p + 1) + } +} + +/** A cone standing on a base ring, apex `height` above it (one spruce tier). */ +export function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void { + const start = mesh.verts.length / STRIDE + mesh.verts.push(base.x, base.y + height, base.z, 0.5, 0) + for (let i = 0; i <= sides; i++) { + const angle = (i / sides) * TAU + mesh.verts.push(base.x + Math.cos(angle) * radius, base.y, base.z + Math.sin(angle) * radius, (i / sides) * 2, 1) + } + for (let i = 0; i < sides; i++) { + // Wound so the outer surface faces out, matching the backface-cull sign. + mesh.indices.push(start, start + 2 + i, start + 1 + i) + } +} + +/** A lumpy low-poly sphere (one canopy blob). Per-ring radius wobble keeps it + * organic without cracking the longitude seam. */ +export function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void { + const start = mesh.verts.length / STRIDE + for (let r = 0; r <= rings; r++) { + const phi = (r / rings) * Math.PI + const cy = Math.cos(phi) + const cr = Math.sin(phi) + const scale = radius * (0.85 + rand() * 0.3) + for (let s = 0; s <= seg; s++) { + const theta = (s / seg) * TAU + mesh.verts.push( + center.x + cr * Math.cos(theta) * scale, + center.y + cy * scale, + center.z + cr * Math.sin(theta) * scale, + (s / seg) * 2, + (r / rings) * 2, + ) + } + } + const row = seg + 1 + for (let r = 0; r < rings; r++) { + for (let s = 0; s < seg; s++) { + const p = start + r * row + s + mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) + } + } +} + +/** Linear interpolation, for the sapling -> full-grown ramps. */ +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +/** Deterministic 0..1 generator (mulberry32) seeded per tree. */ +export function rng(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6D2B79F5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Two unit vectors spanning the plane perpendicular to `axis`. */ +function basis(axis: Vec3): [Vec3, Vec3] { + const ref = Math.abs(axis.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 } + const u = Vec3.normalize(Vec3.cross(ref, axis)) + return [u, Vec3.cross(axis, u)] +} diff --git a/tests/mobs.test.ts b/tests/mobs.test.ts new file mode 100644 index 0000000..d097a9f --- /dev/null +++ b/tests/mobs.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { MOB_KINDS, Mob } from "../engine/scene/Mob" + +// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every +// render worker must agree on that order. Freeze it here: appending a kind is fine, +// but reordering or removing an existing one silently corrupts which mesh/texture a +// worker draws. +test("MOB_KINDS order is frozen (mob SAB ids)", () => { + expect(MOB_KINDS).toEqual(["frog", "bee", "robin"]) +}) + +test("every kind resolves to a complete definition", () => { + for (const kind of MOB_KINDS) { + const d = Mob.def(kind) + expect(d.name).toBe(kind) + expect(typeof d.build).toBe("function") + expect(typeof d.update).toBe("function") + expect(d.boundingRadius).toBeGreaterThan(0) + expect(d.bodyHeight).toBeGreaterThan(0) + } +}) diff --git a/tests/trees.test.ts b/tests/trees.test.ts new file mode 100644 index 0000000..c550675 --- /dev/null +++ b/tests/trees.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { Tree, TREE_KINDS } from "../engine/scene/Tree" + +// The chunk baker (level.ts) accumulates geometry into a mesh per material key and +// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage +// material outside that palette would bake geometry that is silently never drawn. +// Freeze the palette here so a typo'd or unregistered material key fails a test. +const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]) + +test("every tree species maps to known chunk materials", () => { + for (const kind of TREE_KINDS) { + const s = Tree.species(kind) + expect(s.kind).toBe(kind) + expect(typeof s.build).toBe("function") + expect(MATERIAL_KEYS.has(s.trunk)).toBe(true) + expect(MATERIAL_KEYS.has(s.foliage)).toBe(true) + } +})