diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index c2b0d0e..7f9782e 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -6,11 +6,13 @@ How to build, run and drive this repo for runtime verification. ```bash npm install # workspace root -npm run build -w @takeon/engine # engine must build before web -npm run build -w takeon-web +npm run build # engine → pixi → ui → editor → web (order matters) cd web && npm run start & # serves on http://127.0.0.1:3400 ``` +Individual packages: `npm run build -w @takeon/engine`, `-w @takeon/ui`, +`-w @takeon/editor`, `-w takeon-web`. The engine must build before the rest. + Backend (optional — the web app falls back to localStorage without it): ```bash @@ -28,25 +30,75 @@ before `next build` (env is inlined at build time). it (exit 144). Use `pkill -f 'next[-]server'`. - Rebuilding `.next` while an old `next start` is running serves stale chunk hashes (404/400 on `_next/static`). Always kill + restart after rebuild. -- Playwright: launch Chromium with `executablePath: '/opt/pw-browsers/chromium'`. +- Playwright: launch Chromium with + `executablePath: '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'` + (check the versioned directory name — the unversioned `chromium/` path has no + binary in it). +- HUD markup comes from `@takeon/ui` and is `tk-` prefixed; editor markup comes + from `@takeon/editor` and is `tke-` prefixed. Site chrome (garage, customiser, + launch) still uses the plain classes in `web/app/globals.css`. ## Flows worth driving (Playwright) 1. `/customize` → fill `input[aria-label="Rover name"]`, click part options - (`.part-option:has-text("Field Lab")` etc.), click `button.primary:has-text("Buy rover")`. - Full science build (Field Lab + Scoop + NavCam + Sounder) costs 1540 of - 1600 starting credits. + (`.part-option:has-text("Field Lab")` etc. — module options *toggle*, so + clicking "Sample Scoop"/"NavCam" removes the defaults), then `button.primary` + (label is "Buy rover & pick target"); it routes to `/launch?rover=`. 2. `/launch` → `.card:has-text("The Moon") button.primary` → lands on `/mission`. -3. Mission: wait `.hud-top .meter`; canvas is `.mission-canvas`. Click canvas - to focus, then Arrow keys drive. Action buttons: `.action-btn:has-text("Mine")` - etc. — Photo/Scan only render when the rover has those modules. -4. Mining yield appears as `.toast.good` with `+N `; cargo meter is - the 3rd `.hud-top .meter`. + Launch needs the `?rover=` param; visiting it bare shows "Pick a rover first". +3. Mission: wait `.tk-hudbar .tk-meter` (power/hull/hold gauges); canvas is + `.tk-canvas`. Click the canvas for the tile menu (`.tk-tilemenu`, with + "Drive here" / "Mine …"), or use `.tk-dpad button[aria-label^="Drive"]`. + The hotbar (`.tk-actions`) only shows the frequent, capability-gated verbs + directly — `.tk-action:has-text("Mine")` / `"Photo"` / `"Scan"` — plus one + `.tk-action-more` hex. Everything occasional (Build/Craft/Place/Repair/ + Deposit/Hold) is behind it: click `.tk-action-more` to open `.tk-tray`, then + `.tk-tray button:has-text("Build")` etc. The tray closes itself after any + click, on outside click, or on Escape. Modals close via `.tk-modal-close` or + the footer `button:has-text("Close")`. +4. Mining yield appears as `.tk-toast-good` with `+N `; cargo meter is + the 3rd `.tk-hudbar .tk-meter`; open the tray and click + `.tk-tray button:has-text("Hold")` for the cargo table (`.tk-table tbody tr`). 5. Resume: go `/` — active mission card has `button:has-text("Resume mission")`; cargo must survive the round-trip. -6. End: `.hud-top button:has-text("End")` → modal → `button.primary` → back on - `/` with credits chip updated. -7. Canvas paint check: sample `getImageData` for >10 distinct colors. +6. End: `.tk-hudbar button:has-text("End")` → modal → `.tk-btn-primary` → back + on `/` with the credits chip updated. +7. Canvas paint check: sample `getImageData` for >10 distinct colors. The iso + view paints ~1000 distinct colours at 1600×1000; the flat map ~100+. +8. Views: `.tk-hudbar button[title*="2D map"]` switches to the top-down map + (`game.view === 'flat'`), the same button (`title*="3D diorama"`) switches + back. Tile picking, tap-to-drive and the tile menu work in both. + +### Editor (`/editor`) + +Layout is one grid of uniform `.tke-card`s (Scene 2×2, Inspector, Maps, +Analysis, Rover, then Destinations and Events spanning 2) — no sidebars, no +tab-switching between Maps/Analysis/Rover. Chrome is light by default. + +1. Wait for `.tke-toolbar`. `.tke-list-item` lists 6 built-in destinations; + `.tke-field` are inspector rows; `.tke-status` shows `96² · N ms`. +2. Drag a terrain slider (`.tke-group:has-text("Terrain") input[type="range"]`) + → the world regenerates (status ms changes) and the entry gets an + `.tke-badge` reading "edited". Undo is `button[title^="Undo"]`. +3. Maps: `.tke-tab:has-text("Slope"|"Surface"|"Ore density"|"Drivable"|"Noise")` + then sample `.tke-map` with `getImageData` (each view should paint several + distinct colours). `.tke-slice` is the cross-section. +4. Analysis card: `.tke-stat` tiles (Columns, Height, Relief, Drivable, Cliffs, + Landing) — visible without switching views. +5. Play mode: `.tke-primary:has-text("Play")` mounts the real HUD inside the + scene card (`.tk-hudbar .tk-meter` = 3). Drive with the d-pad; engine events + land in `.tke-console-line`. `.tke-primary:has-text("Stop")` returns. +6. Scene view is `.tke-scene` (drag to pan, wheel to zoom, R rotates). + `.tke-viewport-bar button:has-text("3D")` switches it to the 2D map (the + button then reads "2D"); the card hint says which is live. +9. Noise: the inspector has a *Noise field* group (7 fields). Setting type + + fractal regenerates the world, and the Maps `Noise` tab previews the exact + field with a caption from `describeNoise`; `Scatter` previews blue-noise + placement. +7. Theme toggle: `.tke-toolbar button[title*="dark chrome"]` / `[title*="light + chrome"]`, persisted in `localStorage` under `takeon.editor.theme`. +8. Layout check worth keeping: every `.tke-card-head` should measure the same + height, and standard cards the same width/height, at any viewport. Bennu (irregular asteroid) needs a fuel tank module; good probe for void-edge -terrain. Mobile: 390x760 viewport, d-pad `.dpad button[aria-label^="Drive"]`. +terrain. Mobile: 390x760 viewport, d-pad `.tk-dpad button[aria-label^="Drive"]`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b02e1..459b09b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: - run: npm run build -w @takeon/engine - run: npm test -w @takeon/engine - run: npm run build -w @takeon/pixi + - run: npm run build -w @takeon/ui + - run: npm test -w @takeon/ui + - run: npm run build -w @takeon/editor - run: npm run build -w takeon-web backend: @@ -32,7 +35,7 @@ jobs: - run: go build ./... && go vet ./... && go test ./... working-directory: pocketbase - # Publishes @takeon/engine and @takeon/pixi to npmjs.org when a v* tag is + # Publishes the @takeon packages to npmjs.org when a v* tag is # pushed. Requires the NPM_TOKEN repo secret (an npm automation token with # publish rights on the @takeon scope). Idempotent: versions already on the # registry are skipped, so re-running a tag build never fails on republish. @@ -50,11 +53,13 @@ jobs: - run: npm install - run: npm run build -w @takeon/engine && npm test -w @takeon/engine - run: npm run build -w @takeon/pixi + - run: npm run build -w @takeon/ui && npm test -w @takeon/ui + - run: npm run build -w @takeon/editor - name: Publish packages not yet on the registry env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - for pkg in @takeon/engine @takeon/pixi; do + for pkg in @takeon/engine @takeon/pixi @takeon/ui @takeon/editor; do version=$(npm pkg get version -w "$pkg" | node -e "process.stdin.once('data',d=>console.log(Object.values(JSON.parse(d))[0]))") if npm view "$pkg@$version" version >/dev/null 2>&1; then echo "$pkg@$version already published — skipping" diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3fb4c..8734551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,110 @@ # Changelog -`@takeon/engine` and `@takeon/pixi` are versioned **in lockstep** and released -by tagging: pushing a `v*` tag (e.g. `v0.2.0`) runs the CI publish job, which +`@takeon/engine`, `@takeon/pixi`, `@takeon/ui` and `@takeon/editor` are +versioned **in lockstep** and released by tagging: pushing a `v*` tag (e.g. `v0.2.0`) runs the CI publish job, which builds, tests, and publishes any package whose version isn't already on -npmjs.org. Bump both `package.json` versions + add an entry here in the same +npmjs.org. Bump every `package.json` version + add an entry here in the same change. Semver: breaking engine/adapter API → major (minor while pre-1.0), additive → minor, fixes → patch. +## Unreleased + +### @takeon/engine + +- **Runtime body registry** (`world/registry.ts`): `registerBody`, + `registerBodies`, `unregisterBody`, `listBodies`, `isRegisteredBody`. + `getBody` now resolves registered bodies first, so an editor draft or a + backend row can shadow a built-in without patching the catalog. +- **Terrain analysis** (`world/analysis.ts`): `heightField`, `slopeField`, + `reachableMask`, `crossSection` and `analyzeTerrain` — relief, slope + distribution, drivable fraction for a given climb limit, surface/material + mix and total recoverable resources. Pure and DOM-free. +- **Authoring** (`world/authoring.ts`): `createBodyDraft`, `cloneBody`, + `forkBody`, `validateBody` (errors + playability warnings), the + `BODY_FIELDS` inspector schema with `getBodyField`/`setBodyField`, and + `bodyToJson` / `bodyToTypeScript` / `parseBodyJson`. +- `GAME_EVENT_KEYS` lists every event key at runtime (with a compile-time + exhaustiveness guard) so hosts can subscribe to everything. +- `skinBiome` is exported for tools that need the surface-patch field. + +### @takeon/ui (new) + +- React mission shell: `MissionProvider` (engine lifecycle, HUD snapshot, + toasts, autosave, persistence via `SyncAdapter`), `MissionScreen` and the + full HUD component set, plus `TakeOnMission` for one-line embedding. +- Component registry: every component is registered through `createSlot` and + resolved via `TakeOnUIProvider`, so a parent app can replace, wrap, restyle + (`classNames`, `theme`), relabel (`labels`) or extend (`slots`) any part of + the interface. See `docs/UI.md`. + +### @takeon/engine — engine structure + +- **Scene layer** (`scene/`): `buildScene(sim)` flattens a running mission into + renderer-agnostic entities (position, height, facing, variant, flags), so a + host can draw TakeOn with its own technology. Enforced in the layering test. +- **View abstraction** (`render/view.ts`): `SceneView` — draw, pick, resize, + rotate, photo, minimap — implemented by the isometric renderer and by a new + **`FlatRenderer`**: a top-down 2D map with hillshading, contour shading, grid, + scanner reach, home pin and entity glyphs, rasterised once per world change. + `RoverGame.setView('iso' | 'flat')` / `toggleView()` switch at runtime on a + shared camera; `startView` picks the initial one. +- **Entity parts**: the rover moved to `render/rover.ts` and is now assembled + from registered parts (`registerRoverPart`, `listRoverParts`) with + build/state conditions, plus new detail — cargo crates that stack with the + hold, a charge LED strip, hull scorch and sparks, a status strobe, drilling + chips, a sweeping dish, a pulsing scanner tip, solar glint and RTG heat glow. + The 2D view has a painter registry per entity kind (`registerFlatPainter`). +- **Noise library** (`util/noise/`): Perlin, simplex, Worley (F1 / F2−F1 / + cells), white, and blue noise (void-and-cluster mask, Poisson-disk sampling, + `scatterPoints`); `fbm`/`ridged`/`billow` fractals, domain warp, and a + `NoiseConfig` + `makeNoise` factory. Bodies opt in via `terrain.noise`; + without it the shipped worlds regenerate byte-for-byte. +- `Simulation.landingSite`, `BodyField.defaultValue` (so inspectors show the + effective default instead of 0), and a `viewChanged` game event. + +### @takeon/ui — visual direction + +- New default skin: a pulp sci-fi homage (Out There: Ω). Hex cells for the + action bank and drive pad, notched panel frames with hairline neon edges, + segmented resource gauges, uppercase condensed type, comic-caption toasts and + a scene vignette. Pure CSS — no web fonts, no images, nothing fetched. +- `Meter` gained `tone`, `Modal` gained a title bar with a close control, and + `Chip` gained `tone`; all still overridable through the registry, and the + whole palette is `--tk-*` variables. +- View toggle in the HUD bar; `hud.view`, `actions.setView`/`toggleView` and a + `view` prop on `MissionProvider`. +- `ActionBar` decluttered: only capability-gated, frequent verbs (mine, photo, + scan) sit on the hotbar. Occasional actions (build, craft, place, repair, + deposit, hold) collapse behind a single "More" hex that opens `.tk-tray`, a + pop-up list that closes itself on selection, outside click or Escape. New + `ActionItem.menu` flag controls the split for custom action sets. + +### @takeon/editor (new) + +- `TakeOnEditor`: mountable world editor — project tree of built-ins and + drafts, schema-generated inspector with live validation, isometric scene + view with camera controls and a day/night scrub, elevation / slope / + surface / ore / drivability maps, raw noise preview, vertical cross-section, + terrain analysis, rover picker, undo/redo, JSON/TS import-export and an + in-editor play mode wired to the real HUD. See `docs/EDITOR.md`. +- Layout is one grid of uniform cards (scene 2×2, everything else the same + footprint, each scrolling internally) instead of sidebars, so panels stay in + line at every width. Chrome is light by default with a dark toggle + (remembered); the scene stays dark either way. +- Scene card switches between the 3D diorama and the 2D map; the inspector + gained a *Noise field* group; Maps gained a live preview of the body's actual + noise field and a blue-noise **Scatter** preview. + +### web + +- `/editor` route; the mission screen is now a thin host over `@takeon/ui`, + and destination lists resolve through `listBodies()` so edited worlds are + immediately launchable. + +### desktop (new, not published) + +- Optional Electron shell so the editor runs as a macOS/Windows/Linux app. + ## 0.2.0 — first published release ### @takeon/engine diff --git a/CLAUDE.md b/CLAUDE.md index c7cb2a2..9ca0ecd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,10 @@ Isometric voxel rover game. npm workspaces monorepo + Go PocketBase spoke. ## Commands ```bash -npm install && npm run build # builds engine → pixi adapter → web (order matters) -npm test # engine unit tests (vitest) -npm run dev # Next.js dev server on :3400 +npm install && npm run build # engine → pixi → ui → editor → web (order matters) +npm test # engine + @takeon/ui unit tests (vitest) +npm run dev # Next.js dev server on :3400 (editor at /editor) +cd desktop && npm install && npm start # optional Electron shell for the editor cd pocketbase && TAKEON_ALLOW_ANON=true go run . serve --http 127.0.0.1:8094 node scripts/export-catalog.mjs # regen pocketbase/seed/*.json after catalog edits node scripts/fetch-dem.mjs # embed real MOLA/LOLA DEM patches (needs trek.nasa.gov) @@ -18,10 +19,35 @@ Runtime verification recipe: `.claude/skills/verify/SKILL.md`. ## Architecture rules - **All gameplay rules live in `packages/engine/src/sim/` and `parts/`** — - renderer and web UI only observe. The sim is deterministic, fixed 10 Hz. + renderers and web UI only observe. The sim is deterministic, fixed 10 Hz. +- **Layers are enforced**: `util → world → sim → scene → render → input`, with + `core` on top (`test/layering.test.ts` fails on an upward import). `scene/` + flattens a frame into renderer-agnostic entities; `render/` has two views + (`IsoRenderer`, `FlatRenderer`) behind the `SceneView` interface. Architecture + overview: `docs/ENGINE.md`. +- **Entity art is componentised**: the rover is a list of registered parts + (`render/rover.ts`, `registerRoverPart`), the 2D view has a painter per entity + kind (`registerFlatPainter`). Add hardware as a part, not as more branches. +- **UI lives in `packages/ui` (`@takeon/ui`), never in `web/`.** Every + component is registered by key via `createSlot` and resolved through + `TakeOnUIProvider`, so a parent app (Landnam, the editor, the Next.js app) + can replace/wrap/restyle any of them. Adding a component means adding a slot + key + a `SLOT_KEYS` entry; `web/` stays a thin host. See `docs/UI.md`. +- **Authoring APIs belong in the engine, not the editor** — `world/authoring.ts` + (draft/validate/field schema/export), `world/analysis.ts` (heightfield, + slopes, reachability, stats) and `world/registry.ts` (runtime body registry) + are pure and DOM-free so they stay testable and reusable. `@takeon/editor` + is only a UI over them. - Worlds regenerate deterministically from `(BodyDef, seed)`; persistence stores only voxel `edits` + entity state (`MissionState`). Never persist raw - voxels. + voxels. **Anything that changes generation output must be opt-in per body** — + that is why `terrain.noise` exists; `test/noise.test.ts` asserts the shipped + bodies stay byte-identical. Same rule for `terrain.biomes` (chunked biome + skins, `world/biomes.ts`) and `world/kinds.ts` (planet/moon/asteroid/gaseous + preset inheritance + `instantiateBody()` for host-supplied properties like + real temperature) — both additive, both opt-in. `world/layers.ts` is the + seam for a *module* to add a second world grid (caves, sky); core registers + none, so mining stays top-down deposits + ice. See `docs/ENGINE.md`. - The engine has **zero runtime dependencies** and must keep working without DOM access for tests (canvas bits are guarded; `OffscreenCanvas` fallback). - Web ↔ storage goes through `SyncAdapter` (`net/sync.ts`) only. The web app @@ -32,6 +58,8 @@ Runtime verification recipe: `.claude/skills/verify/SKILL.md`. - Part/body catalogs: source of truth is the engine (`parts/catalog.ts`, `world/bodies.ts`); `scripts/export-catalog.mjs` exports them to `pocketbase/seed/` which the Go backend embeds + upserts on serve. + Resolve destinations with `getBody`/`listBodies` (registry-aware), never by + scanning `BODIES` — editor drafts and backend rows register at runtime. ## PocketBase spoke conventions (Star Sailors ecosystem) @@ -51,6 +79,13 @@ Runtime verification recipe: `.claude/skills/verify/SKILL.md`. `pkill -f 'next[-]server'`; always restart `next start` after rebuilding. - Engine tsconfig uses `moduleResolution: bundler` with `.js` import suffixes; keep both or Next/tsc will disagree. +- New `GameEvents` keys must be added to `GAME_EVENT_KEYS` (`types.ts`); a + compile-time guard fails the build otherwise, and the UI firehose iterates it. +- `RoverGameOptions.startView` (not `view`) picks the initial renderer — + `@takeon/pixi`'s mount options already use `view` for the host canvas. +- React packages build with `tsc`; stylesheets are copied by + `scripts/copy-assets.mjs` (tsc ignores CSS). Their `exports` maps need a + `default` condition or Next's webpack resolver rejects the package path. - The iso painter's order is diagonal (`x+y` asc, then z asc); dynamic entities are drawn after chunk blits and occluders re-stamped (`renderer.redrawOccluders`) — don't reorder without checking cliffs. diff --git a/README.md b/README.md index d7e1200..91c436c 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,22 @@ like [Landnam](https://github.com/Signal-K/planet-hunters-experiment-1) — via |---|---| | `packages/engine` | `@takeon/engine` — the whole game as a zero-dependency TypeScript library: voxel worlds, terrain gen, isometric renderer (Canvas 2D, chunk-cached), rover simulation, parts/customiser maths, actions, persistence adapters | | `packages/pixi-adapter` | `@takeon/pixi` — mounts a mission inside an existing PixiJS stage (v7/v8) | -| `web` | Standalone Next.js app: garage, customiser, destination picker, mission HUD. Mobile-friendly (touch d-pad, pinch zoom, tap-to-drive) | +| `packages/ui` | `@takeon/ui` — React mission shell and HUD. Every component is replaceable by the parent app ([docs/UI.md](docs/UI.md)) | +| `packages/editor` | `@takeon/editor` — mountable world editor: inspector, scene view, terrain/noise maps, analysis, play mode ([docs/EDITOR.md](docs/EDITOR.md)) | +| `web` | Standalone Next.js app: garage, customiser, destination picker, mission HUD, `/editor`. Mobile-friendly (touch d-pad, pinch zoom, tap-to-drive) | +| `desktop` | Optional Electron shell to run the editor as a Mac/Windows/Linux app (not an npm workspace) | | `pocketbase` | Go PocketBase **spoke** backend (port 8094) following the Star Sailors hub-and-spoke pattern: JS `pb_migrations`, custom `/api/takeon/*` routes, auth delegated to the shared backend, discovery cross-post hook | +| `docs/ENGINE.md` | How the engine is structured: layers, scenes, views, entity parts, noise, registries | | `docs/INTEGRATION.md` | How to embed TakeOn in Landnam / any PixiJS game, or behind your own storage | +| `docs/UI.md` | Adapting/replacing the UI from a game built on TakeOn | +| `docs/EDITOR.md` | The world editor: terrain, noise, maps, analysis, play mode | ## Quick start (no backend needed) ```bash npm install -npm run build # engine → pixi adapter → web -npm run dev # http://localhost:3400 +npm run build # engine → pixi adapter → ui → editor → web +npm run dev # http://localhost:3400 (editor at /editor) ``` The web app persists to `localStorage` when no backend is configured — fully @@ -37,16 +43,18 @@ playable offline. ## Installing the packages -`@takeon/engine` and `@takeon/pixi` publish to **npmjs.org** (not GitHub +`@takeon/engine`, `@takeon/pixi`, `@takeon/ui` and `@takeon/editor` publish to **npmjs.org** (not GitHub Packages — its npm registry only hosts scopes matching the repo owner, i.e. `@signal-k/*`, and requires auth even for public installs): ```bash npm install @takeon/engine # the whole game as a library npm install @takeon/pixi # + the PixiJS mount adapter +npm install @takeon/ui # + the React HUD (overridable component registry) +npm install @takeon/editor # + the world editor component ``` -Releases are tag-driven: bump both package versions (lockstep), add a +Releases are tag-driven: bump the package versions (lockstep), add a `CHANGELOG.md` entry, and push a `v*` tag — CI builds, tests, and publishes anything not already on the registry. The repo needs an `NPM_TOKEN` secret (npm automation token with publish rights on the `@takeon` scope). @@ -62,8 +70,12 @@ anything not already on the registry. The repo needs an `NPM_TOKEN` secret gravity, sunlight and delta-v: your fuel capacity gates what you can reach, gravity scales fall damage, solar flux scales charging, day length drives the day/night cycle. Bennu is an irregular rubble pile with map-edge cliffs. +- **Two views** — the isometric voxel diorama, or a top-down 2D map with + hillshading, contours, scanner reach and entity glyphs. Toggle from the HUD + (`⬔ / ▦`) or the editor; both draw the same scene, share one camera, and + support tap-to-drive and tile picking. - **On the surface** — drive (keys, touch d-pad, or tap-to-drive), rotate the - isometric perspective in 90° steps (R), mine the voxel terrain (materials + perspective in 90° steps (R), mine the voxel terrain (materials have hardness and yields; ores live in veins), photograph anomalies to document discoveries, scan to reveal them, build structures from cargo (solar array, nav beacon, auto-drill rig, supply cache, refinery, habitat @@ -76,9 +88,25 @@ anything not already on the registry. The repo needs an `NPM_TOKEN` secret discoveries convert to credits. A rover that runs out of battery with no way to recharge — or breaks its chassis — is lost. +The rover is drawn from **parts** rather than a fixed sprite: fitted modules, +cargo load, charge level, hull damage, drilling and nightfall each add or +change hardware on screen, and a host game can register its own parts. + Worlds are generated deterministically from `(body, seed)`, so saves only persist voxel *edits* plus rover/structure state; resume is byte-faithful. +## Noise and terrain fields + +Elevation can come from any of several fields — value, Perlin, simplex, Worley +(craters or fractures) — stacked as fBm, ridged or billow, with domain warping, +all described by one `NoiseConfig` on the body. Blue noise (Poisson-disk) is +available for *placement*: evenly spread scatter with no clumps. The editor +edits the config, previews the exact field the generator will use, and shows a +blue-noise scatter preview. + +Bodies without a `noise` block keep the original field byte-for-byte, so +existing saves stay valid. See [docs/ENGINE.md](docs/ENGINE.md). + ## Real planetary data Two data channels feed generation: @@ -149,10 +177,65 @@ mounted.game.events.on('anomalyDocumented', ({ anomaly }) => { /* award XP */ }) See [docs/INTEGRATION.md](docs/INTEGRATION.md) for the event catalogue and how to supply a custom `SyncAdapter` so mission data lands in *your* database. +## Editing worlds + +TakeOn ships a small game editor — project tree, scene view, inspector, +terrain instruments and a play button — for designing destinations instead of +hand-editing `bodies.ts`: + +```bash +npm run dev # http://localhost:3400/editor +``` + +Or as a desktop app on macOS: + +```bash +npm run build && npm run start -w takeon-web +cd desktop && npm install && npm start +``` + +One grid of uniform cards — scene, inspector, maps, analysis, rover, +destinations, events — with light chrome by default (☾ toggles dark). Edit +terrain, noise and mineral parameters with live regeneration; read the +elevation / slope / surface / ore / drivability maps and a vertical +cross-section; check the analysis numbers (relief, cliff fraction, drivable +percentage, recoverable resources); then press ▶ Play to drive the world with +the real HUD, in the same card. Drafts are published to the engine's body registry, so an edited +world is immediately launchable from the garage. Export as `BodyDef` JSON or as +a TypeScript literal for `world/bodies.ts`. Full guide: +[docs/EDITOR.md](docs/EDITOR.md). + +## Building a game on top of TakeOn + +The HUD is a package (`@takeon/ui`) styled as pulp sci-fi instrumentation — +hex cells, notched panel frames, segmented gauges, no web fonts or images — and +every component in it is resolved through a registry, so a host game can +replace, wrap, restyle or extend any part of the interface: + +```tsx + }, // inject extra chrome + theme: { accent: '#ff8a3d' }, // recolour via CSS variables + }} +/> +``` + +`MissionProvider` owns the engine loop, HUD state, toasts and persistence and +exposes them through hooks, so a game can also drop the stock HUD entirely and +build its own from `useMission()`. Gameplay rules stay in the engine either +way. See [docs/UI.md](docs/UI.md). + ## Tests ```bash -npm test # engine unit tests (terrain determinism, sim, economy) +npm test # engine unit tests (terrain determinism, sim, economy, + # authoring/registry/analysis) + @takeon/ui registry tests ``` ## Performance notes diff --git a/desktop/main.cjs b/desktop/main.cjs new file mode 100644 index 0000000..5a9b5cf --- /dev/null +++ b/desktop/main.cjs @@ -0,0 +1,62 @@ +/** + * Optional Electron shell so the editor runs as a desktop app on macOS + * (and Windows/Linux) instead of a browser tab. + * + * It is deliberately *not* an npm workspace: Electron is a ~150 MB download + * and nobody working on the game itself needs it. See docs/EDITOR.md. + * + * npm run build && npm run start -w takeon-web # serve the app + * cd desktop && npm install && npm start # open the window + */ +const { app, BrowserWindow, shell } = require('electron'); + +const URL = process.env.TAKEON_EDITOR_URL || 'http://127.0.0.1:3400/editor'; + +function createWindow() { + const win = new BrowserWindow({ + width: 1480, + height: 940, + minWidth: 960, + minHeight: 620, + backgroundColor: '#120e2e', + title: 'TakeOn Editor', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + webPreferences: { contextIsolation: true, nodeIntegration: false }, + }); + + // Retry: the dev server is often still booting when the window opens. + const load = (attempt = 0) => { + win.loadURL(URL).catch(() => { + if (attempt > 40) { + win.loadURL( + `data:text/html,${encodeURIComponent( + ` +

