feat: game/engine split refactor + skybox
This commit is contained in:
parent
581e5892b0
commit
eeedcb8e48
34 changed files with 610 additions and 218 deletions
252
.agents/plans/levels-and-editor.md
Normal file
252
.agents/plans/levels-and-editor.md
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Levels & the in-game editor — direction note
|
||||||
|
|
||||||
|
Status: **direction agreed, not yet built.** Captures a brainstorm so the next
|
||||||
|
session starts from the conclusion, not a cold read. Nothing here is committed
|
||||||
|
code; it's the shape we want and *why*.
|
||||||
|
|
||||||
|
## The pivot
|
||||||
|
|
||||||
|
The game is heading toward a **multiplayer twitch shooter**. Each match loads a
|
||||||
|
**fresh, isolated level**. Consequences that shape everything below:
|
||||||
|
|
||||||
|
- **Maps are hand-made, not procedural.** Procedural scatter is demoted to a
|
||||||
|
*tool* (a starting-block generator), never the runtime map path. This is the
|
||||||
|
whole reason the editor matters — the editor *is* the map pipeline.
|
||||||
|
- **Real authoritative server.** The server bakes the same map the clients do and
|
||||||
|
runs the authoritative sim.
|
||||||
|
- **No in-world level switching.** You don't walk through a portal to another
|
||||||
|
world; the match lifecycle loads a level at start. → **portals / transition
|
||||||
|
triggers are explicitly not needed.**
|
||||||
|
- **Persistence and player carry-over are YAGNI** for now — but must not be walled
|
||||||
|
off (see hedges).
|
||||||
|
|
||||||
|
## Naming: `Level` (data) vs `Scene` (runtime)
|
||||||
|
|
||||||
|
Agreed vocabulary — reads as **"bake a `Level` into a `Scene`."** Fits the
|
||||||
|
type + namespace convention.
|
||||||
|
|
||||||
|
- **`Level`** = the authored, serializable **data document** (what an earlier draft
|
||||||
|
called `LevelSpec`). Pure plain data. The editor's output; the network's payload;
|
||||||
|
the server's and client's input.
|
||||||
|
- **`Scene`** = the baked **runtime instance** — the assembled world you render and
|
||||||
|
simulate.
|
||||||
|
|
||||||
|
**Repurposing note (so nobody trips):** today the names mean something narrower —
|
||||||
|
`Level` (`game/level.ts:69`) is a *bake output* and `Scene` (`renderScene.ts:19`)
|
||||||
|
is a *render-only, worker-cloneable subset*. Under the new vocabulary:
|
||||||
|
|
||||||
|
- The name **`Level` is freed** for the authored data document.
|
||||||
|
- **`Scene` grows** to be the whole runtime instance — it **absorbs** the colliders
|
||||||
|
+ live mob array + terrain sampler that currently hang off `Level`. The thing
|
||||||
|
**cloned to workers stays a render-subset projection** of the Scene (no colliders,
|
||||||
|
no live mobs — those are main-thread only), exactly as the current `Scene` already
|
||||||
|
is. This collapses today's incidental `Level`/`Scene` split (main.ts copies
|
||||||
|
fields across) into two clear things.
|
||||||
|
|
||||||
|
So the pipeline is:
|
||||||
|
|
||||||
|
```
|
||||||
|
generator(params) → Level (data) → bake(level, textures) → Scene (runtime)
|
||||||
|
(a TOOL: script / editor / never runtime) (the runtime path)
|
||||||
|
```
|
||||||
|
|
||||||
|
(`bake` is today's `buildLevel`, renamed and re-typed to take a `Level` document.
|
||||||
|
Namespace home — `Scene.from(level)` vs `Level.bake()` — is an open detail.)
|
||||||
|
|
||||||
|
## The spine: the `Level` document
|
||||||
|
|
||||||
|
One serializable, explicit map document is the center of gravity. Four consumers,
|
||||||
|
one artifact:
|
||||||
|
|
||||||
|
- **Editor** *writes* it.
|
||||||
|
- **Server** *reads* it — bakes headless, runs authoritative collision/sim.
|
||||||
|
- **Client** *reads* it — bakes the identical `Scene` to render + predict.
|
||||||
|
- **Network** ships it (or its id) at match start.
|
||||||
|
|
||||||
|
Because the bake is deterministic, server and every client bake **byte-identical
|
||||||
|
geometry and collision** from the same `Level` — so only the document (or its id)
|
||||||
|
crosses the wire, never geometry.
|
||||||
|
|
||||||
|
### The one real refactor
|
||||||
|
|
||||||
|
Today the map *definition* is module-level constants welded **inside**
|
||||||
|
`buildLevel()`. Split generation from baking: the generator *produces* an explicit
|
||||||
|
`Level`; `bake` consumes it. `bake` should treat the **explicit** document (a list
|
||||||
|
of placements) as canonical — the generator emits that form. This one cut serves
|
||||||
|
three futures at once: multiplayer sync, the editor, and (later) persistence.
|
||||||
|
|
||||||
|
## Build order
|
||||||
|
|
||||||
|
1. **`Level` document + `bake(level)` + the gen/bake split.** Pure `game/`,
|
||||||
|
headless, unit-testable, **no UI, no netcode**. The whole foundation — do it
|
||||||
|
first, on its own.
|
||||||
|
2. **Then, in parallel — both ride the document, neither blocks the other:**
|
||||||
|
- **Editor** — in-engine, browser, `app/`. Emits a `Level` file.
|
||||||
|
- **Server** — real authoritative, imports `game/`, bakes the `Level` headless.
|
||||||
|
|
||||||
|
## The editor shape
|
||||||
|
|
||||||
|
- **In-game, single-player, edit↔play toggle.** Same running client: an edit mode
|
||||||
|
to author the `Level` live, and a button that flips to **play mode** and drops you
|
||||||
|
into the environment you're building. "Everything works except multiplayer" —
|
||||||
|
full local sim (player, mobs, collision, later weapons) runs in play mode; no
|
||||||
|
server, no netcode in the editor.
|
||||||
|
- The play toggle is a **re-bake + mode switch** — a loading beat, not a hot path.
|
||||||
|
So it may freely re-bake and even reuse the existing worker respawn/`reconfigure`
|
||||||
|
path; there is **no match, so no live-worker-resync problem** (the one genuinely
|
||||||
|
hard sub-problem, thereby dodged).
|
||||||
|
- The engine already provides the hard parts: live first-person view, deterministic
|
||||||
|
bake, and `camera` + `Terrain.height` for "where's the ground under the cursor"
|
||||||
|
(raycast/picking). The remaining weight is **UI/interaction**. AGENTS.md notes
|
||||||
|
"no DOM-built UI yet (deliberate)" — the editor is where that ends.
|
||||||
|
|
||||||
|
## Editor features (the brainstorm)
|
||||||
|
|
||||||
|
Three feature buckets. For each: what's already there, the real new work, the catch.
|
||||||
|
|
||||||
|
### 1. Sidebar tree — level config + contents
|
||||||
|
|
||||||
|
A **property inspector over the `Level` document.** If the document is clean data,
|
||||||
|
the tree is literally a view of it: each node = a field or an instance; edit a
|
||||||
|
field = mutate the document + re-bake.
|
||||||
|
|
||||||
|
- **World config already exists as data**, just scattered: `SkyConfig`, the
|
||||||
|
`CloudLayer` union (basicCumulus/fancyCumulus + params), `TERRAIN`
|
||||||
|
(amplitude/frequency/peaks/inner/blend), `GROUND_UV`, room dims. Parameterizing =
|
||||||
|
moving these constants into the `Level`.
|
||||||
|
- **The tree forces one categorization: world property vs client look-preset.**
|
||||||
|
`RenderConfig` (internalWidth, colorDepth, dither, lodDistance, filters) is a
|
||||||
|
*per-viewer PS1 dial* — stays client-side. Sky/clouds/terrain = per-map, in the
|
||||||
|
document. But **fog + lighting live in `RenderConfig` today**, and in a shooter
|
||||||
|
fog density = sightlines = a *gameplay/map* property → fog probably moves into the
|
||||||
|
`Level`. Decide the world-vs-viewer line once, up front.
|
||||||
|
- **Re-bake blast radius varies wildly:** sky color = re-run sky, no chunk touch;
|
||||||
|
one tree = re-bake 1–2 chunks; terrain amplitude = re-bake *every* chunk. For a
|
||||||
|
tool, full re-bake per edit is fine to start; scope to dirty cells only if it janks.
|
||||||
|
- **Anti-overengineering line:** "everything parameterized" = *document completeness*
|
||||||
|
(every knob is data — yes). It is **not** a mandate for a generic reflection-UI
|
||||||
|
framework. Hand-wire panels for the handful of config sections; generalize to
|
||||||
|
type-driven widgets only if it hurts.
|
||||||
|
|
||||||
|
### 2. Object tools — add / select / move / rotate
|
||||||
|
|
||||||
|
- **Add** — palette pick + raycast ground (`camera` + `Terrain.height`) → drop an
|
||||||
|
instance record → re-bake. Easy.
|
||||||
|
- **Select / pick** — **no GPU picking needed.** Props bake to anonymous triangles,
|
||||||
|
but the document retains the instance list with positions → **ray-vs-instance-bounds
|
||||||
|
test in JS**, nearest hit wins. (Another reason instances must live on the document.)
|
||||||
|
- **Move** — drag → raycast ground → update `position` → re-bake touched cell(s).
|
||||||
|
Crossing a chunk boundary re-bakes 2 (cell membership is by base position).
|
||||||
|
- **Rotate — the one real engine gap.** Props have **no orientation today** (Tree/
|
||||||
|
Boulder/Bush/Flower carry position + seed + size, no yaw; only *mobs* have
|
||||||
|
`heading`, applied live via `Mat4.compose`). Fix by **separating the per-instance
|
||||||
|
transform from geometry-gen**: `build` emits the instance's **unique local-space**
|
||||||
|
geometry (keep seed/growth = shape), and the **baker applies position + rotation +
|
||||||
|
scale** when appending to the chunk mesh. This mirrors what mobs already do (TRS
|
||||||
|
via `Mat4.compose`) — props become "unique local mesh × transform," baked-in
|
||||||
|
(static) where mobs are live (dynamic). This is the non-trivial refactor the object
|
||||||
|
tools demand; do it early since it touches every prop builder.
|
||||||
|
- **Gizmos** — the rasterizer draws textured tris only, **no line primitive.** A
|
||||||
|
move/rotate gizmo (and selection highlight / wireframe AABB) needs either thin-box
|
||||||
|
tris or a new line-draw path. **Start gizmo-less** (drag on ground = XZ move,
|
||||||
|
scroll/key = yaw; tinted re-draw for selection) and add handles later.
|
||||||
|
|
||||||
|
### 3. Terrain brushes — elevation + texture (the biggest new capability)
|
||||||
|
|
||||||
|
- **Elevation needs a stored, editable heightfield** — the thing that doesn't exist
|
||||||
|
yet. `Terrain.height` is a **pure analytic function** of noise params; a brush has
|
||||||
|
nowhere to write. So: **the `Level` owns a height grid**; procedural gen
|
||||||
|
*initializes* it; brushes edit it directly; `Terrain.height` bilinear-samples the
|
||||||
|
grid. ("Seeding is a tool," made concrete — noise seeds the grid, then you sculpt.)
|
||||||
|
- **Collision comes free** — player + mobs already sample `Terrain.height`, so once
|
||||||
|
it samples the grid, gameplay collision follows. No separate collision bake.
|
||||||
|
- **Welds still hold** — patches weld by sampling shared height at shared world
|
||||||
|
positions; grid-as-source keeps that. Re-bake only the brushed cells.
|
||||||
|
- Side effect: the room "hole" + flat-inner-clearing special cases **dissolve into
|
||||||
|
authored terrain**. An arena may drop the room concept entirely — the map *is* the
|
||||||
|
sculpted terrain.
|
||||||
|
- **Texture brush fits the engine shockingly well** — the chunk baker **already
|
||||||
|
accumulates one mesh per material key** and the renderer draws by DrawGroup list.
|
||||||
|
Ground is one `grass` material today; painting = ground goes to **N materials, each
|
||||||
|
painted tri routed to its material's mesh by a per-vertex/per-cell material id** —
|
||||||
|
the *exact* routing the baker already does for props. **No rasterizer change.** Hard
|
||||||
|
edges between materials (no blend) suit the PS1 look; soft splat-blending would need
|
||||||
|
per-pixel multi-texture rasterizer work — skip it, add later only if missed.
|
||||||
|
|
||||||
|
### Threads that cut across
|
||||||
|
|
||||||
|
1. **The `Level` document gains three responsibilities:** retain instance lists
|
||||||
|
(pick/move), own an editable **height grid** (elevation), own a **ground-material
|
||||||
|
map** (texture paint). All still pure data — the document grew, the architecture
|
||||||
|
didn't strain.
|
||||||
|
2. **One engine refactor unlocks the object tools:** pull per-instance transform
|
||||||
|
(pos/rot/scale) out of prop `build` into the baker — props become "unique local
|
||||||
|
mesh × transform," matching mobs.
|
||||||
|
3. **Terrain flips from function to data** — analytic → stored grid. Biggest single
|
||||||
|
change, but it's what "hand-made maps" *means*, and collision + welds fall out free.
|
||||||
|
4. **Almost none of this needs new *rendering*** — picking is JS, texture paint
|
||||||
|
reuses per-material DrawGroups, terrain is more patch bake. The only genuinely new
|
||||||
|
render bit is small + optional: **line-draw for gizmos/selection**.
|
||||||
|
|
||||||
|
## Why the architecture already fits (the two bets that pre-paid for this)
|
||||||
|
|
||||||
|
- **Deterministic bake** — the world is byte-identical from a `Level` every run →
|
||||||
|
multiplayer world-sync is nearly free (ship the document, everyone bakes the same).
|
||||||
|
- **DOM-free `engine`/`game`, layering test-enforced** — the authoritative server
|
||||||
|
runs `bake(level)` + sim with no renderer; `app/` is client-only glue. The layering
|
||||||
|
seam already enforced (`tests/layering.test.ts`) *is* the client/server seam. Keep
|
||||||
|
the `Level` document + `bake` in `game/`; editor UI in `app/`; server imports `game/`.
|
||||||
|
|
||||||
|
## Cheap hedges (do now, save pain later)
|
||||||
|
|
||||||
|
- The `Level` document is **pure serializable data** — no closures, no behavior baked
|
||||||
|
in (behavior stays code, imported per-side, as `Entity` already does it).
|
||||||
|
- A `Level` has an **id/name** — "load level X" is a reference; later persistence keys
|
||||||
|
off it for free.
|
||||||
|
- **Player state stays out of the `Level`/`Scene`** (already true) — carry-over later
|
||||||
|
touches the player, never the map.
|
||||||
|
- `bake` eats the **explicit** document as canonical — keeps the recipe-vs-explicit
|
||||||
|
network choice open.
|
||||||
|
- **No hardcoded buffer sizes** tied to today's world — mob/framebuffer sizing already
|
||||||
|
re-runs on `setup`; keep it so odd-shaped match maps just work.
|
||||||
|
|
||||||
|
## Current-state facts the next session will need
|
||||||
|
|
||||||
|
- `buildLevel(textures): Level` (`game/level.ts:175`) is the single entry, called once
|
||||||
|
in `app/main.ts`. Today's `Level` (`game/level.ts:69-81`) is a **bake result**
|
||||||
|
(chunks/meshes/colliders/mobs) — under the new naming it becomes part of **`Scene`**;
|
||||||
|
the authored **`Level` document** is the missing *input* half. `buildLevel` becomes
|
||||||
|
`bake(level, textures) → Scene`.
|
||||||
|
- Placement (`placeTrees/placeBoulders/placeBushes/placeFlowers/placeMobs`,
|
||||||
|
`game/level.ts:359+`) is procedural from fixed seeds + counts, fully deterministic.
|
||||||
|
These become the **generator** (emit a `Level`) rather than running inside the bake.
|
||||||
|
- **Prop instances are discarded after bake** — only anonymous triangles survive in
|
||||||
|
per-chunk per-material meshes (`buildChunks`, `game/level.ts:251`). No `level.trees`
|
||||||
|
to iterate. The editor edits the **document**, then re-bakes — never mutates baked
|
||||||
|
meshes in place.
|
||||||
|
- **Mobs are the exception** — retained live, simulated each frame, crossing to workers
|
||||||
|
as packed floats. Add/move/delete is cheap, no worker resync (caveat: mob shared
|
||||||
|
buffer sized once to `scene.mobCount` in `app/renderer.ts` — growing past it needs a
|
||||||
|
re-`setup`).
|
||||||
|
- Instance shapes are already small plain records (position + seed + kind/size/color/
|
||||||
|
growth) — editor-friendly, directly serializable. See
|
||||||
|
`game/actors/{Tree,Boulder,Bush,Flower,Mob}.ts`.
|
||||||
|
- Prop geometry currently bakes **position into world-space verts inside `build`**, with
|
||||||
|
seed/growth driving unique shape — hence the "separate transform from geometry-gen"
|
||||||
|
refactor needed for rotate/scale (see Object tools).
|
||||||
|
- `Terrain.height` (`game/Terrain.ts`) is a **pure analytic function**, no stored
|
||||||
|
heightfield — hence the elevation-brush needs a stored grid (see Terrain brushes).
|
||||||
|
- The render `Scene` (`renderScene.ts:19`) is the **render-only subset** already; the
|
||||||
|
chunk baker already routes geometry to **one mesh per material key** — the mechanism
|
||||||
|
the texture brush reuses.
|
||||||
|
- Workers hold a **one-time structured clone** of the render subset from init
|
||||||
|
(`app/renderer.ts` `setup`); per-frame only camera/matrix/visible/mob buffers cross.
|
||||||
|
Chunk-geometry edits reach workers only via a re-send (`reconfigure`→`setup`, respawns
|
||||||
|
them — the same path keys 1/2/3 use). Fine for a mode-switch re-bake; would be the wall
|
||||||
|
for live in-match editing, which we are **not** doing.
|
||||||
|
|
||||||
|
## Explicitly out of scope / dropped
|
||||||
|
|
||||||
|
Portals & in-world transitions · persistence · player carry-over · procedural as the
|
||||||
|
primary map path · any live-edit-during-a-multiplayer-match capability · GPU picking ·
|
||||||
|
soft terrain-texture blending (hard-edged materials first).
|
||||||
155
AGENTS.md
155
AGENTS.md
|
|
@ -26,8 +26,14 @@ rules live in `.agents/rules/*.md`.
|
||||||
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
||||||
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
||||||
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
||||||
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
|
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the
|
||||||
all browser glue (canvas, input, image decode) lives in `app/`.
|
reusable, content-agnostic **mechanism** (rasterizer, framebuffer, culling, the
|
||||||
|
`Actor`/`Material` interfaces) — no DOM, no game content, could run server-side.
|
||||||
|
`game/` is **this game's content** (the creatures/props, terrain, level, scene
|
||||||
|
assembly + render orchestration) built on the engine interfaces. `app/` is
|
||||||
|
**browser glue** (canvas, input, image decode, the worker render driver + loop).
|
||||||
|
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
|
||||||
|
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
|
||||||
|
|
||||||
## Stack & tooling
|
## Stack & tooling
|
||||||
|
|
||||||
|
|
@ -48,15 +54,16 @@ rules live in `.agents/rules/*.md`.
|
||||||
- `bun run bench:browser` — Playwright: drive headless Chromium through the
|
- `bun run bench:browser` — Playwright: drive headless Chromium through the
|
||||||
`?bench=st`/`?bench=mt` flythrough, print single-thread vs worker frame timings
|
`?bench=st`/`?bench=mt` flythrough, print single-thread vs worker frame timings
|
||||||
(median/p95/max). The real-browser profiler; run it on the target machine.
|
(median/p95/max). The real-browser profiler; run it on the target machine.
|
||||||
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
|
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
|
||||||
this**, not `bun run check` (see Caveats).
|
this**, not `bun run check` (see Caveats).
|
||||||
- `bunx oxlint engine app` — lint.
|
- `bunx oxlint engine game app` — lint.
|
||||||
- `bun test` — tests (none yet).
|
- `bun test` — tests (registry id-order + engine↛game layering guards).
|
||||||
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
|
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `engine/` — headless engine, consumed by `app/` via tsconfig project ref.
|
- `engine/` — content-agnostic mechanism (no DOM, no game content); consumed by
|
||||||
|
`game/` then `app/` via tsconfig project refs.
|
||||||
- `math/` — `Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
|
- `math/` — `Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
|
||||||
- `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order),
|
- `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order),
|
||||||
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
|
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
|
||||||
|
|
@ -69,74 +76,70 @@ rules live in `.agents/rules/*.md`.
|
||||||
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
||||||
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
|
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
|
||||||
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
||||||
(Y-axis billboard), `Terrain` (procedural
|
(Y-axis billboard), `Actor` (the generic `Entity<State, World>` interface —
|
||||||
heightfield around the room: flat clearing in the center, rolling hills, tall
|
build + update + bounds — that a content kind implements; the engine dispatches
|
||||||
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
|
through it, never a `kind` switch).
|
||||||
per chunk, aligned so patches weld crack-free, with a hole for the room;
|
- `game/` — this game's content + world assembly, on the engine interfaces
|
||||||
`Terrain.height` is the shared ground-height sampler for the player), `Actor`
|
(headless: no DOM, imports nothing from `app`).
|
||||||
(the `Entity` interface — geometry + behavior + bounds — that each mob kind
|
- `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops
|
||||||
implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a
|
the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a
|
||||||
`growth` knob; each species a `TreeSpecies` in `trees/<Kind>.ts`, assembled by a
|
short powered flight — the only moving geometry; each kind an `Entity` in
|
||||||
registry — see the Trees section), `Boulder`
|
`mobs/<Kind>.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry.
|
||||||
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
Its local-space mesh is built once per kind; `Mob.update` steps the wander AI
|
||||||
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
|
(leashed to a home anchor, deterministic per evolving `seed`) each frame and the
|
||||||
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
live `position`/`heading`/`scale` become a per-frame model matrix at draw.
|
||||||
samples a 2x2 color-atlas texture, drawn double-sided), `Mob` (a **roaming**
|
`MOB_KINDS` is the SAB id order). `Tree` (oak/spruce/birch, each a `TreeSpecies`
|
||||||
creature — `frog` hops the ground, `bee` hovers/darts, `robin` mostly hops but
|
in `trees/<Kind>.ts` + `trees/treekit.ts` — see the Trees section), `Boulder`
|
||||||
now and then takes a short powered flight — the engine's only moving geometry.
|
(squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the
|
||||||
Unlike the baked props, a mob's low-poly mesh is built once per kind in **local
|
leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props
|
||||||
space**; `Mob.build` bakes the canonical meshes, `Mob.update` steps the wander
|
append into shared per-material meshes; mobs draw live.
|
||||||
AI (leashed to a home anchor, deterministic via an evolving per-mob seed) each
|
- `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling
|
||||||
frame, and the live `position`/`heading`/`scale` become a per-frame model matrix
|
hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle
|
||||||
at draw time. New kinds extend the `MobKind` union + `MOB_KINDS` order).
|
(per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared
|
||||||
- `app/` — browser glue.
|
ground sampler for the player + mobs.
|
||||||
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
|
||||||
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS
|
|
||||||
upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync
|
|
||||||
interval, visible chunks / LOD-aware tri count — the real profiler in play).
|
|
||||||
Also owns the **mob sim**: each frame it steps `Mob.update` for every mob,
|
|
||||||
rebuilds the near-player mob colliders into `level.colliders`, and culls the
|
|
||||||
mobs (`visibleMobs`) so only the visible transforms get dispatched.
|
|
||||||
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it
|
|
||||||
runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a
|
|
||||||
`SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a
|
|
||||||
lock-free `Atomics` barrier; otherwise it renders inline. `dispatch`/`done`
|
|
||||||
are non-blocking so the caller paces on rAF. Per-frame inputs ride shared
|
|
||||||
arrays: camera/matrix/visible-chunk list, plus the visible **mob transforms**
|
|
||||||
(`mobState`, count in the `MOBVIS` control slot). `?bench=st|mt` A/Bs the paths.
|
|
||||||
- `renderScene.ts` — `renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
|
|
||||||
source of render truth (sky + room + culled chunks + sprite + roaming mobs +
|
|
||||||
quantize for a row band). Used full-height by the inline path, per-band by each
|
|
||||||
worker. `Scene` bundles the static meshes/textures (incl. the two canonical mob
|
|
||||||
meshes) so it clones to a worker whole; each mob is drawn double-sided through
|
|
||||||
its own `viewProj × Mat4.compose(...)` model matrix, and `visibleMobs`
|
|
||||||
frustum-culls the moving mobs per frame.
|
|
||||||
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
|
||||||
are already the `Color` layout).
|
|
||||||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
||||||
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
||||||
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
||||||
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
|
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
|
||||||
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs
|
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs
|
||||||
scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move,
|
scattered (`placeMobs`; `FROG_/BEE_/ROBIN_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs
|
||||||
so they carry no baked colliders), then `buildChunks` bakes
|
move, so no baked colliders), then `buildChunks` bakes terrain + props into a
|
||||||
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a
|
`CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a tight AABB + two
|
||||||
tight AABB + two `DrawGroup[]` lists, `near`/`far`, where a `DrawGroup` is a baked
|
`DrawGroup[]` lists `near`/`far`; the baker accumulates one mesh per **material
|
||||||
mesh + its `Material` = texture + cull; the renderer just loops them and knows no
|
key** (`MAT_ORDER`) and routes each prop by its declared material, so it names no
|
||||||
content by name) that `main` frustum-culls; bushes fold into the leaf mesh,
|
texture) that `main` frustum-culls. Trees + boulders bake **twice** — full into
|
||||||
flowers get their own (double-sided) group. Trees + boulders are baked **twice** —
|
`near`, a low-poly impostor into `far` — so a far chunk swaps to the cheap set
|
||||||
full geometry into `near` and a low-poly impostor into `far` (via the builders'
|
with no per-frame work (`chunkFar` / `RenderConfig.lodDistance`).
|
||||||
`lod` arg) — so a far chunk swaps to the cheap group set with no per-frame work
|
`buildLevel(textures)` binds the ground/prop `Material`s once + shares them.
|
||||||
(see `chunkFar` / `RenderConfig.lodDistance`). `buildLevel(textures)` binds the
|
Also: `Aabb` colliders, NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
|
||||||
ground/prop materials once and shares them across chunks. `Aabb` colliders (walls, crate, grown trunks, big
|
sky/cloud config, `FLOOR_LIFT` (a z-bias lifting the stone floor over the terrain
|
||||||
boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
|
skirt). Room surfaces are single flat quads (texturing is perspective-correct).
|
||||||
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
- `renderScene.ts` — `renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
|
||||||
are single flat quads -- no subdivision needed since texturing is
|
source of render truth (sky + room + culled chunk draw-groups + sprite + roaming
|
||||||
perspective-correct.
|
mobs + quantize for a row band). Used full-height by the inline path, per-band by
|
||||||
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run
|
each worker. `Scene` bundles the static meshes/textures (incl. the canonical mob
|
||||||
(`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast
|
meshes) so it clones to a worker whole; each mob draws double-sided through its
|
||||||
running can't tunnel walls; ground height from `Terrain.height` (plus
|
own `viewProj × Mat4.compose(...)` model matrix, and `visibleChunks`/`visibleMobs`
|
||||||
standable AABBs).
|
frustum-cull per frame. `textures.ts` holds the `Textures` palette type.
|
||||||
|
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run (`RUN_MULTIPLIER`)
|
||||||
|
+ circle-vs-AABB/-circle collision, substepped so fast running can't tunnel
|
||||||
|
walls; ground height from `Terrain.height` (plus standable AABBs).
|
||||||
|
- `app/` — browser glue only (top layer; depends on `game` + `engine`).
|
||||||
|
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
||||||
|
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale)
|
||||||
|
+ a multi-line frame HUD (`work + present` critical-path ms, vsync, visible
|
||||||
|
chunks / LOD-aware tris). Owns the **mob sim**: steps `Mob.update` for every mob,
|
||||||
|
rebuilds near-player mob colliders into `level.colliders`, culls mobs
|
||||||
|
(`visibleMobs`) so only visible transforms dispatch.
|
||||||
|
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it runs
|
||||||
|
a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a `SharedArrayBuffer`
|
||||||
|
framebuffer, each owning a disjoint row band, synced by a lock-free `Atomics`
|
||||||
|
barrier; otherwise inline. `dispatch`/`done` are non-blocking so the caller paces
|
||||||
|
on rAF. Per-frame inputs ride shared arrays: camera/matrix/visible-chunk list +
|
||||||
|
visible **mob transforms** (`mobState`, count in `MOBVIS`). `?bench=st|mt` A/Bs
|
||||||
|
the paths.
|
||||||
|
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are
|
||||||
|
already the `Color` layout); returns the `game` `Textures` palette.
|
||||||
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
|
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
|
||||||
`#fps` meter div (styled inline).
|
`#fps` meter div (styled inline).
|
||||||
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
||||||
|
|
@ -206,10 +209,10 @@ front-out — a culled mesh that renders inside-out has its index order flipped
|
||||||
(well inside `fog.far`, so far detail is already fog-dimmed at the switch),
|
(well inside `fog.far`, so far detail is already fog-dimmed at the switch),
|
||||||
`Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop),
|
`Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop),
|
||||||
raise it for more far detail (more tris).
|
raise it for more far detail (more tris).
|
||||||
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
- **`game/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
||||||
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
||||||
(there are no mipmaps); higher = finer but shimmerier.
|
(there are no mipmaps); higher = finer but shimmerier.
|
||||||
- **`app/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
|
- **`game/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
|
||||||
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
|
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
|
||||||
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
|
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
|
||||||
per-cell tests/bounds. This is the lever if a dense world still lags.
|
per-cell tests/bounds. This is the lever if a dense world still lags.
|
||||||
|
|
@ -272,12 +275,12 @@ by a `CloudLayer` discriminated union `kind`:
|
||||||
- **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume,
|
- **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume,
|
||||||
~5 lookups/pixel (pricier; watch the FPS meter).
|
~5 lookups/pixel (pricier; watch the FPS meter).
|
||||||
|
|
||||||
Both are exported presets in `app/level.ts`; the active one is set in
|
Both are exported presets in `game/level.ts`; the active one is set in
|
||||||
`buildLevel`'s `sky.clouds`. Add new cloud types by extending the union and
|
`buildLevel`'s `sky.clouds`. Add new cloud types by extending the union and
|
||||||
branching in the cloud shader. Cost scales with sky resolution — fine at
|
branching in the cloud shader. Cost scales with sky resolution — fine at
|
||||||
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
|
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
|
||||||
|
|
||||||
## Trees (`engine/scene/Tree.ts` + `engine/scene/trees/`)
|
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
|
||||||
|
|
||||||
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
|
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
|
||||||
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
|
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
|
||||||
|
|
@ -298,13 +301,13 @@ canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble
|
||||||
A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
|
A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
|
||||||
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
|
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
|
||||||
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
|
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
|
||||||
still batches into a few draw calls and the baker names no texture. `app/level.ts`
|
still batches into a few draw calls and the baker names no texture. `game/level.ts`
|
||||||
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
|
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
|
||||||
`trees/<Kind>.ts` module (its geometry + material keys) + one entry in the `Tree`
|
`trees/<Kind>.ts` module (its geometry + material keys) + one entry in the `Tree`
|
||||||
registry; only a genuinely new material also needs a `Material` in `buildLevel` +
|
registry; only a genuinely new material also needs a `Material` in `buildLevel` +
|
||||||
its key in `MAT_ORDER`.
|
its key in `MAT_ORDER`.
|
||||||
|
|
||||||
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
|
**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build`
|
||||||
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
|
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
|
||||||
never cracks) into one shared rock mesh, sunk partway into the ground.
|
never cracks) into one shared rock mesh, sunk partway into the ground.
|
||||||
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the
|
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the
|
||||||
|
|
@ -314,7 +317,7 @@ colliders; add a new prop type by cloning the pattern (generator + scatter).
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse
|
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `game/player.ts`) · mouse
|
||||||
look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look
|
look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look
|
||||||
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
|
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
|
||||||
the terrain and toward the peaks.
|
the terrain and toward the peaks.
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -7,5 +7,15 @@
|
||||||
|
|
||||||
Start to make cohesive game:
|
Start to make cohesive game:
|
||||||
|
|
||||||
- Split apart the "engine" and "game"
|
- [x] Split apart the "engine" and "game"
|
||||||
|
- `Level`
|
||||||
|
- Configurable everything
|
||||||
|
- Terrain
|
||||||
|
- Level editor?
|
||||||
|
- Start thinking about MP
|
||||||
|
- Fixes
|
||||||
|
- 1, 2, 3 stopped working
|
||||||
|
- "main.ts" is huge - separate it out. most of whats in here should be captured under the domain term "Client". ideally, architecturally aiming for the client consumes the game which consumes the engine.
|
||||||
|
- Take HMR seriously, start building it in to the big changes
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Texture } from "../engine/render/Texture"
|
import type { Texture } from "../engine/render/Texture"
|
||||||
|
import type { Textures } from "../game/textures"
|
||||||
import barkUrl from "../assets/bark.png"
|
import barkUrl from "../assets/bark.png"
|
||||||
import beeUrl from "../assets/bee.png"
|
import beeUrl from "../assets/bee.png"
|
||||||
import birchUrl from "../assets/birch.png"
|
import birchUrl from "../assets/birch.png"
|
||||||
|
|
@ -12,28 +13,12 @@ import needleUrl from "../assets/needle.png"
|
||||||
import npcUrl from "../assets/npc.png"
|
import npcUrl from "../assets/npc.png"
|
||||||
import robinUrl from "../assets/robin.png"
|
import robinUrl from "../assets/robin.png"
|
||||||
import rockUrl from "../assets/rock.png"
|
import rockUrl from "../assets/rock.png"
|
||||||
|
import skyboxUrl from "../assets/rockies.skybox.png"
|
||||||
import wallUrl from "../assets/wall.png"
|
import wallUrl from "../assets/wall.png"
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load every game texture up front. Call once before starting the loop. */
|
/** Load every game texture up front. Call once before starting the loop. */
|
||||||
export async function loadTextures(): Promise<Textures> {
|
export async function loadTextures(): Promise<Textures> {
|
||||||
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin] = await Promise.all([
|
const [floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin, skybox] = await Promise.all([
|
||||||
loadTexture(floorUrl),
|
loadTexture(floorUrl),
|
||||||
loadTexture(grassUrl),
|
loadTexture(grassUrl),
|
||||||
loadTexture(barkUrl),
|
loadTexture(barkUrl),
|
||||||
|
|
@ -48,8 +33,9 @@ export async function loadTextures(): Promise<Textures> {
|
||||||
loadTexture(frogUrl),
|
loadTexture(frogUrl),
|
||||||
loadTexture(beeUrl),
|
loadTexture(beeUrl),
|
||||||
loadTexture(robinUrl),
|
loadTexture(robinUrl),
|
||||||
|
loadTexture(skyboxUrl),
|
||||||
])
|
])
|
||||||
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin }
|
return { floor, grass, bark, birch, leaf, needle, rock, flower, wall, crate, npc, frog, bee, robin, skybox }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTexture(url: string): Promise<Texture> {
|
function loadTexture(url: string): Promise<Texture> {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Camera } from "../engine/scene/Camera"
|
import { Camera } from "../engine/scene/Camera"
|
||||||
import type { Mesh } from "../engine/scene/Mesh"
|
import type { Mesh } from "../engine/scene/Mesh"
|
||||||
import { Mob, MOB_KINDS, type MobKind } from "../engine/scene/Mob"
|
import { Mob, MOB_KINDS, type MobKind } from "../game/actors/Mob"
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
import type { Vec3 } from "../engine/math/Vec3"
|
||||||
import { loadTextures } from "./assets"
|
import { loadTextures } from "./assets"
|
||||||
import { buildLevel, type Level } from "./level"
|
import { buildLevel, type Level } from "../game/level"
|
||||||
import { EYE_HEIGHT, Player } from "./player"
|
import { EYE_HEIGHT, Player } from "../game/player"
|
||||||
import { createRenderer } from "./renderer"
|
import { createRenderer } from "./renderer"
|
||||||
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
|
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "../game/renderScene"
|
||||||
|
|
||||||
const FOV_DEGREES = 75
|
const FOV_DEGREES = 75
|
||||||
const FOV = (FOV_DEGREES * Math.PI) / 180
|
const FOV = (FOV_DEGREES * Math.PI) / 180
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { MOB_KINDS } from "../engine/scene/Mob"
|
import { MOB_KINDS } from "../game/actors/Mob"
|
||||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
|
||||||
|
|
||||||
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
|
||||||
* scene, this worker's row band, and its index into the per-worker times array. */
|
* scene, this worker's row band, and its index into the per-worker times array. */
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import type { Mat4 } from "../engine/math/Mat4"
|
import type { Mat4 } from "../engine/math/Mat4"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
import type { Camera } from "../engine/scene/Camera"
|
||||||
import { MOB_KINDS } from "../engine/scene/Mob"
|
import { MOB_KINDS } from "../game/actors/Mob"
|
||||||
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
|
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene"
|
||||||
|
|
||||||
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
/** Clouds are drawn at 1/SKY_STEP resolution (the sky base + sun stay per-pixel);
|
||||||
|
* band splits align to it so the cloud block grid stays seamless across workers. */
|
||||||
const SKY_STEP = 2
|
const SKY_STEP = 2
|
||||||
/** Use worker threads when the page can share memory (else single-thread). */
|
/** Use worker threads when the page can share memory (else single-thread). */
|
||||||
const ENABLE_WORKERS = true
|
const ENABLE_WORKERS = true
|
||||||
|
|
|
||||||
BIN
assets/rockies.skybox.png
Normal file
BIN
assets/rockies.skybox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
|
|
@ -57,8 +57,8 @@ export type RenderConfig = {
|
||||||
* color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */
|
* color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */
|
||||||
export namespace RenderConfig {
|
export namespace RenderConfig {
|
||||||
export const standard: RenderConfig = {
|
export const standard: RenderConfig = {
|
||||||
internalWidth: 640,
|
internalWidth: 384,
|
||||||
internalHeight: 360,
|
internalHeight: 216,
|
||||||
upscaleFilter: "nearest",
|
upscaleFilter: "nearest",
|
||||||
colorDepth: 5,
|
colorDepth: 5,
|
||||||
dither: 1,
|
dither: 1,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Color } from "./Color"
|
||||||
import type { Framebuffer } from "./Framebuffer"
|
import type { Framebuffer } from "./Framebuffer"
|
||||||
import { Camera } from "../scene/Camera"
|
import { Camera } from "../scene/Camera"
|
||||||
import { Vec3 } from "../math/Vec3"
|
import { Vec3 } from "../math/Vec3"
|
||||||
|
import { Texture } from "./Texture"
|
||||||
|
|
||||||
/** Fields shared by every cumulus style. */
|
/** Fields shared by every cumulus style. */
|
||||||
export type CumulusBase = {
|
export type CumulusBase = {
|
||||||
|
|
@ -35,7 +36,18 @@ export type FancyCumulus = CumulusBase & {
|
||||||
* branching on `kind` in the cloud shader. */
|
* branching on `kind` in the cloud shader. */
|
||||||
export type CloudLayer = BasicCumulus | FancyCumulus
|
export type CloudLayer = BasicCumulus | FancyCumulus
|
||||||
|
|
||||||
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds. */
|
/** An equirectangular panorama used as the sky's base color in place of the
|
||||||
|
* vertical gradient. The sun glow and clouds still layer over it. Set it on
|
||||||
|
* `SkyConfig.skybox` to switch a level over. */
|
||||||
|
export type Skybox = {
|
||||||
|
texture: Texture
|
||||||
|
/** Azimuth offset in turns (0..1) to spin the panorama to taste. Default 0. */
|
||||||
|
yaw?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds.
|
||||||
|
* If `skybox` is set, the panorama replaces the gradient base while the sun and
|
||||||
|
* clouds still draw over it. */
|
||||||
export type SkyConfig = {
|
export type SkyConfig = {
|
||||||
zenith: Color
|
zenith: Color
|
||||||
horizon: Color
|
horizon: Color
|
||||||
|
|
@ -45,9 +57,17 @@ export type SkyConfig = {
|
||||||
/** Angular radius of the sun's core, in radians. */
|
/** Angular radius of the sun's core, in radians. */
|
||||||
sunSize: number
|
sunSize: number
|
||||||
clouds: CloudLayer | null
|
clouds: CloudLayer | null
|
||||||
|
/** Optional equirectangular panorama; when present, replaces the gradient
|
||||||
|
* base color (sun + clouds still layer over it). */
|
||||||
|
skybox?: Skybox
|
||||||
}
|
}
|
||||||
|
|
||||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
||||||
|
const INV_TAU = 1 / (2 * Math.PI)
|
||||||
|
const INV_PI = 1 / Math.PI
|
||||||
|
/** Keep equirect V a hair off the exact poles: Texture wraps V, so a ray pointing
|
||||||
|
* straight up/down would otherwise blend the panorama's top row into its bottom. */
|
||||||
|
const POLE_EPS = 1e-3
|
||||||
|
|
||||||
export namespace Sky {
|
export namespace Sky {
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,14 +75,16 @@ export namespace Sky {
|
||||||
* frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
|
* frame in place of Framebuffer.clear; opaque geometry then overwrites the sky
|
||||||
* wherever it is nearer. `time` (seconds) drives cloud motion.
|
* wherever it is nearer. `time` (seconds) drives cloud motion.
|
||||||
*
|
*
|
||||||
* Per pixel it reconstructs the view ray from the camera basis, shades a
|
* Per pixel it reconstructs the view ray from the camera basis, shades the
|
||||||
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
|
* base (the equirect panorama if `sky.skybox` is set, else a horizon->zenith
|
||||||
* `sunDir`, then lays crisp-edged cumulus over the top.
|
* gradient), brightens toward `sun` near `sunDir`, then composites cumulus
|
||||||
|
* over the top.
|
||||||
*
|
*
|
||||||
* `step` (>= 1) renders the sky at 1/step resolution: the expensive shading
|
* The base + sun are shaded per pixel so they stay crisp. `step` (>= 1) only
|
||||||
* (the per-pixel cloud fbm dominates the frame) runs once per step x step
|
* lowers the *cloud* resolution: the cloud fbm dominates the frame, so it is
|
||||||
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
|
* sampled once per step x step block and composited across it. Clouds are
|
||||||
* visually and quarters the cloud cost; 1 is full resolution.
|
* low-frequency, so 2 is nearly free visually and quarters the cloud cost; 1
|
||||||
|
* is full cloud resolution.
|
||||||
*/
|
*/
|
||||||
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
|
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
|
||||||
const { width, height, color, depth } = fb
|
const { width, height, color, depth } = fb
|
||||||
|
|
@ -75,19 +97,50 @@ export namespace Sky {
|
||||||
const sun = Vec3.normalize(sky.sunDir)
|
const sun = Vec3.normalize(sky.sunDir)
|
||||||
const cosSun = Math.cos(sky.sunSize)
|
const cosSun = Math.cos(sky.sunSize)
|
||||||
const clouds = sky.clouds
|
const clouds = sky.clouds
|
||||||
|
const skybox = sky.skybox ?? null
|
||||||
|
const skyboxYaw = skybox?.yaw ?? 0
|
||||||
const cloud: CloudSample = { cover: 0, shade: 1 }
|
const cloud: CloudSample = { cover: 0, shade: 1 }
|
||||||
const s = Math.max(1, step | 0)
|
const s = Math.max(1, step | 0)
|
||||||
// Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block
|
// The cheap base (skybox/gradient + sun) is shaded per pixel so it stays
|
||||||
// grid stays global and neighboring bands don't seam.
|
// crisp; only the pricey cloud fbm is amortized -- sampled once per `s`x`s`
|
||||||
|
// block and composited over every pixel in it. So `step` lowers cloud
|
||||||
|
// resolution, not the whole sky. Bands must be step-aligned (callers ensure
|
||||||
|
// it) so the cloud block grid stays global and neighboring bands don't seam.
|
||||||
for (let by = y0; by < bottom; by += s) {
|
for (let by = y0; by < bottom; by += s) {
|
||||||
// Shade at the block center, then flood the whole block with that color.
|
|
||||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
|
||||||
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
|
||||||
const yEnd = Math.min(bottom, by + s)
|
const yEnd = Math.min(bottom, by + s)
|
||||||
|
// Block-center elevation, used only for the shared cloud sample.
|
||||||
|
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||||
|
const ndcYc = 1 - ((sampleY + 0.5) / height) * 2
|
||||||
for (let bx = 0; bx < width; bx += s) {
|
for (let bx = 0; bx < width; bx += s) {
|
||||||
|
const xEnd = Math.min(width, bx + s)
|
||||||
|
// Sample the clouds once for the block, from the block-center ray.
|
||||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||||
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
|
const ndcXc = ((sampleX + 0.5) / width) * 2 - 1
|
||||||
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
let cdx = forward.x + right.x * ndcXc * tanX + up.x * ndcYc * tanY
|
||||||
|
let cdy = forward.y + right.y * ndcXc * tanX + up.y * ndcYc * tanY
|
||||||
|
let cdz = forward.z + right.z * ndcXc * tanX + up.z * ndcYc * tanY
|
||||||
|
const cinv = 1 / Math.hypot(cdx, cdy, cdz)
|
||||||
|
cdx *= cinv
|
||||||
|
cdy *= cinv
|
||||||
|
cdz *= cinv
|
||||||
|
cloud.cover = 0
|
||||||
|
cloud.shade = 1
|
||||||
|
if (clouds !== null && cdy > 0.02) {
|
||||||
|
if (clouds.kind === "fancy") {
|
||||||
|
fancyCumulus(cdx, cdy, cdz, clouds, time, sun, cloud)
|
||||||
|
} else {
|
||||||
|
basicCumulus(cdx, cdy, cdz, clouds, time, cloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cover = cloud.cover
|
||||||
|
const cloudColor = clouds !== null ? Color.scale(clouds.color, cloud.shade) : 0
|
||||||
|
// Per-pixel base: reconstruct this pixel's ray, shade the panorama (or
|
||||||
|
// gradient) + sun, then composite the block's shared cloud on top.
|
||||||
|
for (let y = by; y < yEnd; y++) {
|
||||||
|
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||||
|
const o = y * width
|
||||||
|
for (let x = bx; x < xEnd; x++) {
|
||||||
|
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||||
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
||||||
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
||||||
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
||||||
|
|
@ -95,28 +148,27 @@ export namespace Sky {
|
||||||
dx *= inv
|
dx *= inv
|
||||||
dy *= inv
|
dy *= inv
|
||||||
dz *= inv
|
dz *= inv
|
||||||
|
let c: Color
|
||||||
|
if (skybox !== null) {
|
||||||
|
// Equirectangular lookup: azimuth -> u (wraps at the seam, which
|
||||||
|
// Texture.sample handles), elevation -> v, clamped off the poles
|
||||||
|
// (Texture also wraps V, which would smear top into bottom).
|
||||||
|
const u = Math.atan2(dx, -dz) * INV_TAU + 0.5 + skyboxYaw
|
||||||
|
const lat = Math.acos(Math.max(-1, Math.min(1, dy))) * INV_PI
|
||||||
|
const v = Math.min(1 - POLE_EPS, Math.max(POLE_EPS, lat))
|
||||||
|
c = Texture.sample(skybox.texture, u, v, "linear")
|
||||||
|
} else {
|
||||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||||
const t = Math.max(0, Math.min(1, dy))
|
c = Color.lerp(sky.horizon, sky.zenith, Math.max(0, Math.min(1, dy)))
|
||||||
let c = Color.lerp(sky.horizon, sky.zenith, t)
|
}
|
||||||
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
||||||
if (facing > cosSun) {
|
if (facing > cosSun) {
|
||||||
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||||
c = Color.lerp(c, sky.sun, glow)
|
c = Color.lerp(c, sky.sun, glow)
|
||||||
}
|
}
|
||||||
if (clouds !== null && dy > 0.02) {
|
if (cover > 0) {
|
||||||
if (clouds.kind === "fancy") {
|
c = Color.lerp(c, cloudColor, cover)
|
||||||
fancyCumulus(dx, dy, dz, clouds, time, sun, cloud)
|
|
||||||
} else {
|
|
||||||
basicCumulus(dx, dy, dz, clouds, time, cloud)
|
|
||||||
}
|
}
|
||||||
if (cloud.cover > 0) {
|
|
||||||
c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const xEnd = Math.min(width, bx + s)
|
|
||||||
for (let y = by; y < yEnd; y++) {
|
|
||||||
const o = y * width
|
|
||||||
for (let x = bx; x < xEnd; x++) {
|
|
||||||
color[o + x] = c
|
color[o + x] = c
|
||||||
depth[o + x] = 0
|
depth[o + x] = 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { STRIDE, type Mesh } from "./Mesh"
|
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
||||||
|
|
||||||
/** A procedural heightfield surrounding the room. It is the single source of
|
/** 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
|
* ground height: the outdoor mesh is built from it and the player stands on the
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "./Mesh"
|
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
|
||||||
|
|
||||||
const TAU = Math.PI * 2
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "./Mesh"
|
import { STRIDE, type Mesh } from "../../engine/scene/Mesh"
|
||||||
|
|
||||||
const TAU = Math.PI * 2
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import { Mesh } from "./Mesh"
|
import { Mesh } from "../../engine/scene/Mesh"
|
||||||
|
|
||||||
const TAU = Math.PI * 2
|
const TAU = Math.PI * 2
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Terrain } from "./Terrain"
|
import type { Terrain } from "../Terrain"
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import type { Mesh } from "./Mesh"
|
import type { Mesh } from "../../engine/scene/Mesh"
|
||||||
import type { Entity } from "./Actor"
|
import type { Entity } from "../../engine/scene/Actor"
|
||||||
import { frog } from "./mobs/Frog"
|
import { frog } from "./mobs/Frog"
|
||||||
import { bee } from "./mobs/Bee"
|
import { bee } from "./mobs/Bee"
|
||||||
import { robin } from "./mobs/Robin"
|
import { robin } from "./mobs/Robin"
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Vec3 } from "../math/Vec3"
|
import type { Vec3 } from "../../engine/math/Vec3"
|
||||||
import type { Mesh } from "./Mesh"
|
import type { Mesh } from "../../engine/scene/Mesh"
|
||||||
import { oak } from "./trees/Oak"
|
import { oak } from "./trees/Oak"
|
||||||
import { spruce } from "./trees/Spruce"
|
import { spruce } from "./trees/Spruce"
|
||||||
import { birch } from "./trees/Birch"
|
import { birch } from "./trees/Birch"
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import { Terrain } from "../Terrain"
|
import { Terrain } from "../../Terrain"
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Mob } from "../Mob"
|
||||||
import type { Entity } from "../Actor"
|
import type { Entity } from "../../../engine/scene/Actor"
|
||||||
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
|
import { ellipsoid, nextRand, ovoidZ, wanderHeading, wing } from "./mobkit"
|
||||||
|
|
||||||
// Everything about the bee: small, hovers and darts through the air, wings out.
|
export const bee: Entity<Mob, Terrain> = {
|
||||||
|
name: "bee",
|
||||||
|
build,
|
||||||
|
update,
|
||||||
|
boundingRadius: 0.5,
|
||||||
|
bodyHeight: 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
const LEASH = 6
|
const LEASH = 6
|
||||||
const SPEED = 1.7
|
const SPEED = 1.7
|
||||||
|
|
@ -36,5 +42,3 @@ function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||||
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
|
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
|
||||||
mob.position.y = ground + HOVER + Math.sin(mob.phase * BOB_FREQ) * BOB_AMP
|
mob.position.y = ground + HOVER + Math.sin(mob.phase * BOB_FREQ) * BOB_AMP
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bee: Entity<Mob, Terrain> = { name: "bee", build, update, boundingRadius: 0.5, bodyHeight: 0.5 }
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import { Terrain } from "../Terrain"
|
import { Terrain } from "../../Terrain"
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Mob } from "../Mob"
|
||||||
import type { Entity } from "../Actor"
|
import type { Entity } from "../../../engine/scene/Actor"
|
||||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||||
|
|
||||||
// Everything about the frog: squat, ground-bound, sits then springs a ballistic hop.
|
// 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 LEASH = 5
|
||||||
const REST_MIN = 0.7
|
const REST_MIN = 0.7
|
||||||
const REST_SPAN = 1.8
|
const REST_SPAN = 1.8
|
||||||
|
|
@ -53,5 +61,3 @@ function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||||
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const frog: Entity<Mob, Terrain> = { name: "frog", build, update, boundingRadius: 0.7, bodyHeight: 0.6 }
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { Terrain } from "../Terrain"
|
import { Terrain } from "../../Terrain"
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Mob } from "../Mob"
|
import type { Mob } from "../Mob"
|
||||||
import type { Entity } from "../Actor"
|
import type { Entity } from "../../../engine/scene/Actor"
|
||||||
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
import { ellipsoid, nextRand, wanderHeading } from "./mobkit"
|
||||||
|
|
||||||
// Everything about the robin: round red-breasted bird that mostly hops like a frog
|
// 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.
|
// 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 LEASH = 6
|
||||||
const REST_MIN = 0.5
|
const REST_MIN = 0.5
|
||||||
const REST_SPAN = 1.3
|
const REST_SPAN = 1.3
|
||||||
|
|
@ -74,5 +82,3 @@ function update(mob: Mob, dt: number, terrain: Terrain): void {
|
||||||
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
mob.timer = REST_MIN + nextRand(mob) * REST_SPAN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const robin: Entity<Mob, Terrain> = { name: "robin", build, update, boundingRadius: 0.45, bodyHeight: 0.55 }
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Mob } from "../Mob"
|
import type { Mob } from "../Mob"
|
||||||
import { STRIDE, type Mesh } from "../Mesh"
|
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
||||||
|
|
||||||
// Shared building blocks for the per-kind mob definitions (Frog/Bee/Robin): the
|
// 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
|
// faceted geometry primitives and the deterministic wander helpers. Kept in its own
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Vec3 } from "../../math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Tree, TreeSpecies } from "../Tree"
|
import type { Tree, TreeSpecies } from "../Tree"
|
||||||
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||||
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||||
// drooping canopy -- a lean silhouette between the broad oak and conical spruce.
|
// 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).
|
// 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 {
|
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
||||||
const base = tree.position
|
const base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
|
|
@ -48,5 +50,3 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Vec3 } from "../../math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Tree, TreeSpecies } from "../Tree"
|
import type { Tree, TreeSpecies } from "../Tree"
|
||||||
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
import { blob, lerp, limb, TAU, rng } from "./treekit"
|
||||||
|
|
||||||
// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy
|
// 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.
|
// 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 {
|
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
|
||||||
const base = tree.position
|
const base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
|
|
@ -49,5 +51,3 @@ function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import type { Mesh } from "../Mesh"
|
import type { Mesh } from "../../../engine/scene/Mesh"
|
||||||
import type { Tree, TreeSpecies } from "../Tree"
|
import type { Tree, TreeSpecies } from "../Tree"
|
||||||
import { cone, lerp, limb, rng } from "./treekit"
|
import { cone, lerp, limb, rng } from "./treekit"
|
||||||
|
|
||||||
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
|
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
|
||||||
// than wide). Trunk = brown bark, foliage = spruce needle.
|
// 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 {
|
function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
|
||||||
const base = tree.position
|
const base = tree.position
|
||||||
const g = tree.growth
|
const g = tree.growth
|
||||||
|
|
@ -28,5 +30,3 @@ function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor")
|
||||||
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides)
|
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Vec3 } from "../../math/Vec3"
|
import { Vec3 } from "../../../engine/math/Vec3"
|
||||||
import { STRIDE, type Mesh } from "../Mesh"
|
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
|
||||||
|
|
||||||
// Shared faceted-geometry primitives + the per-tree RNG, used by the species
|
// 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
|
// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry
|
||||||
|
|
@ -2,13 +2,13 @@ import { Color } from "../engine/render/Color"
|
||||||
import type { DrawGroup, Material } from "../engine/render/Material"
|
import type { DrawGroup, Material } from "../engine/render/Material"
|
||||||
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
|
||||||
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
||||||
import { Boulder } from "../engine/scene/Boulder"
|
import { Boulder } from "./actors/Boulder"
|
||||||
import { Bush } from "../engine/scene/Bush"
|
import { Bush } from "./actors/Bush"
|
||||||
import { Flower, type FlowerColor } from "../engine/scene/Flower"
|
import { Flower, type FlowerColor } from "./actors/Flower"
|
||||||
import type { Mob, MobKind } from "../engine/scene/Mob"
|
import type { Mob, MobKind } from "./actors/Mob"
|
||||||
import { Terrain } from "../engine/scene/Terrain"
|
import { Terrain } from "./Terrain"
|
||||||
import { Tree } from "../engine/scene/Tree"
|
import { Tree } from "./actors/Tree"
|
||||||
import type { Textures } from "./assets"
|
import type { Textures } from "./textures"
|
||||||
|
|
||||||
type Corner = [number, number, number]
|
type Corner = [number, number, number]
|
||||||
|
|
||||||
|
|
@ -98,11 +98,11 @@ const FLOOR_LIFT = 0.02
|
||||||
* `outer` for a bigger world. */
|
* `outer` for a bigger world. */
|
||||||
const TERRAIN: Terrain = {
|
const TERRAIN: Terrain = {
|
||||||
inner: ARENA,
|
inner: ARENA,
|
||||||
outer: ARENA * 20,
|
outer: ARENA * 10,
|
||||||
blend: 12,
|
blend: 12,
|
||||||
amplitude: 5,
|
amplitude: 5,
|
||||||
frequency: 0.14,
|
frequency: 0.14,
|
||||||
peakHeight: 90,
|
peakHeight: 0,
|
||||||
peakFrequency: 0.05,
|
peakFrequency: 0.05,
|
||||||
peakStart: 0.45,
|
peakStart: 0.45,
|
||||||
}
|
}
|
||||||
|
|
@ -110,22 +110,22 @@ const TERRAIN: Terrain = {
|
||||||
/** Forest: how many trees to scatter on the grass, and the seed for their
|
/** 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;
|
* 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). */
|
* each rolls oak-or-spruce and a growth 0..1 (sapling .. full grown). */
|
||||||
const TREE_COUNT = 500
|
const TREE_COUNT = 50
|
||||||
const TREE_SEED = 0x5EED
|
const TREE_SEED = 0x5EED
|
||||||
const TREE_REACH = 0.6
|
const TREE_REACH = 1
|
||||||
|
|
||||||
/** Boulders: how many to scatter, their seed, and how far out they reach
|
/** Boulders: how many to scatter, their seed, and how far out they reach
|
||||||
* (fraction of the world). Sizes range small pebble .. big boulder. */
|
* (fraction of the world). Sizes range small pebble .. big boulder. */
|
||||||
const BOULDER_COUNT = 70
|
const BOULDER_COUNT = 50
|
||||||
const BOULDER_SEED = 0xB0142
|
const BOULDER_SEED = 0xB0142
|
||||||
const BOULDER_REACH = 0.7
|
const BOULDER_REACH = 1
|
||||||
|
|
||||||
/** Bushes + flowers: ground detail, kept to the nearer band since they're small
|
/** 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. */
|
* and fog/size hides them far out. Flowers roll white/red/yellow. */
|
||||||
const BUSH_COUNT = 140
|
const BUSH_COUNT = 50
|
||||||
const BUSH_SEED = 0xB554
|
const BUSH_SEED = 0xB554
|
||||||
const BUSH_REACH = 0.35
|
const BUSH_REACH = 1
|
||||||
const FLOWER_COUNT = 340
|
const FLOWER_COUNT = 50
|
||||||
const FLOWER_SEED = 0xF10E
|
const FLOWER_SEED = 0xF10E
|
||||||
const FLOWER_REACH = 0.3
|
const FLOWER_REACH = 0.3
|
||||||
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
||||||
|
|
@ -133,11 +133,11 @@ const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
||||||
/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they
|
/** 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
|
* reach (fraction of the world). Kept modest -- roaming meshes are drawn every
|
||||||
* frame (frustum-culled), not baked into the static chunks. */
|
* frame (frustum-culled), not baked into the static chunks. */
|
||||||
const FROG_COUNT = 40
|
const FROG_COUNT = 20
|
||||||
const BEE_COUNT = 30
|
const BEE_COUNT = 20
|
||||||
const ROBIN_COUNT = 30
|
const ROBIN_COUNT = 20
|
||||||
const MOB_SEED = 0x30B
|
const MOB_SEED = 0x30B
|
||||||
const MOB_REACH = 0.5
|
const MOB_REACH = 1
|
||||||
|
|
||||||
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
||||||
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
||||||
|
|
@ -214,7 +214,8 @@ export function buildLevel(textures: Textures): Level {
|
||||||
sun: Color.rgb(255, 246, 214),
|
sun: Color.rgb(255, 246, 214),
|
||||||
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
||||||
sunSize: 0.04,
|
sunSize: 0.04,
|
||||||
clouds: basicCumulus,
|
clouds: fancyCumulus,
|
||||||
|
skybox: { texture: textures.skybox },
|
||||||
}
|
}
|
||||||
|
|
||||||
const npcPosition = { x: 2, y: 0, z: -1 }
|
const npcPosition = { x: 2, y: 0, z: -1 }
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Terrain } from "../engine/scene/Terrain"
|
import { Terrain } from "./Terrain"
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
import type { Vec3 } from "../engine/math/Vec3"
|
||||||
import type { Aabb, Level } from "./level"
|
import type { Aabb, Level } from "./level"
|
||||||
|
|
||||||
|
|
@ -6,11 +6,11 @@ import { Sky, type SkyConfig } from "../engine/render/Sky"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
import type { Camera } from "../engine/scene/Camera"
|
||||||
import { Mat4 } from "../engine/math/Mat4"
|
import { Mat4 } from "../engine/math/Mat4"
|
||||||
import type { Mesh } from "../engine/scene/Mesh"
|
import type { Mesh } from "../engine/scene/Mesh"
|
||||||
import { Mob, type MobKind } from "../engine/scene/Mob"
|
import { Mob, type MobKind } from "./actors/Mob"
|
||||||
import { Sprite } from "../engine/scene/Sprite"
|
import { Sprite } from "../engine/scene/Sprite"
|
||||||
import type { Vec2 } from "../engine/math/Vec2"
|
import type { Vec2 } from "../engine/math/Vec2"
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
import type { Vec3 } from "../engine/math/Vec3"
|
||||||
import type { Textures } from "./assets"
|
import type { Textures } from "./textures"
|
||||||
import type { Chunk } from "./level"
|
import type { Chunk } from "./level"
|
||||||
|
|
||||||
/** Everything needed to render the world: the room, the cullable chunks, the NPC
|
/** Everything needed to render the world: the room, the cullable chunks, the NPC
|
||||||
23
game/textures.ts
Normal file
23
game/textures.ts
Normal 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
|
||||||
|
}
|
||||||
32
tests/layering.test.ts
Normal file
32
tests/layering.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
// The engine is the reusable, content-agnostic layer: it must import nothing from
|
||||||
|
// game (content) or app (browser glue). game -> engine and app -> game -> engine are
|
||||||
|
// fine; the reverse is the violation. This freezes the Stage-3 seam so a stray import
|
||||||
|
// can't quietly re-couple the layers.
|
||||||
|
function tsFiles(dir: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const p = join(dir, name)
|
||||||
|
if (statSync(p).isDirectory()) {
|
||||||
|
out.push(...tsFiles(p))
|
||||||
|
} else if (p.endsWith(".ts")) {
|
||||||
|
out.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
test("engine imports nothing from game or app", () => {
|
||||||
|
const engineDir = new URL("../engine", import.meta.url).pathname
|
||||||
|
const offenders = tsFiles(engineDir).filter((f) => /from\s+["'][^"']*\/(?:game|app)\//.test(readFileSync(f, "utf8")))
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("game imports nothing from app", () => {
|
||||||
|
const gameDir = new URL("../game", import.meta.url).pathname
|
||||||
|
const offenders = tsFiles(gameDir).filter((f) => /from\s+["'][^"']*\/app\//.test(readFileSync(f, "utf8")))
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { MOB_KINDS, Mob } from "../engine/scene/Mob"
|
import { MOB_KINDS, Mob } from "../game/actors/Mob"
|
||||||
|
|
||||||
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
|
// The mob SAB packs a kind as its index in MOB_KINDS; the main thread and every
|
||||||
// render worker must agree on that order. Freeze it here: appending a kind is fine,
|
// render worker must agree on that order. Freeze it here: appending a kind is fine,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Tree, TREE_KINDS } from "../engine/scene/Tree"
|
import { Tree, TREE_KINDS } from "../game/actors/Tree"
|
||||||
|
|
||||||
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
|
// The chunk baker (level.ts) accumulates geometry into a mesh per material key and
|
||||||
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
|
// only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.base.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.engine.json" }
|
{ "path": "./tsconfig.engine.json" },
|
||||||
|
{ "path": "./tsconfig.game.json" }
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|
|
||||||
12
tsconfig.game.json
Normal file
12
tsconfig.game.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.engine.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"game"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.engine.json"
|
"path": "./tsconfig.engine.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.game.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue