feat: actors stage 2

This commit is contained in:
Dan Finch 2026-08-07 19:11:43 +02:00
parent 4869db01e5
commit 581e5892b0
19 changed files with 752 additions and 594 deletions

View file

@ -63,7 +63,9 @@ rules live in `.agents/rules/*.md`.
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer` Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
(optional backface cull per draw), `Frustum` (6 planes from the viewProj + (optional backface cull per draw), `Frustum` (6 planes from the viewProj +
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no 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), - `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, `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` 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 heightfield around the room: flat clearing in the center, rolling hills, tall
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called 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; 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` `Terrain.height` is the shared ground-height sampler for the player), `Actor`
(procedural low-poly oak/spruce/birch geometry, sapling..full via a `growth` knob; (the `Entity` interface — geometry + behavior + bounds — that each mob kind
`Tree.build` appends into shared trunk + foliage meshes), `Boulder` implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a
`growth` knob; each species a `TreeSpecies` in `trees/<Kind>.ts`, assembled by a
registry — see the Trees section), `Boulder`
(procedural low-poly rock: a squashed, jittered, part-buried sphere; (procedural low-poly rock: a squashed, jittered, part-buried sphere;
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; 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 branching in the cloud shader. Cost scales with sky resolution — fine at
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky). `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 Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
`kind`s carry the species read purely by silhouette: is a `TreeSpecies` definition in its own `trees/<Kind>.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 - **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
lumpy canopy `blob`s (wider than tall, bushy). lumpy canopy `blob`s (wider than tall, bushy).
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing - **`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 `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. 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 A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch `birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
leaf foliage, spruce needles). `app/level.ts` `placeTrees` seeds the forest and mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
rolls the species; add one by extending the union + a builder (a new bark/leaf still batches into a few draw calls and the baker names no texture. `app/level.ts`
look also needs its own texture + per-chunk mesh channel — see `birchBark`). `placeTrees` seeds the forest and rolls the species. **Add a species** = add a
`trees/<Kind>.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` **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 appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it

View file

@ -55,6 +55,14 @@ type ChunkMaterials = {
flower: Material 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 /** 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 * landscape. The room (floor/walls/crate) is small and always drawn; the
* outdoor world is split into `chunks` that are frustum-culled per frame. */ * 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++) { for (let cj = 0; cj < CHUNK_GRID; cj++) {
const z0 = -TERRAIN.outer + cj * cell const z0 = -TERRAIN.outer + cj * cell
const z1 = z0 + cell const z1 = z0 + cell
const grass = mesh() // Accumulate geometry into one mesh per material key, for the near (full) and
const bark = mesh() // far (impostor) LOD sets. Props declare which material(s) they write, so the
const birchBark = mesh() // baker never names a texture -- adding a species/material touches no code here.
const leaf = mesh() const near = new Map<string, Mesh>()
const needle = mesh() const far = new Map<string, Mesh>()
const rock = mesh() const grass = matMesh(near, "grass")
const flowerMesh = mesh() far.set("grass", grass) // the ground is drawn in both LOD sets
const barkFar = mesh()
const birchBarkFar = mesh()
const leafFar = mesh()
const needleFar = mesh()
const rockFar = mesh()
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV) Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) { for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) { if (inCell(tree.position, x0, z0, x1, z1)) {
// Birch trunks go to their own white-bark mesh; oak + birch share the oak const s = Tree.species(tree.kind)
// leaf foliage, spruce keeps its needles. Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
const trunk = tree.kind === "birch" ? birchBark : bark Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
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")
} }
} }
for (const boulder of boulders) { for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) { if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, rock) Boulder.build(boulder, matMesh(near, "rock"))
Boulder.build(boulder, rockFar, "impostor") 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) { for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) { if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, leaf) Bush.build(bush, matMesh(near, "leaf"))
} }
} }
for (const flower of flowers) { for (const flower of flowers) {
if (inCell(flower.position, x0, z0, x1, z1)) { 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) { if (b === null) {
continue continue
} }
// Same draws as before, just described as data. Order is preserved (it chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
// 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 })
} }
} }
return chunks 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 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 /** Lazily get (creating on first use) the accumulation mesh for a material key in a
* that ended up empty (a cell rarely holds every prop kind). */ * chunk's near/far map. Props write into these by key, so the baker stays generic. */
function drawGroups(pairs: [Mesh, Material][]): DrawGroup[] { function matMesh(map: Map<string, Mesh>, 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<string, Mesh>, materials: ChunkMaterials): DrawGroup[] {
const out: DrawGroup[] = [] const out: DrawGroup[] = []
for (const [m, material] of pairs) { for (const key of MAT_ORDER) {
if (m.indices.length > 0) { const m = map.get(key)
out.push({ mesh: m, material }) if (m !== undefined && m.indices.length > 0) {
out.push({ mesh: m, material: materials[key] })
} }
} }
return out return out

View file

@ -1,7 +1,7 @@
import { RenderConfig } from "../engine/render/RenderConfig" import { RenderConfig } from "../engine/render/RenderConfig"
import { Camera } from "../engine/scene/Camera" import { Camera } from "../engine/scene/Camera"
import type { Mesh } from "../engine/scene/Mesh" 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 type { Vec3 } from "../engine/math/Vec3"
import { loadTextures } from "./assets" import { loadTextures } from "./assets"
import { buildLevel, type Level } from "./level" 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<void> { async function main(): Promise<void> {
const textures = await loadTextures() const textures = await loadTextures()
const level = buildLevel(textures) const level = buildLevel(textures)
// Two canonical mob meshes, built once and shared by every instance (the sim // Build each kind's canonical mesh once, shared by every instance (the sim
// supplies each mob's per-frame transform). // supplies each mob's per-frame transform). Registry-driven -- a new kind needs
const frogMesh: Mesh = { verts: [], indices: [] } // no change here.
const beeMesh: Mesh = { verts: [], indices: [] } const mobMesh = {} as Record<MobKind, Mesh>
const robinMesh: Mesh = { verts: [], indices: [] } for (const kind of MOB_KINDS) {
Mob.build("frog", frogMesh) const m: Mesh = { verts: [], indices: [] }
Mob.build("bee", beeMesh) Mob.build(kind, m)
Mob.build("robin", robinMesh) mobMesh[kind] = m
}
const scene: Scene = { const scene: Scene = {
chunks: level.chunks, chunks: level.chunks,
floor: level.floor, floor: level.floor,
walls: level.walls, walls: level.walls,
crate: level.crate, crate: level.crate,
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } }, npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
mobMesh: { frog: frogMesh, bee: beeMesh, robin: robinMesh }, mobMesh,
mobCount: level.mobs.length, mobCount: level.mobs.length,
sky: level.sky, sky: level.sky,
textures, textures,

View file

@ -1,6 +1,7 @@
import type { Framebuffer } from "../engine/render/Framebuffer" import type { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig" 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) /** 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. */ * scene, this worker's row band, and its index into the per-worker times array. */

View file

@ -36,11 +36,7 @@ export type Scene = {
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and * `renderBand` (single-thread) or packed into the shared `mobState` buffer and
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */ * 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 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 export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), 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"]
/** Chunk indices whose bounding box is inside the view frustum. Computed once on /** 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). */ * the main thread and shared with every worker (so they don't each re-cull). */

View file

@ -2,7 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig" import type { RenderConfig } from "../engine/render/RenderConfig"
import type { Mat4 } from "../engine/math/Mat4" import type { Mat4 } from "../engine/math/Mat4"
import type { Camera } from "../engine/scene/Camera" 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. */ /** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
const SKY_STEP = 2 const SKY_STEP = 2

26
engine/scene/Actor.ts Normal file
View file

@ -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<State, World> = {
/** 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
}

View file

@ -1,22 +1,23 @@
import { Terrain } from "./Terrain" import type { Terrain } from "./Terrain"
import type { Vec3 } from "../math/Vec3" import type { Vec3 } from "../math/Vec3"
import { STRIDE, type Mesh } from "./Mesh" import type { Mesh } from "./Mesh"
import type { Entity } from "./Actor"
const TAU = Math.PI * 2 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 /** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
* world). Three kinds, told apart by silhouette + motion: * world). Each kind is an `Entity` definition (geometry + behavior + bounds) living
* frog -- squat, ground-bound, sits then springs a ballistic hop. * in its own module under `mobs/`; this file just assembles them into a registry
* bee -- small, hovers and darts through the air, wings out. * and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/<Kind>.ts` +
* robin -- round red-breasted bird; mostly hops like a frog, but now and then * one entry in `MOB_KINDS`/`DEFS`.
* takes off on a short powered flight to a new perch.
* *
* Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry * A mob's geometry is a **canonical local-space mesh** built once per kind (front =
* is a **canonical local-space mesh** built once per kind (front = +Z, frog/robin * +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/
* feet / bee body at the origin); the live `position`/`heading`/`scale` are turned * `scale` are turned into a per-frame model matrix by the renderer. All wander
* into a per-frame model matrix by the renderer. All wander state lives here so * state lives on the instance so `update` is a pure stepping function of the mob +
* `update` is a pure stepping function of the mob + dt (deterministic via the * dt (deterministic via the evolving `seed`), which keeps the sim on the main
* evolving `seed`), which keeps the sim on the main thread and cloneable-free. */ * thread and cloneable-free. */
export type MobKind = "frog" | "bee" | "robin" export type MobKind = "frog" | "bee" | "robin"
export type Mob = { export type Mob = {
@ -45,294 +46,39 @@ export type Mob = {
grounded: boolean grounded: boolean
} }
// --- Behavior tuning ------------------------------------------------------ /** Canonical kind order. **The index is the id packed into the mob SAB** (see
const FROG_LEASH = 5 * renderer/worker), so this order must be identical in every context and must not
const FROG_REST_MIN = 0.7 * change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */
const FROG_REST_SPAN = 1.8 export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
const FROG_HOP_SPEED = 1.6
const FROG_HOP_IMPULSE = 3.2 /** The per-kind `Entity` definitions, one module each. Imported (not cloned) into
const FROG_GRAVITY = 14 * whatever context uses it, so it works the same on the main thread and in workers. */
const BEE_LEASH = 6 const DEFS: Record<MobKind, Entity<Mob, Terrain>> = { frog, bee, robin }
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
export namespace Mob { export namespace Mob {
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */ /** The definition for a kind (geometry, behavior, bounds). */
export function update(mob: Mob, dt: number, terrain: Terrain): void { export function def(kind: MobKind): Entity<Mob, Terrain> {
if (mob.kind === "frog") { return DEFS[kind]
frog(mob, dt, terrain)
} else if (mob.kind === "bee") {
bee(mob, dt, terrain)
} else {
robin(mob, dt, terrain)
}
} }
/** Append the canonical local-space mesh for `kind` into `mesh` (called once /** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
* per kind at load; every instance shares it, differing only by transform). */ 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 { export function build(kind: MobKind, mesh: Mesh): void {
if (kind === "frog") { DEFS[kind].build(mesh)
buildFrog(mesh)
} else if (kind === "bee") {
buildBee(mesh)
} else {
buildRobin(mesh)
}
} }
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */ /** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number { 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. */ /** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number { export function bodyHeight(kind: MobKind): number {
return kind === "frog" ? 0.6 : kind === "robin" ? 0.55 : 0.5 return DEFS[kind].bodyHeight
}
// --- 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)
} }
} }

View file

@ -1,233 +1,54 @@
import { Vec3 } from "../math/Vec3" import type { Vec3 } from "../math/Vec3"
import { STRIDE, type Mesh } from "./Mesh" 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 /** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales
* scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed` * height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random
* drives the per-tree random wobble so a forest doesn't look cloned. */ * wobble so a forest doesn't look cloned. */
export type Tree = { export type Tree = {
kind: "oak" | "spruce" | "birch" kind: TreeKind
/** Trunk base, sitting on the ground. */ /** Trunk base, sitting on the ground. */
position: Vec3 position: Vec3
growth: number growth: number
seed: number seed: number
} }
/** /** Definition of a tree species: which chunk materials its trunk + foliage bake
* Low-poly tree geometry, in the same faceted flat-shaded style as the rest of * into, plus how to append its geometry. Each lives in its own `trees/<Kind>.ts`
* the world. Two silhouettes carry the species read: * module (silhouette carries the species read); this file just assembles them.
* oak -- short tapered trunk, a couple of branches, a broad cluster of * `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`):
* rounded canopy blobs (bushy, wider than tall). * oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak
* spruce -- tall thin trunk under stacked cones that narrow to a point * `leaf` or spruce `needle`. */
* (tiered, taller than wide). export type TreeSpecies = {
* `build` appends into caller-owned meshes so a whole forest batches into a few kind: TreeKind
* draw calls: all trunks share one bark mesh, foliage splits oak vs spruce so trunk: string
* each can carry its own leaf/needle texture. 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<TreeKind, TreeSpecies> = { oak, spruce, birch }
export namespace Tree { export namespace Tree {
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh /** The species definition for a kind (its trunk/foliage materials + geometry). */
* for its kind (oak leaf vs spruce needle). */ export function species(kind: TreeKind): TreeSpecies {
/** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures + return SPECIES[kind]
* faceted look, same height/position) for far chunks; "full" is up close. */ }
/** 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 { export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
const rand = rng(tree.seed) SPECIES[tree.kind].build(tree, trunk, foliage, lod)
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
}
} }
} }

40
engine/scene/mobs/Bee.ts Normal file
View file

@ -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<Mob, Terrain> = { name: "bee", build, update, boundingRadius: 0.5, bodyHeight: 0.5 }

57
engine/scene/mobs/Frog.ts Normal file
View file

@ -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<Mob, Terrain> = { name: "frog", build, update, boundingRadius: 0.7, bodyHeight: 0.6 }

View file

@ -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<Mob, Terrain> = { name: "robin", build, update, boundingRadius: 0.45, bodyHeight: 0.55 }

120
engine/scene/mobs/mobkit.ts Normal file
View file

@ -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)
}
}
}

View file

@ -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 }

53
engine/scene/trees/Oak.ts Normal file
View file

@ -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 }

View file

@ -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 }

View file

@ -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)]
}

21
tests/mobs.test.ts Normal file
View file

@ -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)
}
})

18
tests/trees.test.ts Normal file
View file

@ -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)
}
})