feat: game/engine split refactor + skybox

This commit is contained in:
Dan Finch 2026-08-08 14:15:32 +02:00
parent 581e5892b0
commit eeedcb8e48
34 changed files with 610 additions and 218 deletions

96
game/actors/Boulder.ts Normal file
View file

@ -0,0 +1,96 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
const TAU = Math.PI * 2
/** One procedural boulder. `radius` is the overall size; `seed` drives the
* per-rock lumpiness and squash so no two look alike. It sits partly sunk into
* the ground at `position`, like a real rock. */
export type Boulder = {
/** Resting point on the ground (the rock is centered a bit above and buried). */
position: Vec3
radius: number
seed: number
}
/**
* Low-poly boulder geometry in the same faceted flat-shaded style as the rest of
* the world. A squashed, per-vertex-jittered sphere reads as an angular chunk of
* rock once flat shading gives each face its own tone. Radial jitter is kept
* seam- and pole-safe (the longitude wrap and both poles reuse one value) so the
* rock never cracks open. `build` appends into a caller-owned mesh, so a whole
* field of boulders batches into a single draw call.
*/
export namespace Boulder {
/** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */
export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void {
const rand = rng(boulder.seed)
const seg = lod === "impostor" ? 4 : 5
const rings = lod === "impostor" ? 2 : 4
const r = boulder.radius
// Squat and slightly oval, so it reads as a rock, not a ball.
const sx = r * (0.8 + rand() * 0.5)
const sy = r * (0.55 + rand() * 0.3)
const sz = r * (0.8 + rand() * 0.5)
const cx = boulder.position.x
const cz = boulder.position.z
// Center lifted less than the half-height, so the base sinks into the ground.
const cy = boulder.position.y + sy * 0.55
const jitter = jitterGrid(seg, rings, rand)
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)
for (let is = 0; is <= seg; is++) {
const theta = (is / seg) * TAU
const j = jitter[ir][is]
mesh.verts.push(
cx + crv * Math.cos(theta) * sx * j,
cy + cyv * sy * j,
cz + crv * Math.sin(theta) * sz * j,
(is / seg) * 1.5,
(ir / rings) * 1.5,
)
}
}
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)
}
}
}
/** Per-vertex radial scale in ~0.72..1.14 for a chunky, angular surface. The
* longitude seam (last column == first) and each pole row (one shared value)
* match so the mesh stays closed. */
function jitterGrid(seg: number, rings: number, rand: () => number): number[][] {
const grid: number[][] = []
for (let ir = 0; ir <= rings; ir++) {
const pole = ir === 0 || ir === rings
grid[ir] = []
for (let is = 0; is <= seg; is++) {
if (is === seg || (pole && is > 0)) {
grid[ir][is] = grid[ir][0]
} else {
grid[ir][is] = 0.72 + rand() * 0.42
}
}
}
return grid
}
/** Deterministic 0..1 generator (mulberry32) seeded per boulder. */
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
}
}
}

76
game/actors/Bush.ts Normal file
View file

@ -0,0 +1,76 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
const TAU = Math.PI * 2
/** A low shrub: a tight cluster of small leafy blobs sitting on the ground.
* Textured with the same leaf sheet as oak canopies, so it batches into the
* chunk's foliage mesh. `size` is the overall spread; `seed` the per-bush wobble. */
export type Bush = {
position: Vec3
size: number
seed: number
}
/** Low-poly bush geometry, same faceted flat-shaded style as the trees. A few
* overlapping jittered spheres read as a rounded shrub; blobs are closed and
* wound outward, so backface culling is safe. `build` appends into a shared
* (leaf-textured) mesh. */
export namespace Bush {
export function build(bush: Bush, mesh: Mesh): void {
const rand = rng(bush.seed)
// A handful of smaller overlapping lumps reads as a soft shrub; one big
// sphere reads as a boulder.
const blobs = 3 + Math.floor(rand() * 3)
const r = bush.size * 0.42
for (let i = 0; i < blobs; i++) {
const angle = rand() * TAU
const dist = i === 0 ? 0 : bush.size * 0.5 * rand()
const cx = bush.position.x + Math.cos(angle) * dist
const cz = bush.position.z + Math.sin(angle) * dist
const cy = bush.position.y + r * (0.5 + rand() * 0.4)
blob(mesh, cx, cy, cz, r * (0.55 + rand() * 0.3), rand)
}
}
/** A small lumpy low-poly sphere, wound outward (matches the oak canopy blob). */
function blob(mesh: Mesh, cx: number, cy: number, cz: number, radius: number, rand: () => number): void {
const seg = 6
const rings = 4
const start = mesh.verts.length / STRIDE
for (let r = 0; r <= rings; r++) {
const phi = (r / rings) * Math.PI
const cyv = Math.cos(phi)
const crv = Math.sin(phi)
const scale = radius * (0.9 + rand() * 0.18)
for (let s = 0; s <= seg; s++) {
const theta = (s / seg) * TAU
mesh.verts.push(
cx + crv * Math.cos(theta) * scale,
cy + cyv * scale,
cz + crv * 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)
}
}
}
/** Deterministic 0..1 generator (mulberry32) seeded per bush. */
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
}
}
}

79
game/actors/Flower.ts Normal file
View file

@ -0,0 +1,79 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { Mesh } from "../../engine/scene/Mesh"
const TAU = Math.PI * 2
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
export type FlowerColor = "white" | "red" | "yellow"
/** A single small flower: a thin crossed-quad stem plus a shallow fan of petals.
* Tiny, so it is drawn double-sided (no backface cull) and carries no collider.
* `size` is roughly its height; `seed` jitters the petals. */
export type Flower = {
position: Vec3
color: FlowerColor
size: number
seed: number
}
/**
* Low-poly flower geometry. The `flower` texture is a 2x2 color atlas -- green
* (stem) plus white / red / yellow blooms -- and every vertex samples the flat
* center of one region, so a flower is solid-colored with no per-flower texture
* or draw call. `build` appends into one shared flower mesh.
*/
export namespace Flower {
/** uv center of each bloom color's atlas region (tile units). */
const BLOOM_UV: Record<FlowerColor, [number, number]> = {
white: [0.75, 0.25],
red: [0.25, 0.75],
yellow: [0.75, 0.75],
}
/** uv center of the green stem region. */
const STEM_U = 0.25
const STEM_V = 0.25
export function build(flower: Flower, mesh: Mesh): void {
const rand = rng(flower.seed)
const p = flower.position
const height = flower.size * (0.8 + rand() * 0.4)
const bloomY = p.y + height
const w = flower.size * 0.04
// Stem: two thin crossed quads so it reads from any angle.
stem(mesh, p.x, p.y, p.z, bloomY, w, 0)
stem(mesh, p.x, p.y, p.z, bloomY, 0, w)
// Bloom: a shallow fan of petals, center raised a touch so it domes.
const [bu, bv] = BLOOM_UV[flower.color]
const rad = flower.size * 0.38
const center = Mesh.push(mesh, p.x, bloomY + rad * 0.3, p.z, bu, bv)
const ring = center + 1
const petals = 5
for (let i = 0; i <= petals; i++) {
const angle = (i / petals) * TAU + rand() * 0.4
Mesh.push(mesh, p.x + Math.cos(angle) * rad, bloomY, p.z + Math.sin(angle) * rad, bu, bv)
}
for (let i = 0; i < petals; i++) {
mesh.indices.push(center, ring + i, ring + i + 1)
}
}
/** A thin vertical quad from the ground to `y1`, width along (dx, dz). */
function stem(mesh: Mesh, x: number, y0: number, z: number, y1: number, dx: number, dz: number): void {
const a = Mesh.push(mesh, x - dx, y0, z - dz, STEM_U, STEM_V)
const b = Mesh.push(mesh, x + dx, y0, z + dz, STEM_U, STEM_V)
const c = Mesh.push(mesh, x + dx, y1, z + dz, STEM_U, STEM_V)
const d = Mesh.push(mesh, x - dx, y1, z - dz, STEM_U, STEM_V)
mesh.indices.push(a, b, c, a, c, d)
}
/** Deterministic 0..1 generator (mulberry32) seeded per flower. */
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
}
}
}

