meat/engine/scene/Mob.ts

339 lines
13 KiB
TypeScript
Raw Normal View History

2026-08-07 12:49:13 +02:00
import { Terrain } from "./Terrain"
import type { Vec3 } from "../math/Vec3"
import { STRIDE, type Mesh } from "./Mesh"
const TAU = Math.PI * 2
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
2026-08-07 13:37:30 +02:00
* 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.
2026-08-07 12:49:13 +02:00
*
* Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry
2026-08-07 13:37:30 +02:00
* 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
2026-08-07 12:49:13 +02:00
* 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. */
2026-08-07 13:37:30 +02:00
export type MobKind = "frog" | "bee" | "robin"
2026-08-07 12:49:13 +02:00
export type Mob = {
kind: MobKind
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
home: Vec3
2026-08-07 13:37:30 +02:00
/** Live feet-center (frog/robin) / body-center (bee), advanced each frame. */
2026-08-07 12:49:13 +02:00
position: Vec3
/** Facing yaw; the mesh's front is local +Z, so world dir = (sin h, 0, cos h). */
heading: number
/** Per-instance size multiplier. */
scale: number
/** Evolving RNG state (mutated by `update`) -- keeps the sim deterministic. */
seed: number
2026-08-07 13:37:30 +02:00
/** Horizontal velocity (frog/robin: mid-hop or -flight; bee: cruise). */
2026-08-07 12:49:13 +02:00
vx: number
vz: number
2026-08-07 13:37:30 +02:00
/** Vertical velocity (frog/robin ballistic hop/flight; bee stays 0, uses a bob). */
2026-08-07 12:49:13 +02:00
vy: number
2026-08-07 13:37:30 +02:00
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
2026-08-07 12:49:13 +02:00
timer: number
2026-08-07 13:37:30 +02:00
/** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining
* powered-flight cruise time (>0 while gliding between perches). */
2026-08-07 12:49:13 +02:00
phase: number
2026-08-07 13:37:30 +02:00
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
2026-08-07 12:49:13 +02:00
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
2026-08-07 13:37:30 +02:00
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
2026-08-07 12:49:13 +02:00
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)
2026-08-07 13:37:30 +02:00
} else if (mob.kind === "bee") {
2026-08-07 12:49:13 +02:00
bee(mob, dt, terrain)
2026-08-07 13:37:30 +02:00
} else {
robin(mob, dt, terrain)
2026-08-07 12:49:13 +02:00
}
}
/** Append the canonical local-space mesh for `kind` into `mesh` (called 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)
2026-08-07 13:37:30 +02:00
} else if (kind === "bee") {
2026-08-07 12:49:13 +02:00
buildBee(mesh)
2026-08-07 13:37:30 +02:00
} else {
buildRobin(mesh)
2026-08-07 12:49:13 +02:00
}
}
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number {
2026-08-07 13:37:30 +02:00
return kind === "frog" ? 0.7 : kind === "robin" ? 0.45 : 0.5
2026-08-07 12:49:13 +02:00
}
/** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number {
2026-08-07 13:37:30 +02:00
return kind === "frog" ? 0.6 : kind === "robin" ? 0.55 : 0.5
2026-08-07 12:49:13 +02:00
}
// --- 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
}
2026-08-07 13:37:30 +02:00
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
}
}
2026-08-07 12:49:13 +02:00
/** 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)
}
2026-08-07 13:37:30 +02:00
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)
}
2026-08-07 12:49:13 +02:00
/** 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)
}
}