refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

View file

@ -1,106 +0,0 @@
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)
}
}

View file

@ -1,5 +1,7 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
@ -22,8 +24,35 @@ export type Boulder = {
* field of boulders batches into a single draw call.
*/
export namespace Boulder {
export function create(material: Material): Prefab<Boulder> {
return {
position: (boulder) => boulder.position,
bakeNear: (boulder, batch) => build(boulder, batch.mesh(material)),
bakeFar: (boulder, batch) =>
build(boulder, batch.mesh(material), "impostor"),
collider(boulder) {
if (boulder.radius <= 0.7) {
return null
}
return {
shape: "box",
minX: boulder.position.x - boulder.radius,
maxX: boulder.position.x + boulder.radius,
minZ: boulder.position.z - boulder.radius,
maxZ: boulder.position.z + boulder.radius,
top: boulder.position.y + boulder.radius * 0.7,
standable: false,
}
},
}
}
/** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */
export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void {
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
@ -67,7 +96,11 @@ export namespace Boulder {
/** 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[][] {
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

View file

@ -1,5 +1,7 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
@ -17,6 +19,13 @@ export type Bush = {
* wound outward, so backface culling is safe. `build` appends into a shared
* (leaf-textured) mesh. */
export namespace Bush {
export function create(material: Material): Prefab<Bush> {
return {
position: (bush) => bush.position,
bakeNear: (bush, batch) => build(bush, batch.mesh(material)),
}
}
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
@ -34,7 +43,14 @@ export namespace Bush {
}
/** 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 {
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

View file

@ -1,17 +1,19 @@
import type { Vec3 } from "../../engine/math/Vec3"
import { Mesh } from "../../engine/scene/Mesh"
import type { Material } from "../../engine/render/Material"
import type { Prefab } from "../../engine/scene/Prefab"
const TAU = Math.PI * 2
/** Flower bloom color, indexing a region of the `flower` texture atlas. */
export type FlowerColor = "white" | "red" | "yellow"
/** Concrete atlas style referenced directly by flower instances. */
export type FlowerStyle = { bloomUv: [number, number] }
/** 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
style: FlowerStyle
size: number
seed: number
}
@ -23,16 +25,21 @@ export type Flower = {
* 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],
}
export const white: FlowerStyle = { bloomUv: [0.75, 0.25] }
export const red: FlowerStyle = { bloomUv: [0.25, 0.75] }
export const yellow: FlowerStyle = { bloomUv: [0.75, 0.75] }
/** uv center of the green stem region. */
const STEM_U = 0.25
const STEM_V = 0.25
export function create(material: Material): Prefab<Flower> {
return {
position: (flower) => flower.position,
bakeNear: (flower, batch) => build(flower, batch.mesh(material)),
}
}
export function build(flower: Flower, mesh: Mesh): void {
const rand = rng(flower.seed)
const p = flower.position
@ -43,14 +50,21 @@ export namespace Flower {
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 [bu, bv] = flower.style.bloomUv
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)
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)
@ -58,7 +72,15 @@ export namespace Flower {
}
/** 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 {
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)

View file

@ -1,27 +1,14 @@
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"
import type { Actor } from "../../engine/scene/Actor"
import type { RenderTransform } from "../../engine/render/RenderScene"
import type { BoxCollider } from "../../engine/world/Collider"
import type { Terrain } from "../../engine/world/Terrain"
/** 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"
/** Runtime actor using shared roaming-creature state. Concrete definitions are
* direct object references supplied by Frog, Bee, Robin, or future content. */
export type Mob = Actor<Terrain>
export type Mob = {
kind: MobKind
export type MobState = {
/** 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. */
@ -39,46 +26,39 @@ export type Mob = {
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
/** Behavior 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]
export function transform(state: MobState): RenderTransform {
return {
x: state.position.x,
y: state.position.y,
z: state.position.z,
heading: state.heading,
scale: state.scale,
}
}
/** 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
export function collider(
state: MobState,
radius: number,
height: number,
standable: boolean,
): BoxCollider {
const half = radius * state.scale * 0.7
return {
shape: "box",
minX: state.position.x - half,
maxX: state.position.x + half,
minZ: state.position.z - half,
maxZ: state.position.z + half,
top: state.position.y + height * state.scale,
standable,
}
}
}

View file

@ -1,54 +1,11 @@
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)
}
}

View file

@ -1,15 +1,28 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
export const bee: Entity<Mob, Terrain> = {
name: "bee",
build,
update,
boundingRadius: 0.5,
bodyHeight: 0.5,
export type Bee = ActorDefinition<MobState, Ground>
export namespace Bee {
export function create(material: Material): Bee {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.6,
minY: -0.5,
maxY: 1,
},
update,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.5, 0.5, false),
}
}
}
const LEASH = 6
@ -20,7 +33,7 @@ const HOVER = 1.1
const BOB_AMP = 0.18
const BOB_FREQ = 3
function build(mesh: Mesh): void {
function build(mesh: Geometry): 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.
@ -30,7 +43,7 @@ function build(mesh: Mesh): void {
wing(mesh, -1, 0.83, 0.99, 0, 1)
}
function update(mob: Mob, dt: number, terrain: Terrain): void {
function update(mob: MobState, dt: number, terrain: Ground): void {
mob.phase += dt
mob.timer -= dt
if (mob.timer <= 0) {

View file

@ -1,17 +1,30 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
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,
export type Frog = ActorDefinition<MobState, Ground>
export namespace Frog {
export function create(material: Material): Frog {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.7,
minY: -0.7,
maxY: 1.3,
},
update,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.7, 0.6, state.grounded),
}
}
}
const LEASH = 5
@ -21,7 +34,7 @@ const HOP_SPEED = 1.6
const HOP_IMPULSE = 3.2
const GRAVITY = 14
function build(mesh: Mesh): void {
function build(mesh: Geometry): 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)
@ -31,7 +44,7 @@ function build(mesh: Mesh): void {
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 {
function update(mob: MobState, dt: number, terrain: Ground): void {
if (mob.grounded) {
mob.timer -= dt
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)

View file

@ -1,18 +1,31 @@
import { Terrain } from "../../Terrain"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Mob } from "../Mob"
import type { Entity } from "../../../engine/scene/Actor"
import { Terrain, type Terrain as Ground } from "../../../engine/world/Terrain"
import { Mesh, type Mesh as Geometry } from "../../../engine/scene/Mesh"
import type { Material } from "../../../engine/render/Material"
import type { ActorDefinition } from "../../../engine/scene/Actor"
import { Mob, type MobState } from "../Mob"
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,
export type Robin = ActorDefinition<MobState, Ground>
export namespace Robin {
export function create(material: Material): Robin {
const mesh = Mesh.create()
build(mesh)
return {
prototype: {
groups: [{ mesh, material }],
radius: 0.5,
minY: -0.5,
maxY: 1,
},
update,
transform: Mob.transform,
collider: (state) => Mob.collider(state, 0.45, 0.55, state.grounded),
}
}
}
const LEASH = 6
@ -27,7 +40,7 @@ const FLY_IMPULSE = 3.5
const CRUISE = 0.8
const GRAVITY = 14
function build(mesh: Mesh): void {
function build(mesh: Geometry): 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).
@ -40,7 +53,7 @@ function build(mesh: Mesh): void {
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 {
function update(mob: MobState, dt: number, terrain: Ground): void {
if (mob.grounded) {
mob.timer -= dt
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)

View file

@ -1,10 +1,8 @@
import type { Mob } from "../Mob"
import type { MobState } 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.
// Shared building blocks for concrete mob definitions: faceted geometry primitives
// and deterministic wander helpers.
export const TAU = Math.PI * 2
@ -13,7 +11,11 @@ export const TAU = Math.PI * 2
/** 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 {
export function wanderHeading(
mob: MobState,
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) {
@ -24,7 +26,7 @@ export function wanderHeading(mob: Mob, leash: number, jitter: number): number {
/** 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 {
export function nextRand(mob: MobState): number {
const a = (mob.seed + 0x6D2B79F5) | 0
mob.seed = a
let t = Math.imul(a ^ (a >>> 15), 1 | a)
@ -33,7 +35,7 @@ export function nextRand(mob: Mob): number {
}
// --- Geometry primitives --------------------------------------------------
// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing --
// Mobs are drawn double-sided, 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. */
@ -61,7 +63,13 @@ export function ellipsoid(
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)
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)
@ -97,13 +105,36 @@ export function ovoidZ(
}
/** 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 {
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,
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)
}

View file

@ -1,15 +1,51 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } 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 }
export type Birch = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
export namespace Birch {
export function create(trunk: Material, foliage: Material): Birch {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider: treeCollider,
}
}
function treeCollider(tree: Tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.2 + 0.15
return {
shape: "box" as const,
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
}
}
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)
@ -18,11 +54,25 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
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)
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)
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.
@ -42,7 +92,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
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 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)

View file

@ -1,14 +1,48 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } 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 }
export type Oak = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
export namespace Oak {
export function create(trunk: Material, foliage: Material): Oak {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider(tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.3 + 0.15
return {
shape: "box",
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
},
}
}
}
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)
@ -19,7 +53,14 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
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)
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
}
@ -43,7 +84,11 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
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 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)

View file

@ -1,20 +1,63 @@
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import type { Material } from "../../../engine/render/Material"
import type { Prefab } from "../../../engine/scene/Prefab"
import type { Tree } 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 }
export type Spruce = Prefab<Tree>
function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
export namespace Spruce {
export function create(trunk: Material, foliage: Material): Spruce {
return {
position: (tree) => tree.position,
bakeNear: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "full"),
bakeFar: (tree, batch) =>
build(tree, batch.mesh(trunk), batch.mesh(foliage), "impostor"),
collider: treeCollider,
}
}
function treeCollider(tree: Tree) {
if (tree.growth <= 0.35) {
return null
}
const radius = tree.growth * 0.2 + 0.15
return {
shape: "box" as const,
minX: tree.position.x - radius,
maxX: tree.position.x + radius,
minZ: tree.position.z - radius,
maxZ: tree.position.z + radius,
top: tree.position.y + 3,
standable: false,
}
}
}
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)
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).

View file

@ -1,9 +1,8 @@
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.
// Shared faceted-geometry primitives + the per-tree RNG used by concrete tree
// prefab modules.
export const TAU = Math.PI * 2

View file

@ -1,51 +1,37 @@
import { Color } from "../engine/render/Color"
import type { DrawGroup, Material } from "../engine/render/Material"
import type { Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { ChunkBuilder } from "../engine/render/ChunkBuilder"
import { Actor, type ActorDefinition } from "../engine/scene/Actor"
import { Mesh } from "../engine/scene/Mesh"
import { MeshBuilder } from "../engine/scene/MeshBuilder"
import {
Prefab,
type PlacedPrefab,
type Prefab as PrefabDefinition,
} from "../engine/scene/Prefab"
import type { BoxCollider, Collider } from "../engine/world/Collider"
import { Level, type Level as RuntimeLevel } from "../engine/world/Level"
import {
Terrain,
type RollingTerrainConfig,
type Terrain as Ground,
} from "../engine/world/Terrain"
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 { Flower, type FlowerStyle } from "./actors/Flower"
import type { Mob, MobState } from "./actors/Mob"
import type { Tree } from "./actors/Tree"
import { Bee } from "./actors/mobs/Bee"
import { Frog } from "./actors/mobs/Frog"
import { Robin } from "./actors/mobs/Robin"
import { Birch } from "./actors/trees/Birch"
import { Oak } from "./actors/trees/Oak"
import { Spruce } from "./actors/trees/Spruce"
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 = {
type Materials = {
floor: Material
grass: Material
bark: Material
birch: Material
@ -53,50 +39,35 @@ type ChunkMaterials = {
needle: Material
rock: Material
flower: Material
wall: Material
crate: Material
npc: Material
frog: Material
bee: Material
robin: Material
}
/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */
type MatKey = keyof ChunkMaterials
type WeightedTree = {
definition: PrefabDefinition<Tree>
weight: number
}
/** 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
type MobSpawn = {
definition: ActorDefinition<MobState, Ground>
count: number
minScale: number
maxScale: number
grounded: boolean
phase: (random: () => number) => number
}
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 = {
const TERRAIN_CONFIG: RollingTerrainConfig = {
inner: ARENA,
outer: ARENA * 10,
blend: 12,
@ -106,52 +77,30 @@ const TERRAIN: Terrain = {
peakFrequency: 0.05,
peakStart: 0.45,
}
const TERRAIN = Terrain.rolling(TERRAIN_CONFIG)
/** 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 FLOWER_STYLES: FlowerStyle[] = [Flower.white, Flower.red, Flower.yellow]
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),
@ -172,33 +121,63 @@ export const fancyCumulus: CloudLayer = {
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)
/** Concrete playground data assembled through engine-owned level mechanisms. */
export function buildLevel(textures: Textures): RuntimeLevel<Ground> {
const materials = createMaterials(textures)
const floor = Mesh.create()
MeshBuilder.quad(
floor,
[-ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, ARENA],
[-ARENA, FLOOR_LIFT, 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)
const walls = Mesh.create()
const thickness = WALL_THICKNESS
MeshBuilder.slab(
walls,
-ARENA,
ARENA,
ARENA - thickness,
ARENA,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
ARENA - thickness,
ARENA,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
-ARENA,
-ARENA + thickness,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
// Crate on the flat room floor.
const crate = mesh()
box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
const crate = Mesh.create()
MeshBuilder.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),
const npcPosition = { x: 2, y: 0, z: -1 }
const staticColliders: Collider[] = [
wall(-ARENA, ARENA, ARENA - thickness, ARENA),
wall(ARENA - thickness, ARENA, -ARENA, ARENA - thickness),
wall(-ARENA, -ARENA + thickness, -ARENA, ARENA - thickness),
{
shape: "box",
minX: CRATE.x - CRATE.half,
maxX: CRATE.x + CRATE.half,
minZ: CRATE.z - CRATE.half,
@ -206,8 +185,94 @@ export function buildLevel(textures: Textures): Level {
top: CRATE.height,
standable: true,
},
{
shape: "circle",
x: npcPosition.x,
z: npcPosition.z,
radius: 0.5,
top: Infinity,
standable: false,
},
]
const treeDefinitions: WeightedTree[] = [
{ definition: Oak.create(materials.bark, materials.leaf), weight: 0.4 },
{
definition: Spruce.create(materials.bark, materials.needle),
weight: 0.32,
},
{ definition: Birch.create(materials.birch, materials.leaf), weight: 0.28 },
]
const props = [
...placeTrees(treeDefinitions),
...placeBoulders(Boulder.create(materials.rock)),
...placeBushes(Bush.create(materials.leaf)),
...placeFlowers(Flower.create(materials.flower)),
]
for (const prop of props) {
if (prop.collider !== null) {
staticColliders.push(prop.collider)
}
}
const chunks = ChunkBuilder.build(
{
minX: TERRAIN.minX,
minZ: TERRAIN.minZ,
maxX: TERRAIN.maxX,
maxZ: TERRAIN.maxZ,
columns: CHUNK_GRID,
rows: CHUNK_GRID,
bakeCell(near, far, cell) {
const ground = near.mesh(materials.grass)
far.use(materials.grass, ground)
Terrain.patch(
TERRAIN,
ground,
cell.x0,
cell.z0,
cell.x1,
cell.z1,
TERRAIN_SUBDIV,
TERRAIN_SUBDIV,
GROUND_UV,
(x, z) => Math.max(Math.abs(x), Math.abs(z)) >= ARENA,
)
},
},
props,
)
const frog = Frog.create(materials.frog)
const bee = Bee.create(materials.bee)
const robin = Robin.create(materials.robin)
const actors = placeMobs([
{
definition: frog,
count: FROG_COUNT,
minScale: 0.5,
maxScale: 0.85,
grounded: true,
phase: () => 0,
},
{
definition: bee,
count: BEE_COUNT,
minScale: 0.5,
maxScale: 0.8,
grounded: false,
phase: (random) => random() * 10,
},
{
definition: robin,
count: ROBIN_COUNT,
minScale: 0.4,
maxScale: 0.65,
grounded: true,
phase: () => 0,
},
])
const sky: SkyConfig = {
zenith: Color.rgb(58, 108, 196),
horizon: Color.rgb(178, 198, 226),
@ -218,12 +283,31 @@ export function buildLevel(textures: Textures): Level {
skybox: { texture: textures.skybox },
}
const npcPosition = { x: 2, y: 0, z: -1 }
return Level.create({
terrain: TERRAIN,
actorWorld: TERRAIN,
actors,
staticColliders,
staticGroups: [
{ mesh: floor, material: materials.floor },
{ mesh: walls, material: materials.wall },
{ mesh: crate, material: materials.crate },
],
chunks,
billboards: [
{
position: npcPosition,
size: { x: 1.1, y: 1.5 },
material: materials.npc,
},
],
sky,
})
}
// 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 = {
function createMaterials(textures: Textures): Materials {
return {
floor: { texture: textures.floor, cull: false },
grass: { texture: textures.grass, cull: true },
bark: { texture: textures.bark, cull: true },
birch: { texture: textures.birch, cull: true },
@ -231,319 +315,219 @@ export function buildLevel(textures: Textures): Level {
needle: { texture: textures.needle, cull: true },
rock: { texture: textures.rock, cull: true },
flower: { texture: textures.flower, cull: false },
wall: { texture: textures.wall, cull: false },
crate: { texture: textures.crate, cull: false },
npc: { texture: textures.npc, cull: false },
frog: { texture: textures.frog, cull: false },
bee: { texture: textures.bee, cull: false },
robin: { texture: textures.robin, 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) {
function placeTrees(definitions: WeightedTree[]): PlacedPrefab[] {
const random = mulberry(TREE_SEED)
const maxDistance = TERRAIN_CONFIG.outer * TREE_REACH
const trees: PlacedPrefab[] = []
for (
let guard = 0;
trees.length < TREE_COUNT && guard < TREE_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 5, 3)
if (point === null) {
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 })
const definition = weightedTree(definitions, random())
const tree: Tree = {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
growth: 0.08 + random() * 0.92,
seed: (random() * 0xFFFFFFFF) | 0,
}
trees.push(Prefab.place(definition, tree))
}
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) {
function placeBoulders(definition: PrefabDefinition<Boulder>): PlacedPrefab[] {
const random = mulberry(BOULDER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BOULDER_REACH
const boulders: PlacedPrefab[] = []
for (
let guard = 0;
boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 4, 2)
if (point === null) {
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 })
}
boulders.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
radius: 0.35 + random() * random() * 2.2,
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
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) {
function placeBushes(definition: PrefabDefinition<Bush>): PlacedPrefab[] {
const random = mulberry(BUSH_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BUSH_REACH
const bushes: PlacedPrefab[] = []
for (
let guard = 0;
bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
bushes.push({ position: { x, y: Terrain.height(TERRAIN, x, z), z }, size: 0.8 + rand() * 1, seed: (rand() * 0xFFFFFFFF) | 0 })
bushes.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
size: 0.8 + random(),
seed: (random() * 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) {
function placeFlowers(definition: PrefabDefinition<Flower>): PlacedPrefab[] {
const random = mulberry(FLOWER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * FLOWER_REACH
const flowers: PlacedPrefab[] = []
for (
let guard = 0;
flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 2, 1)
if (point === null) {
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 })
flowers.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
style: FLOWER_STYLES[(random() * FLOWER_STYLES.length) | 0],
size: 0.28 + random() * 0.22,
seed: (random() * 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
function placeMobs(spawns: MobSpawn[]): Mob[] {
const random = mulberry(MOB_SEED)
const maxDistance = TERRAIN_CONFIG.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
for (const spawn of spawns) {
let placed = 0
for (
let guard = 0;
placed < spawn.count && guard < spawn.count * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
const y = TERRAIN.heightAt(point.x, point.z)
const scale = spawn.minScale + random() * (spawn.maxScale - spawn.minScale)
const heading = random() * Math.PI * 2
const state: MobState = {
home: { x: point.x, y, z: point.z },
position: { x: point.x, y, z: point.z },
heading,
scale,
seed: (random() * 0xFFFFFFFF) | 0,
vx: 0,
vz: 0,
vy: 0,
timer: random() * 1.5,
phase: spawn.phase(random),
grounded: spawn.grounded,
}
mobs.push(Actor.create(spawn.definition, state))
placed++
}
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 scatterPoint(
random: () => number,
maxDistance: number,
clearance: number,
flatMargin: number,
): { x: number; z: number } | null {
const angle = random() * Math.PI * 2
const distance =
ARENA + clearance + random() * (maxDistance - ARENA - clearance)
const x = Math.cos(angle) * distance
const z = Math.sin(angle) * distance
return Math.max(Math.abs(x), Math.abs(z)) < TERRAIN_CONFIG.inner + flatMargin
? null
: { x, z }
}
function weightedTree(
definitions: WeightedTree[],
roll: number,
): PrefabDefinition<Tree> {
let cumulative = 0
for (const entry of definitions) {
cumulative += entry.weight
if (roll < cumulative) {
return entry.definition
}
}
return definitions[definitions.length - 1].definition
}
function wall(
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): BoxCollider {
return {
shape: "box",
minX,
maxX,
minZ,
maxZ,
top: WALL_HEIGHT,
standable: true,
}
}
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)
function mulberry(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6D2B79F5) | 0
let value = Math.imul(state ^ (state >>> 15), 1 | state)
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value)
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
}
}

View file

@ -1,169 +1,32 @@
import { Terrain } from "./Terrain"
import type { Vec3 } from "../engine/math/Vec3"
import type { Aabb, Level } from "./level"
import type {
Character,
CharacterConfig,
} from "../engine/world/CharacterController"
/** 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
export type Player = Character & {
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
/** Concrete player tuning. Movement and collision behavior live in engine. */
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)
export const actorCollisionRange = 3
export const config: CharacterConfig = {
radius: 0.35,
speed: 6,
runMultiplier: 2,
gravity: 22,
jumpSpeed: 14,
eyeHeight: 1.6,
}
/** 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
export function create(): Player {
return {
position: { x: 0, y: 0, z: 8 },
yaw: 0,
pitch: 0,
velocityY: 0,
onGround: true,
}
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
}
}

View file

@ -1,133 +0,0 @@
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
}