feat: actors stage 2
This commit is contained in:
parent
4869db01e5
commit
581e5892b0
19 changed files with 752 additions and 594 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue