diff --git a/.agents/plans/levels-and-editor.md b/.agents/plans/levels-and-editor.md new file mode 100644 index 0000000..b43fb0a --- /dev/null +++ b/.agents/plans/levels-and-editor.md @@ -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). diff --git a/AGENTS.md b/AGENTS.md index 37d075e..ec19f68 100644 --- a/AGENTS.md +++ b/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 (thousands of trees/rocks) affordable — off-screen content costs ~nothing. - **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**. -- **Engine is headless.** `engine/` has no DOM types and could run server-side; - all browser glue (canvas, input, image decode) lives in `app/`. +- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the + 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 @@ -48,15 +54,16 @@ rules live in `.agents/rules/*.md`. - `bun run bench:browser` — Playwright: drive headless Chromium through the `?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. -- `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). -- `bunx oxlint engine app` — lint. -- `bun test` — tests (none yet). +- `bunx oxlint engine game app` — lint. +- `bun test` — tests (registry id-order + engine↛game layering guards). - `bun run serve` — Bun server (`server/server.ts`, a stub for now). ## 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). - `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order), `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), `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` - (Y-axis billboard), `Terrain` (procedural - heightfield around the room: flat clearing in the center, rolling hills, tall - edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called - per chunk, aligned so patches weld crack-free, with a hole for the room; - `Terrain.height` is the shared ground-height sampler for the player), `Actor` - (the `Entity` interface — geometry + behavior + bounds — that each mob kind - implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a - `growth` knob; each species a `TreeSpecies` in `trees/.ts`, assembled by a - registry — see the Trees section), `Boulder` - (procedural low-poly rock: a squashed, jittered, part-buried sphere; - `Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf - blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom; - samples a 2x2 color-atlas texture, drawn double-sided), `Mob` (a **roaming** - creature — `frog` hops the ground, `bee` hovers/darts, `robin` mostly hops but - now and then takes a short powered flight — the engine's only moving geometry. - Unlike the baked props, a mob's low-poly mesh is built once per kind in **local - space**; `Mob.build` bakes the canonical meshes, `Mob.update` steps the wander - AI (leashed to a home anchor, deterministic via an evolving per-mob seed) each - frame, and the live `position`/`heading`/`scale` become a per-frame model matrix - at draw time. New kinds extend the `MobKind` union + `MOB_KINDS` order). -- `app/` — browser glue. - - `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). + (Y-axis billboard), `Actor` (the generic `Entity` interface — + build + update + bounds — that a content kind implements; the engine dispatches + through it, never a `kind` switch). +- `game/` — this game's content + world assembly, on the engine interfaces + (headless: no DOM, imports nothing from `app`). + - `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops + the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a + short powered flight — the only moving geometry; each kind an `Entity` in + `mobs/.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry. + Its local-space mesh is built once per kind; `Mob.update` steps the wander AI + (leashed to a home anchor, deterministic per evolving `seed`) each frame and the + live `position`/`heading`/`scale` become a per-frame model matrix at draw. + `MOB_KINDS` is the SAB id order). `Tree` (oak/spruce/birch, each a `TreeSpecies` + in `trees/.ts` + `trees/treekit.ts` — see the Trees section), `Boulder` + (squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the + leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props + append into shared per-material meshes; mobs draw live. + - `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling + hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle + (per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared + ground sampler for the player + mobs. - `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 `Terrain` world (~20x across). Props are placed first (`placeTrees` / `placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders; `TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs - scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move, - so they carry no baked colliders), then `buildChunks` bakes - terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a - tight AABB + two `DrawGroup[]` lists, `near`/`far`, where a `DrawGroup` is a baked - mesh + its `Material` = texture + cull; the renderer just loops them and knows no - content by name) that `main` frustum-culls; bushes fold into the leaf mesh, - flowers get their own (double-sided) group. Trees + boulders are baked **twice** — - full geometry into `near` and a low-poly impostor into `far` (via the builders' - `lod` arg) — so a far chunk swaps to the cheap group set with no per-frame work - (see `chunkFar` / `RenderConfig.lodDistance`). `buildLevel(textures)` binds the - ground/prop materials once and shares them across chunks. `Aabb` colliders (walls, crate, grown trunks, big - boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it - stays clean over the terrain skirt that laps under the room edges. Room surfaces - are single flat quads -- no subdivision needed since texturing is - perspective-correct. - - `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). + scattered (`placeMobs`; `FROG_/BEE_/ROBIN_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs + move, so no baked colliders), then `buildChunks` bakes terrain + props into a + `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a tight AABB + two + `DrawGroup[]` lists `near`/`far`; the baker accumulates one mesh per **material + key** (`MAT_ORDER`) and routes each prop by its declared material, so it names no + texture) that `main` frustum-culls. Trees + boulders bake **twice** — full into + `near`, a low-poly impostor into `far` — so a far chunk swaps to the cheap set + with no per-frame work (`chunkFar` / `RenderConfig.lodDistance`). + `buildLevel(textures)` binds the ground/prop `Material`s once + shares them. + Also: `Aabb` colliders, NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, + sky/cloud config, `FLOOR_LIFT` (a z-bias lifting the stone floor over the terrain + skirt). Room surfaces are single flat quads (texturing is perspective-correct). + - `renderScene.ts` — `renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single + source of render truth (sky + room + culled chunk draw-groups + 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 canonical mob + meshes) so it clones to a worker whole; each mob draws double-sided through its + own `viewProj × Mat4.compose(...)` model matrix, and `visibleChunks`/`visibleMobs` + 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 `#fps` meter div (styled inline). - `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), `Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop), 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 (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 * 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. @@ -272,12 +275,12 @@ by a `CloudLayer` discriminated union `kind`: - **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume, ~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 branching in the cloud shader. Cost scales with sky resolution — fine at `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 is a `TreeSpecies` definition in its own `trees/.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 `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 -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 `trees/.ts` module (its geometry + material keys) + one entry in the `Tree` registry; only a genuinely new material also needs a `Material` in `buildLevel` + its key in `MAT_ORDER`. -**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build` +**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build` 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. `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 -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 presets. FPS shown bottom-right. The room's north wall is open — walk out onto the terrain and toward the peaks. diff --git a/README.md b/README.md index 88e8e5d..7f80696 100644 --- a/README.md +++ b/README.md @@ -7,5 +7,15 @@ 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 + diff --git a/app/assets.ts b/app/assets.ts index 9e67e17..ddf330f 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -1,4 +1,5 @@ import type { Texture } from "../engine/render/Texture" +import type { Textures } from "../game/textures" import barkUrl from "../assets/bark.png" import beeUrl from "../assets/bee.png" import birchUrl from "../assets/birch.png" @@ -12,28 +13,12 @@ import needleUrl from "../assets/needle.png" import npcUrl from "../assets/npc.png" import robinUrl from "../assets/robin.png" import rockUrl from "../assets/rock.png" +import skyboxUrl from "../assets/rockies.skybox.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. */ export async function loadTextures(): Promise { - 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(grassUrl), loadTexture(barkUrl), @@ -48,8 +33,9 @@ export async function loadTextures(): Promise { loadTexture(frogUrl), loadTexture(beeUrl), 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 { diff --git a/app/main.ts b/app/main.ts index 0ded1be..2e320e6 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,13 +1,13 @@ import { RenderConfig } from "../engine/render/RenderConfig" import { Camera } from "../engine/scene/Camera" 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 { loadTextures } from "./assets" -import { buildLevel, type Level } from "./level" -import { EYE_HEIGHT, Player } from "./player" +import { buildLevel, type Level } from "../game/level" +import { EYE_HEIGHT, Player } from "../game/player" 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 = (FOV_DEGREES * Math.PI) / 180 diff --git a/app/render-worker.ts b/app/render-worker.ts index 50773c4..51e0575 100644 --- a/app/render-worker.ts +++ b/app/render-worker.ts @@ -1,7 +1,7 @@ import type { Framebuffer } from "../engine/render/Framebuffer" import type { RenderConfig } from "../engine/render/RenderConfig" -import { MOB_KINDS } from "../engine/scene/Mob" -import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene" +import { MOB_KINDS } from "../game/actors/Mob" +import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "../game/renderScene" /** 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. */ diff --git a/app/renderer.ts b/app/renderer.ts index 08b4af1..254a6f6 100644 --- a/app/renderer.ts +++ b/app/renderer.ts @@ -2,10 +2,11 @@ import { Framebuffer } from "../engine/render/Framebuffer" import type { RenderConfig } from "../engine/render/RenderConfig" import type { Mat4 } from "../engine/math/Mat4" import type { Camera } from "../engine/scene/Camera" -import { MOB_KINDS } from "../engine/scene/Mob" -import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene" +import { MOB_KINDS } from "../game/actors/Mob" +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 /** Use worker threads when the page can share memory (else single-thread). */ const ENABLE_WORKERS = true diff --git a/assets/rockies.skybox.png b/assets/rockies.skybox.png new file mode 100644 index 0000000..6cbadc6 Binary files /dev/null and b/assets/rockies.skybox.png differ diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index 6ae3df4..ed147f9 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -57,8 +57,8 @@ export type RenderConfig = { * color depth, dither, vertex snap, and filtering from crunchy PS1 to clean. */ export namespace RenderConfig { export const standard: RenderConfig = { - internalWidth: 640, - internalHeight: 360, + internalWidth: 384, + internalHeight: 216, upscaleFilter: "nearest", colorDepth: 5, dither: 1, diff --git a/engine/render/Sky.ts b/engine/render/Sky.ts index 58684dd..488b20e 100644 --- a/engine/render/Sky.ts +++ b/engine/render/Sky.ts @@ -2,6 +2,7 @@ import { Color } from "./Color" import type { Framebuffer } from "./Framebuffer" import { Camera } from "../scene/Camera" import { Vec3 } from "../math/Vec3" +import { Texture } from "./Texture" /** Fields shared by every cumulus style. */ export type CumulusBase = { @@ -35,7 +36,18 @@ export type FancyCumulus = CumulusBase & { * branching on `kind` in the cloud shader. */ 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 = { zenith: Color horizon: Color @@ -45,9 +57,17 @@ export type SkyConfig = { /** Angular radius of the sun's core, in radians. */ sunSize: number 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 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 { /** @@ -55,14 +75,16 @@ export namespace Sky { * frame in place of Framebuffer.clear; opaque geometry then overwrites the sky * wherever it is nearer. `time` (seconds) drives cloud motion. * - * Per pixel it reconstructs the view ray from the camera basis, shades a - * horizon->zenith gradient by the ray's elevation, brightens toward `sun` near - * `sunDir`, then lays crisp-edged cumulus over the top. + * Per pixel it reconstructs the view ray from the camera basis, shades the + * base (the equirect panorama if `sky.skybox` is set, else a horizon->zenith + * 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 per-pixel cloud fbm dominates the frame) runs once per step x step - * block and is copied across it. The sky is low-frequency, so 2 is nearly free - * visually and quarters the cloud cost; 1 is full resolution. + * The base + sun are shaded per pixel so they stay crisp. `step` (>= 1) only + * lowers the *cloud* resolution: the cloud fbm dominates the frame, so it is + * sampled once per step x step block and composited across it. Clouds are + * 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 { const { width, height, color, depth } = fb @@ -75,48 +97,78 @@ export namespace Sky { const sun = Vec3.normalize(sky.sunDir) const cosSun = Math.cos(sky.sunSize) const clouds = sky.clouds + const skybox = sky.skybox ?? null + const skyboxYaw = skybox?.yaw ?? 0 const cloud: CloudSample = { cover: 0, shade: 1 } const s = Math.max(1, step | 0) - // Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block - // grid stays global and neighboring bands don't seam. + // The cheap base (skybox/gradient + sun) is shaded per pixel so it stays + // 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) { - // 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) + // 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) { - const sampleX = Math.min(width - 1, bx + (s >> 1)) - const ndcX = ((sampleX + 0.5) / width) * 2 - 1 - // View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized. - 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 dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY - const inv = 1 / Math.hypot(dx, dy, dz) - dx *= inv - dy *= inv - dz *= inv - // dy is the ray elevation: 0 at the horizon, 1 straight up. - const t = 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 - if (facing > cosSun) { - const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5) - c = Color.lerp(c, sky.sun, glow) - } - if (clouds !== null && dy > 0.02) { - if (clouds.kind === "fancy") { - 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) + // Sample the clouds once for the block, from the block-center ray. + const sampleX = Math.min(width - 1, bx + (s >> 1)) + const ndcXc = ((sampleX + 0.5) / width) * 2 - 1 + 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 dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY + let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY + const inv = 1 / Math.hypot(dx, dy, dz) + dx *= inv + dy *= 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. + c = Color.lerp(sky.horizon, sky.zenith, Math.max(0, Math.min(1, dy))) + } + const facing = dx * sun.x + dy * sun.y + dz * sun.z + if (facing > cosSun) { + const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5) + c = Color.lerp(c, sky.sun, glow) + } + if (cover > 0) { + c = Color.lerp(c, cloudColor, cover) + } color[o + x] = c depth[o + x] = 0 } diff --git a/engine/scene/Terrain.ts b/game/Terrain.ts similarity index 98% rename from engine/scene/Terrain.ts rename to game/Terrain.ts index d1de1ec..b38e6a9 100644 --- a/engine/scene/Terrain.ts +++ b/game/Terrain.ts @@ -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 * ground height: the outdoor mesh is built from it and the player stands on the diff --git a/engine/scene/Boulder.ts b/game/actors/Boulder.ts similarity index 96% rename from engine/scene/Boulder.ts rename to game/actors/Boulder.ts index e71c869..7d412f6 100644 --- a/engine/scene/Boulder.ts +++ b/game/actors/Boulder.ts @@ -1,5 +1,5 @@ -import type { Vec3 } from "../math/Vec3" -import { STRIDE, type Mesh } from "./Mesh" +import type { Vec3 } from "../../engine/math/Vec3" +import { STRIDE, type Mesh } from "../../engine/scene/Mesh" const TAU = Math.PI * 2 diff --git a/engine/scene/Bush.ts b/game/actors/Bush.ts similarity index 95% rename from engine/scene/Bush.ts rename to game/actors/Bush.ts index c55442d..c8fd73e 100644 --- a/engine/scene/Bush.ts +++ b/game/actors/Bush.ts @@ -1,5 +1,5 @@ -import type { Vec3 } from "../math/Vec3" -import { STRIDE, type Mesh } from "./Mesh" +import type { Vec3 } from "../../engine/math/Vec3" +import { STRIDE, type Mesh } from "../../engine/scene/Mesh" const TAU = Math.PI * 2 diff --git a/engine/scene/Flower.ts b/game/actors/Flower.ts similarity index 96% rename from engine/scene/Flower.ts rename to game/actors/Flower.ts index e154e8b..6a13cc7 100644 --- a/engine/scene/Flower.ts +++ b/game/actors/Flower.ts @@ -1,5 +1,5 @@ -import type { Vec3 } from "../math/Vec3" -import { Mesh } from "./Mesh" +import type { Vec3 } from "../../engine/math/Vec3" +import { Mesh } from "../../engine/scene/Mesh" const TAU = Math.PI * 2 diff --git a/engine/scene/Mob.ts b/game/actors/Mob.ts similarity index 94% rename from engine/scene/Mob.ts rename to game/actors/Mob.ts index 4b75469..29b3487 100644 --- a/engine/scene/Mob.ts +++ b/game/actors/Mob.ts @@ -1,7 +1,7 @@ -import type { Terrain } from "./Terrain" -import type { Vec3 } from "../math/Vec3" -import type { Mesh } from "./Mesh" -import type { Entity } from "./Actor" +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" diff --git a/engine/scene/Tree.ts b/game/actors/Tree.ts similarity index 95% rename from engine/scene/Tree.ts rename to game/actors/Tree.ts index d509d5a..9c6751b 100644 --- a/engine/scene/Tree.ts +++ b/game/actors/Tree.ts @@ -1,5 +1,5 @@ -import type { Vec3 } from "../math/Vec3" -import type { Mesh } from "./Mesh" +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" diff --git a/engine/scene/mobs/Bee.ts b/game/actors/mobs/Bee.ts similarity index 79% rename from engine/scene/mobs/Bee.ts rename to game/actors/mobs/Bee.ts index 0e1f236..82d3e8d 100644 --- a/engine/scene/mobs/Bee.ts +++ b/game/actors/mobs/Bee.ts @@ -1,10 +1,16 @@ -import { Terrain } from "../Terrain" -import type { Mesh } from "../Mesh" +import { Terrain } from "../../Terrain" +import type { Mesh } from "../../../engine/scene/Mesh" 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" -// Everything about the bee: small, hovers and darts through the air, wings out. +export const bee: Entity = { + name: "bee", + build, + update, + boundingRadius: 0.5, + bodyHeight: 0.5, +} const LEASH = 6 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) mob.position.y = ground + HOVER + Math.sin(mob.phase * BOB_FREQ) * BOB_AMP } - -export const bee: Entity = { name: "bee", build, update, boundingRadius: 0.5, bodyHeight: 0.5 } diff --git a/engine/scene/mobs/Frog.ts b/game/actors/mobs/Frog.ts similarity index 86% rename from engine/scene/mobs/Frog.ts rename to game/actors/mobs/Frog.ts index c144c01..a703fa8 100644 --- a/engine/scene/mobs/Frog.ts +++ b/game/actors/mobs/Frog.ts @@ -1,11 +1,19 @@ -import { Terrain } from "../Terrain" -import type { Mesh } from "../Mesh" +import { Terrain } from "../../Terrain" +import type { Mesh } from "../../../engine/scene/Mesh" import type { Mob } from "../Mob" -import type { Entity } from "../Actor" +import type { Entity } from "../../../engine/scene/Actor" import { ellipsoid, nextRand, wanderHeading } from "./mobkit" // Everything about the frog: squat, ground-bound, sits then springs a ballistic hop. +export const frog: Entity = { + name: "frog", + build, + update, + boundingRadius: 0.7, + bodyHeight: 0.6, +} + const LEASH = 5 const REST_MIN = 0.7 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 } } - -export const frog: Entity = { name: "frog", build, update, boundingRadius: 0.7, bodyHeight: 0.6 } diff --git a/engine/scene/mobs/Robin.ts b/game/actors/mobs/Robin.ts similarity index 91% rename from engine/scene/mobs/Robin.ts rename to game/actors/mobs/Robin.ts index e7e6846..3a01f60 100644 --- a/engine/scene/mobs/Robin.ts +++ b/game/actors/mobs/Robin.ts @@ -1,12 +1,20 @@ -import { Terrain } from "../Terrain" -import type { Mesh } from "../Mesh" +import { Terrain } from "../../Terrain" +import type { Mesh } from "../../../engine/scene/Mesh" import type { Mob } from "../Mob" -import type { Entity } from "../Actor" +import type { Entity } from "../../../engine/scene/Actor" import { ellipsoid, nextRand, wanderHeading } from "./mobkit" // Everything about the robin: round red-breasted bird that mostly hops like a frog // but now and then takes a short powered flight to a new perch. +export const robin: Entity = { + name: "robin", + build, + update, + boundingRadius: 0.45, + bodyHeight: 0.55, +} + const LEASH = 6 const REST_MIN = 0.5 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 } } - -export const robin: Entity = { name: "robin", build, update, boundingRadius: 0.45, bodyHeight: 0.55 } diff --git a/engine/scene/mobs/mobkit.ts b/game/actors/mobs/mobkit.ts similarity index 98% rename from engine/scene/mobs/mobkit.ts rename to game/actors/mobs/mobkit.ts index 6a1deac..3127ebe 100644 --- a/engine/scene/mobs/mobkit.ts +++ b/game/actors/mobs/mobkit.ts @@ -1,5 +1,5 @@ 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 // faceted geometry primitives and the deterministic wander helpers. Kept in its own diff --git a/engine/scene/trees/Birch.ts b/game/actors/trees/Birch.ts similarity index 95% rename from engine/scene/trees/Birch.ts rename to game/actors/trees/Birch.ts index 6032d4e..00eeba3 100644 --- a/engine/scene/trees/Birch.ts +++ b/game/actors/trees/Birch.ts @@ -1,5 +1,5 @@ -import { Vec3 } from "../../math/Vec3" -import type { Mesh } from "../Mesh" +import { Vec3 } from "../../../engine/math/Vec3" +import type { Mesh } from "../../../engine/scene/Mesh" import type { Tree, TreeSpecies } from "../Tree" import { blob, lerp, limb, TAU, rng } from "./treekit" @@ -7,6 +7,8 @@ import { blob, lerp, limb, TAU, rng } from "./treekit" // drooping canopy -- a lean silhouette between the broad oak and conical spruce. // Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read). +export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build } + function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { const base = tree.position const g = tree.growth @@ -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 } diff --git a/engine/scene/trees/Oak.ts b/game/actors/trees/Oak.ts similarity index 95% rename from engine/scene/trees/Oak.ts rename to game/actors/trees/Oak.ts index c57fdfd..a6e7888 100644 --- a/engine/scene/trees/Oak.ts +++ b/game/actors/trees/Oak.ts @@ -1,11 +1,13 @@ -import { Vec3 } from "../../math/Vec3" -import type { Mesh } from "../Mesh" +import { Vec3 } from "../../../engine/math/Vec3" +import type { Mesh } from "../../../engine/scene/Mesh" import type { Tree, TreeSpecies } from "../Tree" import { blob, lerp, limb, TAU, rng } from "./treekit" // Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy // blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf. +export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build } + function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void { const base = tree.position const g = tree.growth @@ -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 } diff --git a/engine/scene/trees/Spruce.ts b/game/actors/trees/Spruce.ts similarity index 95% rename from engine/scene/trees/Spruce.ts rename to game/actors/trees/Spruce.ts index 57fac37..e7a2286 100644 --- a/engine/scene/trees/Spruce.ts +++ b/game/actors/trees/Spruce.ts @@ -1,10 +1,12 @@ -import type { Mesh } from "../Mesh" +import type { Mesh } from "../../../engine/scene/Mesh" import type { Tree, TreeSpecies } from "../Tree" import { cone, lerp, limb, rng } from "./treekit" // Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller // than wide). Trunk = brown bark, foliage = spruce needle. +export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build } + function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void { const base = tree.position const g = tree.growth @@ -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) } } - -export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build } diff --git a/engine/scene/trees/treekit.ts b/game/actors/trees/treekit.ts similarity index 96% rename from engine/scene/trees/treekit.ts rename to game/actors/trees/treekit.ts index 3d62674..27e2e09 100644 --- a/engine/scene/trees/treekit.ts +++ b/game/actors/trees/treekit.ts @@ -1,5 +1,5 @@ -import { Vec3 } from "../../math/Vec3" -import { STRIDE, type Mesh } from "../Mesh" +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 diff --git a/app/level.ts b/game/level.ts similarity index 96% rename from app/level.ts rename to game/level.ts index 5092fdb..a4780af 100644 --- a/app/level.ts +++ b/game/level.ts @@ -2,13 +2,13 @@ import { Color } from "../engine/render/Color" import type { DrawGroup, Material } from "../engine/render/Material" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import { STRIDE, type Mesh } from "../engine/scene/Mesh" -import { Boulder } from "../engine/scene/Boulder" -import { Bush } from "../engine/scene/Bush" -import { Flower, type FlowerColor } from "../engine/scene/Flower" -import type { Mob, MobKind } from "../engine/scene/Mob" -import { Terrain } from "../engine/scene/Terrain" -import { Tree } from "../engine/scene/Tree" -import type { Textures } from "./assets" +import { Boulder } from "./actors/Boulder" +import { Bush } from "./actors/Bush" +import { Flower, type FlowerColor } from "./actors/Flower" +import type { Mob, MobKind } from "./actors/Mob" +import { Terrain } from "./Terrain" +import { Tree } from "./actors/Tree" +import type { Textures } from "./textures" type Corner = [number, number, number] @@ -98,11 +98,11 @@ const FLOOR_LIFT = 0.02 * `outer` for a bigger world. */ const TERRAIN: Terrain = { inner: ARENA, - outer: ARENA * 20, + outer: ARENA * 10, blend: 12, amplitude: 5, frequency: 0.14, - peakHeight: 90, + peakHeight: 0, peakFrequency: 0.05, peakStart: 0.45, } @@ -110,22 +110,22 @@ const TERRAIN: Terrain = { /** 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 = 500 +const TREE_COUNT = 50 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 * (fraction of the world). Sizes range small pebble .. big boulder. */ -const BOULDER_COUNT = 70 +const BOULDER_COUNT = 50 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 * 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_REACH = 0.35 -const FLOWER_COUNT = 340 +const BUSH_REACH = 1 +const FLOWER_COUNT = 50 const FLOWER_SEED = 0xF10E const FLOWER_REACH = 0.3 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 * reach (fraction of the world). Kept modest -- roaming meshes are drawn every * frame (frustum-culled), not baked into the static chunks. */ -const FROG_COUNT = 40 -const BEE_COUNT = 30 -const ROBIN_COUNT = 30 +const FROG_COUNT = 20 +const BEE_COUNT = 20 +const ROBIN_COUNT = 20 const MOB_SEED = 0x30B -const MOB_REACH = 0.5 +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 @@ -214,7 +214,8 @@ export function buildLevel(textures: Textures): Level { sun: Color.rgb(255, 246, 214), sunDir: { x: 0.3, y: 0.5, z: -0.8 }, sunSize: 0.04, - clouds: basicCumulus, + clouds: fancyCumulus, + skybox: { texture: textures.skybox }, } const npcPosition = { x: 2, y: 0, z: -1 } diff --git a/app/player.ts b/game/player.ts similarity index 99% rename from app/player.ts rename to game/player.ts index 32a6130..f45d376 100644 --- a/app/player.ts +++ b/game/player.ts @@ -1,4 +1,4 @@ -import { Terrain } from "../engine/scene/Terrain" +import { Terrain } from "./Terrain" import type { Vec3 } from "../engine/math/Vec3" import type { Aabb, Level } from "./level" diff --git a/app/renderScene.ts b/game/renderScene.ts similarity index 98% rename from app/renderScene.ts rename to game/renderScene.ts index e7cde7d..96b0d7e 100644 --- a/app/renderScene.ts +++ b/game/renderScene.ts @@ -6,11 +6,11 @@ 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 "../engine/scene/Mob" +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 "./assets" +import type { Textures } from "./textures" import type { Chunk } from "./level" /** Everything needed to render the world: the room, the cullable chunks, the NPC diff --git a/game/textures.ts b/game/textures.ts new file mode 100644 index 0000000..b970e1e --- /dev/null +++ b/game/textures.ts @@ -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 +} diff --git a/tests/layering.test.ts b/tests/layering.test.ts new file mode 100644 index 0000000..b6cefe5 --- /dev/null +++ b/tests/layering.test.ts @@ -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([]) +}) diff --git a/tests/mobs.test.ts b/tests/mobs.test.ts index d097a9f..89b5344 100644 --- a/tests/mobs.test.ts +++ b/tests/mobs.test.ts @@ -1,5 +1,5 @@ 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 // render worker must agree on that order. Freeze it here: appending a kind is fine, diff --git a/tests/trees.test.ts b/tests/trees.test.ts index c550675..5056cf8 100644 --- a/tests/trees.test.ts +++ b/tests/trees.test.ts @@ -1,5 +1,5 @@ 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 // only draws keys listed in MAT_ORDER. A tree species that declares a trunk/foliage diff --git a/tsconfig.app.json b/tsconfig.app.json index cf173ba..a39dc5c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.base.json", "references": [ - { "path": "./tsconfig.engine.json" } + { "path": "./tsconfig.engine.json" }, + { "path": "./tsconfig.game.json" } ], "compilerOptions": { "composite": true, diff --git a/tsconfig.game.json b/tsconfig.game.json new file mode 100644 index 0000000..4577985 --- /dev/null +++ b/tsconfig.game.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.base.json", + "references": [ + { "path": "./tsconfig.engine.json" } + ], + "compilerOptions": { + "composite": true + }, + "include": [ + "game" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 02330e4..3870bbf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,9 @@ { "path": "./tsconfig.engine.json" }, + { + "path": "./tsconfig.game.json" + }, { "path": "./tsconfig.app.json" },