From be1507e4f6645c09e4af5ddc10e46f08e34dbb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:09:40 +0000 Subject: [PATCH 1/6] feat: world editor (@takeon/editor) + host-overridable UI (@takeon/ui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a game built on TakeOn needs: a place to author worlds, and a UI it can adapt. Engine (authoring APIs, all pure/DOM-free so they stay testable): - world/registry.ts — runtime body registry; getBody/listBodies resolve registered bodies before built-ins, so editor drafts and backend rows can shadow a shipped destination without patching the catalog. - world/analysis.ts — heightField, slopeField, reachableMask, crossSection and analyzeTerrain (relief, slope distribution, drivable fraction for a climb limit, surface mix, recoverable resources). - world/authoring.ts — drafts, validation (errors + playability warnings), the BODY_FIELDS inspector schema with dotted get/set, JSON/TS export and import. - GAME_EVENT_KEYS with a compile-time exhaustiveness guard, so hosts can subscribe to every engine event. @takeon/ui (new): MissionProvider owns the mission (engine loop, HUD snapshot, toasts, autosave through SyncAdapter) and every component is resolved through a registry — a parent app can replace, wrap, restyle, relabel or extend any part of the HUD via TakeOnUIProvider, or drop it entirely and build its own from useMission(). The Next.js mission page is now a thin host over it. @takeon/editor (new): TakeOnEditor, a mountable Unity/Godot-shaped tool — project tree, schema-generated inspector with live validation, isometric scene view with camera controls and a day/night scrub, elevation/slope/surface/ore/ drivability maps plus raw noise preview and vertical cross-section, terrain analysis, rover picker, undo/redo, JSON/TS export, and a play mode that boots a real mission on the edited world with the real HUD. Its panels use the same slot registry, so hosts can replace those too. Also: /editor route, optional Electron shell for running the editor as a Mac app, docs/UI.md + docs/EDITOR.md, refreshed verify skill selectors, CI builds and publishes the two new packages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X8nBHoPvmqenMDt2RJ4whw --- .claude/skills/verify/SKILL.md | 56 +- .github/workflows/ci.yml | 9 +- CHANGELOG.md | 55 +- CLAUDE.md | 24 +- README.md | 71 +- desktop/main.cjs | 62 ++ desktop/package.json | 13 + docs/EDITOR.md | 186 +++++ docs/INTEGRATION.md | 30 +- docs/UI.md | 160 ++++ package-lock.json | 51 ++ package.json | 9 +- packages/editor/LICENSE | 21 + packages/editor/package.json | 61 ++ packages/editor/src/TakeOnEditor.tsx | 223 ++++++ packages/editor/src/index.ts | 45 ++ packages/editor/src/maps.ts | 259 +++++++ packages/editor/src/panels/AnalysisPanel.tsx | 117 +++ packages/editor/src/panels/BodyBrowser.tsx | 61 ++ packages/editor/src/panels/ConsolePanel.tsx | 44 ++ packages/editor/src/panels/Inspector.tsx | 166 ++++ packages/editor/src/panels/MapsPanel.tsx | 130 ++++ packages/editor/src/panels/RoverPanel.tsx | 111 +++ packages/editor/src/panels/Toolbar.tsx | 153 ++++ packages/editor/src/panels/Viewport.tsx | 165 ++++ packages/editor/src/state.ts | 272 +++++++ packages/editor/src/styles.css | 717 ++++++++++++++++++ packages/editor/src/useEditorSim.ts | 85 +++ packages/editor/tsconfig.json | 17 + packages/engine/src/index.ts | 48 +- packages/engine/src/types.ts | 43 ++ packages/engine/src/world/analysis.ts | 260 +++++++ packages/engine/src/world/authoring.ts | 339 +++++++++ packages/engine/src/world/bodies.ts | 5 +- packages/engine/src/world/registry.ts | 63 ++ packages/engine/test/authoring.test.ts | 189 +++++ packages/engine/test/engine.test.ts | 29 + packages/ui/LICENSE | 21 + packages/ui/package.json | 62 ++ packages/ui/src/TakeOnMission.tsx | 36 + packages/ui/src/components/ActionBar.tsx | 89 +++ packages/ui/src/components/BuildPanel.tsx | 69 ++ packages/ui/src/components/CargoPanel.tsx | 72 ++ packages/ui/src/components/CraftPanel.tsx | 62 ++ packages/ui/src/components/DPad.tsx | 43 ++ .../ui/src/components/EndMissionDialog.tsx | 49 ++ packages/ui/src/components/HudBar.tsx | 78 ++ packages/ui/src/components/Minimap.tsx | 38 + packages/ui/src/components/MissionCanvas.tsx | 28 + packages/ui/src/components/MissionScreen.tsx | 72 ++ packages/ui/src/components/TileMenu.tsx | 92 +++ packages/ui/src/components/ToastStack.tsx | 23 + packages/ui/src/components/primitives.tsx | 94 +++ packages/ui/src/index.ts | 93 +++ packages/ui/src/mission-context.tsx | 531 +++++++++++++ packages/ui/src/registry.tsx | 191 +++++ packages/ui/src/styles.css | 439 +++++++++++ packages/ui/test/registry.test.tsx | 114 +++ packages/ui/tsconfig.json | 17 + scripts/copy-assets.mjs | 26 + web/app/customize/page.tsx | 4 +- web/app/editor/page.tsx | 20 + web/app/globals.css | 251 +----- web/app/launch/page.tsx | 4 +- web/app/layout.tsx | 5 + web/app/mission/page.tsx | 543 ++----------- web/next.config.mjs | 2 +- web/package.json | 2 + 68 files changed, 6741 insertions(+), 778 deletions(-) create mode 100644 desktop/main.cjs create mode 100644 desktop/package.json create mode 100644 docs/EDITOR.md create mode 100644 docs/UI.md create mode 100644 packages/editor/LICENSE create mode 100644 packages/editor/package.json create mode 100644 packages/editor/src/TakeOnEditor.tsx create mode 100644 packages/editor/src/index.ts create mode 100644 packages/editor/src/maps.ts create mode 100644 packages/editor/src/panels/AnalysisPanel.tsx create mode 100644 packages/editor/src/panels/BodyBrowser.tsx create mode 100644 packages/editor/src/panels/ConsolePanel.tsx create mode 100644 packages/editor/src/panels/Inspector.tsx create mode 100644 packages/editor/src/panels/MapsPanel.tsx create mode 100644 packages/editor/src/panels/RoverPanel.tsx create mode 100644 packages/editor/src/panels/Toolbar.tsx create mode 100644 packages/editor/src/panels/Viewport.tsx create mode 100644 packages/editor/src/state.ts create mode 100644 packages/editor/src/styles.css create mode 100644 packages/editor/src/useEditorSim.ts create mode 100644 packages/editor/tsconfig.json create mode 100644 packages/engine/src/world/analysis.ts create mode 100644 packages/engine/src/world/authoring.ts create mode 100644 packages/engine/src/world/registry.ts create mode 100644 packages/engine/test/authoring.test.ts create mode 100644 packages/ui/LICENSE create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/TakeOnMission.tsx create mode 100644 packages/ui/src/components/ActionBar.tsx create mode 100644 packages/ui/src/components/BuildPanel.tsx create mode 100644 packages/ui/src/components/CargoPanel.tsx create mode 100644 packages/ui/src/components/CraftPanel.tsx create mode 100644 packages/ui/src/components/DPad.tsx create mode 100644 packages/ui/src/components/EndMissionDialog.tsx create mode 100644 packages/ui/src/components/HudBar.tsx create mode 100644 packages/ui/src/components/Minimap.tsx create mode 100644 packages/ui/src/components/MissionCanvas.tsx create mode 100644 packages/ui/src/components/MissionScreen.tsx create mode 100644 packages/ui/src/components/TileMenu.tsx create mode 100644 packages/ui/src/components/ToastStack.tsx create mode 100644 packages/ui/src/components/primitives.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/mission-context.tsx create mode 100644 packages/ui/src/registry.tsx create mode 100644 packages/ui/src/styles.css create mode 100644 packages/ui/test/registry.test.tsx create mode 100644 packages/ui/tsconfig.json create mode 100644 scripts/copy-assets.mjs create mode 100644 web/app/editor/page.tsx diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index c2b0d0e..6920e3d 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,51 @@ 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.), 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` (battery/durability/cargo); 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"]`. + Action buttons are `.tk-action:has-text("Mine")` etc. — Photo/Scan only + render when the rover carries those modules. +4. Mining yield appears as `.tk-toast-good` with `+N `; cargo meter is + the 3rd `.tk-hudbar .tk-meter`; `.tk-action:has-text("Hold")` opens 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. +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. +### Editor (`/editor`) + +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: `.tke-tab:has-text("Analysis")` → `.tke-stat` tiles (Columns, + Height, Relief, Drivable, Cliffs, Landing). +5. Play mode: `.tke-primary:has-text("Play")` mounts the real HUD inside the + stage (`.tk-hudbar .tk-meter` = 3). Drive with the d-pad; engine events land + in `.tke-console-line`. `.tke-primary:has-text("Stop")` returns to edit mode. +6. Scene view is `.tke-scene` (drag to pan, wheel to zoom, R rotates). + 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..f7e3be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,61 @@ # 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/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`. + +### 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..48119bc 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) @@ -19,6 +20,16 @@ Runtime verification recipe: `.claude/skills/verify/SKILL.md`. - **All gameplay rules live in `packages/engine/src/sim/` and `parts/`** — renderer and web UI only observe. The sim is deterministic, fixed 10 Hz. +- **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. @@ -32,6 +43,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 +64,11 @@ 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. +- 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..b67b28b 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,21 @@ 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/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 +42,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). @@ -149,10 +156,62 @@ 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 +``` + +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. 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 UI is a package (`@takeon/ui`), 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..63be5e5 --- /dev/null +++ b/docs/EDITOR.md @@ -0,0 +1,186 @@ +# 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 ─────────────┐ +│ Destinations │ Scene view (real iso renderer) │ Inspector │ +│ built-ins │ drag to pan · wheel zoom · R rotate · ☀ time scrub │ (schema- │ +│ + drafts ├─────────────────────────────────────────────────────┤ generated) │ +│ │ Events console (play mode) │ Maps/Analysis │ +└──────────────┴─────────────────────────────────────────────────────┴────────────────┘ +``` + +## 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 + +The numbers behind the map: 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 + +The rover play mode uses — part dropdowns with 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 on the edited world 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`, `EditorBodyBrowser`, `EditorInspector`, +`EditorViewport`, `EditorMapsPanel`, `EditorAnalysisPanel`, `EditorRoverPanel`, +`EditorConsolePanel` (exported as `EDITOR_SLOT_KEYS`). 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/INTEGRATION.md b/docs/INTEGRATION.md index 8c2edc2..94ab5ae 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -2,8 +2,10 @@ 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)). ## 1. Drop-in canvas (any host) @@ -66,6 +68,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/UI.md b/docs/UI.md new file mode 100644 index 0000000..a769f79 --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,160 @@ +# 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, radius and font in the stock skin comes from a `--tk-*` custom +property: `bg`, `panel`, `panel-soft`, `border`, `text`, `text-dim`, `accent`, +`accent-2`, `good`, `warn`, `bad`, `radius`, `font`. Setting them re-skins the +whole HUD without overriding a single rule (they can also be set in plain CSS +on any ancestor). + +### 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`, `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 / build / craft / place / repair / deposit / hold | +| `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..a5a0917 --- /dev/null +++ b/packages/editor/src/TakeOnEditor.tsx @@ -0,0 +1,223 @@ +import { analyzeTerrain, type BodyDef, type BodyField, type RoverGame, type RoverSpec } 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 { 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 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[]; + /** Overrides for the editor's own panels and for the in-editor mission HUD. */ + ui?: TakeOnUIConfig; + className?: string; +} + +/** + * A minimal game editor for TakeOn worlds — scene view, inspector, project + * tree, analysis and play mode, in the browser or in a desktop shell. + * + * It is one 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, so a host can + * replace any of them (see `EDITOR_SLOT_KEYS`). + */ +export function TakeOnEditor({ + bodies, + storage, + storageKey, + publish, + onChange, + extraFields, + ui, + className, +}: TakeOnEditorProps) { + const editor = useEditorState({ bodies, storage, storageKey, publish, onChange }); + const [mode, setMode] = useState<'edit' | 'play'>('edit'); + const [maxClimb, setMaxClimb] = useState(2); + const [time, setTime] = useState(0); + const [lines, setLines] = useState([]); + const [tab, setTab] = useState<'maps' | 'analysis' | 'rover'>('maps'); + + const { sim, world, version, ms, generating, regenerate } = useEditorSim(editor.body, editor.spec, mode === 'edit'); + + 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); + + 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)} + > + + + ) : ( + + )} +
+ setLines([])} /> +
+ + +
+
+ ); +} + +function PanelHeader({ title }: { title: string }) { + return

{title}

; +} + +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', + '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..98921c9 --- /dev/null +++ b/packages/editor/src/index.ts @@ -0,0 +1,45 @@ +/** + * @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 } 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 { 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..f7e1e31 --- /dev/null +++ b/packages/editor/src/maps.ts @@ -0,0 +1,259 @@ +import { + crossSection, + heightField, + MATERIALS, + Material, + reachableMask, + slopeField, + 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'; + +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.' }, +]; + +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; +} + +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]; + }; + } + 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]; + }; +} + +/** Vertical slice, drawn with the real material palette. */ +export function paintCrossSection( + canvas: HTMLCanvasElement, + world: VoxelWorld, + axis: 'x' | 'y', + index: number, +): void { + const slice = crossSection(world, axis, index); + const w = slice.length; + const h = slice.height; + const buffer = new Uint8ClampedArray(w * h * 4); + for (let z = 0; z < h; z++) { + for (let i = 0; i < w; i++) { + const m = slice.cells[z][i]; + // Rows are painted top-down: the highest voxel is the first image row. + const px = ((h - 1 - z) * w + i) * 4; + const [r, g, b] = m === Material.Air ? [14, 11, 32] : hexToRgb(MATERIALS[m].colors[0]); + buffer[px] = r; + buffer[px + 1] = g; + buffer[px + 2] = b; + buffer[px + 3] = 255; + } + } + blit(canvas, new ImageData(buffer, w, h)); +} + +/** 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/ConsolePanel.tsx b/packages/editor/src/panels/ConsolePanel.tsx new file mode 100644 index 0000000..dc23b56 --- /dev/null +++ b/packages/editor/src/panels/ConsolePanel.tsx @@ -0,0 +1,44 @@ +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[]; + onClear(): void; +} + +/** Live engine event log during play mode — the editor's "output" pane. */ +function DefaultConsolePanel({ lines, onClear }: ConsolePanelProps) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines]); + + return ( +
+
+ Events + +
+
+ {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..8813629 --- /dev/null +++ b/packages/editor/src/panels/Inspector.tsx @@ -0,0 +1,166 @@ +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 ( +