84
game/actors/Mob.ts Normal file
View file

@ -0,0 +1,84 @@
import type { Terrain } from "../Terrain"
import type { Vec3 } from "../../engine/math/Vec3"
import type { Mesh } from "../../engine/scene/Mesh"
import type { Entity } from "../../engine/scene/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). 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`.
*
* 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 = {
kind: MobKind
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
home: Vec3
/** Live feet-center (frog/robin) / 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/robin: mid-hop or -flight; bee: cruise). */
vx: number
vz: number
/** Vertical velocity (frog/robin ballistic hop/flight; bee stays 0, uses a bob). */
vy: number
/** Countdown to the next decision (frog/robin: next hop; bee: next heading change). */
timer: number
/** Per-kind scratch clock: the bee's hover-bob phase; the robin's remaining
* powered-flight cruise time (>0 while gliding between perches). */
phase: number
/** Frog/robin: resting on the ground vs airborne (a hop or a flight). */
grounded: boolean
}
/** 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 {
/** The definition for a kind (geometry, behavior, bounds). */
export function def(kind: MobKind): Entity<Mob, Terrain> {
return DEFS[kind]
}
/** 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 {
DEFS[kind].build(mesh)
}
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number {
return DEFS[kind].boundingRadius
}
/** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number {
return DEFS[kind].bodyHeight
}
}

54
game/actors/Tree.ts Normal file
View file

@ -0,0 +1,54 @@
import type { Vec3 } from "../../engine/math/Vec3"
import type { Mesh } from "../../engine/scene/Mesh"
import { oak } from "./trees/Oak"
import { spruce } from "./trees/Spruce"
import { birch } from "./trees/Birch"
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 / tiers. `seed` drives the per-tree random
* wobble so a forest doesn't look cloned. */
export type Tree = {
kind: TreeKind
/** Trunk base, sitting on the ground. */
position: Vec3
growth: number
seed: number
}
/** 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 {
/** 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 {
SPECIES[tree.kind].build(tree, trunk, foliage, lod)
}
}

44
game/actors/mobs/Bee.ts Normal file
View file

@ -0,0 +1,44 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
export const bee: Entity<Mob, Terrain> = {
name: "bee",
build,
update,
boundingRadius: 0.5,
bodyHeight: 0.5,
}
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
}

63
game/actors/mobs/Frog.ts Normal file
View file

@ -0,0 +1,63 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
export const frog: Entity<Mob, Terrain> = {
name: "frog",
build,
update,
boundingRadius: 0.7,
bodyHeight: 0.6,
}
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
}
}

84
game/actors/mobs/Robin.ts Normal file
View file

@ -0,0 +1,84 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/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.
export const robin: Entity<Mob, Terrain> = {
name: "robin",
build,
update,
boundingRadius: 0.45,
bodyHeight: 0.55,
}
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
}
}

120
game/actors/mobs/mobkit.ts Normal file
View file

@ -0,0 +1,120 @@
import type { Mob } from "../Mob"
import { STRIDE, type Mesh } from "../../../engine/scene/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 "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/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).
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
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)
}
}
}

53
game/actors/trees/Oak.ts Normal file
View file

@ -0,0 +1,53 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/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.
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
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)
}
}
}

View file

@ -0,0 +1,32 @@
import type { Mesh } from "../../../engine/scene/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.
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
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)
}
}

View file

@ -0,0 +1,95 @@
import { Vec3 } from "../../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../../engine/scene/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)]
}