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

106
game/Terrain.ts Normal file
View file

@ -0,0 +1,106 @@
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
/** A procedural heightfield surrounding the room. It is the single source of
* ground height: the outdoor mesh is built from it and the player stands on the
* same `height` samples, so what you see and what you collide with agree. The
* center (out to `inner`) is a flat clearing where the room sits; from there the
* land rolls outward and ramps up into tall peaks at the far edge. Every field
* is a live knob -- edit them in the level to reshape the world. */
export type Terrain = {
/** Half-extent of the flat central clearing (the room lives here); height 0. */
inner: number
/** World half-extent. Peaks ramp up toward this outer rim. */
outer: number
/** Ease-up distance just outside `inner`, so the clearing meets the hills with
* a slope instead of a wall. */
blend: number
/** Rolling-hill height across the open ground. */
amplitude: number
/** Rolling-hill frequency (low = broad hills over the big world). */
frequency: number
/** Extra height of the mountains near the edge -- make this big for peaks. */
peakHeight: number
/** Mountain frequency (low = few, massive ridges). */
peakFrequency: number
/** Fraction of the way out (0..1) where the peaks begin rising. */
peakStart: number
}
export namespace Terrain {
/** Ground height at world (x, z). 0 inside the clearing, rolling hills beyond,
* ramping into peaks toward the edge. Uses a square (Chebyshev) radius so the
* clearing is a square that lines up with the square room. */
export function height(t: Terrain, x: number, z: number): number {
const r = Math.max(Math.abs(x), Math.abs(z))
if (r <= t.inner) {
return 0
}
const rise = smoothstep(t.inner, t.inner + t.blend, r)
const hills = t.amplitude * bumps(x, z, t.frequency)
const k = Math.min(1, (r - t.inner) / (t.outer - t.inner))
const peaks = t.peakHeight * ridges(x, z, t.peakFrequency) * smoothstep(t.peakStart, 1, k)
return rise * (hills + peaks)
}
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
* center is inside the clearing are skipped (the room floor's hole). UVs use
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
* keep the spacing uniform and cell edges aligned, so shared edges weld with
* no cracks. Used to build the terrain per spatial chunk. */
export function patch(
t: Terrain,
mesh: Mesh,
x0: number,
z0: number,
x1: number,
z1: number,
cols: number,
rows: number,
uvScale: number,
): void {
const base = mesh.verts.length / STRIDE
const dx = (x1 - x0) / cols
const dz = (z1 - z0) / rows
const rowLen = cols + 1
for (let i = 0; i <= rows; i++) {
const z = z0 + i * dz
for (let j = 0; j <= cols; j++) {
const x = x0 + j * dx
mesh.verts.push(x, height(t, x, z), z, x * uvScale, z * uvScale)
}
}
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const cx = x0 + (j + 0.5) * dx
const cz = z0 + (i + 0.5) * dz
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
continue
}
const p = base + i * rowLen + j
// Wound so the surface faces up/out, matching the backface-cull sign.
mesh.indices.push(p, p + rowLen + 1, p + 1, p, p + rowLen, p + rowLen + 1)
}
}
}
/** Rolling hills in 0..1, always non-negative so the ground never dips below
* the clearing. */
function bumps(x: number, z: number, f: number): number {
const a = Math.sin(x * f) * Math.cos(z * f)
const b = Math.sin((x + z) * f * 0.5 + 1.7) * 0.5
return (a + b + 1.5) / 3
}
/** Ridged noise in 0..1: crests where the field crosses zero give sharp
* mountain ridgelines rather than round blobs. */
function ridges(x: number, z: number, f: number): number {
const n = Math.sin(x * f + 1.3) * Math.cos(z * f - 0.7) * 0.7 + Math.sin((x + z) * f * 0.6 + 2.5) * 0.3
return 1 - Math.abs(n)
}
function smoothstep(a: number, b: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4)))
return t * t * (3 - 2 * t)
}
}

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

549
game/level.ts Normal file
View file

