feat: actors stage 2
This commit is contained in:
parent
4869db01e5
commit
581e5892b0
19 changed files with 752 additions and 594 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -63,7 +63,9 @@ rules live in `.agents/rules/*.md`.
|
||||||
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
|
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
|
||||||
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
||||||
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
||||||
mipmaps), `Sky` (gradient + sun + procedural clouds; renders at 1/`step` res).
|
mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one,
|
||||||
|
so the renderer draws by list, not by named texture), `Sky` (gradient + sun +
|
||||||
|
procedural clouds; renders at 1/`step` res).
|
||||||
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
||||||
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
|
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
|
||||||
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
||||||
|
|
@ -71,9 +73,11 @@ rules live in `.agents/rules/*.md`.
|
||||||
heightfield around the room: flat clearing in the center, rolling hills, tall
|
heightfield around the room: flat clearing in the center, rolling hills, tall
|
||||||
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
|
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
|
||||||
per chunk, aligned so patches weld crack-free, with a hole for the room;
|
per chunk, aligned so patches weld crack-free, with a hole for the room;
|
||||||
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
|
`Terrain.height` is the shared ground-height sampler for the player), `Actor`
|
||||||
(procedural low-poly oak/spruce/birch geometry, sapling..full via a `growth` knob;
|
(the `Entity` interface — geometry + behavior + bounds — that each mob kind
|
||||||
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a
|
||||||
|
`growth` knob; each species a `TreeSpecies` in `trees/<Kind>.ts`, assembled by a
|
||||||
|
registry — see the Trees section), `Boulder`
|
||||||
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
||||||
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
|
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
|
||||||
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
||||||
|
|
@ -273,10 +277,13 @@ Both are exported presets in `app/level.ts`; the active one is set in
|
||||||
branching in the cloud shader. Cost scales with sky resolution — fine at
|
branching in the cloud shader. Cost scales with sky resolution — fine at
|
||||||
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
|
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
|
||||||
|
|
||||||
## Trees (`engine/scene/Tree.ts`)
|
## Trees (`engine/scene/Tree.ts` + `engine/scene/trees/`)
|
||||||
|
|
||||||
Procedural low-poly geometry, faceted flat-shaded like everything else. Three
|
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
|
||||||
`kind`s carry the species read purely by silhouette:
|
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
|
||||||
|
which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them
|
||||||
|
into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in
|
||||||
|
`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette:
|
||||||
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
|
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
|
||||||
lumpy canopy `blob`s (wider than tall, bushy).
|
lumpy canopy `blob`s (wider than tall, bushy).
|
||||||
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
|
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
|
||||||
|
|
@ -288,11 +295,14 @@ Procedural low-poly geometry, faceted flat-shaded like everything else. Three
|
||||||
|
|
||||||
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
|
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
|
||||||
canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
|
canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
|
||||||
`Tree.build` appends into caller-chosen trunk + foliage meshes, so a forest still
|
A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
|
||||||
batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch
|
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
|
||||||
leaf foliage, spruce needles). `app/level.ts` `placeTrees` seeds the forest and
|
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
|
||||||
rolls the species; add one by extending the union + a builder (a new bark/leaf
|
still batches into a few draw calls and the baker names no texture. `app/level.ts`
|
||||||
look also needs its own texture + per-chunk mesh channel — see `birchBark`).
|
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
|
||||||
|
`trees/<Kind>.ts` module (its geometry + material keys) + one entry in the `Tree`
|
||||||
|
registry; only a genuinely new material also needs a `Material` in `buildLevel` +
|
||||||
|
its key in `MAT_ORDER`.
|
||||||
|
|
||||||
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
|
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
|
||||||
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
|
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
|
||||||
|
|
|
||||||
98
app/level.ts
98
app/level.ts
|
|
@ -55,6 +55,14 @@ type ChunkMaterials = {
|
||||||
flower: Material
|
flower: Material
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */
|
||||||
|
type MatKey = keyof ChunkMaterials
|
||||||
|
|
||||||
|
/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided
|
||||||
|
* -- last), so the per-chunk draw sequence is deterministic and matches the pre-
|
||||||
|
* registry order. Every material key must appear here. */
|
||||||
|
const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]
|
||||||
|
|
||||||
/** The playground: a flat-floored room dropped into the center of a big open
|
/** The playground: a flat-floored room dropped into the center of a big open
|
||||||
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
* landscape. The room (floor/walls/crate) is small and always drawn; the
|
||||||
* outdoor world is split into `chunks` that are frustum-culled per frame. */
|
* outdoor world is split into `chunks` that are frustum-culled per frame. */
|
||||||
|
|
@ -249,74 +257,43 @@ function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bush
|
||||||
for (let cj = 0; cj < CHUNK_GRID; cj++) {
|
for (let cj = 0; cj < CHUNK_GRID; cj++) {
|
||||||
const z0 = -TERRAIN.outer + cj * cell
|
const z0 = -TERRAIN.outer + cj * cell
|
||||||
const z1 = z0 + cell
|
const z1 = z0 + cell
|
||||||
const grass = mesh()
|
// Accumulate geometry into one mesh per material key, for the near (full) and
|
||||||
const bark = mesh()
|
// far (impostor) LOD sets. Props declare which material(s) they write, so the
|
||||||
const birchBark = mesh()
|
// baker never names a texture -- adding a species/material touches no code here.
|
||||||
const leaf = mesh()
|
const near = new Map<string, Mesh>()
|
||||||
const needle = mesh()
|
const far = new Map<string, Mesh>()
|
||||||
const rock = mesh()
|
const grass = matMesh(near, "grass")
|
||||||
const flowerMesh = mesh()
|
far.set("grass", grass) // the ground is drawn in both LOD sets
|
||||||
const barkFar = mesh()
|
|
||||||
const birchBarkFar = mesh()
|
|
||||||
const leafFar = mesh()
|
|
||||||
const needleFar = mesh()
|
|
||||||
const rockFar = mesh()
|
|
||||||
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
|
||||||
for (const tree of trees) {
|
for (const tree of trees) {
|
||||||
if (inCell(tree.position, x0, z0, x1, z1)) {
|
if (inCell(tree.position, x0, z0, x1, z1)) {
|
||||||
// Birch trunks go to their own white-bark mesh; oak + birch share the oak
|
const s = Tree.species(tree.kind)
|
||||||
// leaf foliage, spruce keeps its needles.
|
Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
|
||||||
const trunk = tree.kind === "birch" ? birchBark : bark
|
Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
|
||||||
const trunkFar = tree.kind === "birch" ? birchBarkFar : barkFar
|
|
||||||
const foliage = tree.kind === "spruce" ? needle : leaf
|
|
||||||
const foliageFar = tree.kind === "spruce" ? needleFar : leafFar
|
|
||||||
Tree.build(tree, trunk, foliage)
|
|
||||||
Tree.build(tree, trunkFar, foliageFar, "impostor")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const boulder of boulders) {
|
for (const boulder of boulders) {
|
||||||
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
if (inCell(boulder.position, x0, z0, x1, z1)) {
|
||||||
Boulder.build(boulder, rock)
|
Boulder.build(boulder, matMesh(near, "rock"))
|
||||||
Boulder.build(boulder, rockFar, "impostor")
|
Boulder.build(boulder, matMesh(far, "rock"), "impostor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bushes share the near leaf mesh; they just drop out past lodDistance.
|
// Bushes fold into the near leaf mesh; they just drop out past lodDistance.
|
||||||
for (const bush of bushes) {
|
for (const bush of bushes) {
|
||||||
if (inCell(bush.position, x0, z0, x1, z1)) {
|
if (inCell(bush.position, x0, z0, x1, z1)) {
|
||||||
Bush.build(bush, leaf)
|
Bush.build(bush, matMesh(near, "leaf"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const flower of flowers) {
|
for (const flower of flowers) {
|
||||||
if (inCell(flower.position, x0, z0, x1, z1)) {
|
if (inCell(flower.position, x0, z0, x1, z1)) {
|
||||||
Flower.build(flower, flowerMesh)
|
Flower.build(flower, matMesh(near, "flower"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const b = bounds([grass, bark, birchBark, leaf, needle, rock, flowerMesh])
|
const b = bounds([...near.values()])
|
||||||
if (b === null) {
|
if (b === null) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Same draws as before, just described as data. Order is preserved (it
|
chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
|
||||||
// matches the old fixed sequence): grass, then the solid props, then the
|
|
||||||
// double-sided flowers. Empty meshes are pruned so a chunk only carries the
|
|
||||||
// groups it actually has.
|
|
||||||
const near = drawGroups([
|
|
||||||
[grass, m.grass],
|
|
||||||
[rock, m.rock],
|
|
||||||
[bark, m.bark],
|
|
||||||
[birchBark, m.birch],
|
|
||||||
[leaf, m.leaf],
|
|
||||||
[needle, m.needle],
|
|
||||||
[flowerMesh, m.flower],
|
|
||||||
])
|
|
||||||
const far = drawGroups([
|
|
||||||
[grass, m.grass],
|
|
||||||
[rockFar, m.rock],
|
|
||||||
[barkFar, m.bark],
|
|
||||||
[birchBarkFar, m.birch],
|
|
||||||
[leafFar, m.leaf],
|
|
||||||
[needleFar, m.needle],
|
|
||||||
])
|
|
||||||
chunks.push({ ...b, near, far })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chunks
|
return chunks
|
||||||
|
|
@ -326,13 +303,26 @@ function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number,
|
||||||
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
|
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pair meshes with their materials into a draw-group list, dropping any mesh
|
/** Lazily get (creating on first use) the accumulation mesh for a material key in a
|
||||||
* that ended up empty (a cell rarely holds every prop kind). */
|
* chunk's near/far map. Props write into these by key, so the baker stays generic. */
|
||||||
function drawGroups(pairs: [Mesh, Material][]): DrawGroup[] {
|
function matMesh(map: Map<string, Mesh>, key: string): Mesh {
|
||||||
|
let m = map.get(key)
|
||||||
|
if (m === undefined) {
|
||||||
|
m = mesh()
|
||||||
|
map.set(key, m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material
|
||||||
|
* order (so the draw sequence is deterministic across bakes) and dropping any that
|
||||||
|
* ended up empty (a cell rarely holds every prop kind). */
|
||||||
|
function toGroups(map: Map<string, Mesh>, materials: ChunkMaterials): DrawGroup[] {
|
||||||
const out: DrawGroup[] = []
|
const out: DrawGroup[] = []
|
||||||
for (const [m, material] of pairs) {
|
for (const key of MAT_ORDER) {
|
||||||
if (m.indices.length > 0) {
|
const m = map.get(key)
|
||||||
out.push({ mesh: m, material })
|
if (m !== undefined && m.indices.length > 0) {
|
||||||
|
out.push({ mesh: m, material: materials[key] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
21
app/main.ts
21
app/main.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Camera } from "../engine/scene/Camera"
|
import { Camera } from "../engine/scene/Camera"
|
||||||
import type { Mesh } from "../engine/scene/Mesh"
|
import type { Mesh } from "../engine/scene/Mesh"
|
||||||
import { Mob } from "../engine/scene/Mob"
|
import { Mob, MOB_KINDS, type MobKind } from "../engine/scene/Mob"
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
import type { Vec3 } from "../engine/math/Vec3"
|
||||||
import { loadTextures } from "./assets"
|
import { loadTextures } from "./assets"
|
||||||
import { buildLevel, type Level } from "./level"
|
import { buildLevel, type Level } from "./level"
|
||||||
|
|
@ -48,21 +48,22 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const textures = await loadTextures()
|
const textures = await loadTextures()
|
||||||
const level = buildLevel(textures)
|
const level = buildLevel(textures)
|
||||||
// Two canonical mob meshes, built once and shared by every instance (the sim
|
// Build each kind's canonical mesh once, shared by every instance (the sim
|
||||||
// supplies each mob's per-frame transform).
|
// supplies each mob's per-frame transform). Registry-driven -- a new kind needs
|
||||||
const frogMesh: Mesh = { verts: [], indices: [] }
|
// no change here.
|
||||||
const beeMesh: Mesh = { verts: [], indices: [] }
|
const mobMesh = {} as Record<MobKind, Mesh>
|
||||||
const robinMesh: Mesh = { verts: [], indices: [] }
|
for (const kind of MOB_KINDS) {
|
||||||
Mob.build("frog", frogMesh)
|
const m: Mesh = { verts: [], indices: [] }
|
||||||
Mob.build("bee", beeMesh)
|
Mob.build(kind, m)
|
||||||
Mob.build("robin", robinMesh)
|
mobMesh[kind] = m
|
||||||
|
}
|
||||||
const scene: Scene = {
|
const scene: Scene = {
|
||||||
chunks: level.chunks,
|
chunks: level.chunks,
|
||||||
floor: level.floor,
|
floor: level.floor,
|
||||||
walls: level.walls,
|
walls: level.walls,
|
||||||
crate: level.crate,
|
crate: level.crate,
|
||||||
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
|
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
|
||||||
mobMesh: { frog: frogMesh, bee: beeMesh, robin: robinMesh },
|
mobMesh,
|
||||||
mobCount: level.mobs.length,
|
mobCount: level.mobs.length,
|
||||||
sky: level.sky,
|
sky: level.sky,
|
||||||
textures,
|
textures,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
|
import { MOB_KINDS } from "../engine/scene/Mob"
|
||||||
|
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
||||||
|
|
||||||
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
||||||
* scene, this worker's row band, and its index into the per-worker times array. */
|
* scene, this worker's row band, and its index into the per-worker times array. */
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,7 @@ export type Scene = {
|
||||||
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
|
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
|
||||||
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
|
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
|
||||||
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
|
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
|
||||||
export const MOB_FLOATS = 6 // kind index, x, y, z, heading, scale
|
export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale
|
||||||
/** Canonical kind order -- the index packed into the shared `mobState` buffer
|
|
||||||
* (main packs `indexOf`, each worker reads it back). Keep frog/bee first so the
|
|
||||||
* existing indices don't shift. */
|
|
||||||
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
|
|
||||||
|
|
||||||
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
|
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
|
||||||
* the main thread and shared with every worker (so they don't each re-cull). */
|
* the main thread and shared with every worker (so they don't each re-cull). */
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import type { Mat4 } from "../engine/math/Mat4"
|
import type { Mat4 } from "../engine/math/Mat4"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
import type { Camera } from "../engine/scene/Camera"
|
||||||
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
|
import { MOB_KINDS } from "../engine/scene/Mob"
|
||||||
|
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
||||||
|
|
||||||
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
||||||
const SKY_STEP = 2
|
const SKY_STEP = 2
|
||||||
|
|
|
||||||
26
engine/scene/Actor.ts
Normal file
26
engine/scene/Actor.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { Mesh } from "./Mesh"
|
||||||
|
|
||||||
|
/** Definition of an **Entity** actor kind: something that lives in the world with
|
||||||
|
* its own behavior and a live transform (mobs, and later the npc / powerups) -- as
|
||||||
|
* opposed to a baked, static `Prop`. `State` is the per-instance runtime record the
|
||||||
|
* behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`).
|
||||||
|
*
|
||||||
|
* Every field is plain data or a module function, so a definition is **imported
|
||||||
|
* into each context** (main thread + each render worker) rather than structured-
|
||||||
|
* cloned across the wire -- the per-kind polymorphism is code, not serialized
|
||||||
|
* state. That's what lets a registry of these stay compatible with the worker
|
||||||
|
* renderer (only plain instance data ever crosses; behavior is loaded per side). */
|
||||||
|
export type Entity<State, World> = {
|
||||||
|
/** Stable tag for the kind (also the texture key today). The registry's order,
|
||||||
|
* not this string, is what becomes the id packed into the mob SAB. */
|
||||||
|
name: string
|
||||||
|
/** Build the canonical local-space mesh once; every instance shares it, differing
|
||||||
|
* only by its per-frame model matrix. */
|
||||||
|
build: (mesh: Mesh) => void
|
||||||
|
/** Advance one instance by `dt` seconds. */
|
||||||
|
update: (state: State, dt: number, world: World) => void
|
||||||
|
/** Local bounding radius (pre-scale) for the per-frame cull AABB. */
|
||||||
|
boundingRadius: number
|
||||||
|
/** Local body height (pre-scale) for the top of the stand-on collider. */
|
||||||
|
bodyHeight: number
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
import { Terrain } from "./Terrain"
|
import type { Terrain } from "./Terrain"
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "./Mesh"
|
import type { Mesh } from "./Mesh"
|
||||||
|
import type { Entity } from "./Actor"
|
||||||
const TAU = Math.PI * 2
|
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
|
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
|
||||||
* world). Three kinds, told apart by silhouette + motion:
|
* world). Each kind is an `Entity` definition (geometry + behavior + bounds) living
|
||||||
* frog -- squat, ground-bound, sits then springs a ballistic hop.
|
* in its own module under `mobs/`; this file just assembles them into a registry
|
||||||
* bee -- small, hovers and darts through the air, wings out.
|
* and exposes a thin per-kind dispatch. Adding a kind = add a `mobs/<Kind>.ts` +
|
||||||
* robin -- round red-breasted bird; mostly hops like a frog, but now and then
|
* one entry in `MOB_KINDS`/`DEFS`.
|
||||||
* takes off on a short powered flight to a new perch.
|
|
||||||
*
|
*
|
||||||
* Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry
|
* A mob's geometry is a **canonical local-space mesh** built once per kind (front =
|
||||||
* is a **canonical local-space mesh** built once per kind (front = +Z, frog/robin
|
* +Z, frog/robin feet / bee body at the origin); the live `position`/`heading`/
|
||||||
* feet / bee body at the origin); the live `position`/`heading`/`scale` are turned
|
* `scale` are turned into a per-frame model matrix by the renderer. All wander
|
||||||
* into a per-frame model matrix by the renderer. All wander state lives here so
|
* state lives on the instance so `update` is a pure stepping function of the mob +
|
||||||
* `update` is a pure stepping function of the mob + dt (deterministic via the
|
* dt (deterministic via the evolving `seed`), which keeps the sim on the main
|
||||||
* evolving `seed`), which keeps the sim on the main thread and cloneable-free. */
|
* thread and cloneable-free. */
|
||||||
export type MobKind = "frog" | "bee" | "robin"
|
export type MobKind = "frog" | "bee" | "robin"
|
||||||
|
|
||||||
export type Mob = {
|
export type Mob = {
|
||||||
|
|
@ -45,294 +46,39 @@ export type Mob = {
|
||||||
grounded: boolean
|
grounded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Behavior tuning ------------------------------------------------------
|
/** Canonical kind order. **The index is the id packed into the mob SAB** (see
|
||||||
const FROG_LEASH = 5
|
* renderer/worker), so this order must be identical in every context and must not
|
||||||
const FROG_REST_MIN = 0.7
|
* change under existing kinds -- `mobs.test.ts` guards it. Append new kinds. */
|
||||||
const FROG_REST_SPAN = 1.8
|
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
|
||||||
const FROG_HOP_SPEED = 1.6
|
|
||||||
const FROG_HOP_IMPULSE = 3.2
|
/** The per-kind `Entity` definitions, one module each. Imported (not cloned) into
|
||||||
const FROG_GRAVITY = 14
|
* whatever context uses it, so it works the same on the main thread and in workers. */
|
||||||
const BEE_LEASH = 6
|
const DEFS: Record<MobKind, Entity<Mob, Terrain>> = { frog, bee, robin }
|
||||||
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
|
|
||||||
|
|
||||||
export namespace Mob {
|
export namespace Mob {
|
||||||
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
|
/** The definition for a kind (geometry, behavior, bounds). */
|
||||||
export function update(mob: Mob, dt: number, terrain: Terrain): void {
|
export function def(kind: MobKind): Entity<Mob, Terrain> {
|
||||||
if (mob.kind === "frog") {
|
return DEFS[kind]
|
||||||
frog(mob, dt, terrain)
|
|
||||||
} else if (mob.kind === "bee") {
|
|
||||||
bee(mob, dt, terrain)
|
|
||||||
} else {
|
|
||||||
robin(mob, dt, terrain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append the canonical local-space mesh for `kind` into `mesh` (called once
|
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
|
||||||
* per kind at load; every instance shares it, differing only by transform). */
|
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 {
|
export function build(kind: MobKind, mesh: Mesh): void {
|
||||||
if (kind === "frog") {
|
DEFS[kind].build(mesh)
|
||||||
buildFrog(mesh)
|
|
||||||
} else if (kind === "bee") {
|
|
||||||
buildBee(mesh)
|
|
||||||
} else {
|
|
||||||
buildRobin(mesh)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
|
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
|
||||||
export function boundingRadius(kind: MobKind): number {
|
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. */
|
/** Local body height (pre-scale), for the top of the stand-on collider. */
|
||||||
export function bodyHeight(kind: MobKind): number {
|
export function bodyHeight(kind: MobKind): number {
|
||||||
return kind === "frog" ? 0.6 : kind === "robin" ? 0.55 : 0.5
|
return DEFS[kind].bodyHeight
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,233 +1,54 @@
|
||||||
import { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "./Mesh"
|
import type { Mesh } from "./Mesh"
|
||||||
|
import { oak } from "./trees/Oak"
|
||||||
|
import { spruce } from "./trees/Spruce"
|
||||||
|
import { birch } from "./trees/Birch"
|
||||||
|
|
||||||
const TAU = Math.PI * 2
|
export type TreeKind = "oak" | "spruce" | "birch"
|
||||||
|
|
||||||
/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it
|
/** One procedural tree instance. `growth` 0..1 runs sapling -> full grown: it scales
|
||||||
* scales height and girth and adds canopy blobs (oak) / tiers (spruce). `seed`
|
* height and girth and adds canopy blobs / tiers. `seed` drives the per-tree random
|
||||||
* drives the per-tree random wobble so a forest doesn't look cloned. */
|
* wobble so a forest doesn't look cloned. */
|
||||||
export type Tree = {
|
export type Tree = {
|
||||||
kind: "oak" | "spruce" | "birch"
|
kind: TreeKind
|
||||||
/** Trunk base, sitting on the ground. */
|
/** Trunk base, sitting on the ground. */
|
||||||
position: Vec3
|
position: Vec3
|
||||||
growth: number
|
growth: number
|
||||||
seed: number
|
seed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Definition of a tree species: which chunk materials its trunk + foliage bake
|
||||||
* Low-poly tree geometry, in the same faceted flat-shaded style as the rest of
|
* into, plus how to append its geometry. Each lives in its own `trees/<Kind>.ts`
|
||||||
* the world. Two silhouettes carry the species read:
|
* module (silhouette carries the species read); this file just assembles them.
|
||||||
* oak -- short tapered trunk, a couple of branches, a broad cluster of
|
* `trunk`/`foliage` are chunk-material keys (see `level.ts` `ChunkMaterials`):
|
||||||
* rounded canopy blobs (bushy, wider than tall).
|
* oak/spruce use the brown `bark`, birch the white `birch`; foliage is the oak
|
||||||
* spruce -- tall thin trunk under stacked cones that narrow to a point
|
* `leaf` or spruce `needle`. */
|
||||||
* (tiered, taller than wide).
|
export type TreeSpecies = {
|
||||||
* `build` appends into caller-owned meshes so a whole forest batches into a few
|
kind: TreeKind
|
||||||
* draw calls: all trunks share one bark mesh, foliage splits oak vs spruce so
|
trunk: string
|
||||||
* each can carry its own leaf/needle texture.
|
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 {
|
export namespace Tree {
|
||||||
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
|
/** The species definition for a kind (its trunk/foliage materials + geometry). */
|
||||||
* for its kind (oak leaf vs spruce needle). */
|
export function species(kind: TreeKind): TreeSpecies {
|
||||||
/** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures +
|
return SPECIES[kind]
|
||||||
* faceted look, same height/position) for far chunks; "full" is up close. */
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
|
||||||
const rand = rng(tree.seed)
|
SPECIES[tree.kind].build(tree, trunk, foliage, lod)
|
||||||
if (tree.kind === "oak") {
|
|
||||||
oak(tree.position, tree.growth, rand, trunk, foliage, lod)
|
|
||||||
} else if (tree.kind === "spruce") {
|
|
||||||
spruce(tree.position, tree.growth, rand, trunk, foliage, lod)
|
|
||||||
} else {
|
|
||||||
birch(tree.position, tree.growth, rand, trunk, foliage, lod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function birch(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
|
||||||
// Silver birch: tall, slender, near-straight white trunk under an airy, high,
|
|
||||||
// slightly drooping canopy of small leaf tufts -- a lean silhouette between the
|
|
||||||
// broad oak and the conical spruce (the white bark texture does the rest).
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tapered tube between two points (trunk or branch), `sides`-gonal. */
|
|
||||||
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). */
|
|
||||||
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 oak canopy blob). Per-ring radius wobble keeps
|
|
||||||
* it organic without cracking the longitude seam. */
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number): number {
|
|
||||||
return a + (b - a) * t
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deterministic 0..1 generator (mulberry32) seeded per tree. */
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
engine/scene/mobs/Bee.ts
Normal file
40
engine/scene/mobs/Bee.ts
Normal 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
57
engine/scene/mobs/Frog.ts
Normal 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 }
|
||||||
78
engine/scene/mobs/Robin.ts
Normal file
78
engine/scene/mobs/Robin.ts
Normal 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
120
engine/scene/mobs/mobkit.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
engine/scene/trees/Birch.ts
Normal file
52
engine/scene/trees/Birch.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Vec3 } from "../../math/Vec3"
|
||||||
|
import type { Mesh } from "../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).
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
|
||||||
53
engine/scene/trees/Oak.ts
Normal file
53
engine/scene/trees/Oak.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Vec3 } from "../../math/Vec3"
|
||||||
|
import type { Mesh } from "../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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
|
||||||
32
engine/scene/trees/Spruce.ts
Normal file
32
engine/scene/trees/Spruce.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Mesh } from "../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.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
|
||||||
95
engine/scene/trees/treekit.ts
Normal file
95
engine/scene/trees/treekit.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Vec3 } from "../../math/Vec3"
|
||||||
|
import { STRIDE, type Mesh } from "../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)]
|
||||||
|
}
|
||||||
21
tests/mobs.test.ts
Normal file
21
tests/mobs.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { MOB_KINDS, Mob } from "../engine/scene/Mob"
|
||||||
|
|
||||||
|
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
|
||||||
|
// render worker must agree on that order. Freeze it here: appending a kind is fine,
|
||||||
|
// but reordering or removing an existing one silently corrupts which mesh/texture a
|
||||||
|
// worker draws.
|
||||||
|
test("MOB_KINDS order is frozen (mob SAB ids)", () => {
|
||||||
|
expect(MOB_KINDS).toEqual(["frog", "bee", "robin"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("every kind resolves to a complete definition", () => {
|
||||||
|
for (const kind of MOB_KINDS) {
|
||||||
|
const d = Mob.def(kind)
|
||||||
|
expect(d.name).toBe(kind)
|
||||||
|
expect(typeof d.build).toBe("function")
|
||||||
|
expect(typeof d.update).toBe("function")
|
||||||
|
expect(d.boundingRadius).toBeGreaterThan(0)
|
||||||
|
expect(d.bodyHeight).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
18
tests/trees.test.ts
Normal file
18
tests/trees.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Tree, TREE_KINDS } from "../engine/scene/Tree"
|
||||||
|
|
||||||
|
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
|
||||||
|
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
|
||||||
|
// material outside that palette would bake geometry that is silently never drawn.
|
||||||
|
// Freeze the palette here so a typo'd or unregistered material key fails a test.
|
||||||
|
const MATERIAL_KEYS = new Set(["grass", "rock", "bark", "birch", "leaf", "needle", "flower"])
|
||||||
|
|
||||||
|
test("every tree species maps to known chunk materials", () => {
|
||||||
|
for (const kind of TREE_KINDS) {
|
||||||
|
const s = Tree.species(kind)
|
||||||
|
expect(s.kind).toBe(kind)
|
||||||
|
expect(typeof s.build).toBe("function")
|
||||||
|
expect(MATERIAL_KEYS.has(s.trunk)).toBe(true)
|
||||||
|
expect(MATERIAL_KEYS.has(s.foliage)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue