@jgengine/editor 0.10.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 +325 -0
- package/dist/AssetBrowser.d.ts +20 -0
- package/dist/AssetBrowser.js +49 -0
- package/dist/CatalogsPanel.d.ts +10 -0
- package/dist/CatalogsPanel.js +41 -0
- package/dist/CollectionsPanel.d.ts +10 -0
- package/dist/CollectionsPanel.js +35 -0
- package/dist/DebugDraw.d.ts +17 -0
- package/dist/DebugDraw.js +233 -0
- package/dist/EditorApp.d.ts +26 -0
- package/dist/EditorApp.js +333 -0
- package/dist/EditorCameraDriver.d.ts +5 -0
- package/dist/EditorCameraDriver.js +18 -0
- package/dist/EditorChrome.d.ts +22 -0
- package/dist/EditorChrome.js +559 -0
- package/dist/EditorContextMenu.d.ts +11 -0
- package/dist/EditorContextMenu.js +12 -0
- package/dist/InspectorPanel.d.ts +14 -0
- package/dist/InspectorPanel.js +110 -0
- package/dist/MaterialDropZone.d.ts +10 -0
- package/dist/MaterialDropZone.js +77 -0
- package/dist/OutlinerPanel.d.ts +14 -0
- package/dist/OutlinerPanel.js +84 -0
- package/dist/PerfProbe.d.ts +5 -0
- package/dist/PerfProbe.js +34 -0
- package/dist/PrefabsPanel.d.ts +13 -0
- package/dist/PrefabsPanel.js +42 -0
- package/dist/RuntimePlayBridge.d.ts +18 -0
- package/dist/RuntimePlayBridge.js +140 -0
- package/dist/ScatterPreview.d.ts +11 -0
- package/dist/ScatterPreview.js +30 -0
- package/dist/SchemaInspector.d.ts +19 -0
- package/dist/SchemaInspector.js +78 -0
- package/dist/SelectionGizmo.d.ts +25 -0
- package/dist/SelectionGizmo.js +455 -0
- package/dist/StandaloneEditor.d.ts +44 -0
- package/dist/StandaloneEditor.js +92 -0
- package/dist/TerrainPanel.d.ts +7 -0
- package/dist/TerrainPanel.js +59 -0
- package/dist/TerrainSculpt.d.ts +14 -0
- package/dist/TerrainSculpt.js +415 -0
- package/dist/TriggerInspector.d.ts +13 -0
- package/dist/TriggerInspector.js +60 -0
- package/dist/agent/AgentPanel.d.ts +13 -0
- package/dist/agent/AgentPanel.js +156 -0
- package/dist/agent/context.d.ts +27 -0
- package/dist/agent/context.js +39 -0
- package/dist/agent/endpoint.d.ts +68 -0
- package/dist/agent/endpoint.js +186 -0
- package/dist/agent/toolBridge.d.ts +35 -0
- package/dist/agent/toolBridge.js +93 -0
- package/dist/agent/turn.d.ts +71 -0
- package/dist/agent/turn.js +113 -0
- package/dist/chromeFields.d.ts +15 -0
- package/dist/chromeFields.js +13 -0
- package/dist/chromeStyles.d.ts +3 -0
- package/dist/chromeStyles.js +3 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/mcp/bridgeServer.d.ts +13 -0
- package/dist/mcp/bridgeServer.js +1 -0
- package/dist/mcp/loadGameCatalogs.d.ts +24 -0
- package/dist/mcp/loadGameCatalogs.js +87 -0
- package/dist/mcp/rpcRequest.d.ts +22 -0
- package/dist/mcp/rpcRequest.js +97 -0
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.js +764 -0
- package/dist/outlinerModel.d.ts +76 -0
- package/dist/outlinerModel.js +95 -0
- package/dist/perfMarks.d.ts +25 -0
- package/dist/perfMarks.js +36 -0
- package/dist/session.d.ts +400 -0
- package/dist/session.js +1032 -0
- package/dist/uiStore.d.ts +108 -0
- package/dist/uiStore.js +128 -0
- package/dist/useF2Chord.d.ts +2 -0
- package/dist/useF2Chord.js +32 -0
- package/dist/useStoreSelector.d.ts +31 -0
- package/dist/useStoreSelector.js +51 -0
- package/dist/viewportContextMenu.d.ts +24 -0
- package/dist/viewportContextMenu.js +32 -0
- package/package.json +58 -0
- package/skills/jgengine/SKILL.md +823 -0
- package/skills/jgengine/api.md +416 -0
- package/skills/jgengine/capabilities.md +21 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine/reference-orphans.md +9 -0
- package/skills/jgengine/reference-systems.md +60 -0
- package/skills/jgengine-editor/SKILL.md +382 -0
- package/skills/jgengine-editor/api.md +416 -0
- package/skills/jgengine-editor/capabilities.md +29 -0
- package/skills/jgengine-editor/reference.md +64 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { editorDocumentBounds } from "@jgengine/core/editor/index";
|
|
4
|
+
import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
|
|
5
|
+
import { TERRAIN_MATERIALS } from "./uiStore.js";
|
|
6
|
+
import { INPUT } from "./chromeStyles.js";
|
|
7
|
+
import { SliderRow } from "./chromeFields.js";
|
|
8
|
+
const TERRAIN_BRUSHES = [
|
|
9
|
+
{ kind: "raise", label: "Raise", hint: "Push terrain up" },
|
|
10
|
+
{ kind: "lower", label: "Lower", hint: "Dig terrain down" },
|
|
11
|
+
{ kind: "smooth", label: "Smooth", hint: "Average toward neighbors" },
|
|
12
|
+
{ kind: "flatten", label: "Flatten", hint: "Level to a target height" },
|
|
13
|
+
{ kind: "noise", label: "Noise", hint: "Roughen with fractal detail" },
|
|
14
|
+
{ kind: "ramp", label: "Ramp", hint: "Drag a straight grade A→B" },
|
|
15
|
+
];
|
|
16
|
+
/** Sensible sculpt area when none is authored yet: the document footprint padded, or a 200m square. */
|
|
17
|
+
function defaultTerrainBounds(document) {
|
|
18
|
+
const bounds = editorDocumentBounds(document);
|
|
19
|
+
if (bounds === null)
|
|
20
|
+
return { minX: -100, minZ: -100, maxX: 100, maxZ: 100 };
|
|
21
|
+
const pad = 40;
|
|
22
|
+
const minX = bounds.min.x - pad;
|
|
23
|
+
const minZ = bounds.min.z - pad;
|
|
24
|
+
const maxX = bounds.max.x + pad;
|
|
25
|
+
const maxZ = bounds.max.z + pad;
|
|
26
|
+
const span = Math.max(80, Math.min(400, Math.max(maxX - minX, maxZ - minZ)));
|
|
27
|
+
const cx = (minX + maxX) / 2;
|
|
28
|
+
const cz = (minZ + maxZ) / 2;
|
|
29
|
+
return { minX: cx - span / 2, minZ: cz - span / 2, maxX: cx + span / 2, maxZ: cz + span / 2 };
|
|
30
|
+
}
|
|
31
|
+
function SculptControls({ ui }) {
|
|
32
|
+
const sculpt = ui.getState().sculpt;
|
|
33
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-3 gap-1", children: TERRAIN_BRUSHES.map((brush) => (_jsx("button", { type: "button", title: brush.hint, className: `rounded-md px-1.5 py-1 text-[11px] transition-colors ${sculpt.brush === brush.kind ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "bg-white/[0.04] text-neutral-300 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => ui.patchSculpt({ brush: brush.kind }), children: brush.label }, brush.kind))) }), sculpt.brush === "ramp" ? _jsx("div", { className: "text-[10px] text-neutral-500", children: "Drag from the low end to the high end to grade a slope." }) : null, _jsx(SliderRow, { label: "radius", value: sculpt.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchSculpt({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsx(SliderRow, { label: "strength", value: sculpt.strength, min: 0.05, max: 5, step: 0.05, onChange: (value) => ui.patchSculpt({ strength: value }) }), _jsx(SliderRow, { label: "spacing", value: sculpt.spacing, min: 0.25, max: 12, step: 0.25, onChange: (value) => ui.patchSculpt({ spacing: value }), format: (v) => `${v.toFixed(2)}m` }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "falloff" }), _jsx("div", { className: "ml-auto flex gap-0.5 rounded-md bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["smooth", "linear", "none"].map((mode) => (_jsx("button", { type: "button", className: `rounded px-1.5 py-0.5 text-[10px] capitalize transition-colors ${sculpt.falloff === mode ? "bg-amber-500/80 text-white" : "text-neutral-400 hover:text-neutral-200"}`, onClick: () => ui.patchSculpt({ falloff: mode }), children: mode }, mode))) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "shape" }), _jsx("button", { type: "button", className: "ml-auto rounded-md bg-white/[0.04] px-2 py-0.5 text-[11px] capitalize ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => ui.patchSculpt({ shape: sculpt.shape === "circle" ? "square" : "circle" }), children: sculpt.shape }), _jsxs("label", { className: "flex items-center gap-1 text-[10px] text-neutral-400", children: [_jsx("input", { type: "checkbox", className: "accent-amber-400", checked: sculpt.invert, onChange: (event) => ui.patchSculpt({ invert: event.target.checked }) }), "invert"] })] }), sculpt.brush === "flatten" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "flatten to" }), _jsx("input", { type: "number", step: 0.5, placeholder: "sample", className: `w-24 ${INPUT}`, value: sculpt.flattenHeight ?? "", onChange: (event) => ui.patchSculpt({ flattenHeight: event.target.value === "" ? null : Number(event.target.value) }) })] })) : null, sculpt.brush === "noise" ? (_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "seed" }), _jsx("input", { type: "number", step: 1, className: `w-24 ${INPUT}`, value: sculpt.noiseSeed, onChange: (event) => ui.patchSculpt({ noiseSeed: Math.round(Number(event.target.value)) || 0 }) })] })) : null] }));
|
|
34
|
+
}
|
|
35
|
+
function PaintControls({ session, ui }) {
|
|
36
|
+
const paint = ui.getState().paint;
|
|
37
|
+
const document = session.getState().document;
|
|
38
|
+
const paintDelta = (build) => {
|
|
39
|
+
if (document.terrain === undefined)
|
|
40
|
+
return;
|
|
41
|
+
const delta = build(editableTerrainFromSnapshot(document.terrain));
|
|
42
|
+
if (delta.indices.length > 0)
|
|
43
|
+
session.dispatch({ type: "paintTerrain", delta });
|
|
44
|
+
};
|
|
45
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-2 gap-1", children: TERRAIN_MATERIALS.map((material) => (_jsxs("button", { type: "button", className: `flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] transition-colors ${paint.material === material.id ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "bg-white/[0.04] text-neutral-300 ring-1 ring-inset ring-white/[0.06] hover:bg-white/10"}`, onClick: () => ui.patchPaint({ material: material.id }), children: [_jsx("span", { className: "h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/40", style: { backgroundColor: material.color } }), material.label] }, material.id))) }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Click/drag to paint \u00B7 Alt-click to sample." }), _jsx(SliderRow, { label: "radius", value: paint.radius, min: 1, max: 60, step: 0.5, onChange: (value) => ui.patchPaint({ radius: value }), format: (v) => `${v.toFixed(1)}m` }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "shape" }), _jsx("button", { type: "button", className: "ml-auto rounded-md bg-white/[0.04] px-2 py-0.5 text-[11px] capitalize ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => ui.patchPaint({ shape: paint.shape === "circle" ? "square" : "circle" }), children: paint.shape })] }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(paint.material)), children: "Fill all" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", onClick: () => paintDelta((terrain) => terrain.fillSurfaceDelta(null)), children: "Clear paint" })] }), _jsxs("div", { className: "space-y-1 rounded-lg border border-white/[0.06] p-2", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "Auto rules" }), _jsxs("div", { className: "grid grid-cols-2 gap-1", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[10px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", title: "Paint the selected material onto slopes steeper than ~30\u00B0", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minSlope: 0.6 })), children: "On steep slopes" }), _jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[10px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10", title: "Paint the selected material above height 15", onClick: () => paintDelta((terrain) => terrain.autoPaintDelta({ surface: paint.material, minHeight: 15 })), children: "On high ground" })] })] })] }));
|
|
46
|
+
}
|
|
47
|
+
/** The terrain-tool panel: create/clear the heightfield and drive the sculpt/paint controls. */
|
|
48
|
+
export function TerrainPanel({ session, ui }) {
|
|
49
|
+
const [, setTick] = useState(0);
|
|
50
|
+
useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
|
|
51
|
+
useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
|
|
52
|
+
const uiState = ui.getState();
|
|
53
|
+
const document = session.getState().document;
|
|
54
|
+
const hasTerrain = document.terrain !== undefined;
|
|
55
|
+
const createTerrain = () => {
|
|
56
|
+
session.dispatch({ type: "setTerrain", terrain: createTerrainSnapshot({ bounds: defaultTerrainBounds(document), cellSize: 2 }) });
|
|
57
|
+
};
|
|
58
|
+
return (_jsxs("div", { className: "pointer-events-auto absolute right-3 top-3 z-40 max-h-[calc(100%-1.5rem)] w-64 space-y-2.5 overflow-auto rounded-xl border border-amber-400/20 bg-[#0d0f13]/95 p-3 shadow-2xl shadow-black/60 backdrop-blur-md", children: [_jsxs("div", { className: "flex items-center", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em] text-amber-300", children: "Terrain" }), _jsx("button", { type: "button", className: "ml-auto rounded-md px-2 py-0.5 text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-200", onClick: () => ui.setTool("select"), title: "Back to select tool", children: "\u00D7" })] }), !hasTerrain ? (_jsxs("div", { className: "space-y-2", children: [_jsx("p", { className: "text-[11px] leading-relaxed text-neutral-400", children: "No terrain yet. Create an editable heightfield over the scene, then sculpt its shape and paint material layers on it." }), _jsx("button", { type: "button", className: "w-full rounded-md bg-gradient-to-b from-amber-500 to-amber-600 px-2.5 py-1.5 font-semibold text-white shadow-md shadow-amber-950/50 transition-colors hover:from-amber-400 hover:to-amber-500", onClick: createTerrain, children: "Create terrain" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex gap-0.5 rounded-lg bg-black/40 p-0.5 ring-1 ring-inset ring-white/[0.06]", children: ["sculpt", "paint"].map((mode) => (_jsx("button", { type: "button", className: `flex-1 rounded-md px-2 py-1 text-[11px] capitalize transition-colors ${uiState.terrainMode === mode ? "bg-amber-500/90 text-white shadow-sm shadow-amber-950/50" : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"}`, onClick: () => ui.setTerrainMode(mode), children: mode }, mode))) }), uiState.terrainMode === "paint" ? _jsx(PaintControls, { session: session, ui: ui }) : _jsx(SculptControls, { ui: ui }), _jsxs("div", { className: "flex items-center justify-between gap-2 border-t border-white/[0.06] pt-2", children: [_jsx("button", { type: "button", className: "rounded-md bg-white/[0.04] px-2 py-1 text-[11px] text-neutral-300 ring-1 ring-inset ring-white/[0.06] transition-colors hover:bg-white/10 disabled:opacity-40", onClick: () => session.dispatch({ type: "undo" }), disabled: !session.canUndo(), children: "Undo" }), _jsx("button", { type: "button", className: "rounded-md bg-rose-500/15 px-2 py-1 text-[11px] text-rose-200 ring-1 ring-inset ring-rose-400/25 transition-colors hover:bg-rose-500/25", onClick: () => session.dispatch({ type: "clearTerrain" }), children: "Clear terrain" })] })] }))] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorldFeature } from "@jgengine/core/world/features";
|
|
2
|
+
import type { EditorHostApi } from "./session.js";
|
|
3
|
+
import { type EditorUiStore } from "./uiStore.js";
|
|
4
|
+
/**
|
|
5
|
+
* Terrain-sculpt viewport layer: renders the editable heightfield and, while the terrain tool is
|
|
6
|
+
* active, converts pointer drags into brush strokes. A whole drag batches into one
|
|
7
|
+
* `sculptTerrain` command committed on release, so undo replays the stroke as a single step.
|
|
8
|
+
* @internal — mounted by `EditorApp`; not a game-author entry point.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TerrainSculpt: import("react").MemoExoticComponent<({ api, ui, world, }: {
|
|
11
|
+
api: EditorHostApi;
|
|
12
|
+
ui: EditorUiStore;
|
|
13
|
+
world?: WorldFeature;
|
|
14
|
+
}) => import("react").JSX.Element | null>;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useThree } from "@react-three/fiber";
|
|
3
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { createTerrainPaletteSampler } from "@jgengine/core/world/terrain";
|
|
6
|
+
import { beginSurfaceStroke, beginTerraformStroke, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
|
|
7
|
+
import { useGameContext } from "@jgengine/react/provider";
|
|
8
|
+
import { normalizeHeightBlend, TerraformBrushCursor } from "@jgengine/shell/terrain";
|
|
9
|
+
import { editorPerfMarks } from "./perfMarks.js";
|
|
10
|
+
import { TERRAIN_MATERIAL_COLORS } from "./uiStore.js";
|
|
11
|
+
const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
12
|
+
const LOW = new THREE.Color("#3f5d34");
|
|
13
|
+
const HIGH = new THREE.Color("#8f9c66");
|
|
14
|
+
const COLOR_SPAN = 24;
|
|
15
|
+
/** Resolves a material layer to its render color (its tint, else its palette surface color). */
|
|
16
|
+
function layerColor(layer) {
|
|
17
|
+
return layer.tint ?? TERRAIN_MATERIAL_COLORS[layer.surface] ?? "#7c7f86";
|
|
18
|
+
}
|
|
19
|
+
/** The game's `environment({ terrain })` descriptor, when the world it declares carries one — the same descriptor `TerrainGround` samples at runtime via `createTerrainPaletteSampler`. */
|
|
20
|
+
function terrainDescriptorOf(world) {
|
|
21
|
+
return world?.kind === "environment" ? world.terrain : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** The `[min, max]` height band `TerrainGround` blends `low`→`high` across for this descriptor (mirrors `EnvironmentScene.tsx`'s `TerrainGround` heightRange). */
|
|
24
|
+
function heightRangeOf(descriptor) {
|
|
25
|
+
const base = descriptor.baseHeight ?? 0;
|
|
26
|
+
const swing = descriptor.height * 1.2;
|
|
27
|
+
return [base - swing, base + swing];
|
|
28
|
+
}
|
|
29
|
+
function segmentsFor(snapshot) {
|
|
30
|
+
return Math.max(8, Math.min(180, Math.max(snapshot.cols, snapshot.rows)));
|
|
31
|
+
}
|
|
32
|
+
function boundsKey(bounds, segments) {
|
|
33
|
+
return `${bounds.minX}:${bounds.minZ}:${bounds.maxX}:${bounds.maxZ}:${segments}`;
|
|
34
|
+
}
|
|
35
|
+
/** Effective brush mode after the invert modifier flips raise↔lower. */
|
|
36
|
+
function effectiveBrush(brush, invert) {
|
|
37
|
+
if (!invert)
|
|
38
|
+
return brush;
|
|
39
|
+
if (brush === "raise")
|
|
40
|
+
return "lower";
|
|
41
|
+
if (brush === "lower")
|
|
42
|
+
return "raise";
|
|
43
|
+
return brush;
|
|
44
|
+
}
|
|
45
|
+
function editFromSettings(settings, center, clickHeight) {
|
|
46
|
+
const mode = effectiveBrush(settings.brush, settings.invert);
|
|
47
|
+
const heightLimit = settings.heightLimit.min === null && settings.heightLimit.max === null
|
|
48
|
+
? undefined
|
|
49
|
+
: {
|
|
50
|
+
...(settings.heightLimit.min === null ? {} : { min: settings.heightLimit.min }),
|
|
51
|
+
...(settings.heightLimit.max === null ? {} : { max: settings.heightLimit.max }),
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
mode,
|
|
55
|
+
center,
|
|
56
|
+
radius: settings.radius,
|
|
57
|
+
strength: settings.strength,
|
|
58
|
+
falloff: settings.falloff,
|
|
59
|
+
shape: settings.shape,
|
|
60
|
+
...(mode === "flatten"
|
|
61
|
+
? { target: settings.flattenHeight ?? clickHeight }
|
|
62
|
+
: {}),
|
|
63
|
+
...(mode === "noise" ? { seed: settings.noiseSeed } : {}),
|
|
64
|
+
...(heightLimit === undefined ? {} : { heightLimit }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const PAINT_TINT = new THREE.Color();
|
|
68
|
+
const BLEND_A = new THREE.Color();
|
|
69
|
+
const BLEND_B = new THREE.Color();
|
|
70
|
+
/** World-unit spacing between topographic relief bands drawn on the sculpt preview. */
|
|
71
|
+
const CONTOUR_INTERVAL = 1.5;
|
|
72
|
+
/**
|
|
73
|
+
* Topographic relief baked into a vertex's color so elevation reads from the editor's high camera,
|
|
74
|
+
* where lighting alone leaves gentle hills invisible. Smooth cosine bands every `CONTOUR_INTERVAL`
|
|
75
|
+
* darken slopes toward each band trough (a contour-map look) and higher ground brightens slightly —
|
|
76
|
+
* a `multiply`, so painted grass/dirt/rock keep their hue. Flat ground at a band height (the common
|
|
77
|
+
* unsculpted case at y=0) is left untouched: only varying height shows the bands. `strength` 0 is a
|
|
78
|
+
* no-op.
|
|
79
|
+
*/
|
|
80
|
+
function applyRelief(out, height, strength) {
|
|
81
|
+
if (strength <= 0)
|
|
82
|
+
return;
|
|
83
|
+
const band = 0.5 + 0.5 * Math.cos((height / CONTOUR_INTERVAL) * Math.PI * 2);
|
|
84
|
+
const relief = 1 - strength * 0.4 * (1 - band);
|
|
85
|
+
const lift = 1 + Math.max(-0.12, Math.min(0.22, height * 0.03)) * strength;
|
|
86
|
+
out.multiplyScalar(relief * lift);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The vertex tone under a world point: weighted layer blend, painted surface, or a height gradient.
|
|
90
|
+
* With no painted layer/surface at the point, `paletteAt` (the game's runtime terrain palette
|
|
91
|
+
* sampler, when the world declares one) supplies the low/high colors instead of the flat editor
|
|
92
|
+
* default — so `materialRegions`/base `colors`/`material` (worn paths, rock outcrops, biome tint)
|
|
93
|
+
* show through in the editor the same way `TerrainGround` renders them at runtime.
|
|
94
|
+
*/
|
|
95
|
+
function toneAt(terrain, x, z, height, out, paletteAt, heightRange) {
|
|
96
|
+
const layers = terrain.layers;
|
|
97
|
+
if (layers.length > 0) {
|
|
98
|
+
const weights = terrain.weightsAt(x, z);
|
|
99
|
+
if (weights.length === layers.length) {
|
|
100
|
+
let total = 0;
|
|
101
|
+
let r = 0;
|
|
102
|
+
let g = 0;
|
|
103
|
+
let b = 0;
|
|
104
|
+
for (let l = 0; l < layers.length; l += 1) {
|
|
105
|
+
const w = weights[l] * (layers[l].opacity ?? 1);
|
|
106
|
+
if (w <= 0)
|
|
107
|
+
continue;
|
|
108
|
+
BLEND_B.set(layerColor(layers[l]));
|
|
109
|
+
r += BLEND_B.r * w;
|
|
110
|
+
g += BLEND_B.g * w;
|
|
111
|
+
b += BLEND_B.b * w;
|
|
112
|
+
total += w;
|
|
113
|
+
}
|
|
114
|
+
if (total > 0) {
|
|
115
|
+
out.setRGB(r / total, g / total, b / total);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const surface = terrain.surfaceAt(x, z);
|
|
121
|
+
const painted = surface === null ? undefined : TERRAIN_MATERIAL_COLORS[surface];
|
|
122
|
+
if (painted !== undefined) {
|
|
123
|
+
out.copy(PAINT_TINT.set(painted));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (paletteAt !== null) {
|
|
127
|
+
const palette = paletteAt(x, z);
|
|
128
|
+
const blend = normalizeHeightBlend(height, heightRange[0], heightRange[1]);
|
|
129
|
+
out.copy(BLEND_A.set(palette.low).lerp(BLEND_B.set(palette.high), blend));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const t = Math.max(0, Math.min(1, (height + COLOR_SPAN * 0.25) / COLOR_SPAN));
|
|
133
|
+
out.copy(BLEND_A.copy(LOW).lerp(HIGH, t));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Re-samples vertex Y and vertex color from the live terrain (in place — no reallocation). When a
|
|
137
|
+
* dirty `region` is given, only vertices inside it re-sample — the whole-mesh resample per stamp is
|
|
138
|
+
* the sculpt hot path — while normals recompute across the mesh for seamless shading. Painted/blended
|
|
139
|
+
* layers win the vertex color; unpainted ground falls back to the runtime terrain palette (or the
|
|
140
|
+
* flat height gradient with no descriptor). Records rebuild time.
|
|
141
|
+
*/
|
|
142
|
+
function displace(geometry, terrain, bounds, region, paletteAt, heightRange, contourStrength) {
|
|
143
|
+
const started = performance.now();
|
|
144
|
+
const position = geometry.attributes.position;
|
|
145
|
+
const cx = (bounds.minX + bounds.maxX) / 2;
|
|
146
|
+
const cz = (bounds.minZ + bounds.maxZ) / 2;
|
|
147
|
+
let colors = geometry.attributes.color;
|
|
148
|
+
const full = region === null || colors === undefined || colors.count !== position.count;
|
|
149
|
+
if (colors === undefined || colors.count !== position.count) {
|
|
150
|
+
colors = new THREE.BufferAttribute(new Float32Array(position.count * 3), 3);
|
|
151
|
+
geometry.setAttribute("color", colors);
|
|
152
|
+
}
|
|
153
|
+
const tone = new THREE.Color();
|
|
154
|
+
for (let index = 0; index < position.count; index += 1) {
|
|
155
|
+
const x = position.getX(index) + cx;
|
|
156
|
+
const z = position.getZ(index) + cz;
|
|
157
|
+
if (!full && (x < region.minX || x > region.maxX || z < region.minZ || z > region.maxZ))
|
|
158
|
+
continue;
|
|
159
|
+
const height = terrain.sampleHeight(x, z);
|
|
160
|
+
position.setY(index, height);
|
|
161
|
+
toneAt(terrain, x, z, height, tone, paletteAt, heightRange);
|
|
162
|
+
applyRelief(tone, height, contourStrength);
|
|
163
|
+
colors.setXYZ(index, tone.r, tone.g, tone.b);
|
|
164
|
+
}
|
|
165
|
+
position.needsUpdate = true;
|
|
166
|
+
colors.needsUpdate = true;
|
|
167
|
+
geometry.computeVertexNormals();
|
|
168
|
+
geometry.computeBoundingSphere();
|
|
169
|
+
editorPerfMarks.record("rebuild", performance.now() - started);
|
|
170
|
+
}
|
|
171
|
+
function SculptMesh({ terrain, bounds, segments, revision, regionRef, meshRef, paletteAt, heightRange, contourStrength, }) {
|
|
172
|
+
const geometry = useMemo(() => {
|
|
173
|
+
const width = Math.max(1, bounds.maxX - bounds.minX);
|
|
174
|
+
const depth = Math.max(1, bounds.maxZ - bounds.minZ);
|
|
175
|
+
const geo = new THREE.PlaneGeometry(width, depth, segments, segments);
|
|
176
|
+
geo.rotateX(-Math.PI / 2);
|
|
177
|
+
return geo;
|
|
178
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
|
+
}, [boundsKey(bounds, segments)]);
|
|
180
|
+
useEffect(() => () => geometry.dispose(), [geometry]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
displace(geometry, terrain, bounds, regionRef.current, paletteAt, heightRange, contourStrength);
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [geometry, terrain, revision, paletteAt, heightRange, contourStrength]);
|
|
185
|
+
return (_jsx("mesh", { ref: meshRef, geometry: geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { vertexColors: true, roughness: 0.95, metalness: 0, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) }));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Terrain-sculpt viewport layer: renders the editable heightfield and, while the terrain tool is
|
|
189
|
+
* active, converts pointer drags into brush strokes. A whole drag batches into one
|
|
190
|
+
* `sculptTerrain` command committed on release, so undo replays the stroke as a single step.
|
|
191
|
+
* @internal — mounted by `EditorApp`; not a game-author entry point.
|
|
192
|
+
*/
|
|
193
|
+
export const TerrainSculpt = memo(function TerrainSculpt({ api, ui, world, }) {
|
|
194
|
+
const ctx = useGameContext();
|
|
195
|
+
const gl = useThree((state) => state.gl);
|
|
196
|
+
const camera = useThree((state) => state.camera);
|
|
197
|
+
const controls = useThree((state) => state.controls);
|
|
198
|
+
const session = api.getSession();
|
|
199
|
+
const terrainDescriptor = useMemo(() => terrainDescriptorOf(world), [world]);
|
|
200
|
+
const paletteAt = useMemo(() => (terrainDescriptor === undefined ? null : createTerrainPaletteSampler(terrainDescriptor)), [terrainDescriptor]);
|
|
201
|
+
const heightRange = useMemo(() => (terrainDescriptor === undefined ? [0, 1] : heightRangeOf(terrainDescriptor)), [terrainDescriptor]);
|
|
202
|
+
const [, setTick] = useState(0);
|
|
203
|
+
useEffect(() => session.subscribe(() => setTick((value) => value + 1)), [session]);
|
|
204
|
+
useEffect(() => ui.subscribe(() => setTick((value) => value + 1)), [ui]);
|
|
205
|
+
const snapshot = session.getState().document.terrain ?? null;
|
|
206
|
+
const base = ctx.world.ground;
|
|
207
|
+
const uiState = ui.getState();
|
|
208
|
+
const toolActive = uiState.tool === "terrain";
|
|
209
|
+
const editableRef = useRef(null);
|
|
210
|
+
const boundsRef = useRef(null);
|
|
211
|
+
const meshRef = useRef(null);
|
|
212
|
+
const syncedRef = useRef(null);
|
|
213
|
+
const gridKeyRef = useRef(null);
|
|
214
|
+
const regionRef = useRef(null);
|
|
215
|
+
const [revision, setRevision] = useState(0);
|
|
216
|
+
const [cursor, setCursor] = useState(null);
|
|
217
|
+
const segments = snapshot === null ? 0 : segmentsFor(snapshot);
|
|
218
|
+
const gridKey = snapshot === null ? null : `${boundsKey(snapshot.bounds, segments)}:${snapshot.cellSize}`;
|
|
219
|
+
// region === null forces a whole-mesh rebuild; a stamp passes only its dirty footprint.
|
|
220
|
+
const refresh = (region = null) => {
|
|
221
|
+
regionRef.current = region;
|
|
222
|
+
setRevision((value) => value + 1);
|
|
223
|
+
};
|
|
224
|
+
// Rebuild the live terrain when the grid changes; otherwise re-sync offsets on undo/redo/import.
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (snapshot === null) {
|
|
227
|
+
editableRef.current = null;
|
|
228
|
+
boundsRef.current = null;
|
|
229
|
+
gridKeyRef.current = null;
|
|
230
|
+
syncedRef.current = null;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (gridKeyRef.current !== gridKey || editableRef.current === null) {
|
|
234
|
+
editableRef.current = editableTerrainFromSnapshot(snapshot, base);
|
|
235
|
+
boundsRef.current = snapshot.bounds;
|
|
236
|
+
gridKeyRef.current = gridKey;
|
|
237
|
+
}
|
|
238
|
+
else if (syncedRef.current !== snapshot) {
|
|
239
|
+
editableRef.current.restore(snapshot);
|
|
240
|
+
}
|
|
241
|
+
syncedRef.current = snapshot;
|
|
242
|
+
refresh();
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
}, [snapshot, gridKey, base]);
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!toolActive || snapshot === null) {
|
|
247
|
+
setCursor(null);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const canvas = gl.domElement;
|
|
251
|
+
const raycaster = new THREE.Raycaster();
|
|
252
|
+
const ndc = new THREE.Vector2();
|
|
253
|
+
const planeHit = new THREE.Vector3();
|
|
254
|
+
let active = null;
|
|
255
|
+
let rampFrom = null;
|
|
256
|
+
let clickHeight = 0;
|
|
257
|
+
let last = null;
|
|
258
|
+
const pick = (clientX, clientY) => {
|
|
259
|
+
const started = performance.now();
|
|
260
|
+
const mesh = meshRef.current;
|
|
261
|
+
const rect = canvas.getBoundingClientRect();
|
|
262
|
+
ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
263
|
+
raycaster.setFromCamera(ndc, camera);
|
|
264
|
+
let result = null;
|
|
265
|
+
if (mesh !== null) {
|
|
266
|
+
const hit = raycaster.intersectObject(mesh, false)[0];
|
|
267
|
+
if (hit !== undefined)
|
|
268
|
+
result = { x: hit.point.x, z: hit.point.z };
|
|
269
|
+
}
|
|
270
|
+
if (result === null && raycaster.ray.intersectPlane(GROUND_PLANE, planeHit) !== null) {
|
|
271
|
+
result = { x: planeHit.x, z: planeHit.z };
|
|
272
|
+
}
|
|
273
|
+
editorPerfMarks.record("raycast", performance.now() - started);
|
|
274
|
+
return result;
|
|
275
|
+
};
|
|
276
|
+
// The dirty footprint of a stamp — its brush disc plus a two-cell margin for seamless normals.
|
|
277
|
+
const stampRegion = (point) => {
|
|
278
|
+
const state = ui.getState();
|
|
279
|
+
const radius = state.terrainMode === "paint" ? state.paint.radius : state.sculpt.radius;
|
|
280
|
+
const margin = radius + (snapshot?.cellSize ?? 1) * 2;
|
|
281
|
+
return { minX: point.x - margin, maxX: point.x + margin, minZ: point.z - margin, maxZ: point.z + margin };
|
|
282
|
+
};
|
|
283
|
+
const setControls = (enabled) => {
|
|
284
|
+
if (controls !== null && "enabled" in controls)
|
|
285
|
+
controls.enabled = enabled;
|
|
286
|
+
};
|
|
287
|
+
const stampSpacing = () => {
|
|
288
|
+
const state = ui.getState();
|
|
289
|
+
return state.terrainMode === "paint"
|
|
290
|
+
? Math.max(0.5, state.paint.radius * 0.4)
|
|
291
|
+
: Math.max(0.25, state.sculpt.spacing);
|
|
292
|
+
};
|
|
293
|
+
const stamp = (point) => {
|
|
294
|
+
if (active === null)
|
|
295
|
+
return;
|
|
296
|
+
if (active.kind === "sculpt") {
|
|
297
|
+
active.stroke.stamp(editFromSettings(ui.getState().sculpt, [point.x, point.z], clickHeight));
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const paint = ui.getState().paint;
|
|
301
|
+
active.stroke.stamp({
|
|
302
|
+
mode: "paint",
|
|
303
|
+
center: [point.x, point.z],
|
|
304
|
+
radius: paint.radius,
|
|
305
|
+
surface: paint.material,
|
|
306
|
+
shape: paint.shape,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
last = [point.x, point.z];
|
|
310
|
+
refresh(stampRegion(point));
|
|
311
|
+
};
|
|
312
|
+
const onMove = (event) => {
|
|
313
|
+
const point = pick(event.clientX, event.clientY);
|
|
314
|
+
if (point === null)
|
|
315
|
+
return;
|
|
316
|
+
const terrain = editableRef.current;
|
|
317
|
+
const y = terrain === null ? 0 : terrain.sampleHeight(point.x, point.z);
|
|
318
|
+
setCursor({ x: point.x, y, z: point.z });
|
|
319
|
+
if (active === null || rampFrom !== null)
|
|
320
|
+
return;
|
|
321
|
+
if (last === null || Math.hypot(point.x - last[0], point.z - last[1]) >= stampSpacing())
|
|
322
|
+
stamp(point);
|
|
323
|
+
};
|
|
324
|
+
const onDown = (event) => {
|
|
325
|
+
if (event.button !== 0)
|
|
326
|
+
return;
|
|
327
|
+
const point = pick(event.clientX, event.clientY);
|
|
328
|
+
if (point === null)
|
|
329
|
+
return;
|
|
330
|
+
const terrain = editableRef.current;
|
|
331
|
+
if (terrain === null)
|
|
332
|
+
return;
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
const mode = ui.getState().terrainMode;
|
|
335
|
+
// Alt-click in paint mode is the eyedropper — sample the material under the cursor.
|
|
336
|
+
if (mode === "paint" && event.altKey) {
|
|
337
|
+
const sampled = terrain.surfaceAt(point.x, point.z);
|
|
338
|
+
if (sampled !== null)
|
|
339
|
+
ui.patchPaint({ material: sampled });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
setControls(false);
|
|
343
|
+
clickHeight = terrain.sampleHeight(point.x, point.z);
|
|
344
|
+
last = null;
|
|
345
|
+
if (mode === "paint") {
|
|
346
|
+
active = { kind: "paint", stroke: beginSurfaceStroke(terrain) };
|
|
347
|
+
stamp(point);
|
|
348
|
+
}
|
|
349
|
+
else if (ui.getState().sculpt.brush === "ramp") {
|
|
350
|
+
active = { kind: "sculpt", stroke: beginTerraformStroke(terrain) };
|
|
351
|
+
rampFrom = [point.x, point.z];
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
active = { kind: "sculpt", stroke: beginTerraformStroke(terrain) };
|
|
355
|
+
stamp(point);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const commit = () => {
|
|
359
|
+
setControls(true);
|
|
360
|
+
const current = active;
|
|
361
|
+
active = null;
|
|
362
|
+
rampFrom = null;
|
|
363
|
+
last = null;
|
|
364
|
+
if (current === null)
|
|
365
|
+
return;
|
|
366
|
+
if (current.kind === "sculpt") {
|
|
367
|
+
const delta = current.stroke.delta();
|
|
368
|
+
if (delta.indices.length > 0)
|
|
369
|
+
session.dispatch({ type: "sculptTerrain", delta });
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const delta = current.stroke.delta();
|
|
373
|
+
if (delta.indices.length > 0)
|
|
374
|
+
session.dispatch({ type: "paintTerrain", delta });
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const onUp = (event) => {
|
|
378
|
+
if (event.button !== 0 || active === null)
|
|
379
|
+
return;
|
|
380
|
+
if (rampFrom !== null && active.kind === "sculpt") {
|
|
381
|
+
const point = pick(event.clientX, event.clientY);
|
|
382
|
+
if (point !== null) {
|
|
383
|
+
active.stroke.stamp({
|
|
384
|
+
...editFromSettings(ui.getState().sculpt, rampFrom, clickHeight),
|
|
385
|
+
mode: "ramp",
|
|
386
|
+
to: [point.x, point.z],
|
|
387
|
+
});
|
|
388
|
+
refresh();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
commit();
|
|
392
|
+
};
|
|
393
|
+
const onLeave = () => {
|
|
394
|
+
if (active !== null)
|
|
395
|
+
commit();
|
|
396
|
+
setCursor(null);
|
|
397
|
+
};
|
|
398
|
+
canvas.addEventListener("pointerdown", onDown);
|
|
399
|
+
canvas.addEventListener("pointermove", onMove);
|
|
400
|
+
canvas.addEventListener("pointerup", onUp);
|
|
401
|
+
canvas.addEventListener("pointerleave", onLeave);
|
|
402
|
+
return () => {
|
|
403
|
+
if (active !== null)
|
|
404
|
+
commit();
|
|
405
|
+
canvas.removeEventListener("pointerdown", onDown);
|
|
406
|
+
canvas.removeEventListener("pointermove", onMove);
|
|
407
|
+
canvas.removeEventListener("pointerup", onUp);
|
|
408
|
+
canvas.removeEventListener("pointerleave", onLeave);
|
|
409
|
+
};
|
|
410
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
411
|
+
}, [toolActive, snapshot, gl, camera, controls, session, ui]);
|
|
412
|
+
if (snapshot === null || editableRef.current === null || boundsRef.current === null)
|
|
413
|
+
return null;
|
|
414
|
+
return (_jsxs(_Fragment, { children: [_jsx(SculptMesh, { terrain: editableRef.current, bounds: boundsRef.current, segments: segments, revision: revision, regionRef: regionRef, meshRef: meshRef, paletteAt: paletteAt, heightRange: heightRange, contourStrength: toolActive ? 1 : 0.45 }), toolActive && cursor !== null ? (_jsx(TerraformBrushCursor, { center: [cursor.x, cursor.z], y: cursor.y + 0.1, radius: uiState.terrainMode === "paint" ? uiState.paint.radius : uiState.sculpt.radius, mode: uiState.terrainMode === "paint" ? "paint" : effectiveBrush(uiState.sculpt.brush, uiState.sculpt.invert) })) : null] }));
|
|
415
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type TriggerSourceKind } from "@jgengine/core/scene/authoredTriggers";
|
|
2
|
+
import { type MetaPatch } from "./SchemaInspector.js";
|
|
3
|
+
/**
|
|
4
|
+
* Inspector block for the authored-trigger vocabulary (`on` / `action` + action params).
|
|
5
|
+
* Renders only when the game has registered at least one action for this target; params come from
|
|
6
|
+
* the declared action's ParamSchema via SchemaInspector.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export declare function TriggerInspector({ target, meta, onMeta, }: {
|
|
10
|
+
target: TriggerSourceKind;
|
|
11
|
+
meta: Record<string, unknown> | undefined;
|
|
12
|
+
onMeta: MetaPatch;
|
|
13
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getTriggerAction, listTriggerActions, readFlatTrigger, TRIGGER_ACTION_KEY, TRIGGER_LIST_KEY, TRIGGER_ON_KEY, TRIGGER_RADIUS_KEY, } from "@jgengine/core/scene/authoredTriggers";
|
|
3
|
+
import { defaultParamMeta, parseParams } from "@jgengine/core/scene/sceneKinds";
|
|
4
|
+
import { SchemaInspector } from "./SchemaInspector.js";
|
|
5
|
+
import { INPUT, MICRO } from "./chromeStyles.js";
|
|
6
|
+
import { NumberField } from "./chromeFields.js";
|
|
7
|
+
const EVENTS = [
|
|
8
|
+
{ value: "", label: "— none —" },
|
|
9
|
+
{ value: "enter", label: "On enter" },
|
|
10
|
+
{ value: "exit", label: "On exit" },
|
|
11
|
+
{ value: "interact", label: "On interact" },
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Inspector block for the authored-trigger vocabulary (`on` / `action` + action params).
|
|
15
|
+
* Renders only when the game has registered at least one action for this target; params come from
|
|
16
|
+
* the declared action's ParamSchema via SchemaInspector.
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function TriggerInspector({ target, meta, onMeta, }) {
|
|
20
|
+
const actions = listTriggerActions(target);
|
|
21
|
+
if (actions.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
const flat = readFlatTrigger(meta);
|
|
24
|
+
const multi = Array.isArray(meta?.[TRIGGER_LIST_KEY]);
|
|
25
|
+
const actionDef = flat.action.length > 0 ? getTriggerAction(flat.action) : undefined;
|
|
26
|
+
const allowedEvents = actionDef?.events;
|
|
27
|
+
const eventOptions = EVENTS.filter((entry) => entry.value === "" || allowedEvents === undefined || allowedEvents.includes(entry.value));
|
|
28
|
+
const writeTrigger = (on, action) => {
|
|
29
|
+
if (on === "" || action.length === 0) {
|
|
30
|
+
onMeta({ [TRIGGER_ON_KEY]: "", [TRIGGER_ACTION_KEY]: "", [TRIGGER_LIST_KEY]: [] }, "trigger:clear");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const patch = {
|
|
34
|
+
[TRIGGER_ON_KEY]: on,
|
|
35
|
+
[TRIGGER_ACTION_KEY]: action,
|
|
36
|
+
[TRIGGER_LIST_KEY]: [],
|
|
37
|
+
};
|
|
38
|
+
const definition = getTriggerAction(action);
|
|
39
|
+
if (definition !== undefined) {
|
|
40
|
+
const defaults = defaultParamMeta(definition.schema);
|
|
41
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
42
|
+
if (meta?.[key] === undefined)
|
|
43
|
+
patch[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
onMeta(patch, "trigger:set");
|
|
47
|
+
};
|
|
48
|
+
const onParamMeta = (patch, coalesce) => {
|
|
49
|
+
onMeta({ ...(meta ?? {}), ...patch }, coalesce);
|
|
50
|
+
};
|
|
51
|
+
return (_jsxs("div", { className: "space-y-2 rounded-lg border border-amber-400/15 bg-amber-500/[0.06] p-2.5", children: [_jsx("div", { className: "text-[9px] font-semibold uppercase tracking-[0.14em] text-amber-300", children: "Behavior trigger" }), multi ? (_jsxs("div", { className: "text-[10px] text-neutral-400", children: ["Multi-trigger list on this object \u2014 edit via document JSON / RPC. Clear list to use the simple editor.", _jsx("button", { type: "button", className: "mt-1 block rounded-md bg-white/[0.07] px-2 py-1 text-[10px] ring-1 ring-inset ring-white/[0.06] hover:bg-white/15", onClick: () => onMeta({ [TRIGGER_LIST_KEY]: [] }, "trigger:unlist"), children: "Clear multi-list" })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: "on" }), _jsx("select", { className: `w-36 ${INPUT}`, value: flat.on, onChange: (event) => {
|
|
52
|
+
const on = event.target.value;
|
|
53
|
+
if (on === "")
|
|
54
|
+
writeTrigger("", "");
|
|
55
|
+
else
|
|
56
|
+
writeTrigger(on, flat.action.length > 0 ? flat.action : (actions[0]?.id ?? ""));
|
|
57
|
+
}, children: eventOptions.map((entry) => (_jsx("option", { value: entry.value, children: entry.label }, entry.value || "none"))) })] }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: MICRO, children: "action" }), _jsxs("select", { className: `w-36 ${INPUT}`, value: flat.action, disabled: flat.on === "", onChange: (event) => writeTrigger(flat.on === "" ? "enter" : flat.on, event.target.value), children: [_jsx("option", { value: "", children: "\u2014 choose \u2014" }), actions.map((action) => (_jsx("option", { value: action.id, children: action.label }, action.id)))] })] }), target === "marker" && flat.on !== "" ? (_jsx(NumberField, { label: "trigger r", step: 0.5, value: typeof meta?.[TRIGGER_RADIUS_KEY] === "number" ? meta[TRIGGER_RADIUS_KEY] : typeof meta?.["radius"] === "number" ? meta["radius"] : 2.5, onCommit: (value) => onParamMeta({ [TRIGGER_RADIUS_KEY]: Math.max(0, value) }, "trigger:radius") })) : null, actionDef !== undefined && actionDef.schema.fields.some((field) => field.type !== "action") ? (_jsx(SchemaInspector, { schema: actionDef.schema, label: `${actionDef.label} params`, accent: "#fbbf24", meta: meta, onMeta: onParamMeta, note: flat.on === ""
|
|
58
|
+
? undefined
|
|
59
|
+
: `${flat.on} → ${actionDef.id} (${Object.keys(parseParams(actionDef.schema, meta)).length} params)` })) : null] }))] }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EditorHostApi } from "../session.js";
|
|
2
|
+
import { createDefaultAgentEndpoint } from "./endpoint.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dockable in-editor agent chat: humans and agents share the same RPC/undo surface.
|
|
5
|
+
* Tool calls route through `EditorHostApi.handle`; patches appear in the transcript and can be undone.
|
|
6
|
+
* @internal — mounted by `EditorChrome`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AgentPanel({ api, onClose, }: {
|
|
9
|
+
api: EditorHostApi;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/** Re-export for hosts that want a default endpoint without the panel. */
|
|
13
|
+
export { createDefaultAgentEndpoint };
|