@ -0,0 +1,549 @@
import { Color } from "../engine/render/Color"
import type { DrawGroup, Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { Boulder } from "./actors/Boulder"
import { Bush } from "./actors/Bush"
import { Flower, type FlowerColor } from "./actors/Flower"
import type { Mob, MobKind } from "./actors/Mob"
import { Terrain } from "./Terrain"
import { Tree } from "./actors/Tree"
import type { Textures } from "./textures"
type Corner = [number, number, number]
/** Axis-aligned solid. Blocks the player horizontally while their feet are
* below `top`; if `standable`, its `top` also counts as ground to land on. */
export type Aabb = {
minX: number
maxX: number
minZ: number
maxZ: number
top: number
standable: boolean
}
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
* standing in it, baked into `DrawGroup`s (mesh + material), plus an axis-aligned
* bounding box (tight to the actual geometry, so overhanging canopies aren't
* clipped). The renderer frustum-tests the box and skips the whole cell when it
* is off-screen -- this is what keeps a big, dense world affordable. Empty cells
* are never created; empty groups are pruned at bake time. */
export type Chunk = {
minX: number
minY: number
minZ: number
maxX: number
maxY: number
maxZ: number
/** Full-detail draw groups (grass + full trees/boulders), used up close. */
near: DrawGroup[]
/** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers),
* used once the chunk is past `config.lodDistance` (see `chunkFar`). */
far: DrawGroup[]
}
/** The materials the chunk baker binds its meshes to -- one per ground/prop
* texture. Built once from the loaded `Textures`, shared across every chunk. */
type ChunkMaterials = {
grass: Material
bark: Material
birch: Material
leaf: Material
needle: Material
rock: 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
* landscape. The room (floor/walls/crate) is small and always drawn; the
* outdoor world is split into `chunks` that are frustum-culled per frame. */
export type Level = {
floor: Mesh
walls: Mesh
crate: Mesh
chunks: Chunk[]
colliders: Aabb[]
npcPosition: { x: number; y: number; z: number }
/** Roaming mobs -- simulated on the main thread each frame (see main.ts), not
* baked into the static culled chunks. */
mobs: Mob[]
terrain: Terrain
sky: SkyConfig
}
const ARENA = 12
const WALL_HEIGHT = 4
/** How deep the perimeter walls are. Thick enough to read as solid walls (and to
* give the doorway real jambs); their outer faces sit flush with the room edge,
* so they eat into the interior, not the terrain. */
const WALL_THICKNESS = 1.5
const CRATE = { x: -2, z: -2, half: 1, height: 1 }
/** Z-bias lifting the stone floor above the terrain skirt that laps under the
* room edge (see `buildLevel`). Big enough to beat depth precision, too small
* to see. */
const FLOOR_LIFT = 0.02
/** The world around the room: a flat clearing the size of the room (`inner`),
* rolling hills beyond, ramping into very high peaks at the `outer` rim ~20x
* the room across. Tune freely -- crank `peakHeight` for taller mountains,
* `outer` for a bigger world. */
const TERRAIN: Terrain = {
inner: ARENA,
outer: ARENA * 10,
blend: 12,
amplitude: 5,
frequency: 0.14,
peakHeight: 0,
peakFrequency: 0.05,
peakStart: 0.45,
}
/** Forest: how many trees to scatter on the grass, and the seed for their
* placement/kind/growth. Trees ring the room out to `TREE_REACH` of the world;
* each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */
const TREE_COUNT = 50
const TREE_SEED = 0x5EED
const TREE_REACH = 1
/** Boulders: how many to scatter, their seed, and how far out they reach
* (fraction of the world). Sizes range small pebble .. big boulder. */
const BOULDER_COUNT = 50
const BOULDER_SEED = 0xB0142
const BOULDER_REACH = 1
/** Bushes + flowers: ground detail, kept to the nearer band since they're small
* and fog/size hides them far out. Flowers roll white/red/yellow. */
const BUSH_COUNT = 50
const BUSH_SEED = 0xB554
const BUSH_REACH = 1
const FLOWER_COUNT = 50
const FLOWER_SEED = 0xF10E
const FLOWER_REACH = 0.3
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they
* reach (fraction of the world). Kept modest -- roaming meshes are drawn every
* frame (frustum-culled), not baked into the static chunks. */
const FROG_COUNT = 20
const BEE_COUNT = 20
const ROBIN_COUNT = 20
const MOB_SEED = 0x30B
const MOB_REACH = 1
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the
* granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the
* world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets
* texture tiles/unit. */
const CHUNK_GRID = 12
const TERRAIN_SUBDIV = 5
const GROUND_UV = 0.25
/** The two cloud styles; swap which one the sky uses in `buildLevel`.
* `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier
* heightfield-shaded, domain-warped version with faked volume. */
export const basicCumulus: CloudLayer = {
kind: "basic",
color: Color.rgb(248, 250, 255),
coverage: 0.5,
scale: 0.9,
speed: 0.5,
edge: 0.005,
}
export const fancyCumulus: CloudLayer = {
kind: "fancy",
color: Color.rgb(250, 251, 255),
coverage: 0.5,
scale: 0.6,
speed: 0.5,
edge: 0.02,
warp: 0.4,
relief: 7,
}
export function buildLevel(textures: Textures): Level {
// Flat room floor, lifted a hair above the terrain's clearing (y 0). The
// outdoor grid's cells straddle the room boundary and lap under the floor's
// edges; this small z-bias keeps the flat stone floor winning the depth test
// there instead of z-fighting the grass. The step is invisible at the doorway.
const floor = mesh()
const fy = FLOOR_LIFT
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
const walls = mesh()
const h = WALL_HEIGHT
const t = WALL_THICKNESS
// Three thick perimeter walls, outer faces flush with the room edge; the north
// (-Z) side is left open onto the world. No ceiling, so the sky shows above.
slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z)
slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X)
slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X)
// Crate on the flat room floor.
const crate = mesh()
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
const colliders: Aabb[] = [
wall(-ARENA, ARENA, ARENA - t, ARENA),
wall(ARENA - t, ARENA, -ARENA, ARENA - t),
wall(-ARENA, -ARENA + t, -ARENA, ARENA - t),
{
minX: CRATE.x - CRATE.half,
maxX: CRATE.x + CRATE.half,
minZ: CRATE.z - CRATE.half,
maxZ: CRATE.z + CRATE.half,
top: CRATE.height,
standable: true,
},
]
const sky: SkyConfig = {
zenith: Color.rgb(58, 108, 196),
horizon: Color.rgb(178, 198, 226),
sun: Color.rgb(255, 246, 214),
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
sunSize: 0.04,
clouds: fancyCumulus,
skybox: { texture: textures.skybox },
}
const npcPosition = { x: 2, y: 0, z: -1 }
// The ground/prop materials the chunk baker draws with (grass + trees + rocks +
// flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by
// every chunk, so cloning to a worker dedups them.
const materials: ChunkMaterials = {
grass: { texture: textures.grass, cull: true },
bark: { texture: textures.bark, cull: true },
birch: { texture: textures.birch, cull: true },
leaf: { texture: textures.leaf, cull: true },
needle: { texture: textures.needle, cull: true },
rock: { texture: textures.rock, cull: true },
flower: { texture: textures.flower, cull: false },
}
// Place the props (also pushes their colliders), then bake everything into
// frustum-cullable spatial chunks.
const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders)
const bushes = placeBushes()
const flowers = placeFlowers()
const chunks = buildChunks(materials, trees, boulders, bushes, flowers)
const mobs = placeMobs()
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky }
}
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
* chunks. Each prop lands in the cell holding its base; the cell's bounds are
* grown to the real geometry so overhanging canopies never get culled early.
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
const chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) {
const x0 = -TERRAIN.outer + ci * cell
const x1 = x0 + cell
for (let cj = 0; cj < CHUNK_GRID; cj++) {
const z0 = -TERRAIN.outer + cj * cell
const z1 = z0 + cell
// Accumulate geometry into one mesh per material key, for the near (full) and
// far (impostor) LOD sets. Props declare which material(s) they write, so the
// baker never names a texture -- adding a species/material touches no code here.
const near = new Map<string, Mesh>()
const far = new Map<string, Mesh>()
const grass = matMesh(near, "grass")
far.set("grass", grass) // the ground is drawn in both LOD sets
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) {
const s = Tree.species(tree.kind)
Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
}
}
for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, matMesh(near, "rock"))
Boulder.build(boulder, matMesh(far, "rock"), "impostor")
}
}
// Bushes fold into the near leaf mesh; they just drop out past lodDistance.
for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, matMesh(near, "leaf"))
}
}
for (const flower of flowers) {
if (inCell(flower.position, x0, z0, x1, z1)) {
Flower.build(flower, matMesh(near, "flower"))
}
}
const b = bounds([...near.values()])
if (b === null) {
continue
}
chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
}
}
return chunks
}
function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean {
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
}
/** Lazily get (creating on first use) the accumulation mesh for a material key in a
* chunk's near/far map. Props write into these by key, so the baker stays generic. */
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[] = []
for (const key of MAT_ORDER) {
const m = map.get(key)
if (m !== undefined && m.indices.length > 0) {
out.push({ mesh: m, material: materials[key] })
}
}
return out
}
/** Tight AABB over several meshes' vertices, or null if they are all empty. */
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
let minX = Infinity
let minY = Infinity
let minZ = Infinity
let maxX = -Infinity
let maxY = -Infinity
let maxZ = -Infinity
for (const m of meshes) {
const verts = m.verts
for (let i = 0; i < verts.length; i += STRIDE) {
const x = verts[i]
const y = verts[i + 1]
const z = verts[i + 2]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
minZ = Math.min(minZ, z)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
maxZ = Math.max(maxZ, z)
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
}
/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
* drops a trunk collider so you can't walk through it. */
function placeTrees(colliders: Aabb[]): Tree[] {
const rand = mulberry(TREE_SEED)
const maxDist = TERRAIN.outer * TREE_REACH
const trees: Tree[] = []
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
// Stay out of the room clearing and its flat rim.
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 3) {
continue
}
const roll = rand()
const kind = roll < 0.4 ? "oak" : roll < 0.72 ? "spruce" : "birch"
const growth = 0.08 + rand() * 0.92
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
// Saplings are passable; grown trunks block. Square footprint, non-standable.
if (growth > 0.35) {
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
}
}
return trees
}
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
* small. Each sits on the ground; big ones drop a blocking collider so you
* can't walk through them (little rocks stay passable). */
function placeBoulders(colliders: Aabb[]): Boulder[] {
const rand = mulberry(BOULDER_SEED)
const maxDist = TERRAIN.outer * BOULDER_REACH
const boulders: Boulder[] = []
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
// Square the roll so most rocks are small, a few are big.
const radius = 0.35 + rand() * rand() * 2.2
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
if (radius > 0.7) {
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
}
}
return boulders
}
/** Scatter bushes on the grass near the play area (no colliders -- walk through). */
function placeBushes(): Bush[] {
const rand = mulberry(BUSH_SEED)
const maxDist = TERRAIN.outer * BUSH_REACH
const bushes: Bush[] = []
for (let guard = 0; bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 })
}
return bushes
}
/** Scatter small flowers on the grass near the play area, colors rolled. */
function placeFlowers(): Flower[] {
const rand = mulberry(FLOWER_SEED)
const maxDist = TERRAIN.outer * FLOWER_REACH
const flowers: Flower[] = []
for (let guard = 0; flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 2 + rand() * (maxDist - ARENA - 2)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 1) {
continue
}
const color = FLOWER_COLORS[(rand() * FLOWER_COLORS.length) | 0]
flowers.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, color, size: 0.28 + rand() * 0.22, seed: (rand() * 0xFFFFFFFF) | 0 })
}
return flowers
}
/** Scatter frogs, bees + robins across the grass (like the boulders), each at its
* home anchor with a random heading and size. No colliders here -- mobs move, so
* their block/stand-on AABBs are rebuilt per frame in `main`. */
function placeMobs(): Mob[] {
const rand = mulberry(MOB_SEED)
const maxDist = TERRAIN.outer * MOB_REACH
const mobs: Mob[] = []
const total = FROG_COUNT + BEE_COUNT + ROBIN_COUNT
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
const n = mobs.length
const kind: MobKind = n < FROG_COUNT ? "frog" : n < FROG_COUNT + BEE_COUNT ? "bee" : "robin"
const y = Terrain.height(TERRAIN, x, z)
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : kind === "robin" ? 0.4 + rand() * 0.25 : 0.5 + rand() * 0.3
mobs.push({
kind,
home: { x, y, z },
position: { x, y, z },
heading: rand() * Math.PI * 2,
scale,
seed: (rand() * 0xFFFFFFFF) | 0,
vx: 0,
vz: 0,
vy: 0,
timer: rand() * 1.5,
// Bees hover (never grounded) and use phase for the bob; frogs/robins start
// resting on the ground.
phase: kind === "bee" ? rand() * 10 : 0,
grounded: kind !== "bee",
})
}
return mobs
}
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
function mulberry(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
}
}
function mesh(): Mesh {
return { verts: [], indices: [] }
}
/** A perimeter wall collider: blocks from the sides, and `standable` so you can
* jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true }
}
/** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) ->
* d (0,vs); `us`/`vs` set how many texture tiles span it. No subdivision is
* needed -- texturing is perspective-correct, so a single quad looks right at
* any size. */
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
const base = m.verts.length / STRIDE
m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs)
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
/** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom
* (never seen from below). `tpu` = texture tiles per world unit, so every face
* tiles at the same density whatever its size. Used for the thick walls. */
function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void {
const dx = (x1 - x0) * tpu
const dz = (z1 - z0) * tpu
const dy = (y1 - y0) * tpu
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz)
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy)
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy)
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy)
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy)
}
/** A box centered at (cx, cz), rising `height` units from `base`: top face plus
* four sides, one uv tile per face. No bottom (never seen). */
function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void {
const x0 = cx - half
const x1 = cx + half
const z0 = cz - half
const z1 = cz + half
const y0 = base
const y1 = base + height
quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], 1, 1)
quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], 1, 1)
quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], 1, 1)
}

169
game/player.ts Normal file
View file

@ -0,0 +1,169 @@
import { Terrain } from "./Terrain"
import type { Vec3 } from "../engine/math/Vec3"
import type { Aabb, Level } from "./level"
/** The player as a vertical cylinder. `position` is at the feet; the camera
* eye sits EYE_HEIGHT above it. */
export type Player = {
position: Vec3
yaw: number
pitch: number
velocityY: number
onGround: boolean
}
export const EYE_HEIGHT = 1.6
const RADIUS = 0.35
const SPEED = 6
/** Speed multiplier while a Run key (Shift) is held. Tweak to taste; set high to
* blast across the big terrain -- move+collision is substepped, so walls stay
* solid even at big multipliers. */
const RUN_MULTIPLIER = 2
const GRAVITY = 22
const JUMP_SPEED = 14
const NPC_RADIUS = 0.5
export namespace Player {
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
if (keys.has("Space") && player.onGround) {
player.velocityY = JUMP_SPEED
player.onGround = false
}
// Move + collide in small substeps: collision is discrete (move, then push
// out), so a single big running step could otherwise skip clean through a
// wall. Substepping keeps each advance short enough to always hit it.
const steps = moveSubsteps(keys, dt)
for (let i = 0; i < steps; i++) {
moveHorizontal(player, keys, dt / steps)
collide(player, level)
}
fall(player, dt, level)
}
/** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */
function runFactor(keys: Set<string>): number {
return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1
}
/** Number of move+collide substeps so each advances at most ~RADIUS, keeping
* the player from tunneling walls however fast they run. */
function moveSubsteps(keys: Set<string>, dt: number): number {
const perFrame = SPEED * runFactor(keys) * dt * Math.SQRT2
return Math.max(1, Math.ceil(perFrame / RADIUS))
}
function moveHorizontal(player: Player, keys: Set<string>, dt: number): void {
const speed = SPEED * runFactor(keys) * dt
const fx = Math.sin(player.yaw)
const fz = -Math.cos(player.yaw)
const rx = Math.cos(player.yaw)
const rz = Math.sin(player.yaw)
const p = player.position
if (keys.has("KeyW")) {
p.x += fx * speed
p.z += fz * speed
}
if (keys.has("KeyS")) {
p.x -= fx * speed
p.z -= fz * speed
}
if (keys.has("KeyD")) {
p.x += rx * speed
p.z += rz * speed
}
if (keys.has("KeyA")) {
p.x -= rx * speed
p.z -= rz * speed
}
}
/** Push the player's circle out of any solid it overlaps: level colliders it
* is not standing above, and the NPC. This is what makes walls and the NPC
* impassable while still letting you stand on the crate. */
function collide(player: Player, level: Level): void {
for (const aabb of level.colliders) {
if (player.position.y < aabb.top - 0.01) {
pushFromAabb(player.position, aabb)
}
}
pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS)
}
/** Apply gravity and land on the highest ground under the player. */
function fall(player: Player, dt: number, level: Level): void {
player.velocityY -= GRAVITY * dt
player.position.y += player.velocityY * dt
const ground = groundHeight(player.position, level)
if (player.position.y <= ground) {
player.position.y = ground
player.velocityY = 0
player.onGround = true
} else {
player.onGround = false
}
}
function groundHeight(position: Vec3, level: Level): number {
let ground = Terrain.height(level.terrain, position.x, position.z)
for (const aabb of level.colliders) {
if (
aabb.standable &&
position.x >= aabb.minX &&
position.x <= aabb.maxX &&
position.z >= aabb.minZ &&
position.z <= aabb.maxZ
) {
ground = Math.max(ground, aabb.top)
}
}
return ground
}
function pushFromAabb(position: Vec3, aabb: Aabb): void {
const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x))
const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z))
const dx = position.x - cx
const dz = position.z - cz
const d2 = dx * dx + dz * dz
if (d2 >= RADIUS * RADIUS) {
return
}
if (d2 > 1e-6) {
const d = Math.sqrt(d2)
const push = (RADIUS - d) / d
position.x += dx * push
position.z += dz * push
return
}
// Center is inside the box: eject through the nearest face.
const left = position.x - aabb.minX
const rightSide = aabb.maxX - position.x
const near = position.z - aabb.minZ
const far = aabb.maxZ - position.z
const m = Math.min(left, rightSide, near, far)
if (m === left) {
position.x = aabb.minX - RADIUS
} else if (m === rightSide) {
position.x = aabb.maxX + RADIUS
} else if (m === near) {
position.z = aabb.minZ - RADIUS
} else {
position.z = aabb.maxZ + RADIUS
}
}
function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void {
const dx = position.x - cx
const dz = position.z - cz
const reach = RADIUS + otherRadius
const d2 = dx * dx + dz * dz
if (d2 >= reach * reach || d2 < 1e-6) {
return
}
const d = Math.sqrt(d2)
const push = (reach - d) / d
position.x += dx * push
position.z += dz * push
}
}

