feat: bees and frogs

This commit is contained in:
Dan Finch 2026-08-07 12:49:13 +02:00
parent 46e7070b6b
commit 16f205babe
13 changed files with 540 additions and 31 deletions

View file

@ -20,6 +20,26 @@ export namespace Mat4 {
return out
}
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
* then a translation. Built directly in column-major storage (no intermediate
* matmuls) since it runs per mob per frame. A vertex at local +Z ends up
* pointing along world (sin yaw, 0, cos yaw), i.e. the object faces `yaw`. */
export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 {
const c = Math.cos(yaw)
const s = Math.sin(yaw)
const out = new Float32Array(16)
out[0] = scale * c
out[2] = scale * -s
out[5] = scale
out[8] = scale * s
out[10] = scale * c
out[12] = tx
out[13] = ty
out[14] = tz
out[15] = 1
return out
}
/** Right-handed perspective projection (camera looks down -Z). Maps the view
* frustum to clip space; the -1 in row 3 copies -z into w, so the later
* divide by w is what produces foreshortening. */

264
engine/scene/Mob.ts Normal file
View file

@ -0,0 +1,264 @@
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
* world). Two 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.
*
* 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 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. */
export type MobKind = "frog" | "bee"
export type Mob = {
kind: MobKind
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
home: Vec3
/** Live feet-center (frog) / body-center (bee), advanced each frame. */
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
/** Horizontal velocity (frog: only mid-hop; bee: cruise). */
vx: number
vz: number
/** Vertical velocity (frog ballistic hop; bee stays 0, it uses a bob). */
vy: number
/** Countdown to the next decision (frog: next hop; bee: next heading change). */
timer: number
/** Accumulated time, for the bee's hover bob. */
phase: number
/** Frog only: resting on the ground vs airborne in a hop. */
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
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 {
bee(mob, dt, terrain)
}
}
/** 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)
} else {
buildBee(mesh)
}
}
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number {
return kind === "frog" ? 0.7 : 0.5
}
/** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number {
return kind === "frog" ? 0.6 : 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
}
/** 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)
}
/** 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)
}
}