Nothing serving ${URL}

+

Start the app first: npm run build && npm run start -w takeon-web

+ `, + )}`, + ); + return; + } + setTimeout(() => load(attempt + 1), 500); + }); + }; + load(); + + // External links open in the real browser, not inside the editor window. + win.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: 'deny' }; + }); +} + +app.whenReady().then(() => { + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..fa13ffe --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,13 @@ +{ + "name": "takeon-desktop", + "private": true, + "version": "0.1.0", + "description": "Electron shell that runs the TakeOn editor as a desktop app (macOS/Windows/Linux). Not part of the npm workspace — install on demand.", + "main": "main.cjs", + "scripts": { + "start": "electron ." + }, + "devDependencies": { + "electron": "^33.0.0" + } +} diff --git a/docs/EDITOR.md b/docs/EDITOR.md new file mode 100644 index 0000000..b302851 --- /dev/null +++ b/docs/EDITOR.md @@ -0,0 +1,204 @@ +# The TakeOn editor + +A minimal, Unity/Godot-shaped authoring tool for TakeOn worlds: project tree, +scene view, inspector, analysis instruments and a play button. It exists so +terrain, noise and map work can be *planned* — tuned against real numbers and +driven immediately — instead of guessed at in `bodies.ts` and rebuilt. + +It ships as `@takeon/editor`, a single React component with no routing of its +own, so it runs in this repo's Next.js app, inside a game built on TakeOn +(Landnam), or in a desktop window. + +``` +┌──── toolbar: ▶ Play · undo/redo · seed · climb limit · JSON/TS · ☾ theme ────┐ +│ ┌───────────────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ │ │ Inspector │ │ Maps │ │ +│ │ Scene (2×2 cells) │ ├────────────┤ ├────────────┤ │ +│ │ │ │ Analysis │ │ Rover │ │ +│ └───────────────────────┘ └────────────┘ └────────────┘ │ +│ ┌───────────────────────┐ ┌───────────────────────────┐ │ +│ │ Destinations │ │ Events │ │ +│ └───────────────────────┘ └───────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +Every panel is the same card: same 38 px header, same footprint, its own +internal scroll. Nothing is a sidebar, so no column grows a different height +than its neighbour and nothing shifts as content changes. Cards reflow to +three, two or one column as the window narrows; the scene keeps its 2×2 cell +until there is no room for it. + +The chrome is **light by default** — authoring happens in daylight, and a +bright shell makes the dark scene read as a viewport into the game rather than +more UI. The ☾/☀ button in the toolbar switches to dark chrome and the choice +is remembered; pass `theme="dark"` to start there. + +## Running it + +**In the browser** (also how you run it on a Mac): + +```bash +npm install && npm run build +npm run dev # http://localhost:3400/editor +``` + +**As a desktop app** (macOS, Windows, Linux) — optional Electron shell, kept +out of the workspace so nobody downloads Electron who doesn't want it: + +```bash +npm run build && npm run start -w takeon-web # serve the app +cd desktop && npm install && npm start # opens the editor window +``` + +Point it elsewhere with `TAKEON_EDITOR_URL=https://your-host/editor`. + +**Inside your own game:** + +```tsx +import { TakeOnEditor } from '@takeon/editor'; +import '@takeon/ui/styles.css'; +import '@takeon/editor/styles.css'; + + save(project)} // persist wherever you like +/>; +``` + +## What each panel is for + +### Destinations (project tree) + +Every shipped destination plus this project's drafts. Editing a built-in +creates a draft that *shadows* it (badge: `edited`); **Discard** removes the +draft and restores the shipped definition. **+ New** starts a validated blank +world, **Copy** forks the selection. + +Drafts are published to the engine's runtime body registry as you edit +(`registerBody`), so `getBody('mars')` — in play mode, on `/launch`, anywhere in +the host app — resolves *your* Mars. Pass `publish={false}` to keep the editor +sandboxed. + +### Inspector + +Generated from the engine's field schema (`BODY_FIELDS` in +`world/authoring.ts`), grouped into Identity / Physics / World / Terrain / +Mineralogy / Weather / Palette. Fields marked `↻` feed generation, so changing +them rebuilds the world (debounced ~180 ms). + +Validation runs on every keystroke: errors (which would break generation) block +play mode, warnings ("roughness above 0.9 leaves few drivable routes") don't. +Adding a new `BodyDef` property means describing it in the engine schema — the +inspector picks it up with no editor change. Hosts can append their own fields +via `extraFields`. + +### Scene view + +The real `IsoRenderer` drawing the real generated world, with the simulation +paused: nothing moves and no battery drains, so what you see is purely the +authored terrain. Drag to pan, wheel to zoom, `R` rotates 90°, `F` frames the +landing site, and the ☀ slider scrubs the day/night cycle to check lighting. + +### Maps (terrain planning instruments) + +One pixel per column, sampled from the same world the game will run: + +| View | Answers | +|---|---| +| **Elevation** | Where the relief actually is; does the seed give you basins and ridges or mush | +| **Slope** | Red is steeper than the climb limit — the cliffs that will block a rover | +| **Surface** | The biome patches (silica flats, dust basins, regolith uplands) the player sees | +| **Ore density** | Ore voxels per column; crystal picked out separately | +| **Drivable** | Flood fill from the landing site — green is reachable, red is stranded terrain | +| **Noise** | The raw fBm field at the generator's own frequency, before craters and clamping | + +Click any map to inspect a column (height, surface material, skin biome) and to +move the **cross-section** underneath it, which slices the world vertically with +the real material palette — the fastest way to see how ore veins and ice bands +are actually layered. + +The climb limit used by Slope and Drivable is the toolbar's `climb ≤ n`; set it +to the drivetrain you're designing for (rocker-bogie 2, tracks 3). + +### Analysis + +Its own card, always visible beside the maps — the numbers behind them: relief +range, mean height, cliff fraction, drivable percentage, height histogram, +surface mix and total recoverable resources. +"92% drivable, 4% cliffs, 51k stone" is a design spec; "looks about right" is +not. + +### Rover + +Also its own card: the rover play mode uses, with part dropdowns and the +derived climb, speed, move cost and cargo numbers, plus a warning when the +build can't actually reach this destination's delta-v. Mechanics work is mostly "does this drivetrain cope with +this terrain", so it sits next to the terrain controls. + +### Play mode + +▶ Play boots a real mission *inside the scene card* — same cell, so the layout +never jumps — using the full HUD from `@takeon/ui`, the same components the +shipped game uses. Every engine event lands in the events console (`tick` and `stateChanged` filtered out), so you can +watch `mined`, `damaged`, `blocked` and `weather` fire as you drive. ■ Stop +returns to the scene view. + +## Getting a world out of the editor + +- **⧉ JSON** copies the `BodyDef` — paste it into `pocketbase/seed/bodies.json`, + your own backend, or another project. +- **⧉ TS** copies a TypeScript literal ready to paste into + `packages/engine/src/world/bodies.ts`, key order matching the built-ins. +- **⭳** downloads the JSON; **⭱ Import** parses a pasted `BodyDef` (missing + fields fall back to draft defaults, problems are listed rather than thrown). + +Projects persist to `localStorage` (`takeon.editor.v1`) by default. Pass +`storage={null}` for an in-memory session, or use `onChange` to persist +wherever you like. + +## Extending it + +Panels are registered through `@takeon/ui`'s slot registry, so a host can +replace any of them: + +```tsx + +``` + +Slot keys: `EditorToolbar`, `EditorCard`, `EditorBodyBrowser`, +`EditorInspector`, `EditorViewport`, `EditorMapsPanel`, `EditorAnalysisPanel`, +`EditorRoverPanel`, `EditorConsolePanel` (exported as `EDITOR_SLOT_KEYS`). +Overriding `EditorCard` alone re-skins every panel frame at once; the tool's +own colours come from `--tke-*` variables on `.tke-shell`. The mission HUD inside +play mode uses the same mechanism — see [UI.md](UI.md). + +The pieces are exported individually too (`useEditorState`, `useEditorSim`, +`paintMap`, `Inspector`, …), so you can build a different tool — a terrain +browser, a QA harness — out of the same parts. + +## The engine APIs behind it + +Nothing in the editor is privileged; it is built on public engine exports you +can use directly: + +```ts +import { + createBodyDraft, validateBody, BODY_FIELDS, setBodyField, // authoring + registerBody, listBodies, // runtime catalog + generateTerrain, analyzeTerrain, heightField, slopeField, // measurement + reachableMask, crossSection, + bodyToJson, bodyToTypeScript, parseBodyJson, // import/export +} from '@takeon/engine'; + +const world = generateTerrain(createBodyDraft({ id: 'test', size: 64 })); +const stats = analyzeTerrain(world, { maxClimb: 2 }); +console.log(stats.traversable.reachableFraction, stats.slope.steepFraction); +``` + +All of it is pure and DOM-free, so terrain rules can be asserted in CI: +`packages/engine/test/authoring.test.ts` does exactly that. diff --git a/docs/ENGINE.md b/docs/ENGINE.md new file mode 100644 index 0000000..f1c648f --- /dev/null +++ b/docs/ENGINE.md @@ -0,0 +1,228 @@ +# TakeOn as a game engine + +TakeOn started as one game and grew into the engine underneath it. This is the +map: what the layers are, which concept owns what, and where to add things. + +Everything below is in `packages/engine` unless stated otherwise. The layering +is enforced in CI by `test/layering.test.ts` — lower layers may never import +upward. + +``` +util ──► world ──► sim ──► scene ──► render ──► input + │ │ │ │ │ + │ │ │ │ └── iso diorama · flat 2D map · entity parts + │ │ │ └── renderer-agnostic frame description + │ │ └── the rules: deterministic, fixed 10 Hz + │ └── voxels, terrain generation, analysis, authoring + └── rng · noise (value · perlin · simplex · worley · blue) · events + + core ──► RoverGame (the facade everything embeds) + net ──► SyncAdapter parts ──► catalogue + stats maths + @takeon/ui ──► React shell @takeon/editor ──► authoring tool +``` + +## The concepts + +| Concept | Where | What it owns | +|---|---|---| +| **World** | `world/world.ts` | Dense voxel storage, column heights, player edits | +| **Terrain** | `world/terrain.ts` | Deterministic generation from `(BodyDef, seed)` | +| **Simulation** | `sim/simulation.ts` | Every rule: driving, power, mining, weather, structures. Fixed 10 Hz, renderer-free | +| **Scene** | `scene/` | One frame flattened into entities — positions, facings, variants, flags. Pure data | +| **View** | `render/view.ts` | The `SceneView` contract: draw, pick, resize, rotate, photo, minimap | +| **Entity parts** | `render/rover.ts`, `render/flat-painters.ts` | How an entity is *drawn*, as registered components | +| **Game** | `core/game.ts` | `RoverGame`: loop, input, audio, view switching, save | +| **Authoring** | `world/authoring.ts`, `world/analysis.ts`, `world/registry.ts` | Drafts, validation, the inspector schema, terrain measurement, the runtime catalogue | + +The rule that keeps this honest: **gameplay lives in `sim/` and `parts/`**. +Views and UI observe state and call actions; they never decide outcomes. A game +that replaces every pixel of the presentation still plays identical missions. + +## Scenes, in 2D and 3D + +A scene is the renderer-agnostic description of a frame: + +```ts +const scene = game.scene(); +// { id, name, view, size, time, daylight, home, entities: [ +// { id: 'rover', kind: 'rover', pos: {x, y}, z, facing, data: {...}, ref }, +// { id: 'st_3', kind: 'structure', variant: 'refinery', data: { powered: true } }, +// ] } +``` + +Two views ship, and both draw the same world: + +- **`iso`** — the isometric voxel diorama: smoothed surfaces, cliffs, strata, + ambient life. The default, and what "3D" means here (2.5D projection over + Canvas 2D — there is no GL dependency). +- **`flat`** — the top-down 2D map: hillshaded heightfield, grid, contour + shading, entity glyphs, scanner reach and the home pin. Rasterised once per + world change, so a frame is one scaled blit plus entities. + +```ts +game.setView('flat'); // or game.toggleView() +game.view; // 'flat' +game.camera; // shared by both views — switching keeps your place +``` + +In the game, the HUD's `⬔ / ▦` button toggles them. In the editor, the scene +card's `3D / 2D` button does. Some screens simply read better flat — route +planning, outpost layout, ore surveying — and a UI-heavy scene in a game built +on TakeOn may want no diorama at all. + +To add a third view (WebGL, Pixi, SVG…), implement `SceneView` and draw from +`buildScene(sim)`; nothing else needs to know. + +## Entities are assembled from parts + +The rover is not one painter — it is a list of registered parts, each with a +condition, an order and whether it rides the suspension: + +```ts +import { registerRoverPart } from '@takeon/engine'; + +registerRoverPart({ + id: 'my-game/pennant', + order: 76, + sprung: true, + when: (p) => p.has.scanner, // build-driven + draw: (ctx, p) => { /* vector art in rover-local space */ }, +}); +``` + +The built-in set reacts to the build *and* the live state: drivetrain (wheels +or tracks, upgraded by mobility kits), solar wings with a daylight glint, an +RTG that glows at night, camera mast, sweeping dish, scanner whip with a pulse, +tool arm that swings and throws chips while drilling, crates that stack as the +hold fills, a five-LED charge strip, scorch marks and sparks as the hull wears, +a status strobe that goes red in trouble, and headlights after dusk. + +The flat view has its own painter per entity kind +(`registerFlatPainter('rover', …)`), so a host can add entity types to either +view without forking a renderer. + +## Noise + +`util/noise/` is a small field library rather than one hard-coded function: + +| Sampler | Character | +|---|---| +| `valueNoise2` | The original TakeOn field. Soft, lumpy. Cheapest | +| `perlin2` | Gradient noise — flowing dunes and ridges | +| `simplex2` | Isotropic gradient noise, no grid artefacts | +| `worley2` | Cellular: craters/pans (`f1`), fractures (`f2f1`), plates (`cells`) | +| `blueNoiseMask` / `poissonDisk` / `scatterPoints` | Evenly spread randomness for *placement* — boulders, flora, sample sites, dithering | + +Fractal stacking (`fbm`, `ridged`, `billow`), domain warping and frequency all +come from one config: + +```ts +const field = makeNoise( + { type: 'simplex', fractal: 'ridged', frequency: 0.06, octaves: 5, warp: 4 }, + seed, +); +field(x, y); // [0,1] +``` + +A destination opts in by carrying that config: + +```ts +terrain: { roughness: 0.6, craters: 4, iceCaps: 0, oreRichness: 0.5, + noise: { type: 'perlin', fractal: 'billow', frequency: 0.05 } } +``` + +**Bodies without a `noise` block keep the original field exactly** — the +shipped worlds regenerate byte-for-byte, which matters because saves store only +voxel edits. A test asserts it. + +The editor exposes all of this: a *Noise field* group in the inspector, a live +preview of the exact field the generator will use, and a blue-noise **Scatter** +preview. + +## Kinds, chunked biomes and world layers + +Three related, independently opt-in systems for landscapes that vary by more +than one noise config: + +**Kinds** (`world/kinds.ts`) are an inheritable preset tree above `BodyDef` — +`body` → `planet` → `rocky-planet` / `earth-like`, `body` → `moon` → +`ice-moon` / `rocky-moon`, `body` → `asteroid` → `c/m/s-type-asteroid`, +`body` → `gaseous`. `BodyType` ('planet'/'moon'/'asteroid'/'gaseous') is the +coarse category gameplay branches on; a *kind* bundles the physics/palette/ +weather/climate defaults for a flavour of it, inheriting from its parent so +you only override what's different: + +```ts +import { instantiateBody } from '@takeon/engine'; + +// A host's own solar-system sim hands in the one thing it actually knows — +// this body's real temperature — and gets back a concrete, generatable body. +const body = instantiateBody('rocky-planet', { + id: 'kepler-186f', name: 'Kepler-186f', seed: 42, + climate: { temperature: -63 }, +}); +``` + +**Chunked biomes** (`world/biomes.ts`, opt in with `terrain.biomes: true`) +divide the surface into a `CHUNK_SIZE` (16) grid, each chunk independently +seeded and rolling one `Biome` — a small surface/subsurface material list, +gated by the body's `kind` (`allowedBiomesForKind`) and, if set, `climate` +(a per-chunk temperature sampled around `climate.temperature`, swung by +`climate.tempVariance`). Ore veins stay `OreBand`'s job — a biome only +dresses the top couple of surface voxels, so the "skin" and the "mineral +economy underneath" stay independent, composable layers. Absent the flag, a +body keeps the original single-skin generation byte-for-byte. + +**World layers** (`world/layers.ts`) are the seam for a second grid +alongside the surface world — a cave layer, a sky layer — for a *module* +built on top of TakeOn, not core itself: core registers none, so mining +stays exactly what it is today (top-down, deposits and ice). A module calls +`registerWorldLayer(id, (body, seed, world) => yourData)`; `generateTerrain` +runs every registered generator and attaches the result to +`world.layers[id]`, opaque to core. Deterministic like the rest of +generation, so it needs no persistence of its own. + +See [LANDNAM.md](LANDNAM.md) for how this maps onto a specific host game's +scenes. + +## Registries — the extension points + +| Registry | Add with | Used for | +|---|---|---| +| Destinations | `registerBody(def)` | Editor drafts, backend rows, host worlds | +| Body kinds | `registerBodyKind(kind)` | New planet/moon/asteroid presets to `instantiateBody()` | +| Biomes | `registerBiome(biome)` | New chunk-scale surface skins | +| World layers | `registerWorldLayer(id, fn)` | A module adding an underground/sky grid | +| Materials/resources | `registerMaterial`/`registerResource` | New voxel materials and cargo keys | +| Structures | `registerStructure(def)` | New buildable structures | +| Rover parts | `registerRoverPart(part)` | New hardware on the rover sprite | +| Flat painters | `registerFlatPainter(kind, fn)` | Drawing new entity kinds in 2D | +| UI components | `` | Replacing any HUD component ([UI.md](UI.md)) | +| Editor panels | same registry, `Editor*` keys | Replacing tool panels ([EDITOR.md](EDITOR.md)) | +| Inspector fields | `BODY_FIELDS` + `extraFields` | New authorable properties | +| Persistence | `SyncAdapter` | Putting mission data in your database | +| Events | `game.events` / `GAME_EVENT_KEYS` | Quests, XP, analytics — every event is enumerable | + +## Determinism rules + +1. A world is a pure function of `(BodyDef, seed)`. Never persist voxels; persist + edits (`MissionState`). +2. Anything that changes generation output changes every existing save of that + body. New generation features must be opt-in per body (that is why + `terrain.noise` exists rather than a global switch). +3. The sim uses seeded RNG only — no `Math.random` in `sim/` or `world/`. +4. Renderer-only randomness (ambient life, particles) never feeds back into the + sim, and is not saved. + +## Adding things + +- **A destination** — author it in the editor, export the TS literal into + `world/bodies.ts`, or `registerBody()` it at runtime. +- **A noise type** — add the sampler in `util/noise/`, list it in `NOISE_TYPES`, + and it appears in the config factory, the inspector and the previews. +- **A body property** — add the field to `BodyDef`, describe it in + `BODY_FIELDS`, validate it in `validateBody`. The inspector picks it up. +- **A rover component** — `registerRoverPart`. +- **A view** — implement `SceneView`, draw from `buildScene`. +- **A HUD component** — add a slot key in `@takeon/ui` and register the default. +- **A gameplay rule** — it goes in `sim/`, with a test. Nowhere else. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 8c2edc2..b32ba5a 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -2,8 +2,18 @@ TakeOn's engine is a plain TypeScript library with no framework or asset dependencies — everything (terrain sprites, rover, structures, anomalies) is -drawn procedurally into a canvas it owns. That makes three integration levels -possible. +drawn procedurally into a canvas it owns. That makes several integration levels +possible: a bare canvas, a PixiJS stage, or — if your game is React — +`@takeon/ui`, whose every component the host can replace ([UI.md](UI.md)). +Worlds themselves are authored in `@takeon/editor` ([EDITOR.md](EDITOR.md)), +and the engine's own structure — layers, scenes, views, entity parts, noise — +is mapped in [ENGINE.md](ENGINE.md). For the Star Sailors ecosystem +specifically, [LANDNAM.md](LANDNAM.md) maps Landnam's construction, +underground and settlement/exploration scenes onto this API. + +Missions render in two built-in views: the isometric diorama and a top-down 2D +map (`game.setView('flat')`). If neither suits your game, take +`game.scene()` — a renderer-agnostic list of entities — and draw it yourself. ## 1. Drop-in canvas (any host) @@ -66,6 +76,30 @@ game, and `mounted.destroy()` releases the sprite, texture, input and loop. Returned handle: `{ game, sprite, resize(w, h), destroy() }`. +## 3. Inside a React app (`@takeon/ui`) + +```tsx +import { TakeOnMission } from '@takeon/ui'; +import '@takeon/ui/styles.css'; + + award(credits)} +/>; +``` + +`MissionProvider` owns the engine loop, HUD state, toasts and persistence; +every HUD component is resolved through a registry, so a host game can replace, +wrap, restyle or extend any of them — or drop the stock HUD entirely and build +its own from `useMission()`. Full guide: [UI.md](UI.md). + +Custom destinations authored in the editor (or fetched from your backend) are +published with `registerBody(def)`; `getBody`/`listBodies` then resolve them +everywhere, including in the launch screen and in saved missions. + ## Events `game.events.on(name, handler)` — the full host-integration surface: diff --git a/docs/LANDNAM.md b/docs/LANDNAM.md new file mode 100644 index 0000000..d97725b --- /dev/null +++ b/docs/LANDNAM.md @@ -0,0 +1,97 @@ +# Landnam readiness + +[Landnam](https://github.com/Signal-K/planet-hunters-experiment-1) is a +Star Sailors spoke that vendors `@takeon/engine`/`@takeon/pixi` as `file:` +tarballs (`web/vendor/takeon/`) and currently only wires them into a +standalone technical-preview demo (`TakeonEngineDemo.tsx`), not into live +game state. This maps its four scene families — construction, underground, +settlement/exploration — onto what TakeOn now provides, and what changed in +this repo to close the gaps found during the audit. No changes were made to +the Landnam repository; it is read-only context for this engine. + +## Construction + +Landnam's live build screen (`BuildPlaceScreen.tsx`) already matches TakeOn's +shape: a plot grid, a structure catalog, and a cost-in-minerals gate. TakeOn +covers this with `STRUCTURES`, `game.build(type)`/`built`/`buildFailed` +events, and `RECIPES`/`game.craft(id)` for refining (see +[INTEGRATION.md](INTEGRATION.md)). The gap was that Landnam's structure and +mineral catalogs (`target-structures.ts`, `minerals.ts`) don't line up with +TakeOn's fixed built-in `StructureType`/`ResourceKey` unions — a spoke can't +register its own building or ore without either forking the engine or +waiting on a PR here. + +**Fix**: `StructureType` and `ResourceKey` are now open string unions +(`| (string & {})`), and `MATERIALS`/`RESOURCE_NAMES`/`RESOURCE_VALUE`/ +`STRUCTURES` are backed by a real registry (`registerStructure`, +`registerResource`, `registerResourceValue`, `registerMaterial` — all +exported from `@takeon/engine`). A host registers its own catalog once at +startup; every internal call site (`MATERIALS[m]`, `STRUCTURES[type]`, +build/craft/render code) already reads through these objects, so custom +entries work with zero engine changes. Landnam's 10 `TARGET_STRUCTURES` and +16 `MINERAL_META` entries can be registered directly instead of remapped +onto TakeOn's built-in set. + +Also unblocked: Landnam's orphaned decorative `ConstructionTargetCanvas` +scene (3 fixed pads, no simulation underneath) can now be replaced with a +real `game.build()`-backed screen if Landnam wants it live. + +## Underground + +Landnam's `HubSubsurfaceView.tsx` is a static SVG of three fixed strata +(topsoil/subsoil/bedrock, each a hardcoded mineral). TakeOn had the data for +a *real* underground view (`crossSection()` in `world/analysis.ts`) but no +ore stratification (bodies only had a flat iron/copper/titanium mix) and no +reusable painter — the pixel-to-canvas logic lived inside `@takeon/editor`'s +internal `maps.ts`, unusable by a host game. + +**Fix, two parts**: + +1. **Depth-stratified ore** (`OreBand`, opt-in per body via + `terrain.bands`): fraction-of-column-depth bands (`from`/`to` in `0..1`, + independent of a body's `maxHeight`) each naming which materials can + appear in that stratum. Bodies that don't opt in keep the old flat mix + byte-identical (`test/noise.test.ts`, `test/ore-bands.test.ts`). This is + what makes Landnam's "topsoil is platinum, bedrock is rhodium/gold" idea + an actual generation rule instead of a hand-drawn illustration. +2. **A droppable component**: `crossSectionImage()`/`renderCrossSection()` + (`@takeon/engine`, `render/cross-section.ts`) turn a world slice into + real pixels — DOM-free (`crossSectionImage` feature-detects `ImageData` + so it runs in a worker or a Node test) and canvas-blitting + (`renderCrossSection`). `@takeon/ui` wraps it as ``: a + live panel that follows the rover's position along an axis and redraws + on an interval, styled to match the rest of the HUD + (`.tk-cross-section*` in `styles.css`). It isn't part of the default + `MissionScreen` layout — mount it via a slot, a custom panel, or a + standalone screen, matching how Landnam's subsurface view is its own + route/screen already. + +`@takeon/editor`'s own cross-section tool now delegates to the same +`renderCrossSection()` rather than duplicating the pixel logic, so the +editor preview and the in-game view are guaranteed to agree. + +## Settlement / exploration + +Landnam's `RoverMiningScreen.tsx` is a stub timer, not a simulation — this +is the one scene family that isn't a TakeOn gap so much as a Landnam +integration gap. TakeOn's actual rover sim already exercises this world: +`game.move`/`game.mine`/`game.scan`/`game.photo`, `anomalyDocumented` +discoveries, and the pure offline-first objective module +(`createWorldState`/`explore`/`deriveMissionProgress`) for a lighter, +non-realtime progress loop if Landnam doesn't want the full tile sim. Per +Landnam's own decision doc (`.knowns-outbox/takeon-decision-bundle-*.md`), +exploration is intended as the first mechanic to prove out — nothing further +was needed engine-side for this pass; settlements/refineries/research-labs +are called out there as a later module split, which the construction-scene +work above already anticipates (open catalogs, no engine fork needed). + +## Summary of engine changes + +| Area | Before | After | +|---|---|---| +| `Material`, `ResourceKey`, `StructureType` | closed enum / string unions | open, with a mutation-based runtime registry | +| Ore placement | flat mix, whole column | optional `terrain.bands` depth strata, opt-in per body | +| Underground rendering | trapped in `@takeon/editor`'s `maps.ts` | `@takeon/engine`'s `crossSectionImage`/`renderCrossSection` + `@takeon/ui`'s `` | + +All of the above ships in `@takeon/engine` v0.2.0+ and `@takeon/ui` v0.2.0+; +Landnam picks it up by bumping its vendored tarballs. diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..650c7fa --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,168 @@ +# Adapting the UI (`@takeon/ui`) + +TakeOn's HUD is a React package, and **every component in it is replaceable by +the app that sits on top of TakeOn**. A game built on the module — Landnam, or +anything else in the Star Sailors ecosystem — can keep the parts it likes, +restyle the parts it doesn't, and swap out whole panels, without forking this +repo. + +Two pieces make that work: + +- **`MissionProvider`** owns a running mission: engine lifecycle, a React-shaped + HUD snapshot, toasts, autosave and the player actions. It contains no styling + and no routing. +- **`TakeOnUIProvider`** is a registry. Every built-in component is registered + under a key and looked up at render time, so overriding a key replaces that + component everywhere it appears — including inside components you did not + override. + +```tsx +import { MissionProvider, MissionScreen, TakeOnUIProvider } from '@takeon/ui'; +import '@takeon/ui/styles.css'; + + }} // inject extra chrome + theme={{ accent: '#ff8a3d' }} // recolour via CSS variables +> + + + +; +``` + +Or the one-liner, which wires all three together: + +```tsx + +``` + +## The four levers, cheapest first + +### 1. `theme` — CSS variables + +Every colour, edge and font in the stock skin comes from a `--tk-*` custom +property: `bg`, `panel`, `panel-solid`, `panel-raised`, `border`, `hairline`, +`text`, `text-dim`, `accent`, `accent-2`, `good`, `warn`, `bad`, `radius`, +`notch` (corner cut on panel frames), `font` and `glow`. Setting them re-skins +the whole HUD without overriding a single rule (they can also be set in plain +CSS on any ancestor). + +The stock look is a pulp sci-fi homage — deep-space panels with hairline neon +edges, hex cells for anything you press, segmented gauges, uppercase condensed +type, and a vignette over the scene. It ships as pure CSS: no web fonts, no +images, nothing fetched at runtime. Swap `accent`/`accent-2` and the whole HUD +changes allegiance; set `notch: 0` for square frames. + +### 2. `labels` — strings and icons + +Every user-visible string is looked up by key with the built-in text as +fallback: `action.mine`, `action.build`, `hud.battery` ("Power"), +`hud.durability` ("Hull"), `hud.cargo` ("Hold"), `hud.end`, `tile.drive`, +`end.confirm`, `common.close`, and so on. Useful for tone ("Dig", "Salvage"), +localisation, or matching your game's vocabulary. + +### 3. `classNames` and `slots` — extend without replacing + +`classNames` appends classes to a component's root element, keyed by slot name. +`slots` injects content at named anchors: + +`hudBar.start`, `hudBar.end`, `actionBar.start`, `actionBar.end`, +`tileMenu.end`, `mission.overlay` (exported as `SLOT_ANCHORS`). + +### 4. `components` — replace outright + +Keys (exported as `SLOT_KEYS`): + +| Key | What it is | +|---|---| +| `MissionScreen` | The whole layout | +| `MissionCanvas` | The render surface (mounting it boots the engine) | +| `HudBar` | Top status strip | +| `DPad` | Touch drive pad | +| `ActionBar` | Mine / photo / scan on the hotbar directly; build / craft / place / repair / deposit / hold behind one "More" tray | +| `Minimap` | Corner map | +| `TileMenu` | Tap-a-tile context menu | +| `BuildPanel`, `CraftPanel`, `CargoPanel`, `EndMissionDialog` | Modal panels | +| `ToastStack` | Event feedback | +| `Meter`, `Modal`, `Chip` | Shared primitives — override these to restyle everything at once | + +An override receives the same props as the default and can render the default +to *wrap* rather than replace it: + +```tsx +import { HudBar, useMission } from '@takeon/ui'; + +function MyHudBar(props) { + const { hud } = useMission(); + return ( + <> + + + + ); +} +``` + +Rendering `` (not `.Default`) from inside its own override is safe too: +the registry suppresses the override one level deep instead of recursing. + +## Building a completely custom HUD + +Drop the stock screen and use the hooks. The provider still handles the engine +loop, persistence and toasts: + +```tsx +import { MissionProvider, MissionCanvas, useMission } from '@takeon/ui'; + +function MyHud() { + const { hud, actions, setPanel } = useMission(); + if (!hud) return null; + return ( +
+ + + +
+ ); +} + + + + +; +``` + +`useMission()` gives you `hud` (battery, durability, cargo, banked yield, +daylight, weather, position, anomaly counts, capabilities, current order), +`actions` (every player verb, including `save` and `endMission`), panel and +tile-menu state, the live `RoverGame`, and the toast queue. + +For a HUD outside a mission (shared chrome that renders on any screen), use +`useMissionMaybe()`, which returns `null` instead of throwing. + +## Hooking into the game + +`MissionProvider` props cover the integration points a host game needs: + +| Prop | Purpose | +|---|---| +| `sync` | A `SyncAdapter` — autosave, photo upload, discovery recording and mission payout all route through it | +| `onSave`, `onEnd`, `onPhoto`, `onDiscovery` | Callbacks if you'd rather persist yourself | +| `onEvent` | Firehose of **every** engine event (`GAME_EVENT_KEYS`) for quests, XP and analytics | +| `onReady` | The `RoverGame` instance, for anything the wrapper doesn't expose | +| `toasts`, `audio`, `controls`, `autosaveMs`, `hudIntervalMs` | Turn built-in behaviour off when the host provides its own | + +The provider deliberately does **no data loading**: the host resolves which +world and rover to run (from a route, a database, a quest) and hands them in. +See `web/app/mission/page.tsx` — the standalone app is itself just a host, and +uses no private API. + +## Where the rules stay + +Overriding UI never changes gameplay. All rules live in +`packages/engine/src/sim/` and `parts/`; components only observe state and call +actions. A game that replaces every component still plays identical missions — +which is the point of the split. diff --git a/package-lock.json b/package-lock.json index 3594feb..5597126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "workspaces": [ "packages/engine", "packages/pixi-adapter", + "packages/ui", + "packages/editor", "web" ] }, @@ -981,6 +983,10 @@ "tslib": "^2.8.0" } }, + "node_modules/@takeon/editor": { + "resolved": "packages/editor", + "link": true + }, "node_modules/@takeon/engine": { "resolved": "packages/engine", "link": true @@ -989,6 +995,10 @@ "resolved": "packages/pixi-adapter", "link": true }, + "node_modules/@takeon/ui": { + "resolved": "packages/ui", + "link": true + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2285,6 +2295,25 @@ "node": ">=8" } }, + "packages/editor": { + "name": "@takeon/editor", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@takeon/engine": "^0.2.0", + "@takeon/ui": "^0.2.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.6.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "packages/engine": { "name": "@takeon/engine", "version": "0.2.0", @@ -2313,11 +2342,33 @@ } } }, + "packages/ui": { + "name": "@takeon/ui", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "@takeon/engine": "^0.2.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.6.0", + "vitest": "^4.1.10" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "web": { "name": "takeon-web", "version": "0.1.0", "dependencies": { + "@takeon/editor": "*", "@takeon/engine": "*", + "@takeon/ui": "*", "next": "^15.3.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/package.json b/package.json index 13d089b..41838a5 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,19 @@ "name": "takeon", "private": true, "version": "0.1.0", - "description": "TakeOn — modular isometric voxel rover game for the Star Sailors ecosystem. Standalone or embeddable (PixiJS/Landnam).", + "description": "TakeOn \u2014 modular isometric voxel rover game for the Star Sailors ecosystem. Standalone or embeddable (PixiJS/Landnam).", "workspaces": [ "packages/engine", "packages/pixi-adapter", + "packages/ui", + "packages/editor", "web" ], "scripts": { - "build": "npm run build -w @takeon/engine && npm run build -w @takeon/pixi && npm run build -w takeon-web", - "test": "npm run test -w @takeon/engine", + "build": "npm run build -w @takeon/engine && npm run build -w @takeon/pixi && npm run build -w @takeon/ui && npm run build -w @takeon/editor && npm run build -w takeon-web", + "test": "npm run test -w @takeon/engine && npm run test -w @takeon/ui", "dev": "npm run dev -w takeon-web", + "editor": "npm run dev -w takeon-web", "backend": "cd pocketbase && go run . serve --http 0.0.0.0:8094" } } diff --git a/packages/editor/LICENSE b/packages/editor/LICENSE new file mode 100644 index 0000000..2661875 --- /dev/null +++ b/packages/editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Signal-K + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 0000000..fce63e4 --- /dev/null +++ b/packages/editor/package.json @@ -0,0 +1,61 @@ +{ + "name": "@takeon/editor", + "version": "0.2.0", + "description": "Mountable authoring tool for TakeOn worlds: inspector, live iso viewport, noise/terrain maps and in-editor play mode.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./styles.css": { + "default": "./dist/styles.css" + } + }, + "files": [ + "dist", + "LICENSE" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Signal-K/takeon.git", + "directory": "packages/editor" + }, + "homepage": "https://github.com/Signal-K/takeon#readme", + "bugs": "https://github.com/Signal-K/takeon/issues", + "keywords": [ + "takeon", + "game-editor", + "level-editor", + "terrain", + "noise", + "procedural", + "react" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node ../../scripts/copy-assets.mjs src/styles.css dist/styles.css", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@takeon/engine": "^0.2.0", + "@takeon/ui": "^0.2.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.6.0" + } +} diff --git a/packages/editor/src/TakeOnEditor.tsx b/packages/editor/src/TakeOnEditor.tsx new file mode 100644 index 0000000..4232a96 --- /dev/null +++ b/packages/editor/src/TakeOnEditor.tsx @@ -0,0 +1,273 @@ +import { + analyzeTerrain, + type BodyDef, + type BodyField, + type RoverGame, + type RoverSpec, + type ViewKind, +} from '@takeon/engine'; +import { MissionProvider, MissionScreen, TakeOnUIProvider, type TakeOnUIConfig } from '@takeon/ui'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AnalysisPanel } from './panels/AnalysisPanel.js'; +import { BodyBrowser } from './panels/BodyBrowser.js'; +import { EditorCard } from './panels/Card.js'; +import { ConsolePanel, type ConsoleLine } from './panels/ConsolePanel.js'; +import { Inspector } from './panels/Inspector.js'; +import { MapsPanel } from './panels/MapsPanel.js'; +import { RoverPanel } from './panels/RoverPanel.js'; +import { Toolbar } from './panels/Toolbar.js'; +import { Viewport } from './panels/Viewport.js'; +import { useEditorSim } from './useEditorSim.js'; +import { useEditorState, type EditorPersistedState, type EditorStorage } from './state.js'; + +export type EditorTheme = 'light' | 'dark'; + +export interface TakeOnEditorProps { + /** Extra destinations to edit alongside the built-ins (e.g. backend rows). */ + bodies?: BodyDef[]; + /** Where the project is stored; pass null to keep it in memory only. */ + storage?: EditorStorage | null; + storageKey?: string; + /** Publish drafts to the engine's body registry as they change (default true). */ + publish?: boolean; + /** Fires on every project change — persist to your own backend here. */ + onChange?(state: EditorPersistedState): void; + /** Host-specific inspector fields, using the engine's field schema shape. */ + extraFields?: BodyField[]; + /** Initial chrome theme (default light); the toolbar toggle overrides it. */ + theme?: EditorTheme; + /** Overrides for the editor's own panels and for the in-editor mission HUD. */ + ui?: TakeOnUIConfig; + className?: string; +} + +const THEME_KEY = 'takeon.editor.theme'; + +/** + * A minimal game editor for TakeOn worlds — scene view, inspector, project + * tree, terrain instruments and play mode, in the browser or in a desktop + * shell. + * + * Layout is one uniform grid of equal-height cards rather than sidebars: every + * panel has the same header, the same footprint and the same scroll behaviour, + * so nothing shifts as content grows. It is a single React component with no + * routing of its own, so it drops into this repo's Next.js app, into Landnam, + * or into an Electron window unchanged. Every panel is registered through + * @takeon/ui's slot registry (see `EDITOR_SLOT_KEYS`). + */ +export function TakeOnEditor({ + bodies, + storage, + storageKey, + publish, + onChange, + extraFields, + theme: initialTheme = 'light', + ui, + className, +}: TakeOnEditorProps) { + const editor = useEditorState({ bodies, storage, storageKey, publish, onChange }); + const [mode, setMode] = useState<'edit' | 'play'>('edit'); + const [theme, setTheme] = useState(initialTheme); + const [maxClimb, setMaxClimb] = useState(2); + const [view, setView] = useState('iso'); + const [time, setTime] = useState(0); + const [lines, setLines] = useState([]); + + const { sim, world, version, ms, generating, regenerate } = useEditorSim(editor.body, editor.spec, mode === 'edit'); + + // Remember the chrome theme across sessions; the scene stays dark either way. + useEffect(() => { + if (typeof localStorage === 'undefined') return; + const stored = localStorage.getItem(THEME_KEY); + if (stored === 'light' || stored === 'dark') setTheme(stored); + }, []); + useEffect(() => { + if (typeof localStorage !== 'undefined') localStorage.setItem(THEME_KEY, theme); + }, [theme]); + + const landing = useMemo( + () => (world ? analyzeTerrain(world, { maxClimb }).landingSite : null), + // Recomputed with the world; `version` marks a fresh build. + // eslint-disable-next-line react-hooks/exhaustive-deps + [world, version, maxClimb], + ); + + // Stop play mode when the world being played is edited out from under it. + useEffect(() => { + if (mode === 'play' && !editor.validation.ok) setMode('edit'); + }, [mode, editor.validation.ok]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey)) return; + if (e.key.toLowerCase() !== 'z') return; + e.preventDefault(); + if (e.shiftKey) editor.redo(); + else editor.undo(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [editor]); + + const playGame = useRef(null); + const log = useCallback((key: string, payload: unknown) => { + // `tick` fires 10×/s and `stateChanged` shadows other events — logging + // them would bury everything the designer actually pressed a button for. + if (key === 'tick' || key === 'stateChanged') return; + setLines((cur) => { + const line: ConsoleLine = { + id: (cur[cur.length - 1]?.id ?? 0) + 1, + t: playGame.current?.sim.time ?? 0, + key, + detail: summarise(payload), + }; + return [...cur.slice(-199), line]; + }); + }, []); + + const setSpec = (spec: RoverSpec) => editor.setSpec(spec); + const problems = editor.validation.errors.length + editor.validation.warnings.length; + + return ( + +
+ editor.patch('seed', Math.floor(Math.random() * 100000))} + onRegenerate={regenerate} + onImport={editor.importJson} + /> + +
+ + {mode === 'play' ? ( + { + playGame.current = game; + }} + onEvent={(key, payload) => log(key, payload)} + > + + + ) : ( + + )} + + + 0 ? `${problems} note(s)` : 'valid'}> + + + + + + + + + + + + + + + + + + + + setLines([])}> + Clear + + } + flush + > + setLines([])} /> + +
+
+
+ ); +} + +function summarise(payload: unknown): string { + if (!payload || typeof payload !== 'object') return ''; + const parts: string[] = []; + for (const [key, value] of Object.entries(payload as Record)) { + if (value === null || value === undefined) continue; + if (typeof value === 'object') { + const obj = value as Record; + if (typeof obj.x === 'number' && typeof obj.y === 'number') parts.push(`${key}=[${obj.x},${obj.y}]`); + continue; + } + if (typeof value === 'string' && value.length > 40) continue; + parts.push(`${key}=${String(value)}`); + } + return parts.slice(0, 4).join(' '); +} + +/** Panel slot keys a host can override through `TakeOnUIProvider`. */ +export const EDITOR_SLOT_KEYS = [ + 'EditorToolbar', + 'EditorCard', + 'EditorBodyBrowser', + 'EditorInspector', + 'EditorViewport', + 'EditorMapsPanel', + 'EditorAnalysisPanel', + 'EditorRoverPanel', + 'EditorConsolePanel', +] as const; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts new file mode 100644 index 0000000..77de3a1 --- /dev/null +++ b/packages/editor/src/index.ts @@ -0,0 +1,46 @@ +/** + * @takeon/editor — a minimal, embeddable game editor for TakeOn worlds. + * + * ```tsx + * import { TakeOnEditor } from '@takeon/editor'; + * import '@takeon/ui/styles.css'; + * import '@takeon/editor/styles.css'; + * + * save(project)} /> + * ``` + * + * The editor is a plain React component: it runs in this repo's Next.js app at + * `/editor`, inside another game (Landnam) or in a desktop shell, and it edits + * the same `BodyDef`s the engine generates worlds from — no separate format. + */ + +export { TakeOnEditor, EDITOR_SLOT_KEYS, type TakeOnEditorProps, type EditorTheme } from './TakeOnEditor.js'; +export { + useEditorState, + EDITOR_STORAGE_KEY, + type EditorState, + type EditorStateOptions, + type EditorEntry, + type EditorPersistedState, + type EditorStorage, +} from './state.js'; +export { useEditorSim, type EditorSim } from './useEditorSim.js'; +export { + paintMap, + paintCrossSection, + paintField, + mapPointToTile, + MAP_KINDS, + type MapKind, + type PaintOptions, +} from './maps.js'; + +export { Toolbar, type ToolbarProps } from './panels/Toolbar.js'; +export { EditorCard, type EditorCardProps } from './panels/Card.js'; +export { BodyBrowser, type BodyBrowserProps } from './panels/BodyBrowser.js'; +export { Inspector, type InspectorProps } from './panels/Inspector.js'; +export { Viewport, type ViewportProps } from './panels/Viewport.js'; +export { MapsPanel, type MapsPanelProps } from './panels/MapsPanel.js'; +export { AnalysisPanel, type AnalysisPanelProps } from './panels/AnalysisPanel.js'; +export { RoverPanel, type RoverPanelProps } from './panels/RoverPanel.js'; +export { ConsolePanel, type ConsoleLine, type ConsolePanelProps } from './panels/ConsolePanel.js'; diff --git a/packages/editor/src/maps.ts b/packages/editor/src/maps.ts new file mode 100644 index 0000000..ea139be --- /dev/null +++ b/packages/editor/src/maps.ts @@ -0,0 +1,287 @@ +import { + chunkBiome, + chunkCoords, + heightField, + MATERIALS, + Material, + reachableMask, + renderCrossSection, + slopeField, + type BodyDef, + type VoxelWorld, +} from '@takeon/engine'; + +/** + * Top-down diagnostic views of a generated world. + * + * These are the terrain-planning instruments: elevation, steepness, surface + * material, ore density and drivability, all sampled from the *same* world the + * game will run, so what you tune here is what ships. Each painter renders at + * one pixel per column into an offscreen buffer, then scales it up with + * smoothing off so individual voxels stay legible. + */ + +export type MapKind = 'elevation' | 'slope' | 'surface' | 'ore' | 'reach' | 'biome'; + +export const MAP_KINDS: { id: MapKind; label: string; help: string }[] = [ + { id: 'elevation', label: 'Elevation', help: 'Column height — the heightmap the generator produced.' }, + { id: 'slope', label: 'Slope', help: 'Worst height step to a neighbour. Red is above the climb limit.' }, + { id: 'surface', label: 'Surface', help: 'Top-voxel material: the biome patches the player sees.' }, + { id: 'ore', label: 'Ore density', help: 'Ore voxels per column, including crystal.' }, + { id: 'reach', label: 'Drivable', help: 'Flood fill from the landing site with the current climb limit.' }, + { id: 'biome', label: 'Biomes', help: 'Chunk-by-chunk biome assignment (needs terrain.biomes on). One flat colour per body otherwise.' }, +]; + +export interface PaintOptions { + /** Drivetrain climb limit for slope/reach views. */ + maxClimb?: number; + /** Start column for the drivability fill. */ + from?: { x: number; y: number }; + /** Row/column highlighted by the cross-section view. */ + marker?: { axis: 'x' | 'y'; index: number } | null; + /** Tile to mark (the landing site by default). */ + pin?: { x: number; y: number } | null; + /** Required for the 'biome' map kind — biome selection reads climate/kind off the body, not the generated world. */ + body?: BodyDef; +} + +type RGB = [number, number, number]; + +const VOID: RGB = [10, 8, 24]; + +/** Paint one of the diagnostic maps into a canvas, letterboxed to fit. */ +export function paintMap(canvas: HTMLCanvasElement, world: VoxelWorld, kind: MapKind, opts: PaintOptions = {}): void { + const size = world.size; + const maxClimb = opts.maxClimb ?? 2; + const colour = pickPainter(world, kind, maxClimb, opts); + const buffer = new Uint8ClampedArray(size * size * 4); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const [r, g, b] = colour(x, y); + const i = (y * size + x) * 4; + buffer[i] = r; + buffer[i + 1] = g; + buffer[i + 2] = b; + buffer[i + 3] = 255; + } + } + blit(canvas, new ImageData(buffer, size, size), (ctx, scale, ox, oy) => { + if (opts.marker) { + ctx.fillStyle = 'rgba(69, 224, 196, 0.5)'; + if (opts.marker.axis === 'y') ctx.fillRect(ox, oy + opts.marker.index * scale, size * scale, Math.max(1, scale)); + else ctx.fillRect(ox + opts.marker.index * scale, oy, Math.max(1, scale), size * scale); + } + if (opts.pin) { + ctx.strokeStyle = '#ffc857'; + ctx.lineWidth = 2; + ctx.strokeRect(ox + opts.pin.x * scale - 3, oy + opts.pin.y * scale - 3, scale + 6, scale + 6); + } + }); +} + +function pickPainter(world: VoxelWorld, kind: MapKind, maxClimb: number, opts: PaintOptions): (x: number, y: number) => RGB { + const size = world.size; + if (kind === 'elevation') { + const heights = heightField(world); + let hi = 1; + for (let i = 0; i < heights.length; i++) if (heights[i] > hi) hi = heights[i]; + return (x, y) => { + const h = heights[y * size + x]; + return h < 0 ? VOID : terrainRamp(h / hi); + }; + } + if (kind === 'slope') { + const slopes = slopeField(world); + return (x, y) => { + const s = slopes[y * size + x]; + if (s < 0) return VOID; + if (s > maxClimb) return [232, 76, 76]; + const t = maxClimb > 0 ? s / maxClimb : 0; + return [40 + t * 150, 190 - t * 60, 150 - t * 60]; + }; + } + if (kind === 'surface') { + return (x, y) => { + const h = world.height(x, y); + if (h < 0) return VOID; + return hexToRgb(MATERIALS[world.surfaceMaterial(x, y)].colors[0]); + }; + } + if (kind === 'ore') { + return (x, y) => { + const h = world.height(x, y); + if (h < 0) return VOID; + let ore = 0; + let crystal = 0; + for (let z = 0; z <= h; z++) { + const m = world.get(x, y, z); + if (m === Material.Crystal) crystal++; + else if (m === Material.IronOre || m === Material.CopperOre || m === Material.TitaniumOre) ore++; + } + if (crystal > 0) return [199, 125, 255]; + if (ore === 0) return [26, 22, 54]; + const t = Math.min(1, ore / 6); + return [60 + t * 190, 50 + t * 90, 40]; + }; + } + if (kind === 'biome') { + const body = opts.body; + return (x, y) => { + const h = world.height(x, y); + if (h < 0) return VOID; + if (!body) return [90, 84, 110]; + const { cx, cy } = chunkCoords(x, y); + return biomeColor(chunkBiome(body, cx, cy, body.seed).id); + }; + } + const mask = reachableMask(world, opts.from ?? { x: size >> 1, y: size >> 1 }, maxClimb); + return (x, y) => { + const h = world.height(x, y); + if (h < 0) return VOID; + return mask[y * size + x] ? [69, 180, 140] : [150, 80, 60]; + }; +} + +/** Stable, distinct colour per biome id — a string hash into HSL, not a lookup table, so custom biomes get one for free. */ +function biomeColor(id: string): RGB { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + const hue = h % 360; + return hslToRgb(hue, 0.55, 0.55); +} + +function hslToRgb(h: number, s: number, l: number): RGB { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let [r, g, b] = [0, 0, 0]; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + return [(r + m) * 255, (g + m) * 255, (b + m) * 255]; +} + +/** + * Vertical slice, drawn with the real material palette — this is also the + * "underground scene" mechanic: `@takeon/engine`'s `renderCrossSection` (the + * same function this delegates to) is host-usable directly, and + * `@takeon/ui`'s `` wraps it as a live, mission-driven + * panel, not just an editor tool. + */ +export function paintCrossSection( + canvas: HTMLCanvasElement, + world: VoxelWorld, + axis: 'x' | 'y', + index: number, +): void { + renderCrossSection(canvas, world, axis, index); +} + +/** Raw 2D noise preview, for tuning frequency/octaves before generating. */ +export function paintField( + canvas: HTMLCanvasElement, + size: number, + sample: (x: number, y: number) => number, + ramp: (t: number) => RGB = grayRamp, +): void { + const buffer = new Uint8ClampedArray(size * size * 4); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const [r, g, b] = ramp(Math.max(0, Math.min(1, sample(x, y)))); + const i = (y * size + x) * 4; + buffer[i] = r; + buffer[i + 1] = g; + buffer[i + 2] = b; + buffer[i + 3] = 255; + } + } + blit(canvas, new ImageData(buffer, size, size)); +} + +/** + * Canvas pixel → world column for the top-down maps, matching `blit`'s + * letterboxing so clicks land on the tile under the cursor. + */ +export function mapPointToTile( + canvas: HTMLCanvasElement, + world: VoxelWorld, + clientX: number, + clientY: number, +): { x: number; y: number } | null { + const rect = canvas.getBoundingClientRect(); + const scale = Math.max(0.0001, Math.min(rect.width / world.size, rect.height / world.size)); + const ox = (rect.width - world.size * scale) / 2; + const oy = (rect.height - world.size * scale) / 2; + const x = Math.floor((clientX - rect.left - ox) / scale); + const y = Math.floor((clientY - rect.top - oy) / scale); + if (x < 0 || y < 0 || x >= world.size || y >= world.size) return null; + return { x, y }; +} + +function blit( + canvas: HTMLCanvasElement, + image: ImageData, + overlay?: (ctx: CanvasRenderingContext2D, scale: number, ox: number, oy: number) => void, +): void { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const rect = canvas.getBoundingClientRect(); + const dpr = Math.min(2, typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1); + const w = Math.max(1, Math.round((rect.width || image.width) * dpr)); + const h = Math.max(1, Math.round((rect.height || image.height) * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const cssW = w / dpr; + const cssH = h / dpr; + ctx.clearRect(0, 0, cssW, cssH); + + const source = document.createElement('canvas'); + source.width = image.width; + source.height = image.height; + source.getContext('2d')?.putImageData(image, 0, 0); + + const scale = Math.min(cssW / image.width, cssH / image.height); + const ox = (cssW - image.width * scale) / 2; + const oy = (cssH - image.height * scale) / 2; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(source, ox, oy, image.width * scale, image.height * scale); + overlay?.(ctx, scale, ox, oy); +} + +/** Low → deep blue-violet, mid → warm ochre, high → pale. */ +function terrainRamp(t: number): RGB { + const stops: [number, RGB][] = [ + [0, [32, 26, 72]], + [0.35, [92, 62, 120]], + [0.6, [196, 128, 74]], + [0.85, [235, 196, 122]], + [1, [248, 240, 224]], + ]; + for (let i = 1; i < stops.length; i++) { + if (t <= stops[i][0]) { + const [t0, c0] = stops[i - 1]; + const [t1, c1] = stops[i]; + const k = (t - t0) / (t1 - t0 || 1); + return [c0[0] + (c1[0] - c0[0]) * k, c0[1] + (c1[1] - c0[1]) * k, c0[2] + (c1[2] - c0[2]) * k]; + } + } + return stops[stops.length - 1][1]; +} + +function grayRamp(t: number): RGB { + const v = 20 + t * 220; + return [v, v, v]; +} + +function hexToRgb(hex: string): RGB { + const h = hex.replace('#', ''); + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; + const n = parseInt(full, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} diff --git a/packages/editor/src/panels/AnalysisPanel.tsx b/packages/editor/src/panels/AnalysisPanel.tsx new file mode 100644 index 0000000..f4d006a --- /dev/null +++ b/packages/editor/src/panels/AnalysisPanel.tsx @@ -0,0 +1,117 @@ +import { + analyzeTerrain, + MATERIALS, + RESOURCE_NAMES, + type Material, + type ResourceKey, + type VoxelWorld, +} from '@takeon/engine'; +import { createSlot } from '@takeon/ui'; +import { useMemo } from 'react'; + +export interface AnalysisPanelProps { + world: VoxelWorld | null; + /** Climb limit used for the drivability figure. */ + maxClimb: number; + version: number; +} + +/** + * The numbers behind the map: relief, steepness, drivable fraction, surface + * mix and total recoverable resources. This is what turns "looks about right" + * into "this world is 92% drivable and holds 1.2k iron". + */ +function DefaultAnalysisPanel({ world, maxClimb, version }: AnalysisPanelProps) { + const analysis = useMemo( + () => (world ? analyzeTerrain(world, { maxClimb }) : null), + // `version` changes whenever the world is regenerated in place. + // eslint-disable-next-line react-hooks/exhaustive-deps + [world, maxClimb, version], + ); + if (!analysis) return
No world generated yet.
; + + const surface = Object.entries(analysis.surface) + .map(([m, count]) => ({ material: Number(m) as Material, count: count ?? 0 })) + .sort((a, b) => b.count - a.count); + const resources = Object.entries(analysis.resources) + .map(([r, amount]) => ({ resource: r as ResourceKey, amount: amount ?? 0 })) + .sort((a, b) => b.amount - a.amount); + const peak = Math.max(1, ...analysis.heightHistogram); + + return ( +
+
+ + + + + 0.35 ? 'warn' : undefined} + /> + +
+ +

Height distribution

+
+ {analysis.heightHistogram.map((count, z) => ( + + ))} +
+ +

Surface mix

+
    + {surface.map(({ material, count }) => ( +
  • + + {MATERIALS[material].name} + + + + {((count / Math.max(1, analysis.solidColumns)) * 100).toFixed(0)}% +
  • + ))} +
+ +

Recoverable resources

+ + + {resources.map(({ resource, amount }) => ( + + + + + ))} + +
{RESOURCE_NAMES[resource]}{amount.toLocaleString()}
+

Totals assume every voxel is mined — treat them as an upper bound on mission yield.

+
+ ); +} + +function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone?: 'good' | 'warn' | 'bad' }) { + return ( +
+ {label} + {value} + {sub && {sub}} +
+ ); +} + +export const AnalysisPanel = createSlot('EditorAnalysisPanel', DefaultAnalysisPanel); diff --git a/packages/editor/src/panels/BodyBrowser.tsx b/packages/editor/src/panels/BodyBrowser.tsx new file mode 100644 index 0000000..1eb4b23 --- /dev/null +++ b/packages/editor/src/panels/BodyBrowser.tsx @@ -0,0 +1,61 @@ +import { createSlot } from '@takeon/ui'; +import type { EditorEntry } from '../state.js'; + +export interface BodyBrowserProps { + entries: EditorEntry[]; + selectedId: string; + onSelect(id: string): void; + onCreate(): void; + onDuplicate(): void; + onRemove(): void; + canRemove: boolean; +} + +/** Project tree: shipped destinations plus this project's drafts. */ +function DefaultBodyBrowser({ + entries, + selectedId, + onSelect, + onCreate, + onDuplicate, + onRemove, + canRemove, +}: BodyBrowserProps) { + return ( +
+
+ + + +
+
    + {entries.map((entry) => ( +
  • + +
  • + ))} +
+
+ ); +} + +export const BodyBrowser = createSlot('EditorBodyBrowser', DefaultBodyBrowser); diff --git a/packages/editor/src/panels/Card.tsx b/packages/editor/src/panels/Card.tsx new file mode 100644 index 0000000..0d702d9 --- /dev/null +++ b/packages/editor/src/panels/Card.tsx @@ -0,0 +1,35 @@ +import { createSlot } from '@takeon/ui'; +import type { ReactNode } from 'react'; + +export interface EditorCardProps { + title: string; + /** Short description shown next to the title. */ + hint?: string; + /** Controls docked in the card header. */ + actions?: ReactNode; + children: ReactNode; + /** Extra class, e.g. the scene card's grid span. */ + className?: string; + /** Body without padding (canvases fill their card). */ + flush?: boolean; +} + +/** + * Every panel in the editor is one of these: identical header height, + * identical body, identical footprint in the grid. Consistency here is what + * keeps the layout in line instead of ragged. + */ +function DefaultEditorCard({ title, hint, actions, children, className, flush }: EditorCardProps) { + return ( +
+
+

{title}

+ {hint && {hint}} + {actions &&
{actions}
} +
+
{children}
+
+ ); +} + +export const EditorCard = createSlot('EditorCard', DefaultEditorCard); diff --git a/packages/editor/src/panels/ConsolePanel.tsx b/packages/editor/src/panels/ConsolePanel.tsx new file mode 100644 index 0000000..467ed15 --- /dev/null +++ b/packages/editor/src/panels/ConsolePanel.tsx @@ -0,0 +1,39 @@ +import { createSlot } from '@takeon/ui'; +import { useEffect, useRef } from 'react'; + +export interface ConsoleLine { + id: number; + t: number; + key: string; + detail: string; +} + +export interface ConsolePanelProps { + lines: ConsoleLine[]; + /** Kept for hosts that render their own header; the card supplies one. */ + onClear(): void; +} + +/** Live engine event log during play mode — the editor's "output" pane. */ +function DefaultConsolePanel({ lines }: ConsolePanelProps) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines]); + + return ( +
+
+ {lines.length === 0 && Press ▶ Play to drive the world and watch events here.} + {lines.map((line) => ( +
+ {line.t.toFixed(1)}s {line.key} {line.detail} +
+ ))} +
+
+ ); +} + +export const ConsolePanel = createSlot('EditorConsolePanel', DefaultConsolePanel); diff --git a/packages/editor/src/panels/Inspector.tsx b/packages/editor/src/panels/Inspector.tsx new file mode 100644 index 0000000..934de42 --- /dev/null +++ b/packages/editor/src/panels/Inspector.tsx @@ -0,0 +1,171 @@ +import { + BODY_FIELD_GROUPS, + BODY_FIELDS, + getBodyField, + type BodyDef, + type BodyField, + type BodyValidation, +} from '@takeon/engine'; +import { createSlot } from '@takeon/ui'; +import { useState } from 'react'; + +export interface InspectorProps { + body: BodyDef; + validation: BodyValidation; + onPatch(path: string, value: unknown): void; + /** Extra fields contributed by the host game (same schema shape). */ + extraFields?: BodyField[]; +} + +/** + * Property editor for the selected destination, generated from the engine's + * field schema — so a new BodyDef property becomes editable by describing it + * in `world/authoring.ts`, not by touching the editor. + */ +function DefaultInspector({ body, validation, onPatch, extraFields }: InspectorProps) { + const fields = extraFields ? [...BODY_FIELDS, ...extraFields] : BODY_FIELDS; + const [collapsed, setCollapsed] = useState>({}); + + return ( +
+ {(validation.errors.length > 0 || validation.warnings.length > 0) && ( +
+ {validation.errors.map((e) => ( +
+ ✕ {e} +
+ ))} + {validation.warnings.map((w) => ( +
+ ⚠ {w} +
+ ))} +
+ )} + + {BODY_FIELD_GROUPS.map((group) => { + const groupFields = fields.filter((f) => f.group === group.id); + if (groupFields.length === 0) return null; + const open = !collapsed[group.id]; + return ( +
+ + {open && ( +
+ {groupFields.map((field) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +} + +function Field({ field, body, onPatch }: { field: BodyField; body: BodyDef; onPatch: InspectorProps['onPatch'] }) { + const raw = getBodyField(body, field.path); + + if (field.kind === 'boolean') { + return ( + + ); + } + + if (field.kind === 'select') { + return ( + + ); + } + + if (field.kind === 'color') { + const value = typeof raw === 'string' && raw ? raw : '#000000'; + return ( + + ); + } + + if (field.kind === 'longtext') { + return ( +