133
game/renderScene.ts Normal file
View file

@ -0,0 +1,133 @@
import { Framebuffer } from "../engine/render/Framebuffer"
import { Frustum } from "../engine/render/Frustum"
import { Rasterizer } from "../engine/render/Rasterizer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { Sky, type SkyConfig } from "../engine/render/Sky"
import type { Camera } from "../engine/scene/Camera"
import { Mat4 } from "../engine/math/Mat4"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob, type MobKind } from "./actors/Mob"
import { Sprite } from "../engine/scene/Sprite"
import type { Vec2 } from "../engine/math/Vec2"
import type { Vec3 } from "../engine/math/Vec3"
import type { Textures } from "./textures"
import type { Chunk } from "./level"
/** Everything needed to render the world: the room, the cullable chunks, the NPC
* billboard source, sky, and textures. Bundled so it can be handed to a worker
* whole (it is plain data + typed arrays, structured-clone friendly). */
export type Scene = {
chunks: Chunk[]
floor: Mesh
walls: Mesh
crate: Mesh
npc: { position: Vec3; size: Vec2 }
/** Canonical local-space mob meshes, one per kind, built once + shared by every
* instance (each instance differs only by its per-frame model matrix). */
mobMesh: Record<MobKind, Mesh>
/** How many mobs the sim has -- sizes the worker's shared transform buffer. */
mobCount: number
sky: SkyConfig
textures: Textures
}
/** One mob's live transform for a frame: which mesh + where/how to place it.
* Produced by `visibleMobs` on the main thread, then either passed straight to
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
* 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 const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale
/** 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). */
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
const frustum = Frustum.fromViewProj(viewProj)
const out: number[] = []
for (let i = 0; i < chunks.length; i++) {
const c = chunks[i]
if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
out.push(i)
}
}
return out
}
/** The `MobDraw`s for mobs whose world AABB is inside the view frustum. Mobs move,
* so (unlike chunks) they can't be baked into the culled world -- they're culled
* here per frame instead. Computed once on the main thread; the visible set is
* what gets shipped to the workers. */
export function visibleMobs(mobs: Mob[], viewProj: Mat4): MobDraw[] {
const frustum = Frustum.fromViewProj(viewProj)
const out: MobDraw[] = []
for (const m of mobs) {
const r = Mob.boundingRadius(m.kind) * m.scale
const h = Mob.bodyHeight(m.kind) * m.scale
const p = m.position
if (Frustum.intersectsAabb(frustum, p.x - r, p.y - r, p.z - r, p.x + r, p.y + h + r, p.z + r)) {
out.push({ kind: m.kind, x: p.x, y: p.y, z: p.z, heading: m.heading, scale: m.scale })
}
}
return out
}
/**
* Render rows [y0, y1) of one frame into `fb`. This is the single source of
* render truth: the single-thread path calls it with the full height, and each
* worker calls it with its own disjoint band -- same output either way, and no
* two bands touch the same pixel (so the shared framebuffer needs no locking).
*/
export function renderBand(
fb: Framebuffer,
scene: Scene,
camera: Camera,
viewProj: Mat4,
visible: number[],
mobDraws: MobDraw[],
config: RenderConfig,
skyStep: number,
time: number,
y0: number,
y1: number,
): void {
const tx = scene.textures
Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1)
// Room: small and always near, drawn unconditionally (double-sided).
Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1)
Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1)
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
for (const i of visible) {
const c = scene.chunks[i]
// Past lodDistance, draw the cheap impostor group set instead of full detail.
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band
// makes the identical choice -- no full/impostor seam across bands. The loop
// is content-agnostic: each group carries its own mesh + material.
const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near
for (const g of groups) {
Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1)
}
}
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
// Roaming mobs: each is the shared local-space mesh for its kind, placed by its
// own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're
// small and few, so the winding-correct backface cull isn't worth the fuss.
for (const m of mobDraws) {
const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale))
Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1)
}
Framebuffer.quantize(fb, config, y0, y1)
}
/** Whether a chunk is far enough to draw its impostor meshes: squared distance
* from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on
* camera, the chunk's baked bounds, and the config constant, all of which every
* worker already holds, so the choice is identical across bands. */
export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
if (!(lodDistance < Infinity)) {
return false
}
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
}

23
game/textures.ts Normal file
View file

@ -0,0 +1,23 @@
import type { Texture } from "../engine/render/Texture"
/** The game's texture palette -- the named surfaces content refers to (materials,
* mobs, room). `app/assets.ts` loads the actual pixels via the DOM; this is the
* shape both sides agree on, kept in `game/` so content never imports the browser
* loader. Filenames in `/assets` are the contract. */
export type Textures = {
floor: Texture
grass: Texture
bark: Texture
birch: Texture
leaf: Texture
needle: Texture
rock: Texture
flower: Texture
wall: Texture
crate: Texture
npc: Texture
frog: Texture
bee: Texture
robin: Texture
skybox: Texture
}