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`
(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/<Kind>.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/<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
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/<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`
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it

View file

@ -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<string, Mesh>()
const far = new Map<string, Mesh>()
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<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[] = []
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

View file

@ -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<void> {
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<MobKind, Mesh>
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,

View file

@ -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. */

View file

@ -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). */

View file

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

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 { 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/<Kind>.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<MobKind, Entity<Mob, Terrain>> = { 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<Mob, Terrain> {
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
}
}

View file

@ -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/<Kind>.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<TreeKind, TreeSpecies> = { 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)
}
}

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