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

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