@jgengine/editor 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@ Agents building on the published SDK can also read this programmatically:
12
12
  same data as typed values, so an updater can diff its installed version against
13
13
  the latest and surface the migration steps.
14
14
 
15
+ ## 0.13.0
16
+
17
+ ### Migrate
18
+
19
+ - **Nothing required** — this release is additive fixes, defaults, and new drop-in primitives. Bump `@jgengine/*` pins to `^0.13.0` (CLI `jgengine` is 0.10.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
20
+ - Two defaults changed in the player's favor, both overridable: **offline games now persist to localStorage automatically** (#1207) and **rigged catalog assets animate by default** via clip metadata + semantic clip roles (#1209). If a game relied on rigged models staying frozen or on saves being memory-only, opt out explicitly; otherwise you get better behavior for free.
21
+
22
+ ### Added
23
+
24
+ - **Drop-in inventory grid, window manager, and character sheet** (#1220) — `@jgengine/react` ships `InventoryGrid` (real drag/stack/split), a toggleable-window system (`panels`: bag, character sheet, spellbook-style windows with `@jgengine/core/ui/panelModel` state), and a `CharacterSheet` paperdoll, all HudTheme-skinnable — wire in a line or two instead of re-deriving from raw divs.
25
+ - **Serializable runtime snapshots** (#1179) — capture/restore of live runtime state, including magazine state (`@jgengine/core/combat/magazine`) and stat modifiers, for saves and replication.
26
+ - **Portable weapon plumbing** (#1211) — `@jgengine/core/combat/weaponFire` composes trigger → magazine → spread → projectile/hitscan as data, no game-local glue.
27
+ - **Portable leveling** (#1206) and **portable damage-pool access** (#1208) — progression and damage pools exposed as genre-agnostic seams on the game context.
28
+ - **Named combat VFX presets** (#1204) — `@jgengine/core/combat/vfxPresets`: a visible attack is one line.
29
+ - **Runtime-measured render-bounds hitboxes** (#1227) — colliders wrap what the shell actually mounts, not the raw asset bounds.
30
+ - **Character motion feel** — procedural part motion for rig-less part-composed characters (#1222) plus squash & stretch and death splat (#1229).
31
+ - **City fabric** (#1191) — `streetGenerator` + `cityGenerator`, editor bake verb, street-aware lots, road junctions, grounding, and a web playground.
32
+ - **`jgengine recipe` CLI** (#1214, #1231) — vetted wired compositions, compile-pinned against the SDK, listed and copied from the CLI.
33
+ - **Editor/debug modes mirror to the URL** with a proper editor exit (#1203); game template ships a dependency-free WebGL screenshot script (#1202).
34
+
35
+ ### Changed
36
+
37
+ - Offline games persist to localStorage by default (#1207); rigged assets animate by default (#1209).
38
+ - RTS camera pan inversion fixed (#1228); sprite raycast warning silenced and duplicate nameplate health bars removed (#1194); scaffolded F2+E editor Tailwind `@source` renders its theme (#1195).
39
+ - Outside-game DX: stat-pool no-op stability, camera-transparent decor, cleaner install/version output, headless UI-intent seam (#1223); new-game briefs direct engine bugs/gaps upstream (#1200).
40
+
15
41
  ## 0.12.0
16
42
 
17
43
  ### Migrate
@@ -5,7 +5,13 @@ export interface EditorAssetEntry {
5
5
  label: string;
6
6
  kind: "model" | "catalog" | "marker";
7
7
  url?: string;
8
+ /** Animation clip names from the asset index — present on rigged catalog assets. */
9
+ clips?: readonly string[];
8
10
  }
11
+ /** "idle · walk · run" summary of a rigged asset's detected animation roles for the browser row badge.
12
+ * @internal
13
+ */
14
+ export declare function describeClipRoles(clips: readonly string[]): string;
9
15
  /** Custom drag mime carrying a material id — read by hierarchy rows and the viewport drop zone. */
10
16
  export declare const MATERIAL_DRAG_MIME = "application/x-jgengine-material";
11
17
  /**
@@ -26,6 +32,7 @@ export declare function AssetBrowser({ assets, session, onPlace, }: {
26
32
  /** Turns a game's asset catalog ids into editor asset entries for the browser panel. */
27
33
  export declare function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => {
28
34
  url?: string;
35
+ clips?: readonly string[];
29
36
  } | null): EditorAssetEntry[];
30
37
  /**
31
38
  * Converts a durable/ephemeral standalone import into a Content Browser entry.
@@ -1,6 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useState } from "react";
3
+ import { rolesFromClips } from "@jgengine/core/game/clipRoles";
3
4
  import { TERRAIN_MATERIALS } from "./uiStore.js";
5
+ /** "idle · walk · run" summary of a rigged asset's detected animation roles for the browser row badge.
6
+ * @internal
7
+ */
8
+ export function describeClipRoles(clips) {
9
+ const roles = Object.keys(rolesFromClips(clips));
10
+ return roles.length === 0 ? "no roles detected" : roles.join(" · ");
11
+ }
4
12
  /** Custom drag mime carrying a material id — read by hierarchy rows and the viewport drop zone. */
5
13
  export const MATERIAL_DRAG_MIME = "application/x-jgengine-material";
6
14
  /**
@@ -46,7 +54,7 @@ export function AssetBrowser({ assets, session, onPlace, }) {
46
54
  return assets;
47
55
  return assets.filter((entry) => entry.id.toLowerCase().includes(needle) || entry.label.toLowerCase().includes(needle));
48
56
  }, [assets, query]);
49
- return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-1", children: [_jsx(MaterialsPalette, {}), _jsx("input", { type: "search", placeholder: "Search assets\u2026", value: query, onChange: (event) => setQuery(event.target.value), className: "rounded-md border border-white/10 bg-black/40 px-2.5 py-1.5 text-neutral-100 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60" }), _jsxs("div", { className: "min-h-0 flex-1 space-y-0.5 overflow-auto pr-1", children: [filtered.map((entry) => (_jsxs("div", { className: "group flex items-center gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-white/[0.06]", children: [_jsxs("div", { className: "min-w-0 flex-1 truncate", children: [_jsx("div", { className: "truncate text-neutral-100", children: entry.label }), _jsxs("div", { className: "truncate text-[10px] text-neutral-500", children: [entry.kind, " \u00B7 ", entry.id] })] }), _jsx("button", { type: "button", className: "shrink-0 rounded-md bg-cyan-500/20 px-2 py-0.5 text-cyan-200 opacity-0 ring-1 ring-inset ring-cyan-400/30 transition-all hover:bg-cyan-500/30 focus-visible:opacity-100 group-hover:opacity-100", onClick: () => onPlace(entry), children: "Place" })] }, entry.id))), filtered.length === 0 ? _jsx("div", { className: "text-neutral-500", children: "No assets match." }) : null] }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.05] px-2 py-1 text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 hover:text-neutral-100", onClick: () => {
57
+ return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-1", children: [_jsx(MaterialsPalette, {}), _jsx("input", { type: "search", placeholder: "Search assets\u2026", value: query, onChange: (event) => setQuery(event.target.value), className: "rounded-md border border-white/10 bg-black/40 px-2.5 py-1.5 text-neutral-100 outline-none transition-colors placeholder:text-neutral-600 focus:border-cyan-400/60 focus:bg-black/60" }), _jsxs("div", { className: "min-h-0 flex-1 space-y-0.5 overflow-auto pr-1", children: [filtered.map((entry) => (_jsxs("div", { className: "group flex items-center gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-white/[0.06]", children: [_jsxs("div", { className: "min-w-0 flex-1 truncate", children: [_jsx("div", { className: "truncate text-neutral-100", children: entry.label }), _jsxs("div", { className: "truncate text-[10px] text-neutral-500", children: [entry.kind, " \u00B7 ", entry.id] }), entry.clips !== undefined && entry.clips.length > 0 ? (_jsxs("div", { className: "truncate text-[10px] text-emerald-400/80", title: `${entry.clips.length} animation clips (auto-animates when placed):\n${entry.clips.join("\n")}`, children: ["\u25B6 ", entry.clips.length, " clips \u00B7 ", describeClipRoles(entry.clips)] })) : null] }), _jsx("button", { type: "button", className: "shrink-0 rounded-md bg-cyan-500/20 px-2 py-0.5 text-cyan-200 opacity-0 ring-1 ring-inset ring-cyan-400/30 transition-all hover:bg-cyan-500/30 focus-visible:opacity-100 group-hover:opacity-100", onClick: () => onPlace(entry), children: "Place" })] }, entry.id))), filtered.length === 0 ? _jsx("div", { className: "text-neutral-500", children: "No assets match." }) : null] }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.05] px-2 py-1 text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 hover:text-neutral-100", onClick: () => {
50
58
  const id = `marker_${Date.now().toString(36)}`;
51
59
  const focus = null;
52
60
  void focus;
@@ -71,6 +79,7 @@ export function assetsFromCatalog(ids, resolve) {
71
79
  label: id,
72
80
  kind: "model",
73
81
  ...(resolved?.url === undefined ? {} : { url: resolved.url }),
82
+ ...(resolved?.clips === undefined ? {} : { clips: resolved.clips }),
74
83
  };
75
84
  });
76
85
  }
@@ -23,6 +23,12 @@ export interface EditorAppProps {
23
23
  * session identity and presence rows. Omit when the host has nothing live — the panel stays honest.
24
24
  */
25
25
  networkPresence?: EditorNetworkPresenceInput;
26
+ /**
27
+ * Called to close the editor and return to the game. Passed by `GameHost` when the editor was
28
+ * summoned over a running game; when set, the chrome shows an "Exit to game" control. Omit for the
29
+ * standalone editor, which has no game to return to.
30
+ */
31
+ onExitEditor?: () => void;
26
32
  }
27
33
  /** Persists an exported document JSON; resolves with where it landed or why it failed. */
28
34
  export type EditorSaveFn = (json: string) => Promise<{
@@ -31,4 +37,4 @@ export type EditorSaveFn = (json: string) => Promise<{
31
37
  error?: string;
32
38
  }>;
33
39
  /** Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. */
34
- export declare function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence }: EditorAppProps): import("react").JSX.Element;
40
+ export declare function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }: EditorAppProps): import("react").JSX.Element;
package/dist/EditorApp.js CHANGED
@@ -244,7 +244,7 @@ function resolveEditorCamera(document) {
244
244
  };
245
245
  }
246
246
  /** Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes. */
247
- export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence }) {
247
+ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }) {
248
248
  const resolvedModeChip = modeChip === undefined ? EditorModeChip : modeChip;
249
249
  const saveFn = useMemo(() => save ?? endpointSaver(gameId), [save, gameId]);
250
250
  const networkSnapshot = useMemo(() => buildEditorNetworkSnapshot({
@@ -483,7 +483,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
483
483
  }
484
484
  };
485
485
  const GameUI = function EditorUi() {
486
- return (_jsx(EditorChrome, { gameId: gameId, session: host.api.getSession(), api: host.api, assets: catalogAssets, ui: ui, baselineDocument: host.baselineDocument, save: saveFn, networkSnapshot: networkSnapshot, onRegisterAsset: registerImportedAsset }));
486
+ return (_jsx(EditorChrome, { gameId: gameId, session: host.api.getSession(), api: host.api, assets: catalogAssets, ui: ui, baselineDocument: host.baselineDocument, save: saveFn, networkSnapshot: networkSnapshot, onRegisterAsset: registerImportedAsset, onExitEditor: onExitEditor }));
487
487
  };
488
488
  return {
489
489
  ...playable,
@@ -495,7 +495,7 @@ export function EditorApp({ gameId, playable, layers, catalogs, save, modeChip,
495
495
  // WorldOverlay above is the editor layers, not PandoraViewmodel.
496
496
  camera: inspectionCamera,
497
497
  };
498
- }, [playable, host, gameId, initialCamera, cameraProjection, catalogAssets, ui, readout, mode, saveFn, resolvedModeChip, networkSnapshot]);
498
+ }, [playable, host, gameId, initialCamera, cameraProjection, catalogAssets, ui, readout, mode, saveFn, resolvedModeChip, networkSnapshot, onExitEditor]);
499
499
  const showElevation = useStoreSelector(ui, (s) => s.showElevation);
500
500
  const onPanelCommit = useCallback((id, panel) => {
501
501
  host.session.dispatch({ type: "setUiPanel", id, patch: panel });
@@ -10,7 +10,7 @@ import type { EditorNetworkSnapshot } from "./networkSnapshot.js";
10
10
  * assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
11
11
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
12
12
  */
13
- export declare function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset, onRegisterAsset, }: {
13
+ export declare function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset, onRegisterAsset, onExitEditor, }: {
14
14
  gameId: string;
15
15
  session: EditorSession;
16
16
  api: EditorHostApi;
@@ -23,6 +23,8 @@ export declare function EditorChrome({ gameId, session, api, assets, ui, baselin
23
23
  path?: string;
24
24
  error?: string;
25
25
  }>;
26
+ /** Closes the editor and returns to the game; set only when summoned over a game. Shows "Exit to game". */
27
+ onExitEditor?: () => void;
26
28
  /**
27
29
  * Network workspace inspection payload (adapter config + optional host presence).
28
30
  * Built by `EditorApp` from the game definition; live presence rows only when the host injects them.
@@ -111,7 +111,7 @@ const LEFT_PAGES = [
111
111
  * assistant), viewport overlays, and status bar — wired to the session, UI store, layout store,
112
112
  * and host RPC. Mounted by `EditorApp`; not a game-author entry point.
113
113
  */
114
- export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, }) {
114
+ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }) {
115
115
  const [, setTick] = useState(0);
116
116
  const layoutRef = useRef(null);
117
117
  layoutRef.current ??= createShellLayoutStore(gameId);
@@ -169,6 +169,8 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
169
169
  return { objects, foliage };
170
170
  }, [state.document]);
171
171
  useF2Chord("KeyE", () => api.setMode("play"));
172
+ // F2+Q leaves the editor entirely (back to the game) — only meaningful when summoned over one.
173
+ useF2Chord("KeyQ", useCallback(() => onExitEditor?.(), [onExitEditor]));
172
174
  useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
173
175
  useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
174
176
  useEffect(() => layout.subscribe(() => setTick((value) => value + 1)), [layout]);
@@ -672,7 +674,9 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
672
674
  ? `Drawing ${placement.kind}: click to add points (${uiState.pathDraft.length}) · Enter finish · Esc cancel`
673
675
  : `Placing ${placement.tool === "note" ? "note" : placement.kind}: click the world · Shift-click places more · Esc cancel`;
674
676
  const railActive = uiState.tool === "terrain" ? "terrain" : layoutState.workspace;
675
- return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { gameId: gameId, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
677
+ // Only advertise the exit chord when there is a game to return to.
678
+ const shortcuts = onExitEditor !== undefined ? [...SHORTCUTS, { keys: "F2+Q", action: "Exit editor to game" }] : SHORTCUTS;
679
+ return (_jsxs("div", { className: "pointer-events-none absolute inset-0 z-50 flex flex-col text-xs text-neutral-100", children: [_jsx(TopAppBar, { gameId: gameId, onExitToGame: onExitEditor, dirty: dirty, saveState: docSave.saveState, lastSavedAt: docSave.lastSavedAt, saveAvailable: docSave.available, saveError: docSave.saveError, canUndo: session.canUndo(), canRedo: session.canRedo(), onUndo: () => session.dispatch({ type: "undo" }), onRedo: () => session.dispatch({ type: "redo" }), onSave: docSave.doSave, onPlay: () => api.setMode("play"), onWalk: () => api.setMode("walk"), onHud: () => api.setMode("hud"), onImport: () => importInputRef.current?.click(), onExport: exportJson, onCopyJson: copyExportJson, onOpenPalette: () => setPaletteQuery(""), onToggleHelp: () => setHelpOpen((value) => !value), onResetLayout: () => layout.reset() }), _jsx("input", { ref: importInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: (event) => {
676
680
  const file = event.target.files?.[0];
677
681
  if (file !== undefined)
678
682
  importFile(file);
@@ -747,5 +751,5 @@ export function EditorChrome({ gameId, session, api, assets, ui, baselineDocumen
747
751
  ["profiler", "Profiler"],
748
752
  ["animation", "Animation"],
749
753
  ["assistant", "AI Assistant"],
750
- ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: SHORTCUTS.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
754
+ ].map(([id, label]) => (_jsx("button", { type: "button", onClick: () => openBottomTab(id), className: `rounded-[4px] px-2 py-0.5 text-[10px] text-neutral-500 transition-colors hover:bg-white/[0.05] hover:text-neutral-300 ${FOCUS_RING}`, children: label }, id))), _jsx("span", { className: "ml-auto text-[9px] text-neutral-600", children: "Ctrl+B opens the content browser" })] }))] }), layoutState.rightOpen ? (_jsxs(_Fragment, { children: [_jsx(PanelResizer, { orientation: "vertical", label: "Resize inspector panel", sign: -1, onResize: (delta) => layout.resize("rightWidth", delta) }), _jsx("aside", { className: `pointer-events-auto hidden min-h-0 flex-col overflow-hidden border-l ${BORDER} bg-[#111318] md:flex`, style: { width: layoutState.rightWidth }, "aria-label": "Inspector dock", children: _jsx(InspectorPanel, { session: session, ui: ui, api: api, tab: layoutState.inspectorTab, onSelectTab: (tab) => layout.patch({ inspectorTab: tab }), collapsed: layoutState.collapsed, onToggleSection: (id) => layout.toggleSection(id), onClose: () => layout.patch({ rightOpen: false }) }) })] })) : null] }), _jsx(StatusBar, { api: api, history: perfHistory, objects: sceneStats.objects, foliage: sceneStats.foliage, selectionCount: state.selection.length, autosave: true }), paletteQuery !== null ? (_jsx(CommandPalette, { commands: paletteCommands, initialQuery: paletteQuery, onClose: () => setPaletteQuery(null) })) : null, helpOpen ? (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-[2px]", onClick: () => setHelpOpen(false), children: _jsxs("div", { className: "w-[28rem] max-w-[90vw] rounded-[10px] border border-white/10 bg-[#14171d] p-5 shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), role: "dialog", "aria-label": "Keyboard shortcuts", children: [_jsxs("div", { className: "mb-3 flex items-center", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-semibold tracking-wide text-neutral-100", children: [_jsx(Icon, { name: "command", size: 15, className: "text-cyan-300" }), "Keyboard shortcuts"] }), _jsx(IconButton, { icon: "close", label: "Close shortcuts", tone: "ghost", className: "ml-auto", onClick: () => setHelpOpen(false) })] }), _jsx("div", { className: "grid max-h-[60vh] grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 overflow-auto", children: shortcuts.map((entry) => (_jsxs("div", { className: "contents", children: [_jsx("span", { className: "justify-self-start", children: _jsx(Kbd, { children: entry.keys }) }), _jsx("span", { className: "text-neutral-300", children: entry.action })] }, entry.keys))) })] }) })) : null] }));
751
755
  }
@@ -4,6 +4,7 @@ import { documentHandlers } from "./document.js";
4
4
  import { gridHandlers } from "./grids.js";
5
5
  import { hierarchyHandlers } from "./hierarchy.js";
6
6
  import { minimapHandlers } from "./minimap.js";
7
+ import { streetsHandlers } from "./streets.js";
7
8
  import { runtimeHandlers } from "./runtime.js";
8
9
  import { terrainHandlers } from "./terrain.js";
9
10
  /**
@@ -16,6 +17,7 @@ export const EDITOR_RPC_HANDLERS = {
16
17
  ...catalogHandlers,
17
18
  ...terrainHandlers,
18
19
  ...minimapHandlers,
20
+ ...streetsHandlers,
19
21
  ...hierarchyHandlers,
20
22
  ...collectionHandlers,
21
23
  ...gridHandlers,
@@ -0,0 +1,3 @@
1
+ import type { HandlerTable } from "./context.js";
2
+ /** Document, selection, camera, mode, asset placement, and status verbs. */
3
+ export declare const streetsHandlers: Pick<HandlerTable, "generate_streets">;
@@ -0,0 +1,159 @@
1
+ import { generateStreets } from "@jgengine/core/world/streetGenerator";
2
+ /** Fully-defaulted slider preset for an open street net (resolves to `net` in `streetNetworkMode`). */
3
+ const NET_DEFAULTS = {
4
+ gridness: 0.9,
5
+ loopiness: 0.2,
6
+ connectivity: 0.35,
7
+ branching: 0.3,
8
+ deadEnds: 0.5,
9
+ segmentLength: 48,
10
+ aspect: 1,
11
+ winding: 0.2,
12
+ minCurveRadius: 30,
13
+ minTurnAngle: 0,
14
+ maxTurnAngle: 120,
15
+ width: 7,
16
+ boulevards: 0.3,
17
+ };
18
+ /** Fully-defaulted slider preset for a closed race circuit (resolves to `circuit` in `streetNetworkMode`). */
19
+ const CIRCUIT_DEFAULTS = {
20
+ gridness: 0.1,
21
+ loopiness: 1,
22
+ connectivity: 0,
23
+ branching: 0,
24
+ deadEnds: 0,
25
+ segmentLength: 60,
26
+ aspect: 1,
27
+ winding: 0.5,
28
+ minCurveRadius: 24,
29
+ minTurnAngle: 0,
30
+ maxTurnAngle: 70,
31
+ width: 12,
32
+ boulevards: 0,
33
+ };
34
+ /** The numeric slider keys a caller may overlay onto the mode preset via `params`. */
35
+ const RULE_KEYS = [
36
+ "gridness",
37
+ "loopiness",
38
+ "connectivity",
39
+ "branching",
40
+ "deadEnds",
41
+ "segmentLength",
42
+ "aspect",
43
+ "winding",
44
+ "minCurveRadius",
45
+ "minTurnAngle",
46
+ "maxTurnAngle",
47
+ "width",
48
+ "boulevards",
49
+ ];
50
+ /** The tag every baked path carries in `meta.generator`, so a re-run can find and replace its own output. */
51
+ const GENERATOR_TAG = "streetGenerator";
52
+ /** Stable id prefix for a seed's baked paths — the idempotency key for replace-on-re-run. */
53
+ function genPrefix(seed) {
54
+ return `gen-${seed}-`;
55
+ }
56
+ /** True when a path was baked by this generator for `seed` (matches id prefix OR the meta tag). */
57
+ function isGeneratedForSeed(path, seed) {
58
+ if (path.id.startsWith(genPrefix(seed)))
59
+ return true;
60
+ const meta = path.meta;
61
+ return meta !== undefined && meta.generator === GENERATOR_TAG && meta.seed === seed;
62
+ }
63
+ /** Half-extents (hx, hz) and world origin a volume fills, so the generator grows inside its footprint. */
64
+ function volumeFootprint(volume) {
65
+ if (volume.shape === "box" && volume.halfExtents !== undefined) {
66
+ return { hx: Math.max(1, volume.halfExtents.x), hz: Math.max(1, volume.halfExtents.z), origin: volume.center };
67
+ }
68
+ // sphere / cylinder (or a box missing halfExtents): a radius is symmetric in x/z.
69
+ const r = Math.max(1, volume.radius ?? 0);
70
+ return { hx: r, hz: r, origin: volume.center };
71
+ }
72
+ /** Document, selection, camera, mode, asset placement, and status verbs. */
73
+ export const streetsHandlers = {
74
+ generate_streets: (ctx, request) => {
75
+ const doc = ctx.session.getState().document;
76
+ const seed = request.seed ?? "path";
77
+ const requestedMode = request.mode === "circuit" ? "circuit" : "net";
78
+ // Resolve the footprint + world origin from a named volume or from explicit bounds.
79
+ let hx;
80
+ let hz;
81
+ let origin;
82
+ if (request.volumeId !== undefined) {
83
+ const volume = doc.volumes.find((v) => v.id === request.volumeId);
84
+ if (volume === undefined)
85
+ return { ok: false, error: `volume not found: ${request.volumeId}` };
86
+ ({ hx, hz, origin } = volumeFootprint(volume));
87
+ }
88
+ else {
89
+ const center = request.center ?? { x: 0, y: 0, z: 0 };
90
+ if (request.halfX === undefined || request.halfZ === undefined) {
91
+ return { ok: false, error: "generate_streets requires volumeId, or center + halfX + halfZ" };
92
+ }
93
+ hx = Math.max(1, request.halfX);
94
+ hz = Math.max(1, request.halfZ);
95
+ origin = center;
96
+ }
97
+ // Build rules: mode preset, overlaid with any numeric slider overrides from `params`.
98
+ const preset = requestedMode === "circuit" ? CIRCUIT_DEFAULTS : NET_DEFAULTS;
99
+ const rules = { seed, ...preset };
100
+ const params = request.params;
101
+ if (params !== undefined) {
102
+ for (const key of RULE_KEYS) {
103
+ const value = params[key];
104
+ if (typeof value === "number" && Number.isFinite(value)) {
105
+ rules[key] = value;
106
+ }
107
+ }
108
+ }
109
+ const network = generateStreets(rules, hx, hz);
110
+ // The generator resolves the ACTUAL topology from the rules; use it for the default path kind so a
111
+ // circuit bakes `route`s and a net bakes `road`s even when the caller's sliders cross the boundary.
112
+ const kind = request.kind ?? (network.mode === "circuit" ? "route" : "road");
113
+ const baseY = origin.y;
114
+ const toWorld = (street) => street.points.map(([lx, lz]) => ({ x: origin.x + lx, y: baseY, z: origin.z + lz }));
115
+ // Through-streets → one path each (a circuit's ring is a single closed loop street: first ≈ last).
116
+ const newPaths = [];
117
+ network.streets.forEach((street, index) => {
118
+ const points = toWorld(street);
119
+ if (points.length < 2)
120
+ return;
121
+ newPaths.push({
122
+ id: `${genPrefix(seed)}${index}`,
123
+ kind,
124
+ points,
125
+ width: street.width,
126
+ label: `${kind} ${index}`,
127
+ meta: {
128
+ generator: GENERATOR_TAG,
129
+ seed,
130
+ mode: network.mode,
131
+ level: street.level,
132
+ loop: street.loop,
133
+ },
134
+ });
135
+ });
136
+ // Idempotent replace: drop this seed's prior baked paths, keep everything else, append the fresh set —
137
+ // all as ONE undoable structural edit via replaceDocument.
138
+ const kept = doc.paths.filter((p) => !isGeneratedForSeed(p, seed));
139
+ const nextDoc = { ...doc, paths: [...kept, ...newPaths] };
140
+ const { applied } = ctx.dispatchGuarded({ type: "replaceDocument", document: nextDoc });
141
+ if (!applied)
142
+ return { ok: false, error: "generate_streets: document replace had no effect" };
143
+ // replaceDocument clears selection; re-select the freshly baked paths for the caller.
144
+ ctx.session.dispatch({ type: "select", ids: newPaths.map((p) => p.id) });
145
+ return {
146
+ ok: true,
147
+ result: {
148
+ seed,
149
+ requestedMode,
150
+ mode: network.mode,
151
+ kind,
152
+ pathCount: newPaths.length,
153
+ removed: doc.paths.length - kept.length,
154
+ ids: newPaths.map((p) => p.id),
155
+ footprint: { hx, hz, origin },
156
+ },
157
+ };
158
+ },
159
+ };
@@ -71,6 +71,7 @@ export const RPC_FIELD_SCHEMAS = {
71
71
  set_terrain_layers: [req(oa("layers", [req(s("id")), req(s("surface")), n("roughness"), n("tiling"), b("triplanar"), s("tint"), n("opacity")]))],
72
72
  blend_terrain: [req(s("surface")), req(n("x")), req(n("z")), n("radius"), n("strength"), s("shape", SHAPE)],
73
73
  convert_scatter: [req(s("pathId"))],
74
+ generate_streets: [s("volumeId"), v3("center"), n("halfX"), n("halfZ"), s("seed"), s("mode", ["net", "circuit"]), s("kind"), o("params")],
74
75
  bake_minimap: [n("padding"), n("resolution"), n("waterLevel")],
75
76
  add_foliage: [req(oa("points", [req(n("x")), req(n("z"))])), n("density"), s("item"), s("seed"), n("minSpacing")],
76
77
  scatter_summary: [],
package/dist/mcp/tools.js CHANGED
@@ -64,6 +64,7 @@ const TOOL_DESCRIPTIONS = [
64
64
  { name: "set_terrain_layers", description: "Replace the reorderable terrain material layer stack; a params-only edit keeps painted blends." },
65
65
  { name: "blend_terrain", description: "Blend-paint a material layer's weight at x/z (weighted multi-layer blend); auto-adds the layer." },
66
66
  { name: "convert_scatter", description: "Detach a foliage/scatter region into individually-editable placed prop markers, removing the region." },
67
+ { name: "generate_streets", description: "Bake the seed-driven street generator into the scene as authorable road/route paths inside a volume (or explicit center+halfX/halfZ). mode net→roads, circuit→closed route; params overlays sliders (gridness/loopiness/connectivity/winding/...). Idempotent: re-running the same seed replaces its own prior output. One undoable edit; round-trips --save." },
67
68
  { name: "bake_minimap", description: "Rasterize the authored terrain into a stored minimap PNG on the document (runtime reads it as the Minimap background). Live-editor only — needs the mounted viewport's terrain sampler, not a headless CLI." },
68
69
  { name: "add_foliage", description: "Add a foliage/scatter region from a closed polygon (≥3 x/z points) with density and item." },
69
70
  { name: "scatter_summary", description: "Count foliage/scatter regions and their total deterministic instance placements." },
package/dist/session.d.ts CHANGED
@@ -195,6 +195,20 @@ export type EditorBridgeRequest = {
195
195
  } | {
196
196
  method: "convert_scatter";
197
197
  pathId: string;
198
+ } | {
199
+ method: "generate_streets";
200
+ volumeId?: string;
201
+ center?: {
202
+ x: number;
203
+ y: number;
204
+ z: number;
205
+ };
206
+ halfX?: number;
207
+ halfZ?: number;
208
+ seed?: string;
209
+ mode?: "net" | "circuit";
210
+ kind?: string;
211
+ params?: Record<string, unknown>;
198
212
  } | {
199
213
  method: "bake_minimap";
200
214
  padding?: number;
@@ -8,8 +8,10 @@ export type TopBarSaveState = "idle" | "saving" | "saved" | "error";
8
8
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
9
9
  * runtime pause/step RPCs so mode switches keep shell chrome.
10
10
  */
11
- export declare function TopAppBar({ gameId, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
11
+ export declare function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: {
12
12
  gameId: string;
13
+ /** When set (editor summoned over a game), renders an "Exit to game" control that closes the editor. */
14
+ onExitToGame?: () => void;
13
15
  dirty: boolean;
14
16
  saveState: TopBarSaveState;
15
17
  /** Epoch ms of the last successful save this session; omit/null when never saved. */
@@ -34,7 +34,7 @@ function SaveStatus({ dirty, saveState, lastSavedAt, }) {
34
34
  * edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the
35
35
  * runtime pause/step RPCs so mode switches keep shell chrome.
36
36
  */
37
- export function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
37
+ export function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }) {
38
38
  const [menuOpen, setMenuOpen] = useState(false);
39
39
  const menuRef = useRef(null);
40
40
  useEffect(() => {
@@ -56,7 +56,7 @@ export function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAv
56
56
  };
57
57
  }, [menuOpen]);
58
58
  const menuItem = `flex w-full items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-[12px] text-neutral-300 transition-colors hover:bg-cyan-500/15 hover:text-cyan-100 ${FOCUS_RING}`;
59
- return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
59
+ return (_jsxs("header", { className: `pointer-events-auto relative z-[60] flex h-12 shrink-0 items-center gap-2 border-b ${BORDER} bg-[#111318] px-2.5`, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [onExitToGame !== undefined ? (_jsxs("button", { type: "button", onClick: onExitToGame, title: "Close the editor and return to the game (F2+Q)", className: `flex h-8 shrink-0 items-center gap-1.5 rounded-[6px] border border-white/[0.08] bg-black/30 pl-2 pr-2.5 text-[12px] text-neutral-300 transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] hover:text-neutral-100 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "chevronRight", size: 14, className: "rotate-180" }), "Exit to game"] })) : null, _jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] bg-gradient-to-br from-cyan-500/30 to-indigo-500/25 text-[11px] font-bold tracking-wide text-cyan-200 ring-1 ring-inset ring-cyan-400/30", "aria-hidden": "true", children: "JG" }), _jsxs("div", { className: "min-w-0 leading-tight", children: [_jsx("div", { className: "truncate text-[13px] font-semibold text-neutral-100", children: gameId }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Scene Editor" })] }), _jsx("div", { className: "ml-1 hidden md:block", children: _jsx(SaveStatus, { dirty: dirty, saveState: saveState, lastSavedAt: lastSavedAt }) })] }), _jsx("div", { className: "flex flex-1 justify-center px-2", children: _jsxs("button", { type: "button", onClick: onOpenPalette, "aria-label": "Open command palette", className: `flex h-8 w-full max-w-[420px] items-center gap-2 rounded-[6px] border border-white/[0.08] bg-black/30 px-3 text-[12px] text-neutral-500 transition-colors hover:border-white/[0.14] hover:text-neutral-400 ${FOCUS_RING}`, children: [_jsx(Icon, { name: "search", size: 13 }), _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: "Search or run a command\u2026" }), _jsxs("span", { className: "hidden items-center gap-0.5 sm:flex", children: [_jsx(Kbd, { children: "Ctrl" }), _jsx(Kbd, { children: "K" })] })] }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(IconButton, { icon: "undo", label: "Undo (Ctrl+Z)", onClick: onUndo, disabled: !canUndo, tone: "ghost" }), _jsx(IconButton, { icon: "redo", label: "Redo (Ctrl+Y)", onClick: onRedo, disabled: !canRedo, tone: "ghost" }), _jsx(ToolbarDivider, {}), _jsxs("button", { type: "button", onClick: onPlay, className: `flex h-8 items-center gap-1.5 rounded-[6px] bg-emerald-600 px-3.5 text-[12px] font-semibold text-white shadow-sm shadow-emerald-950/40 transition-colors hover:bg-emerald-500 ${FOCUS_RING}`, title: "Enter Play mode (F2+E toggles back)", children: [_jsx(Icon, { name: "play", size: 12 }), "Play"] }), _jsx(IconButton, { icon: "pause", label: "Pause \u2014 available in Play mode", disabled: true }), _jsx(IconButton, { icon: "step", label: "Step one frame \u2014 available in Play mode", disabled: true }), _jsx(ToolbarDivider, {}), _jsx(IconButton, { icon: "walk", label: "Walk the world", onClick: onWalk, tone: "ghost" }), _jsx(IconButton, { icon: "panel", label: "Lay out HUD panels", onClick: onHud, tone: "ghost" }), _jsx(ToolbarDivider, {}), saveAvailable ? (_jsxs("button", { type: "button", onClick: onSave, disabled: saveState === "saving", title: saveError ?? "Write the scene to the game's editor.scene.json (Ctrl+S)", className: `flex h-8 items-center gap-1.5 rounded-[6px] px-3 text-[12px] font-medium transition-colors ${FOCUS_RING} ${saveState === "error"
60
60
  ? "bg-rose-600/90 text-white hover:bg-rose-500"
61
61
  : dirty
62
62
  ? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40 hover:bg-cyan-500"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/editor",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.12.0",
35
- "@jgengine/react": "^0.12.0",
36
- "@jgengine/shell": "^0.12.0"
34
+ "@jgengine/core": "^0.13.0",
35
+ "@jgengine/react": "^0.13.0",
36
+ "@jgengine/shell": "^0.13.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@react-three/drei": "^10.0.0",
@@ -42,6 +42,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
42
42
 
43
43
  Within each selected domain:
44
44
 
45
+ 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
45
46
  1. Search `capabilities.md` by intent; it maps needs to imports.
46
47
  2. Open `api.md` only when exact signatures or export inventory are needed.
47
48
  3. Open the linked reference only for a deeper recipe or trap.
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -38,7 +38,7 @@
38
38
  - `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/world/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
39
39
  - `auditManifest` (function): function auditManifest(manifest: SceneOwnershipManifest): OwnershipDiagnostic[] — Audit every declaration in a manifest, keying diagnostics by each object's provenance id. An empty result means the manifest declares all of its content cleanly — every object is authored, generated, bakeable, or a reasoned runtime/transient object.
40
40
  - `biomes` (function): function biomes(config: BiomesWorldConfig): WorldFeature — Declares a biome-painted world — the whole-world alternative to a single `environment()` terrain.
41
- - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style.
41
+ - `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
42
42
  - `classifyOwnership` (function): function classifyOwnership(declaration: SceneOwnershipDeclaration): OwnershipVerdict — Resolve one declaration into a single boundary verdict.
43
43
  - `collectOwnershipDiagnostics` (function): function collectOwnershipDiagnostics(entries: Iterable<readonly [string, SceneOwnershipDeclaration]>): OwnershipDiagnostic[] — Audit a keyed set of declarations and return one diagnostic per object that breaks the boundary contract (a `reject` verdict carrying a `violation`). Deterministic: diagnostics come back in the iteration order of `entries`.
44
44
  - `contextVerb` (function): function contextVerb(label: string, command: string, args?: Record<string, unknown>): ContextVerb — Builds a {@link ContextVerb} for a right-click menu entry.
@@ -209,11 +209,18 @@
209
209
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
210
210
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
211
211
 
212
+ ## @jgengine/core/devtools/urlFlags
213
+
214
+ - `readUrlFlag` (function): function readUrlFlag(param: string): boolean — True when `param` is present and not an explicit off value (`0`/`false`/`off`/`no`).
215
+ - `readUrlParam` (function): function readUrlParam(param: string): string | null — The current query value of `param`, or null when absent (or no DOM).
216
+ - `subscribeUrlChange` (function): function subscribeUrlChange(listener: () => void): () => void — Fires `listener` whenever the query string may have changed out from under a flag — browser back/forward (`popstate`). Manual address-bar edits reload the page, so initial reads cover those; this keeps in-app state honest across history navigation. Returns an unsubscribe.
217
+ - `writeUrlParam` (function): function writeUrlParam(param: string, value: string | null): void — Sets `param` to `value`, or removes it when `value` is null, rewriting the URL in place with `history.replaceState` so the rest of the query and the hash survive and no history entry is pushed. No-ops without a DOM, and skips the write when the URL already matches.
218
+
212
219
  ## @jgengine/core/meta/changelog
213
220
 
214
221
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
215
222
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
216
- - `VERSION` (const): const VERSION: "0.12.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
223
+ - `VERSION` (const): const VERSION: "0.13.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
217
224
 
218
225
  ## @jgengine/core/runtime/adapter
219
226
 
@@ -276,7 +283,7 @@
276
283
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
277
284
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
278
285
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
279
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
286
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
280
287
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
281
288
  - `createGameContext` (function): function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext — ⚠ undocumented
282
289
 
@@ -307,7 +314,7 @@
307
314
  - `SceneObjectContext` (interface): interface SceneObjectContext extends ObjectStore — ⚠ undocumented
308
315
  - `SceneWorldItemContext` (interface): interface SceneWorldItemContext — ⚠ undocumented
309
316
  - `TelegraphInput` (interface): interface TelegraphInput — ⚠ undocumented
310
- - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst; `from`/`to` accept an instance id or a world point, `color` is a `0xRRGGBB` tint, and `durationMs` defaults per `kind`.
317
+ - `VfxInput` (interface): interface VfxInput — Request a transient spell/ability VFX burst. The easy path is a named `preset` — `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"` and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`, `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color` yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
311
318
  - `WorldItemPickupResult` (type): type WorldItemPickupResult = | { status: "ok"; record: WorldItemRecord } | { status: "rejected"; reason: string } — ⚠ undocumented
312
319
 
313
320
  ## @jgengine/core/runtime/gameRuntime
@@ -322,6 +329,7 @@
322
329
 
323
330
  ## @jgengine/core/runtime/headlessRunner
324
331
 
332
+ - `HeadlessCommandInterface` (interface): interface HeadlessCommandInterface — Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`.
325
333
  - `HeadlessInput` (interface): interface HeadlessInput — One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame.
326
334
  - `HeadlessRunner` (interface): interface HeadlessRunner — A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole play path (time, input, `onTick`, behaviour nav, optional player movement) runs from `core` primitives alone, so a non-React host (a server tick, a test, a CLI replay) can play a real game and read its world snapshot. The shell's FrameDriver is one such driver bolted to `useFrame`; this is the same step distilled out of the render tree.
327
335
  - `HeadlessRunnerOptions` (interface): interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> — ⚠ undocumented
@@ -393,7 +393,7 @@
393
393
  - `EDITOR_AGENT_KEY_FALLBACK_ENV` (const): const EDITOR_AGENT_KEY_FALLBACK_ENV: "ANTHROPIC_API_KEY" — Fallback API-key env when `JGENGINE_EDITOR_AGENT_KEY` is unset (`ANTHROPIC_API_KEY`).
394
394
  - `EDITOR_AGENT_URL_ENV` (const): const EDITOR_AGENT_URL_ENV: "JGENGINE_EDITOR_AGENT_URL" — Env var name for the remote agent HTTP URL (`JGENGINE_EDITOR_AGENT_URL`).
395
395
  - `EDITOR_MCP_TOOLS` (const): const EDITOR_MCP_TOOLS: readonly EditorMcpTool[] — Full set of MCP tools an agent can call to drive the live scene editor.
396
- - `EditorApp` (function): function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes.
396
+ - `EditorApp` (function): function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes.
397
397
  - `EditorAppProps` (interface): interface EditorAppProps — Props for mounting the scene editor over a playable game.
398
398
  - `EditorAssetEntry` (interface): interface EditorAssetEntry — A searchable, placeable asset shown in the editor's asset browser panel.
399
399
  - `EditorAssetInfo` (interface): interface EditorAssetInfo — A placeable asset entry offered in the editor's asset browser.
@@ -402,7 +402,7 @@
402
402
  - `EditorBridgeServer` (interface): interface EditorBridgeServer — A running editor bridge server: its bound port, URL, and a stop handle.
403
403
  - `EditorBridgeServerOptions` (interface): interface EditorBridgeServerOptions — Options for starting the editor's HTTP bridge server: host api, port, hostname.
404
404
  - `EditorCameraDriver` (const): const EditorCameraDriver: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — Smoothly pans the orbit camera to the editor host's focus target when it changes.
405
- - `EditorChrome` (function): function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, }: { gameId: string; session: EditorSession; api: EditorHostApi; assets: readonly EditorAssetEntry[]; ui: EditorUiStore; /** The document as loaded — d… — The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI assistant), viewport overlays, and status bar — wired to the session, UI store, layout store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
405
+ - `EditorChrome` (function): function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }: { gameId: string; session: EditorSession; api: EditorHostApi; assets: readonly EditorAssetEntry[]; ui: EditorUiStore; /** The document… — The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI assistant), viewport overlays, and status bar — wired to the session, UI store, layout store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
406
406
  - `EditorHostApi` (interface): interface EditorHostApi — The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC.
407
407
  - `EditorLayerOverlays` (function): function EditorLayerOverlays({ document, visibility, selection, hoverId = null, onSelect, activePathPoint, groundHeightAt, }: { document: EditorDocument; visibility: EditorKindVisibility; selection: readonly string[]; /** Pre-selection hover id from viewport pointer pick; ignored when already select… — Renders every visible marker, volume, path, and note from a document as in-scene 3D gizmos.
408
408
  - `EditorMcpTool` (interface): interface EditorMcpTool — One MCP tool descriptor — same verbs as the in-browser host RPC.
@@ -436,7 +436,7 @@
436
436
  - `TerrainMode` (type): type TerrainMode = "sculpt" | "paint" — The terrain tool's active sub-mode: reshape the heightfield, or paint material layers onto it.
437
437
  - `ViewportSelect` (const): const ViewportSelect: React.MemoExoticComponent<({ api, ui }: { api: EditorHostApi; ui: EditorUiStore; }) => null> — Canvas click-to-select and click-to-place. Document objects pick by screen proximity (registration always matches what you see) with click-cycling through stacked candidates and shift/ctrl additive selection; everything else picks by occlusion-ordered raycast against the tagged scene graph. When a placement tool is armed, clicks author new markers, volumes, notes, or path points at the ground hit instead of selecting.
438
438
  - `VirtualWindow` (interface): interface VirtualWindow — The visible slice of a fixed-row-height list: which rows to mount and the spacer geometry.
439
- - `assetsFromCatalog` (function): function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => { url?: string } | null): EditorAssetEntry[] — Turns a game's asset catalog ids into editor asset entries for the browser panel.
439
+ - `assetsFromCatalog` (function): function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => { url?: string; clips?: readonly string[] } | null): EditorAssetEntry[] — Turns a game's asset catalog ids into editor asset entries for the browser panel.
440
440
  - `blankWorld` (function): function blankWorld(_seed = "standalone"): EnvironmentWorldFeature — The default flat-ground authoring canvas the standalone editor opens on when the host supplies none. Deliberately thin — bare sculptable ground under a clear default sky. Dressing (sky look, foliage scatter, props) is what the author adds here, written into the scene document; it is never pre-baked into the canvas. The `seed` parameter is legacy and unused.
441
441
  - `createBlankPlayable` (function): function createBlankPlayable(options: BlankPlayableOptions = {}): PlayableGame — Builds a minimal gameless `PlayableGame` — a flat world plus an asset catalog — for the editor to mount over.
442
442
  - `createDefaultAgentEndpoint` (function): function createDefaultAgentEndpoint(config: AgentEndpointConfig = resolveAgentEndpointConfig()): AgentEndpoint — Picks HTTP endpoint when `JGENGINE_EDITOR_AGENT_URL` (or config.url) is set, otherwise the offline local agent.
@@ -461,7 +461,7 @@
461
461
  - `AssetBrowser` (function): function AssetBrowser({ assets, session, onPlace, }: { assets: readonly EditorAssetEntry[]; session: EditorSession; onPlace: (entry: EditorAssetEntry) => void; }): React.JSX.Element — Searchable panel for placing catalog assets or an empty marker into the scene.
462
462
  - `EditorAssetEntry` (interface): interface EditorAssetEntry — A searchable, placeable asset shown in the editor's asset browser panel.
463
463
  - `MATERIAL_DRAG_MIME` (const): const MATERIAL_DRAG_MIME: "application/x-jgengine-material" — Custom drag mime carrying a material id — read by hierarchy rows and the viewport drop zone.
464
- - `assetsFromCatalog` (function): function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => { url?: string } | null): EditorAssetEntry[] — Turns a game's asset catalog ids into editor asset entries for the browser panel.
464
+ - `assetsFromCatalog` (function): function assetsFromCatalog(ids: readonly string[], resolve?: (id: string) => { url?: string; clips?: readonly string[] } | null): EditorAssetEntry[] — Turns a game's asset catalog ids into editor asset entries for the browser panel.
465
465
 
466
466
  ## @jgengine/editor/DebugDraw
467
467
 
@@ -470,7 +470,7 @@
470
470
 
471
471
  ## @jgengine/editor/EditorApp
472
472
 
473
- - `EditorApp` (function): function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes.
473
+ - `EditorApp` (function): function EditorApp({ gameId, playable, layers, catalogs, save, modeChip, networkPresence, onExitEditor }: EditorAppProps): React.JSX.Element — Top-level scene editor: author spawns/zones/paths/notes visually over edit, walk, or play modes.
474
474
  - `EditorAppProps` (interface): interface EditorAppProps — Props for mounting the scene editor over a playable game.
475
475
  - `EditorSaveFn` (type): type EditorSaveFn = (json: string) => Promise<{ ok: boolean; path?: string; error?: string }> — Persists an exported document JSON; resolves with where it landed or why it failed.
476
476
 
@@ -480,7 +480,7 @@
480
480
 
481
481
  ## @jgengine/editor/EditorChrome
482
482
 
483
- - `EditorChrome` (function): function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, }: { gameId: string; session: EditorSession; api: EditorHostApi; assets: readonly EditorAssetEntry[]; ui: EditorUiStore; /** The document as loaded — d… — The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI assistant), viewport overlays, and status bar — wired to the session, UI store, layout store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
483
+ - `EditorChrome` (function): function EditorChrome({ gameId, session, api, assets, ui, baselineDocument, save, networkSnapshot, importAsset = importAssetToHost, onRegisterAsset, onExitEditor, }: { gameId: string; session: EditorSession; api: EditorHostApi; assets: readonly EditorAssetEntry[]; ui: EditorUiStore; /** The document… — The full editor UI shell — global app bar, contextual scene toolbar, workspace rail, resizable hierarchy/inspector docks, tabbed bottom dock (content browser, console, profiler, AI assistant), viewport overlays, and status bar — wired to the session, UI store, layout store, and host RPC. Mounted by `EditorApp`; not a game-author entry point.
484
484
 
485
485
  ## @jgengine/editor/PerfProbe
486
486
 
@@ -613,6 +613,10 @@
613
613
 
614
614
  - `runtimeHandlers` (const): const runtimeHandlers: Pick< HandlerTable, | "push_document_patch" | "pull_document_patches" | "document_revision" | "push_runtime_delta" | "pull_runtime_deltas" | "runtime_snapshot" | "runtime_summary" | "runtime_get" | "runtime_set" | "runtime_pause" | "runtime_resume" | "runtime_step" | "set_runt… — Live-sync document patches, the ephemeral runtime reverse channel, and play-mode poke verbs.
615
615
 
616
+ ## @jgengine/editor/handlers/streets
617
+
618
+ - `streetsHandlers` (const): const streetsHandlers: Pick<HandlerTable, "generate_streets"> — Document, selection, camera, mode, asset placement, and status verbs.
619
+
616
620
  ## @jgengine/editor/handlers/terrain
617
621
 
618
622
  - `terrainHandlers` (const): const terrainHandlers: Pick< HandlerTable, | "create_terrain" | "sculpt_terrain" | "terrain_summary" | "paint_terrain" | "fill_terrain" | "auto_paint" | "terrain_materials" | "terrain_layers" | "set_terrain_layers" | "blend_terrain" | "convert_scatter" | "add_foliage" | "scatter_summary" > — Sculpt heightfield, material painting, terrain layers, and foliage/scatter verbs.
@@ -760,7 +764,7 @@
760
764
 
761
765
  ## @jgengine/editor/shell/TopAppBar
762
766
 
763
- - `TopAppBar` (function): function TopAppBar({ gameId, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: { gameId: string; dirty: boolean; saveState: TopBarSaveState; /… — Global application bar: identity + save state on the left, command palette in the center, history / run controls / document actions on the right. Pause and Step stay disabled in edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the runtime pause/step RPCs so mode switches keep shell chrome.
767
+ - `TopAppBar` (function): function TopAppBar({ gameId, onExitToGame, dirty, saveState, lastSavedAt = null, saveAvailable, saveError, canUndo, canRedo, onUndo, onRedo, onSave, onPlay, onWalk, onHud, onImport, onExport, onCopyJson, onOpenPalette, onToggleHelp, onResetLayout, }: { gameId: string; /** When set (editor summoned o… — Global application bar: identity + save state on the left, command palette in the center, history / run controls / document actions on the right. Pause and Step stay disabled in edit mode; Play mode mounts {@link PlayModeBar}, which wires the same controls to the runtime pause/step RPCs so mode switches keep shell chrome.
764
768
  - `TopBarSaveState` (type): type TopBarSaveState = "idle" | "saving" | "saved" | "error" — Document save lifecycle mirrored from `useDocumentSave`.
765
769
 
766
770
  ## @jgengine/editor/shell/ViewportOverlays