Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
@@ -10,8 +10,9 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
|||||||
|
|
||||||
| Term | Meaning |
|
| Term | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) and typed properties. Assets have **no position**. |
|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`) and typed properties. Assets have **no position**. |
|
||||||
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties. Scenes cannot be placed. |
|
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **owner** (another object of the scene). Scenes cannot be placed. |
|
||||||
|
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its owner is the object the graph acts on. |
|
||||||
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
|
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
|
||||||
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
|
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
|
||||||
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||||
@@ -22,7 +23,36 @@ default 640×360) and a `zoom`; the frame it covers in world units is `size / zo
|
|||||||
mode that frame is drawn as an outline. In play mode only that frame is rendered – fitted into
|
mode that frame is drawn as an outline. In play mode only that frame is rendered – fitted into
|
||||||
the viewport with black letterboxing – and objects outside it are culled. A scene without a
|
the viewport with black letterboxing – and objects outside it are culled. A scene without a
|
||||||
camera renders nothing in play mode. Play has no built-in behaviour (no input, no movement):
|
camera renders nothing in play mode. Play has no built-in behaviour (no input, no movement):
|
||||||
behaviour will come with scripting in a later phase.
|
everything that happens is defined by behavior graphs.
|
||||||
|
|
||||||
|
## Behaviors (node graphs)
|
||||||
|
|
||||||
|
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
|
||||||
|
area (the **Scene** tab shows the viewport). Ports come in two kinds: **exec** (white
|
||||||
|
arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`,
|
||||||
|
`object`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls
|
||||||
|
data inputs lazily from connected outputs. Unconnected data inputs use the inline value
|
||||||
|
shown on the node and editable in the inspector.
|
||||||
|
|
||||||
|
- **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they
|
||||||
|
appear in the inspector, can be overridden per placed instance, and each running instance
|
||||||
|
gets its own mutable copy. `Get variable` / `Set variable` read and write them.
|
||||||
|
- **Owner**: a placed behavior may point at another object of the scene (inspector → Owner,
|
||||||
|
dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself.
|
||||||
|
- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`,
|
||||||
|
`On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Object by name`, `Get/Set
|
||||||
|
variable`, `Get/Set property`, `Get position`, literals, `Add/Subtract/Multiply/Divide`,
|
||||||
|
`Compare`, `And/Or/Not`, `Key is down` · actions `Move`, `Set position`, `Destroy`, `Log`,
|
||||||
|
`Stop game`. A node type is one `registerNode()` call; palette, ports, validation and
|
||||||
|
inspector fields are generated from it.
|
||||||
|
- **Editing**: right-click the canvas for the searchable palette, drag port → port to connect
|
||||||
|
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
|
||||||
|
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
|
||||||
|
the empty canvas pans. Everything is undoable.
|
||||||
|
- **Live view**: while playing with the Graph tab open, the exec edges and nodes that just ran
|
||||||
|
light up – the same canvas draws editor and game, so this costs nothing extra.
|
||||||
|
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||||||
|
touch world entities. A runaway loop throws after 1000 steps and stops play with a notice.
|
||||||
|
|
||||||
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
||||||
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
||||||
@@ -47,10 +77,12 @@ inspector next to X/Y, undoable like any move, and applies in edit and play mode
|
|||||||
```
|
```
|
||||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||||
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
||||||
src/runtime/ World, Entity, System interface, Input tracker (no systems yet)
|
src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested)
|
||||||
|
src/runtime/ World, Entity, System interface, Input tracker, BehaviorSystem
|
||||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||||
src/viewport/ scene renderer: model in edit mode, world in play mode
|
src/viewport/ scene renderer: model in edit mode, world in play mode
|
||||||
src/editor/ App composition, state, panels, inspector field factory
|
src/editor/ App composition, state, panels, inspector field factory
|
||||||
|
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
|
||||||
api/ PHP backend data/projects/ persisted projects
|
api/ PHP backend data/projects/ persisted projects
|
||||||
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
|
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
|
||||||
```
|
```
|
||||||
@@ -96,7 +128,7 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi
|
|||||||
`Esc` stop · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1),
|
`Esc` stop · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1),
|
||||||
`↑`/`↓` step · viewport: wheel zooms, middle-drag or `Shift`+drag pans.
|
`↑`/`↓` step · viewport: wheel zooms, middle-drag or `Shift`+drag pans.
|
||||||
|
|
||||||
## Out of scope for phase 1
|
## Not yet
|
||||||
|
|
||||||
Textures on sprites, physics, scripting, multi-select, z-order UI, auth, editing while playing,
|
Textures on sprites, physics/collisions, spawning from graphs, multi-select, auth, editing
|
||||||
touch-optimised layout.
|
while playing, touch-optimised layout, graph comments/groups.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { createEmptyGraph } from '../graph/Graph';
|
||||||
import type { Asset } from '../model/Asset';
|
import type { Asset } from '../model/Asset';
|
||||||
import type { AssetType } from '../model/AssetTypes';
|
import type { AssetType } from '../model/AssetTypes';
|
||||||
import type { PropertyValue } from '../model/Properties';
|
import type { PropertyValue } from '../model/Properties';
|
||||||
import { findAsset, findUsages, type Project } from '../model/Project';
|
import { findAsset, findUsages, type Project } from '../model/Project';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
import { coerceValue, defaultsFor, schemaFor } from '../properties/PropertySchema';
|
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
|
||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
|||||||
return { ...base, type: 'camera' };
|
return { ...base, type: 'camera' };
|
||||||
case 'sprite':
|
case 'sprite':
|
||||||
return { ...base, type: 'sprite' };
|
return { ...base, type: 'sprite' };
|
||||||
|
case 'behavior':
|
||||||
|
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export class DeleteAssetCommand implements Command {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
private removed?: { asset: Asset; index: number };
|
private removed?: { asset: Asset; index: number };
|
||||||
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
|
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
|
||||||
|
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
|
||||||
private previousActiveScene = '';
|
private previousActiveScene = '';
|
||||||
|
|
||||||
constructor(private readonly assetId: string) {
|
constructor(private readonly assetId: string) {
|
||||||
@@ -62,9 +66,18 @@ export class DeleteAssetCommand implements Command {
|
|||||||
object,
|
object,
|
||||||
index: scene.objects.indexOf(object),
|
index: scene.objects.indexOf(object),
|
||||||
}));
|
}));
|
||||||
|
this.clearedOwners = [];
|
||||||
for (const usage of this.removedUsages) {
|
for (const usage of this.removedUsages) {
|
||||||
const scene = findAsset(project, usage.sceneId);
|
const scene = findAsset(project, usage.sceneId);
|
||||||
if (scene && scene.type === 'scene') scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
|
if (!scene || scene.type !== 'scene') continue;
|
||||||
|
scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
|
||||||
|
// Objects that pointed at a removed object as their owner lose that reference.
|
||||||
|
for (const other of scene.objects) {
|
||||||
|
if (other.ownerId === usage.object.id) {
|
||||||
|
this.clearedOwners.push({ sceneId: scene.id, objectId: other.id, ownerId: other.ownerId });
|
||||||
|
other.ownerId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.removed = { asset, index };
|
this.removed = { asset, index };
|
||||||
project.assets.splice(index, 1);
|
project.assets.splice(index, 1);
|
||||||
@@ -83,6 +96,11 @@ export class DeleteAssetCommand implements Command {
|
|||||||
const scene = findAsset(project, usage.sceneId);
|
const scene = findAsset(project, usage.sceneId);
|
||||||
if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object);
|
if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object);
|
||||||
}
|
}
|
||||||
|
for (const cleared of this.clearedOwners) {
|
||||||
|
const scene = findAsset(project, cleared.sceneId);
|
||||||
|
const object = scene && scene.type === 'scene' ? scene.objects.find((o) => o.id === cleared.objectId) : undefined;
|
||||||
|
if (object) object.ownerId = cleared.ownerId;
|
||||||
|
}
|
||||||
project.activeSceneId = this.previousActiveScene;
|
project.activeSceneId = this.previousActiveScene;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +132,7 @@ export class SetAssetPropertyCommand implements Command {
|
|||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = requireAsset(project, this.assetId);
|
const asset = requireAsset(project, this.assetId);
|
||||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||||
this.previous = asset.props[this.key];
|
this.previous = asset.props[this.key];
|
||||||
asset.props[this.key] = coerceValue(def, this.value);
|
asset.props[this.key] = coerceValue(def, this.value);
|
||||||
@@ -138,7 +156,7 @@ export class ResetAssetPropertyCommand implements Command {
|
|||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const asset = requireAsset(project, this.assetId);
|
const asset = requireAsset(project, this.assetId);
|
||||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||||
this.previous = asset.props[this.key];
|
this.previous = asset.props[this.key];
|
||||||
asset.props[this.key] = def.default;
|
asset.props[this.key] = def.default;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
|
||||||
|
import { requireNodeDef } from '../graph/NodeRegistry';
|
||||||
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
|
import type { Project } from '../model/Project';
|
||||||
|
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||||||
|
import { newId } from '../ids';
|
||||||
|
import { type Command, CommandError } from './Command';
|
||||||
|
import { requireBehavior } from './variableCommands';
|
||||||
|
|
||||||
|
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
|
||||||
|
export class AddNodeCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
readonly node: GraphNode;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
|
||||||
|
const def = requireNodeDef(type);
|
||||||
|
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def.params), ...params } };
|
||||||
|
this.label = `Add ${def.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
graph.nodes = graph.nodes.filter((n) => n.id !== this.node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a node and every edge touching it. */
|
||||||
|
export class RemoveNodeCommand implements Command {
|
||||||
|
readonly label = 'Remove node';
|
||||||
|
private removed?: { node: GraphNode; index: number; edges: { edge: GraphEdge; index: number }[] };
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly nodeId: string) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
const index = graph.nodes.findIndex((n) => n.id === this.nodeId);
|
||||||
|
const node = graph.nodes[index];
|
||||||
|
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||||||
|
const edges = edgesOfNode(graph, this.nodeId).map((edge) => ({ edge, index: graph.edges.indexOf(edge) }));
|
||||||
|
this.removed = { node, index, edges };
|
||||||
|
graph.nodes.splice(index, 1);
|
||||||
|
graph.edges = graph.edges.filter((e) => e.from.node !== this.nodeId && e.to.node !== this.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
if (!this.removed) return;
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
graph.nodes.splice(this.removed.index, 0, this.removed.node);
|
||||||
|
for (const { edge, index } of [...this.removed.edges].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MoveNodeCommand implements Command {
|
||||||
|
readonly label = 'Move node';
|
||||||
|
private previous = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number, previous?: { x: number; y: number }) {
|
||||||
|
if (previous) this.previous = { ...previous };
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
this.previous = { x: node.x, y: node.y };
|
||||||
|
node.x = Math.round(this.x);
|
||||||
|
node.y = Math.round(this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
node.x = this.previous.x;
|
||||||
|
node.y = this.previous.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets a node param or the inline value of an unconnected data input. */
|
||||||
|
export class SetNodeParamCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private previous: PropertyValue | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly key: string, private readonly value: PropertyValue) {
|
||||||
|
this.label = `Set ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
const def = requireNodeDef(node.type);
|
||||||
|
const param = def.params.find((p) => p.key === this.key);
|
||||||
|
this.previous = node.params[this.key];
|
||||||
|
// Inline input values are stored as-is; declared params are coerced against their definition.
|
||||||
|
node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
if (this.previous === undefined) delete node.params[this.key];
|
||||||
|
else node.params[this.key] = this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects an output to an input. Validates port existence and type compatibility; a data
|
||||||
|
* input holds at most one edge, so an existing one is replaced (and restored on undo).
|
||||||
|
*/
|
||||||
|
export class ConnectCommand implements Command {
|
||||||
|
readonly label = 'Connect';
|
||||||
|
readonly edge: GraphEdge;
|
||||||
|
private replaced: { edge: GraphEdge; index: number }[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
|
||||||
|
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
const fromNode = findNode(graph, this.edge.from.node);
|
||||||
|
const toNode = findNode(graph, this.edge.to.node);
|
||||||
|
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||||
|
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||||
|
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
|
||||||
|
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
|
||||||
|
if (!out || !inp) throw new CommandError('Port not found');
|
||||||
|
if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||||
|
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
|
||||||
|
this.replaced = [];
|
||||||
|
if (inp.type !== 'exec') {
|
||||||
|
for (const existing of edgesInto(graph, this.edge.to.node, this.edge.to.port)) {
|
||||||
|
this.replaced.push({ edge: existing, index: graph.edges.indexOf(existing) });
|
||||||
|
}
|
||||||
|
graph.edges = graph.edges.filter((e) => !this.replaced.some((r) => r.edge === e));
|
||||||
|
}
|
||||||
|
graph.edges.push(this.edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
graph.edges = graph.edges.filter((e) => e.id !== this.edge.id);
|
||||||
|
for (const { edge, index } of [...this.replaced].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DisconnectCommand implements Command {
|
||||||
|
readonly label = 'Disconnect';
|
||||||
|
private removed?: { edge: GraphEdge; index: number };
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly edgeId: string) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const graph = requireBehavior(project, this.assetId).graph;
|
||||||
|
const index = graph.edges.findIndex((e) => e.id === this.edgeId);
|
||||||
|
const edge = graph.edges[index];
|
||||||
|
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||||
|
this.removed = { edge, index };
|
||||||
|
graph.edges.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
if (this.removed) requireBehavior(project, this.assetId).graph.edges.splice(this.removed.index, 0, this.removed.edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNode(project: Project, assetId: string, nodeId: string): GraphNode {
|
||||||
|
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
|
||||||
|
if (!node) throw new CommandError(`Node ${nodeId} not found`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
|
||||||
|
return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { isPlaceableType } from '../model/AssetTypes';
|
|||||||
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
|
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
|
||||||
import { findScene, findSceneObject, type Project } from '../model/Project';
|
import { findScene, findSceneObject, type Project } from '../model/Project';
|
||||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||||
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
|
||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
import { requireAsset } from './assetCommands';
|
import { requireAsset } from './assetCommands';
|
||||||
@@ -13,7 +13,7 @@ function requireScene(project: Project, sceneId: string) {
|
|||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
||||||
const object = findSceneObject(requireScene(project, sceneId), objectId);
|
const object = findSceneObject(requireScene(project, sceneId), objectId);
|
||||||
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
|
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
|
||||||
return object;
|
return object;
|
||||||
@@ -42,6 +42,7 @@ export class PlaceAssetCommand implements Command {
|
|||||||
export class RemoveSceneObjectCommand implements Command {
|
export class RemoveSceneObjectCommand implements Command {
|
||||||
readonly label = 'Remove object';
|
readonly label = 'Remove object';
|
||||||
private removed?: { object: SceneObject; index: number };
|
private removed?: { object: SceneObject; index: number };
|
||||||
|
private clearedOwners: string[] = [];
|
||||||
|
|
||||||
constructor(private readonly sceneId: string, private readonly objectId: string) {}
|
constructor(private readonly sceneId: string, private readonly objectId: string) {}
|
||||||
|
|
||||||
@@ -52,11 +53,24 @@ export class RemoveSceneObjectCommand implements Command {
|
|||||||
if (!object) throw new CommandError(`Scene object ${this.objectId} not found`);
|
if (!object) throw new CommandError(`Scene object ${this.objectId} not found`);
|
||||||
this.removed = { object, index };
|
this.removed = { object, index };
|
||||||
scene.objects.splice(index, 1);
|
scene.objects.splice(index, 1);
|
||||||
|
// Objects owned by the removed one lose their owner reference.
|
||||||
|
this.clearedOwners = [];
|
||||||
|
for (const other of scene.objects) {
|
||||||
|
if (other.ownerId === this.objectId) {
|
||||||
|
other.ownerId = '';
|
||||||
|
this.clearedOwners.push(other.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
if (!this.removed) return;
|
if (!this.removed) return;
|
||||||
requireScene(project, this.sceneId).objects.splice(this.removed.index, 0, this.removed.object);
|
const scene = requireScene(project, this.sceneId);
|
||||||
|
scene.objects.splice(this.removed.index, 0, this.removed.object);
|
||||||
|
for (const id of this.clearedOwners) {
|
||||||
|
const other = findSceneObject(scene, id);
|
||||||
|
if (other) other.ownerId = this.objectId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +133,7 @@ export class SetOverrideCommand implements Command {
|
|||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const object = requireObject(project, this.sceneId, this.objectId);
|
const object = requireObject(project, this.sceneId, this.objectId);
|
||||||
const asset = requireAsset(project, object.assetId);
|
const asset = requireAsset(project, object.assetId);
|
||||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||||
this.previous = object.overrides[this.key];
|
this.previous = object.overrides[this.key];
|
||||||
object.overrides[this.key] = coerceValue(def, this.value);
|
object.overrides[this.key] = coerceValue(def, this.value);
|
||||||
@@ -152,3 +166,25 @@ export class ClearOverrideCommand implements Command {
|
|||||||
requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous;
|
requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Points an object (typically a behavior) at another object of the same scene, or at nothing (''). */
|
||||||
|
export class SetOwnerCommand implements Command {
|
||||||
|
readonly label = 'Set owner';
|
||||||
|
private previous = '';
|
||||||
|
|
||||||
|
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly ownerId: string) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const object = requireObject(project, this.sceneId, this.objectId);
|
||||||
|
if (this.ownerId) {
|
||||||
|
if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself');
|
||||||
|
requireObject(project, this.sceneId, this.ownerId);
|
||||||
|
}
|
||||||
|
this.previous = object.ownerId;
|
||||||
|
object.ownerId = this.ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
requireObject(project, this.sceneId, this.objectId).ownerId = this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { nodeDef } from '../graph/NodeRegistry';
|
||||||
|
import type { BehaviorAsset } from '../model/Asset';
|
||||||
|
import type { PropertyValue } from '../model/Properties';
|
||||||
|
import { findAsset, findUsages, type Project } from '../model/Project';
|
||||||
|
import { type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||||||
|
import { type Command, CommandError } from './Command';
|
||||||
|
|
||||||
|
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
|
||||||
|
const asset = findAsset(project, assetId);
|
||||||
|
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "var1", "var2", … – the first variable name not yet used by the behavior. */
|
||||||
|
export function nextVariableName(asset: BehaviorAsset, base = 'var'): string {
|
||||||
|
const taken = new Set(asset.variables.map((v) => v.key));
|
||||||
|
for (let i = 1; ; i++) {
|
||||||
|
const candidate = `${base}${i}`;
|
||||||
|
if (!taken.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declares a new variable on a behavior; its default value is written to `props`. */
|
||||||
|
export class AddVariableCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
readonly def: PropertyDef;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
|
||||||
|
this.def = variableDef(key, kind);
|
||||||
|
this.label = `Add variable ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requireBehavior(project, this.assetId);
|
||||||
|
if (!isValidVariableName(this.def.key)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
|
||||||
|
if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`);
|
||||||
|
asset.variables.push(this.def);
|
||||||
|
asset.props[this.def.key] = this.def.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const asset = requireBehavior(project, this.assetId);
|
||||||
|
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
|
||||||
|
delete asset.props[this.def.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a variable together with its default and every instance override. */
|
||||||
|
export class RemoveVariableCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private removed?: { def: PropertyDef; index: number; value: PropertyValue | undefined };
|
||||||
|
private overrides: { sceneId: string; objectId: string; value: PropertyValue }[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly key: string) {
|
||||||
|
this.label = `Remove variable ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requireBehavior(project, this.assetId);
|
||||||
|
const index = asset.variables.findIndex((v) => v.key === this.key);
|
||||||
|
const def = asset.variables[index];
|
||||||
|
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||||
|
this.removed = { def, index, value: asset.props[this.key] };
|
||||||
|
asset.variables.splice(index, 1);
|
||||||
|
delete asset.props[this.key];
|
||||||
|
this.overrides = [];
|
||||||
|
for (const { scene, object } of findUsages(project, this.assetId)) {
|
||||||
|
const value = object.overrides[this.key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
this.overrides.push({ sceneId: scene.id, objectId: object.id, value });
|
||||||
|
delete object.overrides[this.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
if (!this.removed) return;
|
||||||
|
const asset = requireBehavior(project, this.assetId);
|
||||||
|
asset.variables.splice(this.removed.index, 0, this.removed.def);
|
||||||
|
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||||
|
for (const o of this.overrides) {
|
||||||
|
const object = findUsages(project, this.assetId).find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object;
|
||||||
|
if (object) object.overrides[this.key] = o.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames a variable everywhere: definition, default, instance overrides and graph nodes referring to it. */
|
||||||
|
export class RenameVariableCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
|
||||||
|
this.label = `Rename variable ${from}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
this.rename(project, this.from, this.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
this.rename(project, this.to, this.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rename(project: Project, from: string, to: string): void {
|
||||||
|
const asset = requireBehavior(project, this.assetId);
|
||||||
|
if (from === to) return;
|
||||||
|
if (!isValidVariableName(to)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
|
||||||
|
const def = asset.variables.find((v) => v.key === from);
|
||||||
|
if (!def) throw new CommandError(`Variable "${from}" not found`);
|
||||||
|
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
|
||||||
|
def.key = to;
|
||||||
|
def.label = to;
|
||||||
|
if (asset.props[from] !== undefined) {
|
||||||
|
asset.props[to] = asset.props[from]!;
|
||||||
|
delete asset.props[from];
|
||||||
|
}
|
||||||
|
for (const { object } of findUsages(project, this.assetId)) {
|
||||||
|
if (object.overrides[from] === undefined) continue;
|
||||||
|
object.overrides[to] = object.overrides[from]!;
|
||||||
|
delete object.overrides[from];
|
||||||
|
}
|
||||||
|
for (const node of asset.graph.nodes) {
|
||||||
|
const def = nodeDef(node.type);
|
||||||
|
for (const param of def?.params ?? []) {
|
||||||
|
if (param.key === 'variable' && node.params.variable === from) node.params.variable = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidVariableName(name: string): boolean {
|
||||||
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port data types. `exec` carries control flow (Blueprint-style white arrows); the others
|
||||||
|
* carry values. `any` accepts every value type (variables, logging).
|
||||||
|
*/
|
||||||
|
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
|
||||||
|
|
||||||
|
export interface PortRef {
|
||||||
|
node: string;
|
||||||
|
port: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One node of a behavior graph. `type` names a NodeDef in the registry; `params` holds
|
||||||
|
* the node's own settings (e.g. which key) and inline values of unconnected data inputs,
|
||||||
|
* both keyed by the param / port key.
|
||||||
|
*/
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
params: PropertyBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connection from an output port to an input port. */
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string;
|
||||||
|
from: PortRef;
|
||||||
|
to: PortRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Graph {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyGraph(): Graph {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
|
||||||
|
return graph.nodes.find((n) => n.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
|
||||||
|
return graph.edges.find((e) => e.id === edgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
|
||||||
|
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
|
||||||
|
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edges leaving one output port. */
|
||||||
|
export function edgesOutOf(graph: Graph, nodeId: string, port: string): GraphEdge[] {
|
||||||
|
return graph.edges.filter((e) => e.from.node === nodeId && e.from.port === port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every edge touching a node (used when removing it). */
|
||||||
|
export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
|
||||||
|
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a value may flow from an output of type `from` into an input of type `to`. */
|
||||||
|
export function portsCompatible(from: PortType, to: PortType): boolean {
|
||||||
|
if (from === 'exec' || to === 'exec') return from === to;
|
||||||
|
return from === to || from === 'any' || to === 'any';
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||||
|
import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
|
||||||
|
* recurses along exec edges, so the limit also keeps the call stack shallow.
|
||||||
|
*/
|
||||||
|
const MAX_STEPS_PER_FIRE = 1000;
|
||||||
|
|
||||||
|
export interface TraceListener {
|
||||||
|
/** An exec edge was followed. */
|
||||||
|
onEdge(edgeId: string): void;
|
||||||
|
/** A node ran (exec) or was evaluated (data). */
|
||||||
|
onNode(nodeId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
|
||||||
|
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
|
||||||
|
* pulls the value from the connected output – evaluating pure data nodes lazily and
|
||||||
|
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
|
||||||
|
* so every instance has its own.
|
||||||
|
*/
|
||||||
|
export class GraphInterpreter {
|
||||||
|
/** Per-node persistent state across pulses (timer accumulators etc.). */
|
||||||
|
readonly nodeState = new Map<string, Record<string, number>>();
|
||||||
|
private outputCache = new Map<string, Record<string, Value>>();
|
||||||
|
private steps = 0;
|
||||||
|
trace: TraceListener | null = null;
|
||||||
|
|
||||||
|
constructor(readonly graph: Graph) {}
|
||||||
|
|
||||||
|
/** Nodes of one event type, in graph order. */
|
||||||
|
eventNodes(type: string): GraphNode[] {
|
||||||
|
return this.graph.nodes.filter((n) => n.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
|
||||||
|
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
|
||||||
|
*/
|
||||||
|
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
|
||||||
|
let fired = 0;
|
||||||
|
for (const node of this.eventNodes(type)) {
|
||||||
|
if (filter && !filter(node)) continue;
|
||||||
|
this.outputCache = new Map();
|
||||||
|
this.steps = 0;
|
||||||
|
this.outputCache.set(node.id, { ...outputs });
|
||||||
|
this.trace?.onNode(node.id);
|
||||||
|
this.follow(node, 'exec', ctx);
|
||||||
|
fired++;
|
||||||
|
}
|
||||||
|
return fired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
|
||||||
|
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
|
||||||
|
for (const edge of edgesOutOf(this.graph, node.id, port)) {
|
||||||
|
const target = this.node(edge.to.node);
|
||||||
|
if (!target) continue;
|
||||||
|
this.trace?.onEdge(edge.id);
|
||||||
|
this.execute(target, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private execute(node: GraphNode, ctx: NodeContext): void {
|
||||||
|
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
|
||||||
|
const def = requireNodeDef(node.type);
|
||||||
|
if (!def.run) return;
|
||||||
|
const inputs = this.collectInputs(node, ctx);
|
||||||
|
const result = def.run(ctx, inputs, node.params);
|
||||||
|
this.outputCache.set(node.id, result.outputs ?? {});
|
||||||
|
this.trace?.onNode(node.id);
|
||||||
|
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||||
|
for (const port of next) this.follow(node, port, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||||
|
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||||
|
const def = requireNodeDef(node.type);
|
||||||
|
const inputs: Record<string, Value> = {};
|
||||||
|
for (const port of def.inputs) {
|
||||||
|
if (port.type === 'exec') continue;
|
||||||
|
const edge = edgesInto(this.graph, node.id, port.key)[0];
|
||||||
|
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
||||||
|
else inputs[port.key] = node.params[port.key] ?? null;
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
||||||
|
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
||||||
|
const cached = this.outputCache.get(nodeId);
|
||||||
|
if (cached) return cached[port] ?? null;
|
||||||
|
const source = this.node(nodeId);
|
||||||
|
if (!source) return null;
|
||||||
|
const def = requireNodeDef(source.type);
|
||||||
|
// Exec nodes that have not run in this pulse have no value yet.
|
||||||
|
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||||
|
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||||
|
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||||
|
this.outputCache.set(nodeId, {});
|
||||||
|
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||||
|
this.outputCache.set(nodeId, outputs);
|
||||||
|
this.trace?.onNode(nodeId);
|
||||||
|
return outputs[port] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private node(id: string): GraphNode | undefined {
|
||||||
|
return this.graph.nodes.find((n) => n.id === id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
|
import type { PropertyDef } from '../properties/PropertySchema';
|
||||||
|
import type { PortType } from './Graph';
|
||||||
|
|
||||||
|
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||||||
|
export type Value = PropertyValue | EntityHandle | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit
|
||||||
|
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
|
||||||
|
*/
|
||||||
|
export interface EntityHandle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
props: PropertyBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
|
||||||
|
* by test doubles. Everything the graph does to the world goes through here, so the core
|
||||||
|
* stays free of runtime imports.
|
||||||
|
*/
|
||||||
|
export interface NodeContext {
|
||||||
|
/** The behavior entity itself. */
|
||||||
|
self: EntityHandle;
|
||||||
|
/** The scene object the behavior was attached to, if any. */
|
||||||
|
owner: EntityHandle | null;
|
||||||
|
/** Mutable variable state of this behavior instance. */
|
||||||
|
variables: PropertyBag;
|
||||||
|
/** Seconds since the world started and since the last tick. */
|
||||||
|
time: number;
|
||||||
|
dt: number;
|
||||||
|
findByName(name: string): EntityHandle | null;
|
||||||
|
isKeyDown(key: string): boolean;
|
||||||
|
log(message: string): void;
|
||||||
|
destroy(target: EntityHandle): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: PortType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||||||
|
|
||||||
|
export interface RunResult {
|
||||||
|
/** Values for the node's data outputs (cached for the rest of the pulse). */
|
||||||
|
outputs?: Record<string, Value>;
|
||||||
|
/** Exec output(s) to continue along; omitted = the node ends the flow. */
|
||||||
|
next?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes one node type: its ports, its inline settings and how it runs. The editor
|
||||||
|
* palette, port rendering, connection validation and the inspector fields are all derived
|
||||||
|
* from this – adding a node type means adding one definition, no UI code.
|
||||||
|
*/
|
||||||
|
export interface NodeDef {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
category: NodeCategory;
|
||||||
|
/** Shown in the palette. */
|
||||||
|
description: string;
|
||||||
|
inputs: PortDef[];
|
||||||
|
outputs: PortDef[];
|
||||||
|
/** Node settings that are not ports (key name, operator, variable). */
|
||||||
|
params: PropertyDef[];
|
||||||
|
/**
|
||||||
|
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
|
||||||
|
* input downstream needs their value; exec nodes run when the flow reaches them.
|
||||||
|
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
|
||||||
|
*/
|
||||||
|
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defs = new Map<string, NodeDef>();
|
||||||
|
|
||||||
|
export function registerNode(def: NodeDef): NodeDef {
|
||||||
|
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
|
||||||
|
defs.set(def.type, def);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeDef(type: string): NodeDef | undefined {
|
||||||
|
return defs.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNodeDef(type: string): NodeDef {
|
||||||
|
const def = defs.get(type);
|
||||||
|
if (!def) throw new Error(`Unknown node type "${type}"`);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allNodeDefs(): NodeDef[] {
|
||||||
|
return [...defs.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventNode(def: NodeDef): boolean {
|
||||||
|
return def.category === 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExecInput(def: NodeDef): boolean {
|
||||||
|
return def.inputs.some((p) => p.type === 'exec');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
|
||||||
|
export function inlineInputs(def: NodeDef): PortDef[] {
|
||||||
|
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A PropertyDef for editing an inline input value of the given port. */
|
||||||
|
export function inlineInputDef(port: PortDef): PropertyDef {
|
||||||
|
switch (port.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return { key: port.key, label: port.label, kind: 'boolean', default: false };
|
||||||
|
case 'string':
|
||||||
|
case 'any':
|
||||||
|
return { key: port.key, label: port.label, kind: 'text', default: '' };
|
||||||
|
default:
|
||||||
|
return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- value helpers for node implementations ---------------------------------------------
|
||||||
|
|
||||||
|
export function asNumber(value: Value | undefined): number {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'boolean') return value ? 1 : 0;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asBoolean(value: Value | undefined): boolean {
|
||||||
|
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asString(value: Value | undefined): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'object') return value.name;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asEntity(value: Value | undefined): EntityHandle | null {
|
||||||
|
return value && typeof value === 'object' ? value : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Importing the graph package registers the built-in node set as a side effect.
|
||||||
|
import './nodes';
|
||||||
|
|
||||||
|
export * from './Graph';
|
||||||
|
export * from './NodeRegistry';
|
||||||
|
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { PropertyDef } from '../properties/PropertySchema';
|
||||||
|
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
|
||||||
|
* ports, inspector fields, validation) is derived. Importing this module registers them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
|
||||||
|
const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
|
||||||
|
const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
|
||||||
|
const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
|
||||||
|
const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
|
||||||
|
|
||||||
|
const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||||
|
/** Placeholder: the graph inspector replaces the options with the asset's variables. */
|
||||||
|
const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
|
||||||
|
|
||||||
|
// --- events -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] });
|
||||||
|
registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] });
|
||||||
|
registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||||
|
registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||||
|
registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] });
|
||||||
|
registerNode({
|
||||||
|
type: 'OnTimer',
|
||||||
|
label: 'On timer',
|
||||||
|
category: 'event',
|
||||||
|
description: 'Runs every N seconds.',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- flow ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({
|
||||||
|
type: 'Branch',
|
||||||
|
label: 'Branch',
|
||||||
|
category: 'flow',
|
||||||
|
description: 'Continues along "then" or "else" depending on the condition.',
|
||||||
|
inputs: [exec(), bool('condition')],
|
||||||
|
outputs: [exec('then', 'then'), exec('else', 'else')],
|
||||||
|
params: [],
|
||||||
|
run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }),
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'Sequence',
|
||||||
|
label: 'Sequence',
|
||||||
|
category: 'flow',
|
||||||
|
description: 'Runs its outputs one after another.',
|
||||||
|
inputs: [exec()],
|
||||||
|
outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')],
|
||||||
|
params: [],
|
||||||
|
run: () => ({ next: ['a', 'b', 'c'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- data: references --------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
|
||||||
|
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
|
||||||
|
registerNode({
|
||||||
|
type: 'ObjectByName',
|
||||||
|
label: 'Object by name',
|
||||||
|
category: 'data',
|
||||||
|
description: 'Finds a scene object by its name.',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [obj('object')],
|
||||||
|
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
|
||||||
|
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- variables ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({
|
||||||
|
type: 'GetVariable',
|
||||||
|
label: 'Get variable',
|
||||||
|
category: 'variable',
|
||||||
|
description: 'Reads one of this behavior\'s variables.',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [any('value')],
|
||||||
|
params: [variableParam],
|
||||||
|
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'SetVariable',
|
||||||
|
label: 'Set variable',
|
||||||
|
category: 'variable',
|
||||||
|
description: 'Writes one of this behavior\'s variables.',
|
||||||
|
inputs: [exec(), any('value')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [variableParam],
|
||||||
|
run: (ctx, inputs, params) => {
|
||||||
|
const key = String(params.variable ?? '');
|
||||||
|
const current = ctx.variables[key];
|
||||||
|
const value = inputs.value;
|
||||||
|
if (key) {
|
||||||
|
if (typeof current === 'number') ctx.variables[key] = asNumber(value);
|
||||||
|
else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value);
|
||||||
|
else ctx.variables[key] = asString(value);
|
||||||
|
}
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- data: properties and positions --------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({
|
||||||
|
type: 'GetProperty',
|
||||||
|
label: 'Get property',
|
||||||
|
category: 'data',
|
||||||
|
description: 'Reads a property (width, color, …) of an object.',
|
||||||
|
inputs: [obj('object')],
|
||||||
|
outputs: [any('value')],
|
||||||
|
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||||
|
run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }),
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'SetProperty',
|
||||||
|
label: 'Set property',
|
||||||
|
category: 'action',
|
||||||
|
description: 'Writes a property (width, color, …) of an object.',
|
||||||
|
inputs: [exec(), obj('object'), any('value')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||||
|
run: (_ctx, inputs, params) => {
|
||||||
|
const target = asEntity(inputs.object);
|
||||||
|
const key = String(params.property ?? '');
|
||||||
|
if (target && key) {
|
||||||
|
const current = target.props[key];
|
||||||
|
const value = inputs.value;
|
||||||
|
if (typeof current === 'number') target.props[key] = asNumber(value);
|
||||||
|
else if (typeof current === 'boolean') target.props[key] = asBoolean(value);
|
||||||
|
else target.props[key] = asString(value);
|
||||||
|
}
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'GetPosition',
|
||||||
|
label: 'Get position',
|
||||||
|
category: 'data',
|
||||||
|
description: 'The x/y position of an object.',
|
||||||
|
inputs: [obj('object')],
|
||||||
|
outputs: [num('x'), num('y')],
|
||||||
|
params: [],
|
||||||
|
run: (_ctx, inputs) => {
|
||||||
|
const target = asEntity(inputs.object);
|
||||||
|
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- data: literals and math ---------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) });
|
||||||
|
registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) });
|
||||||
|
registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) });
|
||||||
|
|
||||||
|
const MATH: [string, string, string, (a: number, b: number) => number][] = [
|
||||||
|
['Add', 'Add', 'a + b', (a, b) => a + b],
|
||||||
|
['Subtract', 'Subtract', 'a − b', (a, b) => a - b],
|
||||||
|
['Multiply', 'Multiply', 'a × b', (a, b) => a * b],
|
||||||
|
['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)],
|
||||||
|
];
|
||||||
|
for (const [type, label, description, fn] of MATH) {
|
||||||
|
registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPARE: Record<string, (a: number, b: number) => boolean> = {
|
||||||
|
'==': (a, b) => a === b,
|
||||||
|
'!=': (a, b) => a !== b,
|
||||||
|
'<': (a, b) => a < b,
|
||||||
|
'<=': (a, b) => a <= b,
|
||||||
|
'>': (a, b) => a > b,
|
||||||
|
'>=': (a, b) => a >= b,
|
||||||
|
};
|
||||||
|
registerNode({
|
||||||
|
type: 'Compare',
|
||||||
|
label: 'Compare',
|
||||||
|
category: 'data',
|
||||||
|
description: 'Compares two numbers.',
|
||||||
|
inputs: [num('a'), num('b')],
|
||||||
|
outputs: [bool('result')],
|
||||||
|
params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }],
|
||||||
|
run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }),
|
||||||
|
});
|
||||||
|
registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) });
|
||||||
|
registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) });
|
||||||
|
registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) });
|
||||||
|
registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) });
|
||||||
|
|
||||||
|
// --- actions --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
registerNode({
|
||||||
|
type: 'Move',
|
||||||
|
label: 'Move',
|
||||||
|
category: 'action',
|
||||||
|
description: 'Moves an object by dx/dy.',
|
||||||
|
inputs: [exec(), obj('object'), num('dx'), num('dy')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [],
|
||||||
|
run: (_c, i) => {
|
||||||
|
const target = asEntity(i.object);
|
||||||
|
if (target) {
|
||||||
|
target.x += asNumber(i.dx);
|
||||||
|
target.y += asNumber(i.dy);
|
||||||
|
}
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'SetPosition',
|
||||||
|
label: 'Set position',
|
||||||
|
category: 'action',
|
||||||
|
description: 'Places an object at x/y.',
|
||||||
|
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [],
|
||||||
|
run: (_c, i) => {
|
||||||
|
const target = asEntity(i.object);
|
||||||
|
if (target) {
|
||||||
|
target.x = asNumber(i.x);
|
||||||
|
target.y = asNumber(i.y);
|
||||||
|
}
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'Destroy',
|
||||||
|
label: 'Destroy',
|
||||||
|
category: 'action',
|
||||||
|
description: 'Removes an object from the running game.',
|
||||||
|
inputs: [exec(), obj('object')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [],
|
||||||
|
run: (ctx, i) => {
|
||||||
|
const target = asEntity(i.object);
|
||||||
|
if (target) ctx.destroy(target);
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerNode({
|
||||||
|
type: 'Log',
|
||||||
|
label: 'Log',
|
||||||
|
category: 'action',
|
||||||
|
description: 'Prints a message to the console.',
|
||||||
|
inputs: [exec(), any('message')],
|
||||||
|
outputs: [exec()],
|
||||||
|
params: [],
|
||||||
|
run: (ctx, i) => {
|
||||||
|
ctx.log(asString(i.message));
|
||||||
|
return { next: 'exec' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
|
||||||
+18
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Graph } from '../graph/Graph';
|
||||||
|
import type { PropertyDef } from '../properties/PropertySchema';
|
||||||
import type { AssetType } from './AssetTypes';
|
import type { AssetType } from './AssetTypes';
|
||||||
import type { PropertyBag } from './Properties';
|
import type { PropertyBag } from './Properties';
|
||||||
import type { SceneObject } from './SceneObject';
|
import type { SceneObject } from './SceneObject';
|
||||||
@@ -24,8 +26,23 @@ export interface SceneAsset extends AssetBase {
|
|||||||
objects: SceneObject[];
|
objects: SceneObject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Asset = SpriteAsset | CameraAsset | SceneAsset;
|
/**
|
||||||
|
* A behavior is logic: a node graph plus the variables it declares. Variables are ordinary
|
||||||
|
* properties – `variables` is the asset's own property schema, `props` holds their default
|
||||||
|
* values, and placed instances override them like any other property.
|
||||||
|
*/
|
||||||
|
export interface BehaviorAsset extends AssetBase {
|
||||||
|
type: 'behavior';
|
||||||
|
variables: PropertyDef[];
|
||||||
|
graph: Graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset;
|
||||||
|
|
||||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||||
return asset.type === 'scene';
|
return asset.type === 'scene';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBehaviorAsset(asset: Asset): asset is BehaviorAsset {
|
||||||
|
return asset.type === 'behavior';
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/** All asset types known in phase 1. Scenes are assets too, but cannot be placed in scenes. */
|
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
|
||||||
export const ASSET_TYPES = ['scene', 'camera', 'sprite'] as const;
|
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior'] as const;
|
||||||
|
|
||||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||||
|
|
||||||
@@ -13,3 +13,11 @@ export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
|||||||
export function isAssetType(value: unknown): value is AssetType {
|
export function isAssetType(value: unknown): value is AssetType {
|
||||||
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
|
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types drawn as editor symbols (icon in the overlay layer) instead of as scene content.
|
||||||
|
* They exist in the scene for their position, not for their looks.
|
||||||
|
*/
|
||||||
|
export function isSymbolType(type: AssetType): boolean {
|
||||||
|
return type === 'camera' || type === 'behavior';
|
||||||
|
}
|
||||||
@@ -13,9 +13,21 @@ export interface SceneObject {
|
|||||||
/** World position plus draw order (`z`). */
|
/** World position plus draw order (`z`). */
|
||||||
position: Position;
|
position: Position;
|
||||||
overrides: PropertyBag;
|
overrides: PropertyBag;
|
||||||
|
/**
|
||||||
|
* Another object of the same scene this one acts on (used by behaviors: "the owner").
|
||||||
|
* Empty string = none. Any object may carry one, only behaviors interpret it.
|
||||||
|
*/
|
||||||
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
|
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
|
||||||
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
|
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
|
||||||
return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {} };
|
return {
|
||||||
|
id,
|
||||||
|
assetId,
|
||||||
|
name: '',
|
||||||
|
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
|
||||||
|
overrides: {},
|
||||||
|
ownerId: '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Asset } from '../model/Asset';
|
||||||
import type { AssetType } from '../model/AssetTypes';
|
import type { AssetType } from '../model/AssetTypes';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
|
|
||||||
@@ -9,12 +10,20 @@ export type PropertyDef =
|
|||||||
| { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number }
|
| { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number }
|
||||||
| { key: string; label: string; kind: 'text'; default: string }
|
| { key: string; label: string; kind: 'text'; default: string }
|
||||||
| { key: string; label: string; kind: 'color'; default: number }
|
| { key: string; label: string; kind: 'color'; default: number }
|
||||||
| { key: string; label: string; kind: 'boolean'; default: boolean };
|
| { key: string; label: string; kind: 'boolean'; default: boolean }
|
||||||
|
/** One of a fixed set of string values, shown as a dropdown. */
|
||||||
|
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||||||
|
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||||
|
| { key: string; label: string; kind: 'key'; default: string };
|
||||||
|
|
||||||
export type PropertyKind = PropertyDef['kind'];
|
export type PropertyKind = PropertyDef['kind'];
|
||||||
|
|
||||||
export type PropertySchema = readonly PropertyDef[];
|
export type PropertySchema = readonly PropertyDef[];
|
||||||
|
|
||||||
|
/** Kinds a behavior variable may have (what the node graph can compute with). */
|
||||||
|
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
|
||||||
|
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||||
|
|
||||||
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
|
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
|
||||||
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||||||
@@ -28,6 +37,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
|||||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
|
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
|
||||||
],
|
],
|
||||||
|
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||||||
|
behavior: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Properties every scene object has regardless of its asset type. */
|
/** Properties every scene object has regardless of its asset type. */
|
||||||
@@ -36,16 +47,38 @@ export const SCENE_OBJECT_SCHEMA: PropertySchema = [
|
|||||||
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
|
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
|
||||||
export function schemaFor(type: AssetType): PropertySchema {
|
export function schemaFor(type: AssetType): PropertySchema {
|
||||||
return ASSET_SCHEMAS[type];
|
return ASSET_SCHEMAS[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
|
||||||
|
export function schemaForAsset(asset: Asset): PropertySchema {
|
||||||
|
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultsFor(type: AssetType): PropertyBag {
|
export function defaultsFor(type: AssetType): PropertyBag {
|
||||||
|
return defaultsOf(ASSET_SCHEMAS[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultsOf(schema: PropertySchema): PropertyBag {
|
||||||
const bag: PropertyBag = {};
|
const bag: PropertyBag = {};
|
||||||
for (const def of ASSET_SCHEMAS[type]) bag[def.key] = def.default;
|
for (const def of schema) bag[def.key] = def.default;
|
||||||
return bag;
|
return bag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Builds a variable definition of the given kind with the kind's natural default. */
|
||||||
|
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
|
||||||
|
switch (kind) {
|
||||||
|
case 'number':
|
||||||
|
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
|
||||||
|
case 'boolean':
|
||||||
|
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||||
|
case 'text':
|
||||||
|
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */
|
/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */
|
||||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||||
switch (def.kind) {
|
switch (def.kind) {
|
||||||
@@ -63,6 +96,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
case 'boolean':
|
case 'boolean':
|
||||||
return Boolean(value);
|
return Boolean(value);
|
||||||
case 'text':
|
case 'text':
|
||||||
|
case 'key':
|
||||||
return String(value);
|
return String(value);
|
||||||
|
case 'enum': {
|
||||||
|
const s = String(value);
|
||||||
|
return def.options.some((o) => o.value === s) ? s : def.default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Asset } from '../model/Asset';
|
import type { Asset } from '../model/Asset';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
import { schemaFor } from './PropertySchema';
|
import { schemaForAsset } from './PropertySchema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective property values of an asset: schema defaults filled with the asset's own values.
|
* Effective property values of an asset: schema defaults filled with the asset's own values.
|
||||||
@@ -9,7 +9,7 @@ import { schemaFor } from './PropertySchema';
|
|||||||
*/
|
*/
|
||||||
export function resolveAssetProps(asset: Asset): PropertyBag {
|
export function resolveAssetProps(asset: Asset): PropertyBag {
|
||||||
const bag: PropertyBag = {};
|
const bag: PropertyBag = {};
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaForAsset(asset)) {
|
||||||
const own = asset.props[def.key];
|
const own = asset.props[def.key];
|
||||||
bag[def.key] = own !== undefined ? own : def.default;
|
bag[def.key] = own !== undefined ? own : def.default;
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ export function resolveAssetProps(asset: Asset): PropertyBag {
|
|||||||
*/
|
*/
|
||||||
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
|
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
|
||||||
const bag = resolveAssetProps(asset);
|
const bag = resolveAssetProps(asset);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaForAsset(asset)) {
|
||||||
const override = object.overrides[def.key];
|
const override = object.overrides[def.key];
|
||||||
if (override !== undefined) bag[def.key] = override;
|
if (override !== undefined) bag[def.key] = override;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ export function isOverridden(object: SceneObject, key: string): boolean {
|
|||||||
* counterpart of an override. The inspector highlights such values and offers a reset.
|
* counterpart of an override. The inspector highlights such values and offers a reset.
|
||||||
*/
|
*/
|
||||||
export function isModified(asset: Asset, key: string): boolean {
|
export function isModified(asset: Asset, key: string): boolean {
|
||||||
const def = schemaFor(asset.type).find((d) => d.key === key);
|
const def = schemaForAsset(asset).find((d) => d.key === key);
|
||||||
if (!def) return false;
|
if (!def) return false;
|
||||||
const own = asset.props[key];
|
const own = asset.props[key];
|
||||||
return own !== undefined && own !== def.default;
|
return own !== undefined && own !== def.default;
|
||||||
|
|||||||
+82
-15
@@ -1,9 +1,11 @@
|
|||||||
|
// The graph package index registers the built-in nodes; unknown node types are dropped below.
|
||||||
|
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
||||||
import type { Asset } from '../model/Asset';
|
import type { Asset } from '../model/Asset';
|
||||||
import { type AssetType, isAssetType } from '../model/AssetTypes';
|
import { isAssetType } from '../model/AssetTypes';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||||
import type { SceneObject } from '../model/SceneObject';
|
import type { SceneObject } from '../model/SceneObject';
|
||||||
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
|
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
|
||||||
@@ -14,22 +16,34 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
const r = asRecord(raw);
|
const r = asRecord(raw);
|
||||||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
||||||
|
|
||||||
// Pass 1: asset ids and types, needed to coerce scene-object overrides in pass 2.
|
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
|
||||||
const types = new Map<string, AssetType>();
|
// needed to coerce scene-object overrides in pass 2.
|
||||||
|
const schemas = new Map<string, PropertySchema>();
|
||||||
|
const variables = new Map<string, PropertyDef[]>();
|
||||||
for (const a of rawAssets) {
|
for (const a of rawAssets) {
|
||||||
if (typeof a.id === 'string' && a.id && isAssetType(a.type)) types.set(a.id, a.type);
|
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||||
|
if (a.type === 'behavior') {
|
||||||
|
const vars = normalizeVariables(a.variables);
|
||||||
|
variables.set(a.id, vars);
|
||||||
|
schemas.set(a.id, vars);
|
||||||
|
} else {
|
||||||
|
schemas.set(a.id, schemaFor(a.type));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2: build assets.
|
// Pass 2: build assets.
|
||||||
const assets: Asset[] = [];
|
const assets: Asset[] = [];
|
||||||
for (const a of rawAssets) {
|
for (const a of rawAssets) {
|
||||||
const id = typeof a.id === 'string' ? a.id : '';
|
const id = typeof a.id === 'string' ? a.id : '';
|
||||||
const type = types.get(id);
|
const schema = schemas.get(id);
|
||||||
if (!type) continue;
|
if (!schema || !isAssetType(a.type)) continue;
|
||||||
|
const type = a.type;
|
||||||
const name = typeof a.name === 'string' ? a.name : type;
|
const name = typeof a.name === 'string' ? a.name : type;
|
||||||
const props = normalizeBag(a.props, type, false);
|
const props = normalizeBag(a.props, schema, false);
|
||||||
if (type === 'scene') {
|
if (type === 'scene') {
|
||||||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, types) });
|
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
|
||||||
|
} else if (type === 'behavior') {
|
||||||
|
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
|
||||||
} else {
|
} else {
|
||||||
assets.push({ id, type, name, props });
|
assets.push({ id, type, name, props });
|
||||||
}
|
}
|
||||||
@@ -55,29 +69,82 @@ export function serializeProject(project: Project): unknown {
|
|||||||
return JSON.parse(JSON.stringify(project));
|
return JSON.parse(JSON.stringify(project));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeObjects(raw: unknown, types: Map<string, AssetType>): SceneObject[] {
|
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
|
||||||
const objects: SceneObject[] = [];
|
const objects: SceneObject[] = [];
|
||||||
for (const item of Array.isArray(raw) ? raw : []) {
|
for (const item of Array.isArray(raw) ? raw : []) {
|
||||||
const o = asRecord(item);
|
const o = asRecord(item);
|
||||||
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
||||||
const type = types.get(o.assetId);
|
const schema = schemas.get(o.assetId);
|
||||||
if (!type || type === 'scene') continue; // dangling reference or illegal scene-in-scene
|
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
||||||
const p = asRecord(o.position);
|
const p = asRecord(o.position);
|
||||||
objects.push({
|
objects.push({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
assetId: o.assetId,
|
assetId: o.assetId,
|
||||||
name: typeof o.name === 'string' ? o.name : '',
|
name: typeof o.name === 'string' ? o.name : '',
|
||||||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||||
overrides: normalizeBag(o.overrides, type, true),
|
overrides: normalizeBag(o.overrides, schema, true),
|
||||||
|
ownerId: typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Owners must exist in the same scene and differ from the object itself.
|
||||||
|
const ids = new Set(objects.map((o) => o.id));
|
||||||
|
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = '';
|
||||||
return objects;
|
return objects;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBag(raw: unknown, type: AssetType, sparse: boolean): PropertyBag {
|
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||||
|
const defs: PropertyDef[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of Array.isArray(raw) ? raw : []) {
|
||||||
|
const v = asRecord(item);
|
||||||
|
const key = typeof v.key === 'string' ? v.key : '';
|
||||||
|
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const def = variableDef(key, kind);
|
||||||
|
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
|
||||||
|
defs.push(def);
|
||||||
|
}
|
||||||
|
return defs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGraph(raw: unknown): Graph {
|
||||||
|
const g = asRecord(raw);
|
||||||
|
const graph = createEmptyGraph();
|
||||||
|
const nodeIds = new Set<string>();
|
||||||
|
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
|
||||||
|
const n = asRecord(item);
|
||||||
|
const id = typeof n.id === 'string' ? n.id : '';
|
||||||
|
const type = typeof n.type === 'string' ? n.type : '';
|
||||||
|
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
|
||||||
|
nodeIds.add(id);
|
||||||
|
const params: PropertyBag = {};
|
||||||
|
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
||||||
|
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
|
||||||
|
}
|
||||||
|
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||||
|
graph.nodes.push(node);
|
||||||
|
}
|
||||||
|
const edgeIds = new Set<string>();
|
||||||
|
for (const item of Array.isArray(g.edges) ? g.edges : []) {
|
||||||
|
const e = asRecord(item);
|
||||||
|
const from = asRecord(e.from);
|
||||||
|
const to = asRecord(e.to);
|
||||||
|
const id = typeof e.id === 'string' ? e.id : '';
|
||||||
|
if (!id || edgeIds.has(id)) continue;
|
||||||
|
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
|
||||||
|
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
|
||||||
|
edgeIds.add(id);
|
||||||
|
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
|
||||||
|
graph.edges.push(edge);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||||
const r = asRecord(raw);
|
const r = asRecord(raw);
|
||||||
const bag: PropertyBag = {};
|
const bag: PropertyBag = {};
|
||||||
for (const def of schemaFor(type)) {
|
for (const def of schema) {
|
||||||
const value = r[def.key];
|
const value = r[def.key];
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
if (!sparse) bag[def.key] = def.default;
|
if (!sparse) bag[def.key] = def.default;
|
||||||
|
|||||||
+94
-9
@@ -1,13 +1,17 @@
|
|||||||
import { type Application, Container, Text } from 'pixi.js';
|
import { type Application, Container, Text } from 'pixi.js';
|
||||||
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||||
|
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||||
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||||
import { listScenes } from '../core/model/Project';
|
import { listScenes } from '../core/model/Project';
|
||||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
|
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
|
||||||
|
import { Input } from '../runtime/systems/Input';
|
||||||
import { World } from '../runtime/World';
|
import { World } from '../runtime/World';
|
||||||
import { Dialog, HStack, Menu, type MenuItem, Splitter, ui, VStack, type Widget } from '../ui';
|
import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||||
import { Viewport } from '../viewport/Viewport';
|
import { Viewport } from '../viewport/Viewport';
|
||||||
import { attachAutosave } from './autosave';
|
import { attachAutosave } from './autosave';
|
||||||
|
import { GraphEditor } from './graph/GraphEditor';
|
||||||
import { AssetPanel } from './panels/AssetPanel';
|
import { AssetPanel } from './panels/AssetPanel';
|
||||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||||
import { Inspector } from './panels/Inspector';
|
import { Inspector } from './panels/Inspector';
|
||||||
@@ -16,12 +20,14 @@ import { EditorState } from './state';
|
|||||||
|
|
||||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
const API_ENDPOINT = '/game-maker/api/index.php';
|
||||||
|
|
||||||
/** Composes panels, viewport and runtime into the one application that edits and plays. */
|
type CentreTab = 'scene' | 'graph';
|
||||||
|
|
||||||
|
/** Composes panels, viewport, graph editor and runtime into the one application that edits and plays. */
|
||||||
export async function startEditor(app: Application, root: VStack): Promise<void> {
|
export async function startEditor(app: Application, root: VStack): Promise<void> {
|
||||||
const state = new EditorState();
|
const state = new EditorState();
|
||||||
const store: ProjectStore = new HttpProjectStore(API_ENDPOINT);
|
const store: ProjectStore = new HttpProjectStore(API_ENDPOINT);
|
||||||
const editor = new Editor(app, root, state, store);
|
const editor = new Editor(app, root, state, store);
|
||||||
installDebugHook(app, state, editor.viewport);
|
installDebugHook(app, state, editor);
|
||||||
await editor.boot();
|
await editor.boot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +35,8 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
* `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to
|
* `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to
|
||||||
* inspect state. Harmless in production (read-only access).
|
* inspect state. Harmless in production (read-only access).
|
||||||
*/
|
*/
|
||||||
function installDebugHook(app: Application, state: EditorState, viewport: Viewport): void {
|
function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||||||
|
const { viewport, graphEditor } = editor;
|
||||||
const viewportCenter = () => {
|
const viewportCenter = () => {
|
||||||
const p = viewport.getGlobalPosition();
|
const p = viewport.getGlobalPosition();
|
||||||
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 };
|
return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 };
|
||||||
@@ -50,17 +57,42 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo
|
|||||||
const p = node.getGlobalPosition();
|
const p = node.getGlobalPosition();
|
||||||
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
|
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
|
||||||
};
|
};
|
||||||
(window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder() };
|
(window as unknown as { __gm: unknown }).__gm = {
|
||||||
|
state,
|
||||||
|
locate,
|
||||||
|
viewportCenter,
|
||||||
|
viewportInfo: () => viewport.debugInfo(),
|
||||||
|
layerOf: (id: string) => viewport.layerOf(id),
|
||||||
|
drawOrder: () => viewport.drawOrder(),
|
||||||
|
graph: {
|
||||||
|
current: () => graphEditor.currentAssetId,
|
||||||
|
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
|
||||||
|
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
|
||||||
|
center: () => {
|
||||||
|
const p = graphEditor.getGlobalPosition();
|
||||||
|
return { x: p.x + graphEditor.w / 2, y: p.y + graphEditor.h / 2 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
world: () => editor.worldSnapshot(),
|
||||||
|
tab: () => editor.tab,
|
||||||
|
showTab: (tab: CentreTab) => editor.showTab(tab),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class Editor {
|
class Editor {
|
||||||
readonly viewport: Viewport;
|
readonly viewport: Viewport;
|
||||||
|
readonly graphEditor: GraphEditor;
|
||||||
|
tab: CentreTab = 'scene';
|
||||||
private readonly leftColumn: VStack;
|
private readonly leftColumn: VStack;
|
||||||
private readonly inspector: Inspector;
|
private readonly inspector: Inspector;
|
||||||
private readonly leftSplitter = new Splitter('x');
|
private readonly leftSplitter = new Splitter('x');
|
||||||
private readonly rightSplitter = new Splitter('x');
|
private readonly rightSplitter = new Splitter('x');
|
||||||
private readonly workspace: HStack;
|
private readonly workspace: HStack;
|
||||||
|
private readonly centre: VStack;
|
||||||
|
private readonly tabButtons: Record<CentreTab, Button>;
|
||||||
private world: World | null = null;
|
private world: World | null = null;
|
||||||
|
private behaviors: BehaviorSystem | null = null;
|
||||||
|
private readonly input = new Input();
|
||||||
private leftWidth = 240;
|
private leftWidth = 240;
|
||||||
private rightWidth = 300;
|
private rightWidth = 300;
|
||||||
|
|
||||||
@@ -74,11 +106,30 @@ class Editor {
|
|||||||
|
|
||||||
this.workspace = new HStack();
|
this.workspace = new HStack();
|
||||||
this.viewport = new Viewport(state);
|
this.viewport = new Viewport(state);
|
||||||
|
this.graphEditor = new GraphEditor(state);
|
||||||
|
this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
|
||||||
|
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
|
||||||
this.leftColumn = new VStack({ gap: 1 });
|
this.leftColumn = new VStack({ gap: 1 });
|
||||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
|
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
|
||||||
this.leftColumn.add(new HierarchyPanel(state), 1);
|
this.leftColumn.add(new HierarchyPanel(state), 1);
|
||||||
this.inspector = new Inspector(state);
|
this.inspector = new Inspector(state);
|
||||||
|
|
||||||
|
// Centre: a tab strip (Scene | Graph) above the viewport or the graph editor.
|
||||||
|
this.centre = new VStack();
|
||||||
|
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
|
||||||
|
tabs.minHeight = Theme.controlHeight + 8;
|
||||||
|
this.tabButtons = {
|
||||||
|
scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }),
|
||||||
|
graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }),
|
||||||
|
};
|
||||||
|
tabs.add(this.tabButtons.scene);
|
||||||
|
tabs.add(this.tabButtons.graph);
|
||||||
|
this.centre.add(tabs);
|
||||||
|
this.centre.add(this.viewport, 1);
|
||||||
|
this.centre.add(this.graphEditor, 1);
|
||||||
|
this.graphEditor.visible = false;
|
||||||
|
this.tabButtons.scene.setToggled(true);
|
||||||
|
|
||||||
this.leftColumn.minWidth = this.leftWidth;
|
this.leftColumn.minWidth = this.leftWidth;
|
||||||
this.inspector.minWidth = this.rightWidth;
|
this.inspector.minWidth = this.rightWidth;
|
||||||
this.leftSplitter.onDrag = (delta) => {
|
this.leftSplitter.onDrag = (delta) => {
|
||||||
@@ -93,7 +144,7 @@ class Editor {
|
|||||||
};
|
};
|
||||||
this.workspace.add(this.leftColumn);
|
this.workspace.add(this.leftColumn);
|
||||||
this.workspace.add(this.leftSplitter);
|
this.workspace.add(this.leftSplitter);
|
||||||
this.workspace.add(this.viewport, 1);
|
this.workspace.add(this.centre, 1);
|
||||||
this.workspace.add(this.rightSplitter);
|
this.workspace.add(this.rightSplitter);
|
||||||
this.workspace.add(this.inspector);
|
this.workspace.add(this.inspector);
|
||||||
root.add(this.workspace, 1);
|
root.add(this.workspace, 1);
|
||||||
@@ -103,6 +154,20 @@ class Editor {
|
|||||||
this.installShortcuts();
|
this.installShortcuts();
|
||||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||||
state.on('mode', () => this.applyMode());
|
state.on('mode', () => this.applyMode());
|
||||||
|
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
|
||||||
|
state.on('selection', () => {
|
||||||
|
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showTab(tab: CentreTab): void {
|
||||||
|
this.tab = tab;
|
||||||
|
this.viewport.visible = tab === 'scene';
|
||||||
|
this.graphEditor.visible = tab === 'graph';
|
||||||
|
this.tabButtons.scene.setToggled(tab === 'scene');
|
||||||
|
this.tabButtons.graph.setToggled(tab === 'graph');
|
||||||
|
this.centre.relayout();
|
||||||
|
ui().focus.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- project lifecycle ---------------------------------------------------------------
|
// --- project lifecycle ---------------------------------------------------------------
|
||||||
@@ -185,25 +250,44 @@ class Editor {
|
|||||||
const scene = this.state.activeScene;
|
const scene = this.state.activeScene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
ui().focus.blur();
|
ui().focus.blur();
|
||||||
// No systems yet: the world is static. Behaviour arrives with scripting in a later phase.
|
|
||||||
this.world = World.instantiate(this.state.project, scene.id);
|
this.world = World.instantiate(this.state.project, scene.id);
|
||||||
|
this.input.attach();
|
||||||
|
this.behaviors = new BehaviorSystem(this.state.project, this.input);
|
||||||
|
this.world.addSystem(this.behaviors);
|
||||||
this.viewport.setWorld(this.world);
|
this.viewport.setWorld(this.world);
|
||||||
this.state.setMode('play');
|
this.state.setMode('play');
|
||||||
this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
|
this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
|
||||||
|
if (this.world.stopRequested) this.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private stop(): void {
|
private stop(): void {
|
||||||
this.world?.destroy();
|
const world = this.world;
|
||||||
this.world = null;
|
this.world = null;
|
||||||
|
this.behaviors = null;
|
||||||
|
this.input.detach();
|
||||||
|
world?.destroy();
|
||||||
this.viewport.setWorld(null);
|
this.viewport.setWorld(null);
|
||||||
this.state.setMode('edit');
|
this.state.setMode('edit');
|
||||||
this.state.setNotice('');
|
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
|
||||||
|
this.state.setNotice(lastError ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private tick(dt: number): void {
|
private tick(dt: number): void {
|
||||||
if (!this.world) return;
|
if (!this.world) return;
|
||||||
this.world.tick(Math.min(dt, 0.1));
|
this.world.tick(Math.min(dt, 0.1));
|
||||||
|
this.input.endFrame();
|
||||||
|
if (this.world.stopRequested) {
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.viewport.redraw();
|
this.viewport.redraw();
|
||||||
|
if (this.graphEditor.visible) this.graphEditor.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only view of the running world for tests. */
|
||||||
|
worldSnapshot(): { entities: { objectId: string; x: number; y: number; props: Record<string, unknown> }[]; log: string[] } | null {
|
||||||
|
if (!this.world) return null;
|
||||||
|
return { entities: this.world.entities.map((e) => ({ objectId: e.objectId, x: e.x, y: e.y, props: { ...e.props } })), log: [...this.world.log] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyMode(): void {
|
private applyMode(): void {
|
||||||
@@ -238,6 +322,7 @@ class Editor {
|
|||||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
const s = this.state.selection;
|
const s = this.state.selection;
|
||||||
if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId));
|
if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId));
|
||||||
|
else if (s?.kind === 'node') this.state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
|
||||||
else if (s?.kind === 'asset') {
|
else if (s?.kind === 'asset') {
|
||||||
const asset = this.state.project.assets.find((a) => a.id === s.assetId);
|
const asset = this.state.project.assets.find((a) => a.id === s.assetId);
|
||||||
if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [
|
if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||||
|
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
|
||||||
|
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
|
||||||
|
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||||
|
import { findAsset } from '../../core/model/Project';
|
||||||
|
import type { Trace } from '../../runtime/systems/BehaviorSystem';
|
||||||
|
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||||
|
import { Menu, type Size, Theme, textStyle, Widget } from '../../ui';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { NodePalette } from './NodePalette';
|
||||||
|
import { NodeView, portColor } from './NodeView';
|
||||||
|
|
||||||
|
const GRID_STEP = 24;
|
||||||
|
|
||||||
|
interface PortAnchor {
|
||||||
|
nodeId: string;
|
||||||
|
port: string;
|
||||||
|
isOutput: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node graph editor for one behavior asset. Shows the graph of whatever behavior the
|
||||||
|
* selection refers to (asset, placed object or node) and keeps showing it while unrelated
|
||||||
|
* things are selected, so the user can flip between Scene and Graph tabs freely.
|
||||||
|
*
|
||||||
|
* Interactions: drag nodes, drag from port to port to connect, right-click the canvas for
|
||||||
|
* the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag
|
||||||
|
* the empty canvas to pan. All edits go through commands (undoable).
|
||||||
|
*/
|
||||||
|
export class GraphEditor extends Widget {
|
||||||
|
private readonly bg = new Graphics();
|
||||||
|
private readonly grid = new Graphics();
|
||||||
|
private readonly clip = new Graphics();
|
||||||
|
private readonly canvas = new Container();
|
||||||
|
private readonly edges = new Graphics();
|
||||||
|
private readonly nodeLayer = new Container();
|
||||||
|
private readonly tempEdge = new Graphics();
|
||||||
|
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||||
|
private readonly views = new Map<string, NodeView>();
|
||||||
|
private assetId: string | null = null;
|
||||||
|
private pan = { x: 0, y: 0 };
|
||||||
|
private zoom = 1;
|
||||||
|
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||||
|
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
|
||||||
|
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
|
||||||
|
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||||||
|
private needsFit = true;
|
||||||
|
/** Supplies live execution data while playing (null otherwise). */
|
||||||
|
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||||
|
timeProvider: () => number = () => 0;
|
||||||
|
|
||||||
|
constructor(private readonly state: EditorState) {
|
||||||
|
super();
|
||||||
|
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||||
|
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
|
||||||
|
this.canvas.mask = this.clip;
|
||||||
|
this.cursor = 'default';
|
||||||
|
|
||||||
|
this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e));
|
||||||
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||||
|
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||||
|
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
|
||||||
|
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
const before = this.screenToGraph(local.x, local.y);
|
||||||
|
this.zoom = Math.max(0.25, Math.min(2.5, this.zoom * Math.exp(-e.deltaY * 0.0015)));
|
||||||
|
const after = this.screenToGraph(local.x, local.y);
|
||||||
|
this.pan.x += (after.x - before.x) * this.zoom;
|
||||||
|
this.pan.y += (after.y - before.y) * this.zoom;
|
||||||
|
this.applyView();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
state.on('selection', () => this.followSelection());
|
||||||
|
state.on('load', () => { this.assetId = null; this.followSelection(); });
|
||||||
|
state.on('project', () => this.sync());
|
||||||
|
state.on('mode', () => this.redraw());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The behavior whose graph is shown, if any. */
|
||||||
|
get currentAssetId(): string | null {
|
||||||
|
return this.assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): Size {
|
||||||
|
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- model binding ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private asset(): BehaviorAsset | null {
|
||||||
|
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||||
|
return asset && asset.type === 'behavior' ? asset : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private followSelection(): void {
|
||||||
|
const wanted = this.state.selectedBehaviorId;
|
||||||
|
if (wanted && wanted !== this.assetId) {
|
||||||
|
this.assetId = wanted;
|
||||||
|
this.sync();
|
||||||
|
this.needsFit = true;
|
||||||
|
this.fitView();
|
||||||
|
} else if (!this.asset()) {
|
||||||
|
this.assetId = null;
|
||||||
|
this.sync();
|
||||||
|
} else {
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||||||
|
private sync(): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) {
|
||||||
|
this.assetId = null;
|
||||||
|
for (const view of this.views.values()) view.destroy({ children: true });
|
||||||
|
this.views.clear();
|
||||||
|
this.redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const node of asset.graph.nodes) {
|
||||||
|
const def = nodeDef(node.type);
|
||||||
|
if (!def) continue;
|
||||||
|
seen.add(node.id);
|
||||||
|
let view = this.views.get(node.id);
|
||||||
|
if (!view || view.node !== node) {
|
||||||
|
view?.destroy({ children: true });
|
||||||
|
view = new NodeView(node, def);
|
||||||
|
view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e));
|
||||||
|
this.views.set(node.id, view);
|
||||||
|
this.nodeLayer.addChild(view);
|
||||||
|
}
|
||||||
|
view.sync(node, asset.graph, asset);
|
||||||
|
}
|
||||||
|
for (const [id, view] of this.views) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
view.destroy({ children: true });
|
||||||
|
this.views.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||||
|
private fitView(): void {
|
||||||
|
if (this.w === 0 || this.h === 0) return;
|
||||||
|
this.needsFit = false;
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset || asset.graph.nodes.length === 0) {
|
||||||
|
this.pan = { x: this.w / 2 - 80, y: this.h / 3 };
|
||||||
|
this.zoom = 1;
|
||||||
|
} else {
|
||||||
|
const xs = asset.graph.nodes.map((n) => n.x);
|
||||||
|
const ys = asset.graph.nodes.map((n) => n.y);
|
||||||
|
const cx = (Math.min(...xs) + Math.max(...xs) + 176) / 2;
|
||||||
|
const cy = (Math.min(...ys) + Math.max(...ys) + 80) / 2;
|
||||||
|
this.zoom = 1;
|
||||||
|
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||||
|
}
|
||||||
|
this.applyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyView(): void {
|
||||||
|
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||||||
|
this.canvas.scale.set(this.zoom);
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenToGraph(sx: number, sy: number): { x: number; y: number } {
|
||||||
|
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pointer -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||||
|
if (e.target !== this) return;
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
if (e.button === 2) {
|
||||||
|
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button === 0 || e.button === 1) {
|
||||||
|
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||||
|
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||||||
|
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const local = view.toLocal(e.global);
|
||||||
|
const hit = view.portAt(local.x, local.y);
|
||||||
|
if (e.button === 2) {
|
||||||
|
if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y);
|
||||||
|
else this.openNodeMenu(view, e.global.x, e.global.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (hit) {
|
||||||
|
const p = this.toLocal(e.global);
|
||||||
|
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
|
||||||
|
this.redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
|
||||||
|
const p = this.toLocal(e.global);
|
||||||
|
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
|
||||||
|
this.nodeLayer.addChild(view); // raise
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerMove(e: FederatedPointerEvent): void {
|
||||||
|
if (this.panning) {
|
||||||
|
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||||
|
this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
|
||||||
|
this.applyView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const local = this.toLocal(e.global);
|
||||||
|
const g = this.screenToGraph(local.x, local.y);
|
||||||
|
if (this.dragging) {
|
||||||
|
const dx = g.x - this.dragging.pointer.x;
|
||||||
|
const dy = g.y - this.dragging.pointer.y;
|
||||||
|
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
|
||||||
|
this.dragging.moved = true;
|
||||||
|
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
node.x = Math.round(this.dragging.start.x + dx);
|
||||||
|
node.y = Math.round(this.dragging.start.y + dy);
|
||||||
|
this.state.preview();
|
||||||
|
} else if (this.connecting) {
|
||||||
|
this.connecting.pointer = g;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerUp(e: FederatedPointerEvent): void {
|
||||||
|
this.panning = null;
|
||||||
|
const drag = this.dragging;
|
||||||
|
this.dragging = null;
|
||||||
|
if (drag && this.assetId) {
|
||||||
|
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||||
|
if (node && drag.moved) {
|
||||||
|
const next = { x: node.x, y: node.y };
|
||||||
|
node.x = drag.start.x;
|
||||||
|
node.y = drag.start.y;
|
||||||
|
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y, drag.start));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const connecting = this.connecting;
|
||||||
|
this.connecting = null;
|
||||||
|
if (connecting && this.assetId) {
|
||||||
|
const target = this.portUnder(e.global.x, e.global.y);
|
||||||
|
if (target && target.isOutput !== connecting.from.isOutput) {
|
||||||
|
const out = connecting.from.isOutput ? connecting.from : target;
|
||||||
|
const inp = connecting.from.isOutput ? target : connecting.from;
|
||||||
|
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private portUnder(globalX: number, globalY: number): PortAnchor | null {
|
||||||
|
for (const view of this.views.values()) {
|
||||||
|
const local = view.toLocal({ x: globalX, y: globalY });
|
||||||
|
const hit = view.portAt(local.x, local.y);
|
||||||
|
if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- menus -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||||
|
new NodePalette((def: NodeDef) => {
|
||||||
|
if (!this.assetId) return;
|
||||||
|
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
|
||||||
|
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
|
||||||
|
}).open(globalX, globalY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||||
|
const assetId = this.assetId;
|
||||||
|
if (!assetId) return;
|
||||||
|
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
|
||||||
|
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
|
||||||
|
const asset = this.asset();
|
||||||
|
if (!asset) return;
|
||||||
|
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
|
||||||
|
if (edges.length === 0) return;
|
||||||
|
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drawing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
|
||||||
|
if (this.needsFit) this.fitView();
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||||||
|
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||||
|
this.drawGrid();
|
||||||
|
const asset = this.asset();
|
||||||
|
this.canvas.visible = asset !== null;
|
||||||
|
if (!asset) {
|
||||||
|
this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playing = this.state.mode === 'play';
|
||||||
|
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
|
||||||
|
|
||||||
|
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||||
|
const now = this.timeProvider();
|
||||||
|
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
||||||
|
const t = map?.get(id);
|
||||||
|
return t !== undefined && now - t <= TRACE_FADE;
|
||||||
|
};
|
||||||
|
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
|
||||||
|
for (const view of this.views.values()) {
|
||||||
|
view.selected = view.node.id === selectedNode;
|
||||||
|
view.hot = isHot(trace?.nodes, view.node.id);
|
||||||
|
view.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.edges.clear();
|
||||||
|
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
|
||||||
|
|
||||||
|
this.tempEdge.clear();
|
||||||
|
if (this.connecting) {
|
||||||
|
const view = this.views.get(this.connecting.from.nodeId);
|
||||||
|
const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput);
|
||||||
|
if (view && p) {
|
||||||
|
const a = { x: view.x + p.x, y: view.y + p.y };
|
||||||
|
const b = this.connecting.pointer;
|
||||||
|
const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a];
|
||||||
|
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||||
|
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawEdge(edge: GraphEdge, hot: boolean): void {
|
||||||
|
const from = this.views.get(edge.from.node);
|
||||||
|
const to = this.views.get(edge.to.node);
|
||||||
|
const a = from?.portPosition(edge.from.port, true);
|
||||||
|
const b = to?.portPosition(edge.to.port, false);
|
||||||
|
if (!from || !to || !a || !b) return;
|
||||||
|
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
|
||||||
|
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
|
||||||
|
const valid = portsCompatible(type, inType);
|
||||||
|
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
|
||||||
|
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGrid(): void {
|
||||||
|
this.grid.clear();
|
||||||
|
const step = GRID_STEP * this.zoom;
|
||||||
|
if (step < 8) return;
|
||||||
|
const startX = ((this.pan.x % step) + step) % step;
|
||||||
|
const startY = ((this.pan.y % step) + step) % step;
|
||||||
|
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||||||
|
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||||
|
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.04 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- test hooks -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Global screen position of a port (tests drive connections with it). */
|
||||||
|
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
|
||||||
|
const view = this.views.get(nodeId);
|
||||||
|
const p = view?.portPosition(port, isOutput);
|
||||||
|
if (!view || !p) return null;
|
||||||
|
const g = view.toGlobal({ x: p.x, y: p.y });
|
||||||
|
return { x: g.x, y: g.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global screen position of a node's title bar centre. */
|
||||||
|
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||||
|
const view = this.views.get(nodeId);
|
||||||
|
if (!view) return null;
|
||||||
|
const g = view.toGlobal({ x: view.w / 2, y: 12 });
|
||||||
|
return { x: g.x, y: g.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal S-curve between two points, as used by every node editor. */
|
||||||
|
function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||||
|
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||||
|
g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { allNodeDefs, type NodeCategory, type NodeDef } from '../../core/graph';
|
||||||
|
import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
|
||||||
|
|
||||||
|
/** Searchable list of node types; right-click menu of the graph editor. */
|
||||||
|
export class NodePalette extends Popup {
|
||||||
|
private readonly search: TextField;
|
||||||
|
private readonly list = new ListView();
|
||||||
|
private filtered: NodeDef[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly onPick: (def: NodeDef) => void) {
|
||||||
|
super({ padding: 4, gap: 4 });
|
||||||
|
this.minWidth = 240;
|
||||||
|
this.search = new TextField('', {
|
||||||
|
placeholder: 'Search nodes…',
|
||||||
|
onInput: () => this.refresh(),
|
||||||
|
onChange: () => {
|
||||||
|
const first = this.filtered[0];
|
||||||
|
if (first) this.pick(first);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.content.add(this.search);
|
||||||
|
const scroll = new ScrollContainer();
|
||||||
|
scroll.minHeight = 260;
|
||||||
|
scroll.setContent(this.list);
|
||||||
|
this.content.add(scroll, 1);
|
||||||
|
this.list.rowHeight = Theme.rowHeight - 2;
|
||||||
|
this.list.emptyText = 'No matching node';
|
||||||
|
this.list.onSelect = (id) => {
|
||||||
|
const def = this.filtered.find((d) => d.type === id);
|
||||||
|
if (def) this.pick(def);
|
||||||
|
};
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
override open(globalX: number, globalY: number): void {
|
||||||
|
super.open(globalX, globalY);
|
||||||
|
ui().focus.focus(this.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
const query = this.search.value.trim().toLowerCase();
|
||||||
|
this.filtered = allNodeDefs()
|
||||||
|
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
|
||||||
|
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
|
||||||
|
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
|
||||||
|
(this.content.items[1] as ScrollContainer | undefined)?.relayoutContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pick(def: NodeDef): void {
|
||||||
|
this.close();
|
||||||
|
this.onPick(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
|
||||||
|
import { edgesInto, type Graph, type GraphNode, inlineInputs, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
|
||||||
|
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||||
|
import { Theme, textStyle, Widget } from '../../ui';
|
||||||
|
import { keyLabel } from '../inspector/fieldFactory';
|
||||||
|
|
||||||
|
export const NODE_WIDTH = 176;
|
||||||
|
export const TITLE_HEIGHT = 24;
|
||||||
|
export const ROW_HEIGHT = 20;
|
||||||
|
export const PORT_RADIUS = 5;
|
||||||
|
/** Distance (graph units) within which a press counts as hitting a port. */
|
||||||
|
export const PORT_HIT_RADIUS = 9;
|
||||||
|
const PAD = 6;
|
||||||
|
|
||||||
|
export function categoryColor(category: NodeCategory): number {
|
||||||
|
const c = Theme.colors;
|
||||||
|
switch (category) {
|
||||||
|
case 'event': return c.categoryEvent;
|
||||||
|
case 'flow': return c.categoryFlow;
|
||||||
|
case 'data': return c.categoryData;
|
||||||
|
case 'variable': return c.categoryVariable;
|
||||||
|
case 'action': return c.categoryAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portColor(type: PortType): number {
|
||||||
|
const c = Theme.colors;
|
||||||
|
switch (type) {
|
||||||
|
case 'exec': return c.portExec;
|
||||||
|
case 'number': return c.portNumber;
|
||||||
|
case 'boolean': return c.portBoolean;
|
||||||
|
case 'string': return c.portString;
|
||||||
|
case 'object': return c.portObject;
|
||||||
|
case 'any': return c.portAny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortHit {
|
||||||
|
port: PortDef;
|
||||||
|
isOutput: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws one graph node: a title bar coloured by category, a line per setting and a row per
|
||||||
|
* port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view
|
||||||
|
* never interprets what a node does. Positioned in graph coordinates by the GraphEditor.
|
||||||
|
*/
|
||||||
|
export class NodeView extends Widget {
|
||||||
|
private readonly box = new Graphics();
|
||||||
|
private readonly ports = new Graphics();
|
||||||
|
private readonly texts: Text[] = [];
|
||||||
|
private paramLines: string[] = [];
|
||||||
|
selected = false;
|
||||||
|
hot = false;
|
||||||
|
/** Whether a data input currently has a connection (inline value hidden). */
|
||||||
|
private connected = new Set<string>();
|
||||||
|
|
||||||
|
constructor(readonly node: GraphNode, readonly def: NodeDef) {
|
||||||
|
super();
|
||||||
|
this.cursor = 'grab';
|
||||||
|
this.addChild(this.box, this.ports);
|
||||||
|
this.sync(node, { nodes: [node], edges: [] }, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
get titleText(): string {
|
||||||
|
return this.def.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
|
||||||
|
override resize(w: number, h: number): void {
|
||||||
|
super.resize(w, h);
|
||||||
|
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads params and connections; rebuilds the text lines. */
|
||||||
|
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null): void {
|
||||||
|
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p.kind, node.params[p.key], asset)}`);
|
||||||
|
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
|
||||||
|
const rows = this.rowCount();
|
||||||
|
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD;
|
||||||
|
this.rebuildTexts();
|
||||||
|
this.resize(NODE_WIDTH, height);
|
||||||
|
this.position.set(node.x, node.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowCount(): number {
|
||||||
|
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local position of a port's centre. */
|
||||||
|
portPosition(key: string, isOutput: boolean): { x: number; y: number } | null {
|
||||||
|
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||||||
|
const index = list.findIndex((p) => p.key === key);
|
||||||
|
if (index < 0) return null;
|
||||||
|
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowY(index: number): number {
|
||||||
|
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which port (if any) sits under a local point. */
|
||||||
|
portAt(localX: number, localY: number): PortHit | null {
|
||||||
|
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const px = isOutput ? this.w : 0;
|
||||||
|
if (Math.hypot(localX - px, localY - this.rowY(i)) <= PORT_HIT_RADIUS) return { port: list[i]!, isOutput };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildTexts(): void {
|
||||||
|
for (const t of this.texts) t.destroy();
|
||||||
|
this.texts.length = 0;
|
||||||
|
const add = (text: string, style: Parameters<typeof textStyle>[0], x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
|
||||||
|
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
|
||||||
|
node.position.set(align === 'left' ? x : x - node.width, y);
|
||||||
|
this.addChild(node);
|
||||||
|
this.texts.push(node);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
|
||||||
|
let y = TITLE_HEIGHT + PAD;
|
||||||
|
for (const line of this.paramLines) {
|
||||||
|
add(line, 'small', PAD + 2, y + 2);
|
||||||
|
y += ROW_HEIGHT;
|
||||||
|
}
|
||||||
|
const rows = this.rowCount();
|
||||||
|
for (let i = 0; i < rows; i++) {
|
||||||
|
const input = this.def.inputs[i];
|
||||||
|
const output = this.def.outputs[i];
|
||||||
|
const rowTop = y + i * ROW_HEIGHT + 2;
|
||||||
|
if (input) {
|
||||||
|
let label = input.label;
|
||||||
|
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
|
||||||
|
const value = this.node.params[input.key];
|
||||||
|
if (value !== undefined && value !== '') label += ` ${formatValue(value)}`;
|
||||||
|
}
|
||||||
|
add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
|
||||||
|
}
|
||||||
|
if (output) add(output.label || '', output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
const c = Theme.colors;
|
||||||
|
this.box.clear();
|
||||||
|
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||||||
|
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||||||
|
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||||||
|
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
|
||||||
|
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
|
||||||
|
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
|
||||||
|
|
||||||
|
this.ports.clear();
|
||||||
|
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
|
||||||
|
list.forEach((port, i) => {
|
||||||
|
const x = isOutput ? this.w : 0;
|
||||||
|
const y = this.rowY(i);
|
||||||
|
if (port.type === 'exec') {
|
||||||
|
this.ports.moveTo(x - 5, y - 5).lineTo(x + 1, y - 5).lineTo(x + 6, y).lineTo(x + 1, y + 5).lineTo(x - 5, y + 5).closePath().fill(portColor('exec'));
|
||||||
|
} else {
|
||||||
|
this.ports.circle(x, y, PORT_RADIUS).fill(portColor(port.type));
|
||||||
|
this.ports.circle(x, y, PORT_RADIUS).stroke({ width: 1, color: c.node });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatParam(kind: string, value: unknown, asset: BehaviorAsset | null): string {
|
||||||
|
if (value === undefined || value === '') return '—';
|
||||||
|
if (kind === 'key') return keyLabel(String(value));
|
||||||
|
if (kind === 'enum' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`;
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit(text: string, style: Parameters<typeof textStyle>[0], maxWidth: number): string {
|
||||||
|
const s = textStyle(style);
|
||||||
|
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
|
||||||
|
let cut = text;
|
||||||
|
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
|
||||||
|
return cut + '…';
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { PropertyValue } from '../../core/model/Properties';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||||
import { Button, Checkbox, ColorField, HStack, Label, NumberField, TextField, Theme, type Widget } from '../../ui';
|
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, TextField, Theme, ui, type Widget } from '../../ui';
|
||||||
|
|
||||||
export interface FieldHandlers {
|
export interface FieldHandlers {
|
||||||
/** Live value while dragging/typing – apply as a preview, no undo entry. */
|
/** Live value while dragging/typing – apply as a preview, no undo entry. */
|
||||||
@@ -20,6 +20,8 @@ export interface FieldRow {
|
|||||||
setModified(modified: boolean): void;
|
setModified(modified: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const FIELD_LABEL_WIDTH = 64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps one PropertySchema entry to a labelled row containing the matching widget.
|
* Maps one PropertySchema entry to a labelled row containing the matching widget.
|
||||||
* The inspector is entirely generated through this function: adding a new property
|
* The inspector is entirely generated through this function: adding a new property
|
||||||
@@ -29,45 +31,12 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
|||||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
row.minHeight = Theme.rowHeight;
|
row.minHeight = Theme.rowHeight;
|
||||||
const label = new Label(def.label, { style: 'muted' });
|
const label = new Label(def.label, { style: 'muted' });
|
||||||
label.minWidth = 64;
|
label.minWidth = FIELD_LABEL_WIDTH;
|
||||||
row.add(label);
|
row.add(label);
|
||||||
|
|
||||||
let setValue: (v: PropertyValue) => void;
|
const field = createField(def, value, handlers);
|
||||||
|
row.add(field.widget, field.grow ? 1 : 0);
|
||||||
switch (def.kind) {
|
if (!field.grow) row.add(new Label(''), 1);
|
||||||
case 'number': {
|
|
||||||
const field = new NumberField(Number(value), {
|
|
||||||
min: def.min,
|
|
||||||
max: def.max,
|
|
||||||
step: def.step ?? 1,
|
|
||||||
onInput: handlers.onInput,
|
|
||||||
onChange: handlers.onChange,
|
|
||||||
});
|
|
||||||
row.add(field, 1);
|
|
||||||
setValue = (v) => field.setNumber(Number(v));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'text': {
|
|
||||||
const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
|
||||||
row.add(field, 1);
|
|
||||||
setValue = (v) => field.setValue(String(v));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'color': {
|
|
||||||
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
|
||||||
row.add(field, 1);
|
|
||||||
setValue = (v) => field.setValue(Number(v));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'boolean': {
|
|
||||||
const field = new Checkbox(Boolean(value));
|
|
||||||
field.onChange = handlers.onChange;
|
|
||||||
row.add(field);
|
|
||||||
row.add(new Label(''), 1);
|
|
||||||
setValue = (v) => field.setChecked(Boolean(v));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let reset: Button | null = null;
|
let reset: Button | null = null;
|
||||||
if (onReset) {
|
if (onReset) {
|
||||||
@@ -78,7 +47,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
row,
|
row,
|
||||||
setValue,
|
setValue: field.setValue,
|
||||||
setModified(modified) {
|
setModified(modified) {
|
||||||
label.setStyle(modified ? 'bodyBold' : 'muted');
|
label.setStyle(modified ? 'bodyBold' : 'muted');
|
||||||
if (reset && reset.visible !== modified) {
|
if (reset && reset.visible !== modified) {
|
||||||
@@ -88,3 +57,107 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Field {
|
||||||
|
widget: Widget;
|
||||||
|
/** Whether the widget should take the remaining row width. */
|
||||||
|
grow: boolean;
|
||||||
|
setValue(value: PropertyValue): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bare widget for a property definition (no label, no reset). */
|
||||||
|
export function createField(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers): Field {
|
||||||
|
switch (def.kind) {
|
||||||
|
case 'number': {
|
||||||
|
const field = new NumberField(Number(value), {
|
||||||
|
min: def.min,
|
||||||
|
max: def.max,
|
||||||
|
step: def.step ?? 1,
|
||||||
|
onInput: handlers.onInput,
|
||||||
|
onChange: handlers.onChange,
|
||||||
|
});
|
||||||
|
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||||
|
return { widget: field, grow: true, setValue: (v) => field.setValue(String(v)) };
|
||||||
|
}
|
||||||
|
case 'color': {
|
||||||
|
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||||||
|
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
|
||||||
|
}
|
||||||
|
case 'boolean': {
|
||||||
|
const field = new Checkbox(Boolean(value));
|
||||||
|
field.onChange = handlers.onChange;
|
||||||
|
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
|
||||||
|
}
|
||||||
|
case 'enum': {
|
||||||
|
const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
|
||||||
|
const button = new Button(`${labelOf(value)} ▾`);
|
||||||
|
button.onClick = () => {
|
||||||
|
const g = button.getGlobalPosition();
|
||||||
|
const items = def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }));
|
||||||
|
if (items.length === 0) items.push({ label: 'No options', onSelect: () => {} });
|
||||||
|
new Menu(items).open(g.x, g.y + button.h + 2);
|
||||||
|
};
|
||||||
|
return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) };
|
||||||
|
}
|
||||||
|
case 'key': {
|
||||||
|
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
|
||||||
|
return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows a key name; click, then press a key to change it (Escape cancels). */
|
||||||
|
class KeyCaptureButton extends Button {
|
||||||
|
private key: string;
|
||||||
|
private capturing = false;
|
||||||
|
|
||||||
|
constructor(key: string, private readonly onCapture: (key: string) => void) {
|
||||||
|
super(keyLabel(key));
|
||||||
|
this.key = key;
|
||||||
|
this.onClick = () => {
|
||||||
|
this.capturing = true;
|
||||||
|
this.setText('Press a key…');
|
||||||
|
ui().focus.focus(this);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setKey(key: string): void {
|
||||||
|
this.key = key;
|
||||||
|
if (!this.capturing) this.setText(keyLabel(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
override onKeyDown(event: KeyboardEvent): boolean {
|
||||||
|
if (!this.capturing) return super.onKeyDown(event);
|
||||||
|
event.preventDefault();
|
||||||
|
this.capturing = false;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.setText(keyLabel(this.key));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) {
|
||||||
|
this.capturing = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.key = event.key;
|
||||||
|
this.setText(keyLabel(this.key));
|
||||||
|
this.onCapture(this.key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBlur(): void {
|
||||||
|
super.onBlur();
|
||||||
|
if (this.capturing) {
|
||||||
|
this.capturing = false;
|
||||||
|
this.setText(keyLabel(this.key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyLabel(key: string): string {
|
||||||
|
if (key === ' ') return 'Space';
|
||||||
|
if (key === '') return '—';
|
||||||
|
return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : '');
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
|
|||||||
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
|
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
|
|
||||||
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite' };
|
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior' };
|
||||||
|
|
||||||
/** Lists all assets of the project; creates, places and deletes them. */
|
/** Lists all assets of the project; creates, places and deletes them. */
|
||||||
export class AssetPanel extends Panel {
|
export class AssetPanel extends Panel {
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ export class HierarchyPanel extends Panel {
|
|||||||
(scene?.objects ?? []).map((object) => {
|
(scene?.objects ?? []).map((object) => {
|
||||||
const asset = findAsset(this.state.project, object.assetId);
|
const asset = findAsset(this.state.project, object.assetId);
|
||||||
const props = asset ? resolveSceneObjectProps(asset, object) : {};
|
const props = asset ? resolveSceneObjectProps(asset, object) : {};
|
||||||
|
const owner = object.ownerId ? scene?.objects.find((o) => o.id === object.ownerId) : undefined;
|
||||||
|
const ownerName = owner ? owner.name || findAsset(this.state.project, owner.assetId)?.name || '?' : '';
|
||||||
return {
|
return {
|
||||||
id: object.id,
|
id: object.id,
|
||||||
label: object.name || asset?.name || '?',
|
label: object.name || asset?.name || '?',
|
||||||
hint: asset?.type ?? '',
|
hint: owner ? `→ ${ownerName}` : asset?.type ?? '',
|
||||||
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
|
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
+240
-36
@@ -1,21 +1,31 @@
|
|||||||
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||||
|
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
import {
|
import {
|
||||||
ClearOverrideCommand,
|
ClearOverrideCommand,
|
||||||
MoveSceneObjectCommand,
|
MoveSceneObjectCommand,
|
||||||
RenameSceneObjectCommand,
|
RenameSceneObjectCommand,
|
||||||
SetOverrideCommand,
|
SetOverrideCommand,
|
||||||
|
SetOwnerCommand,
|
||||||
} from '../../core/commands/sceneObjectCommands';
|
} from '../../core/commands/sceneObjectCommands';
|
||||||
|
import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
|
||||||
|
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
|
||||||
|
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||||
import type { Position, PropertyValue } from '../../core/model/Properties';
|
import type { Position, PropertyValue } from '../../core/model/Properties';
|
||||||
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
|
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
|
||||||
import { schemaFor } from '../../core/properties/PropertySchema';
|
import type { SceneObject } from '../../core/model/SceneObject';
|
||||||
|
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
|
||||||
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
|
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||||
import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
|
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
|
||||||
import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
|
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
|
||||||
import type { EditorState, Selection } from '../state';
|
import type { EditorState, Selection } from '../state';
|
||||||
|
|
||||||
|
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
|
||||||
|
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the properties of the current selection. The field list is generated from the
|
* Shows the properties of the current selection. The field list is generated from the
|
||||||
* PropertySchema; scene objects additionally get Position and per-property overrides.
|
* PropertySchema; scene objects additionally get Position, Owner and per-property
|
||||||
|
* overrides; behaviors get their variables; graph nodes get their settings and inline inputs.
|
||||||
*/
|
*/
|
||||||
export class Inspector extends Panel {
|
export class Inspector extends Panel {
|
||||||
private readonly scroll = new ScrollContainer();
|
private readonly scroll = new ScrollContainer();
|
||||||
@@ -23,6 +33,8 @@ export class Inspector extends Panel {
|
|||||||
private fields = new Map<string, FieldRow>();
|
private fields = new Map<string, FieldRow>();
|
||||||
private nameField: TextField | null = null;
|
private nameField: TextField | null = null;
|
||||||
private builtFor: Selection = null;
|
private builtFor: Selection = null;
|
||||||
|
/** Describes the structure the fields were built for; a change means a rebuild. */
|
||||||
|
private signature = '';
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||||||
@@ -42,18 +54,45 @@ export class Inspector extends Panel {
|
|||||||
this.body = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
this.body = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
||||||
this.fields = new Map();
|
this.fields = new Map();
|
||||||
this.nameField = null;
|
this.nameField = null;
|
||||||
|
this.signature = this.currentSignature();
|
||||||
|
|
||||||
const s = this.state.selection;
|
const s = this.state.selection;
|
||||||
if (!s) {
|
if (!s) {
|
||||||
this.body.add(new Label('Select an asset or a scene object.', { style: 'muted' }));
|
this.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
|
||||||
} else if (s.kind === 'asset') {
|
} else if (s.kind === 'asset') {
|
||||||
this.buildAsset(s.assetId);
|
this.buildAsset(s.assetId);
|
||||||
} else {
|
} else if (s.kind === 'object') {
|
||||||
this.buildObject(s.sceneId, s.objectId);
|
this.buildObject(s.sceneId, s.objectId);
|
||||||
|
} else {
|
||||||
|
this.buildNode(s.assetId, s.nodeId);
|
||||||
}
|
}
|
||||||
this.scroll.setContent(this.body);
|
this.scroll.setContent(this.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Structure-defining facts of the selection: schema keys, connected inputs. */
|
||||||
|
private currentSignature(): string {
|
||||||
|
const s = this.state.selection;
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.kind === 'asset') {
|
||||||
|
const asset = findAsset(this.state.project, s.assetId);
|
||||||
|
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
|
||||||
|
}
|
||||||
|
if (s.kind === 'object') {
|
||||||
|
const object = this.currentObject(s.sceneId, s.objectId);
|
||||||
|
const asset = object && findAsset(this.state.project, object.assetId);
|
||||||
|
return asset ? `${object.ownerId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
|
||||||
|
}
|
||||||
|
const asset = findAsset(this.state.project, s.assetId);
|
||||||
|
if (!asset || asset.type !== 'behavior') return 'missing';
|
||||||
|
const node = asset.graph.nodes.find((n) => n.id === s.nodeId);
|
||||||
|
if (!node) return 'missing';
|
||||||
|
const def = nodeDef(node.type);
|
||||||
|
const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
|
||||||
|
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assets ------------------------------------------------------------------------------
|
||||||
|
|
||||||
private buildAsset(assetId: string): void {
|
private buildAsset(assetId: string): void {
|
||||||
const asset = findAsset(this.state.project, assetId);
|
const asset = findAsset(this.state.project, assetId);
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
@@ -66,37 +105,87 @@ export class Inspector extends Panel {
|
|||||||
});
|
});
|
||||||
this.body.add(this.labelled('Name', this.nameField));
|
this.body.add(this.labelled('Name', this.nameField));
|
||||||
|
|
||||||
|
if (asset.type === 'behavior') {
|
||||||
|
this.buildVariables(asset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Asset properties: values that differ from the schema default are marked and resettable.
|
// Asset properties: values that differ from the schema default are marked and resettable.
|
||||||
const values = resolveAssetProps(asset);
|
const values = resolveAssetProps(asset);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaForAsset(asset)) {
|
||||||
let original: PropertyValue | undefined;
|
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
|
||||||
const row = createFieldRow(
|
|
||||||
def,
|
|
||||||
values[def.key] ?? def.default,
|
|
||||||
{
|
|
||||||
onInput: (value) => {
|
|
||||||
const target = findAsset(this.state.project, assetId);
|
|
||||||
if (!target) return;
|
|
||||||
if (original === undefined) original = target.props[def.key];
|
|
||||||
target.props[def.key] = value;
|
|
||||||
this.state.preview();
|
|
||||||
},
|
|
||||||
onChange: (value) => {
|
|
||||||
const target = findAsset(this.state.project, assetId);
|
|
||||||
if (!target) return;
|
|
||||||
if (original !== undefined) target.props[def.key] = original;
|
|
||||||
original = undefined;
|
|
||||||
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
() => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)),
|
|
||||||
);
|
|
||||||
row.setModified(isModified(asset, def.key));
|
row.setModified(isModified(asset, def.key));
|
||||||
this.fields.set(def.key, row);
|
this.fields.set(def.key, row);
|
||||||
this.body.add(row.row);
|
this.body.add(row.row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assetHandlers(assetId: string, def: PropertyDef) {
|
||||||
|
let original: PropertyValue | undefined;
|
||||||
|
return {
|
||||||
|
onInput: (value: PropertyValue) => {
|
||||||
|
const target = findAsset(this.state.project, assetId);
|
||||||
|
if (!target) return;
|
||||||
|
if (original === undefined) original = target.props[def.key];
|
||||||
|
target.props[def.key] = value;
|
||||||
|
this.state.preview();
|
||||||
|
},
|
||||||
|
onChange: (value: PropertyValue) => {
|
||||||
|
const target = findAsset(this.state.project, assetId);
|
||||||
|
if (!target) return;
|
||||||
|
if (original !== undefined) target.props[def.key] = original;
|
||||||
|
original = undefined;
|
||||||
|
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variables of a behavior: name, kind mark, default value, remove – plus an "add" menu. */
|
||||||
|
private buildVariables(asset: BehaviorAsset): void {
|
||||||
|
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||||
|
const add = new Button('+ Variable ▾', { variant: 'ghost' });
|
||||||
|
add.onClick = () => {
|
||||||
|
const g = add.getGlobalPosition();
|
||||||
|
new Menu(
|
||||||
|
VARIABLE_KINDS.map((kind) => ({
|
||||||
|
label: VARIABLE_KIND_LABELS[kind],
|
||||||
|
onSelect: () => {
|
||||||
|
const current = findAsset(this.state.project, asset.id);
|
||||||
|
if (current?.type === 'behavior') this.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).open(g.x, g.y + add.h + 2);
|
||||||
|
};
|
||||||
|
header.add(add);
|
||||||
|
this.body.add(header);
|
||||||
|
|
||||||
|
if (asset.variables.length === 0) this.body.add(new Label('No variables yet.', { style: 'muted' }));
|
||||||
|
const values = resolveAssetProps(asset);
|
||||||
|
for (const def of asset.variables) {
|
||||||
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
row.minHeight = Theme.rowHeight;
|
||||||
|
const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
|
||||||
|
mark.minWidth = 18;
|
||||||
|
row.add(mark);
|
||||||
|
const name = new TextField(def.key, {
|
||||||
|
width: 90,
|
||||||
|
onChange: (next) => {
|
||||||
|
if (!this.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()))) this.rebuild();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
row.add(name);
|
||||||
|
const field = createField(def, values[def.key] ?? def.default, this.assetHandlers(asset.id, def));
|
||||||
|
row.add(field.widget, field.grow ? 1 : 0);
|
||||||
|
if (!field.grow) row.add(new Label(''), 1);
|
||||||
|
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => this.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
|
||||||
|
this.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
|
||||||
|
this.body.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scene objects -------------------------------------------------------------------
|
||||||
|
|
||||||
private buildObject(sceneId: string, objectId: string): void {
|
private buildObject(sceneId: string, objectId: string): void {
|
||||||
const scene = findScene(this.state.project, sceneId);
|
const scene = findScene(this.state.project, sceneId);
|
||||||
const object = scene && findSceneObject(scene, objectId);
|
const object = scene && findSceneObject(scene, objectId);
|
||||||
@@ -112,11 +201,13 @@ export class Inspector extends Panel {
|
|||||||
|
|
||||||
const assetRow = new HStack({ gap: Theme.gap, align: 'center' });
|
const assetRow = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
const assetLabel = new Label('Asset', { style: 'muted' });
|
const assetLabel = new Label('Asset', { style: 'muted' });
|
||||||
assetLabel.minWidth = 64;
|
assetLabel.minWidth = FIELD_LABEL_WIDTH;
|
||||||
assetRow.add(assetLabel);
|
assetRow.add(assetLabel);
|
||||||
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
|
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
|
||||||
this.body.add(assetRow);
|
this.body.add(assetRow);
|
||||||
|
|
||||||
|
if (asset.type === 'behavior') this.body.add(this.ownerRow(scene.objects, object, sceneId));
|
||||||
|
|
||||||
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
|
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
|
||||||
this.body.add(new Label('POSITION', { style: 'title' }));
|
this.body.add(new Label('POSITION', { style: 'title' }));
|
||||||
for (const axis of ['x', 'y', 'z'] as const) {
|
for (const axis of ['x', 'y', 'z'] as const) {
|
||||||
@@ -143,10 +234,12 @@ export class Inspector extends Panel {
|
|||||||
this.body.add(row.row);
|
this.body.add(row.row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asset properties with overrides.
|
// Asset properties (or behavior variables) with overrides.
|
||||||
this.body.add(new Label('PROPERTIES', { style: 'title' }));
|
const schema = schemaForAsset(asset);
|
||||||
|
if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
|
||||||
|
if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
|
||||||
const values = resolveSceneObjectProps(asset, object);
|
const values = resolveSceneObjectProps(asset, object);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schema) {
|
||||||
let original: PropertyValue | undefined;
|
let original: PropertyValue | undefined;
|
||||||
let hadOverride = false;
|
let hadOverride = false;
|
||||||
const row = createFieldRow(
|
const row = createFieldRow(
|
||||||
@@ -182,10 +275,104 @@ export class Inspector extends Panel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Owner: <name> ▾" – picks another object of the same scene, or none. */
|
||||||
|
private ownerRow(objects: SceneObject[], object: SceneObject, sceneId: string): HStack {
|
||||||
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
row.minHeight = Theme.rowHeight;
|
||||||
|
const label = new Label('Owner', { style: 'muted' });
|
||||||
|
label.minWidth = FIELD_LABEL_WIDTH;
|
||||||
|
row.add(label);
|
||||||
|
const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?';
|
||||||
|
const owner = objects.find((o) => o.id === object.ownerId);
|
||||||
|
const button = new Button(`${owner ? nameOf(owner) : 'None'} ▾`);
|
||||||
|
button.onClick = () => {
|
||||||
|
const g = button.getGlobalPosition();
|
||||||
|
const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, '')) }];
|
||||||
|
for (const candidate of objects) {
|
||||||
|
if (candidate.id === object.id) continue;
|
||||||
|
items.push({ label: candidate.id === object.ownerId ? `● ${nameOf(candidate)}` : nameOf(candidate), onSelect: () => this.state.execute(new SetOwnerCommand(sceneId, object.id, candidate.id)) });
|
||||||
|
}
|
||||||
|
new Menu(items).open(g.x, g.y + button.h + 2);
|
||||||
|
};
|
||||||
|
row.add(button, 1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- graph nodes ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private buildNode(assetId: string, nodeId: string): void {
|
||||||
|
const asset = findAsset(this.state.project, assetId);
|
||||||
|
if (!asset || asset.type !== 'behavior') return;
|
||||||
|
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||||
|
const def = node && nodeDef(node.type);
|
||||||
|
if (!node || !def) return;
|
||||||
|
|
||||||
|
this.body.add(new Label(`${def.category.toUpperCase()} NODE`, { style: 'title' }));
|
||||||
|
this.body.add(new Label(def.label, { style: 'bodyBold' }));
|
||||||
|
this.body.add(new Label(def.description, { style: 'muted' }));
|
||||||
|
|
||||||
|
const handlers = (key: string) => {
|
||||||
|
let original: PropertyValue | undefined;
|
||||||
|
let had = false;
|
||||||
|
return {
|
||||||
|
onInput: (value: PropertyValue) => {
|
||||||
|
const target = this.currentNode(assetId, nodeId);
|
||||||
|
if (!target) return;
|
||||||
|
if (original === undefined) {
|
||||||
|
had = target.params[key] !== undefined;
|
||||||
|
original = target.params[key] ?? value;
|
||||||
|
}
|
||||||
|
target.params[key] = value;
|
||||||
|
this.state.preview();
|
||||||
|
},
|
||||||
|
onChange: (value: PropertyValue) => {
|
||||||
|
const target = this.currentNode(assetId, nodeId);
|
||||||
|
if (!target) return;
|
||||||
|
if (original !== undefined) {
|
||||||
|
if (had) target.params[key] = original;
|
||||||
|
else delete target.params[key];
|
||||||
|
}
|
||||||
|
original = undefined;
|
||||||
|
this.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (def.params.length > 0) this.body.add(new Label('SETTINGS', { style: 'title' }));
|
||||||
|
for (const param of def.params) {
|
||||||
|
const effective = this.resolveParamDef(param, asset);
|
||||||
|
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, handlers(param.key));
|
||||||
|
this.fields.set(`param:${param.key}`, row);
|
||||||
|
this.body.add(row.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
|
||||||
|
if (free.length > 0) this.body.add(new Label('INPUTS (unconnected)', { style: 'title' }));
|
||||||
|
for (const port of free) {
|
||||||
|
const inline = inlineInputDef(port);
|
||||||
|
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, handlers(port.key));
|
||||||
|
this.fields.set(`input:${port.key}`, row);
|
||||||
|
this.body.add(row.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.body.add(new Label(''));
|
||||||
|
this.body.add(new Button('Delete node', { variant: 'danger', onClick: () => this.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variable params pick from the asset's variables; everything else is used as declared. */
|
||||||
|
private resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
|
||||||
|
if (param.kind === 'enum' && param.key === 'variable' && asset.type === 'behavior') {
|
||||||
|
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
|
||||||
|
}
|
||||||
|
return param;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- refresh ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Push current model values into the widgets (after undo, drag in viewport, …). */
|
/** Push current model values into the widgets (after undo, drag in viewport, …). */
|
||||||
private refreshValues(): void {
|
private refreshValues(): void {
|
||||||
const s = this.state.selection;
|
const s = this.state.selection;
|
||||||
if (!sameTarget(s, this.builtFor)) {
|
if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
|
||||||
this.rebuild();
|
this.rebuild();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,6 +388,17 @@ export class Inspector extends Panel {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (s.kind === 'node') {
|
||||||
|
const node = this.currentNode(s.assetId, s.nodeId);
|
||||||
|
const def = node && nodeDef(node.type);
|
||||||
|
if (!node || !def) return this.rebuild();
|
||||||
|
for (const [key, row] of this.fields) {
|
||||||
|
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||||||
|
const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
|
||||||
|
row.setValue(node.params[name] ?? fallback ?? '');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const object = this.currentObject(s.sceneId, s.objectId);
|
const object = this.currentObject(s.sceneId, s.objectId);
|
||||||
const asset = object && findAsset(this.state.project, object.assetId);
|
const asset = object && findAsset(this.state.project, object.assetId);
|
||||||
if (!object || !asset) return this.rebuild();
|
if (!object || !asset) return this.rebuild();
|
||||||
@@ -209,7 +407,7 @@ export class Inspector extends Panel {
|
|||||||
this.fields.get('y')?.setValue(object.position.y);
|
this.fields.get('y')?.setValue(object.position.y);
|
||||||
this.fields.get('z')?.setValue(object.position.z);
|
this.fields.get('z')?.setValue(object.position.z);
|
||||||
const values = resolveSceneObjectProps(asset, object);
|
const values = resolveSceneObjectProps(asset, object);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaForAsset(asset)) {
|
||||||
const row = this.fields.get(def.key);
|
const row = this.fields.get(def.key);
|
||||||
row?.setValue(values[def.key] ?? def.default);
|
row?.setValue(values[def.key] ?? def.default);
|
||||||
row?.setModified(isOverridden(object, def.key));
|
row?.setModified(isOverridden(object, def.key));
|
||||||
@@ -221,11 +419,16 @@ export class Inspector extends Panel {
|
|||||||
return scene && findSceneObject(scene, objectId);
|
return scene && findSceneObject(scene, objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentNode(assetId: string, nodeId: string) {
|
||||||
|
const asset = findAsset(this.state.project, assetId);
|
||||||
|
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private labelled(text: string, field: TextField): HStack {
|
private labelled(text: string, field: TextField): HStack {
|
||||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
row.minHeight = Theme.rowHeight;
|
row.minHeight = Theme.rowHeight;
|
||||||
const label = new Label(text, { style: 'muted' });
|
const label = new Label(text, { style: 'muted' });
|
||||||
label.minWidth = 64;
|
label.minWidth = FIELD_LABEL_WIDTH;
|
||||||
row.add(label);
|
row.add(label);
|
||||||
row.add(field, 1);
|
row.add(field, 1);
|
||||||
return row;
|
return row;
|
||||||
@@ -237,5 +440,6 @@ function sameTarget(a: Selection, b: Selection): boolean {
|
|||||||
if (a.kind !== b.kind) return false;
|
if (a.kind !== b.kind) return false;
|
||||||
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
||||||
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
|
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
|
||||||
|
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import { createEmptyProject, findScene, type Project } from '../core/model/Proje
|
|||||||
export type Selection =
|
export type Selection =
|
||||||
| { kind: 'asset'; assetId: string }
|
| { kind: 'asset'; assetId: string }
|
||||||
| { kind: 'object'; sceneId: string; objectId: string }
|
| { kind: 'object'; sceneId: string; objectId: string }
|
||||||
|
/** A node inside a behavior's graph. */
|
||||||
|
| { kind: 'node'; assetId: string; nodeId: string }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export type EditorMode = 'edit' | 'play';
|
export type EditorMode = 'edit' | 'play';
|
||||||
@@ -129,6 +131,22 @@ export class EditorState {
|
|||||||
const scene = findScene(this.project, s.sceneId);
|
const scene = findScene(this.project, s.sceneId);
|
||||||
if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null);
|
if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null);
|
||||||
}
|
}
|
||||||
|
if (s.kind === 'node') {
|
||||||
|
const asset = this.project.assets.find((a) => a.id === s.assetId);
|
||||||
|
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
|
||||||
|
if (!asset || asset.type !== 'behavior') this.select(null);
|
||||||
|
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
|
||||||
|
get selectedBehaviorId(): string | null {
|
||||||
|
const s = this.selection;
|
||||||
|
if (!s) return null;
|
||||||
|
if (s.kind === 'node') return s.assetId;
|
||||||
|
const assetId = s.kind === 'asset' ? s.assetId : findScene(this.project, s.sceneId)?.objects.find((o) => o.id === s.objectId)?.assetId;
|
||||||
|
const asset = assetId ? this.project.assets.find((a) => a.id === assetId) : undefined;
|
||||||
|
return asset && asset.type === 'behavior' ? asset.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- mode / status -------------------------------------------------------------------
|
// --- mode / status -------------------------------------------------------------------
|
||||||
@@ -156,5 +174,6 @@ function sameSelection(a: Selection, b: Selection): boolean {
|
|||||||
if (!a || !b || a.kind !== b.kind) return false;
|
if (!a || !b || a.kind !== b.kind) return false;
|
||||||
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
||||||
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
|
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
|
||||||
|
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -17,5 +17,8 @@ export interface Entity {
|
|||||||
y: number;
|
y: number;
|
||||||
/** Draw order; higher is drawn on top. */
|
/** Draw order; higher is drawn on top. */
|
||||||
z: number;
|
z: number;
|
||||||
|
/** For behaviors: the resolved variables (mutable state). For others: resolved properties. */
|
||||||
props: PropertyBag;
|
props: PropertyBag;
|
||||||
|
/** Scene object id of the owner (behaviors), empty when none. */
|
||||||
|
ownerObjectId: string;
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,10 @@ import type { System } from './systems/System';
|
|||||||
export class World {
|
export class World {
|
||||||
readonly entities: Entity[] = [];
|
readonly entities: Entity[] = [];
|
||||||
readonly backgroundColor: number;
|
readonly backgroundColor: number;
|
||||||
|
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
|
||||||
|
stopRequested = false;
|
||||||
|
/** Messages logged by behaviors (Log node), newest last. */
|
||||||
|
readonly log: string[] = [];
|
||||||
private readonly systems: System[] = [];
|
private readonly systems: System[] = [];
|
||||||
private elapsed = 0;
|
private elapsed = 0;
|
||||||
|
|
||||||
@@ -36,6 +40,7 @@ export class World {
|
|||||||
y: object.position.y,
|
y: object.position.y,
|
||||||
z: object.position.z,
|
z: object.position.z,
|
||||||
props: resolveSceneObjectProps(asset, object),
|
props: resolveSceneObjectProps(asset, object),
|
||||||
|
ownerObjectId: object.ownerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return world;
|
return world;
|
||||||
@@ -61,10 +66,19 @@ export class World {
|
|||||||
return this.entities.find((e) => e.objectId === objectId);
|
return this.entities.find((e) => e.objectId === objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findByName(name: string): Entity | undefined {
|
||||||
|
return this.entities.find((e) => e.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
firstOfType(type: Entity['type']): Entity | undefined {
|
firstOfType(type: Entity['type']): Entity | undefined {
|
||||||
return this.entities.find((e) => e.type === type);
|
return this.entities.find((e) => e.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove(entity: Entity): void {
|
||||||
|
const index = this.entities.indexOf(entity);
|
||||||
|
if (index >= 0) this.entities.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
for (const system of this.systems) system.stop?.(this);
|
for (const system of this.systems) system.stop?.(this);
|
||||||
this.systems.length = 0;
|
this.systems.length = 0;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListener } from '../../core/graph';
|
||||||
|
import { findAsset, type Project } from '../../core/model/Project';
|
||||||
|
import type { Entity } from '../Entity';
|
||||||
|
import type { World } from '../World';
|
||||||
|
import type { Input } from './Input';
|
||||||
|
import type { System } from './System';
|
||||||
|
|
||||||
|
/** How long (seconds) a traversed edge/node counts as "hot" for the editor's live highlight. */
|
||||||
|
export const TRACE_FADE = 0.35;
|
||||||
|
|
||||||
|
/** Recency of exec edges and nodes, keyed by id, in world time. */
|
||||||
|
export interface Trace {
|
||||||
|
edges: Map<string, number>;
|
||||||
|
nodes: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Instance {
|
||||||
|
entity: Entity;
|
||||||
|
interpreter: GraphInterpreter;
|
||||||
|
trace: Trace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the graph of every behavior entity. Each instance has its own interpreter (and
|
||||||
|
* therefore its own timers and variable state); the project is only read to find graphs.
|
||||||
|
*/
|
||||||
|
export class BehaviorSystem implements System {
|
||||||
|
private instances: Instance[] = [];
|
||||||
|
private world: World | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly project: Project, private readonly input: Input) {}
|
||||||
|
|
||||||
|
start(world: World): void {
|
||||||
|
this.world = world;
|
||||||
|
this.instances = [];
|
||||||
|
for (const entity of world.entities) {
|
||||||
|
if (entity.type !== 'behavior') continue;
|
||||||
|
const asset = findAsset(this.project, entity.assetId);
|
||||||
|
if (!asset || asset.type !== 'behavior') continue;
|
||||||
|
const trace: Trace = { edges: new Map(), nodes: new Map() };
|
||||||
|
const interpreter = new GraphInterpreter(asset.graph);
|
||||||
|
interpreter.trace = this.traceListener(trace, world);
|
||||||
|
this.instances.push({ entity, interpreter, trace });
|
||||||
|
}
|
||||||
|
for (const instance of this.instances) this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(world: World, dt: number): void {
|
||||||
|
const keyOf = (node: { params: Record<string, unknown> }) => String(node.params.key ?? '');
|
||||||
|
for (const instance of this.instances) {
|
||||||
|
if (!world.entities.includes(instance.entity)) continue; // destroyed during play
|
||||||
|
this.run(instance, (i, ctx) => {
|
||||||
|
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
|
||||||
|
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
|
||||||
|
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
|
||||||
|
for (const node of i.eventNodes('OnTimer')) {
|
||||||
|
const state = i.nodeState.get(node.id) ?? { elapsed: 0 };
|
||||||
|
i.nodeState.set(node.id, state);
|
||||||
|
const interval = Math.max(0.01, Number(node.params.interval ?? 1));
|
||||||
|
state.elapsed = (state.elapsed ?? 0) + dt;
|
||||||
|
while (state.elapsed >= interval) {
|
||||||
|
state.elapsed -= interval;
|
||||||
|
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i.fire('OnUpdate', ctx, { dt });
|
||||||
|
}, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.instances = [];
|
||||||
|
this.world = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live highlight data for the graph editor: merged over all instances of the asset. */
|
||||||
|
traceFor(assetId: string): Trace {
|
||||||
|
const merged: Trace = { edges: new Map(), nodes: new Map() };
|
||||||
|
for (const instance of this.instances) {
|
||||||
|
if (instance.entity.assetId !== assetId) continue;
|
||||||
|
for (const [id, t] of instance.trace.edges) merged.edges.set(id, Math.max(t, merged.edges.get(id) ?? -Infinity));
|
||||||
|
for (const [id, t] of instance.trace.nodes) merged.nodes.set(id, Math.max(t, merged.nodes.get(id) ?? -Infinity));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(instance: Instance, body: (interpreter: GraphInterpreter, ctx: NodeContext) => void, dt: number): void {
|
||||||
|
const world = this.world;
|
||||||
|
if (!world) return;
|
||||||
|
const ctx = this.context(instance.entity, world, dt);
|
||||||
|
try {
|
||||||
|
body(instance.interpreter, ctx);
|
||||||
|
} catch (error) {
|
||||||
|
world.log.push(`Error in behavior "${instance.entity.name}": ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
world.stopRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private context(entity: Entity, world: World, dt: number): NodeContext {
|
||||||
|
return {
|
||||||
|
self: entity,
|
||||||
|
owner: entity.ownerObjectId ? world.findByObjectId(entity.ownerObjectId) ?? null : null,
|
||||||
|
variables: entity.props,
|
||||||
|
time: world.time,
|
||||||
|
dt,
|
||||||
|
findByName: (name) => world.findByName(name) ?? null,
|
||||||
|
isKeyDown: (key) => this.input.isDown(key),
|
||||||
|
log: (message) => {
|
||||||
|
world.log.push(message);
|
||||||
|
console.log(`[behavior ${entity.name}]`, message);
|
||||||
|
},
|
||||||
|
destroy: (target: EntityHandle) => {
|
||||||
|
const found = world.entities.find((e) => e === target || e.id === target.id);
|
||||||
|
if (found) world.remove(found);
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
world.stopRequested = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private traceListener(trace: Trace, world: World): TraceListener {
|
||||||
|
return {
|
||||||
|
onEdge: (id) => trace.edges.set(id, world.time),
|
||||||
|
onNode: (id) => trace.nodes.set(id, world.time),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */
|
/**
|
||||||
|
* Keyboard state tracker for play mode. Records which keys are held and which went down /
|
||||||
|
* up since the last `endFrame()`, so systems can react to edges as well as to held keys.
|
||||||
|
*/
|
||||||
export class Input {
|
export class Input {
|
||||||
private readonly down = new Set<string>();
|
private readonly down = new Set<string>();
|
||||||
|
private readonly pressed = new Set<string>();
|
||||||
|
private readonly released = new Set<string>();
|
||||||
private readonly onKeyDown = (e: KeyboardEvent) => {
|
private readonly onKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
||||||
|
if (!this.down.has(e.key)) this.pressed.add(e.key); // ignore auto-repeat
|
||||||
this.down.add(e.key);
|
this.down.add(e.key);
|
||||||
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
|
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
|
||||||
};
|
};
|
||||||
private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key);
|
private readonly onKeyUp = (e: KeyboardEvent) => {
|
||||||
private readonly onBlur = () => this.down.clear();
|
if (this.down.delete(e.key)) this.released.add(e.key);
|
||||||
|
};
|
||||||
|
private readonly onBlur = () => {
|
||||||
|
for (const key of this.down) this.released.add(key);
|
||||||
|
this.down.clear();
|
||||||
|
};
|
||||||
|
|
||||||
attach(): void {
|
attach(): void {
|
||||||
window.addEventListener('keydown', this.onKeyDown);
|
window.addEventListener('keydown', this.onKeyDown);
|
||||||
@@ -20,12 +31,34 @@ export class Input {
|
|||||||
window.removeEventListener('keyup', this.onKeyUp);
|
window.removeEventListener('keyup', this.onKeyUp);
|
||||||
window.removeEventListener('blur', this.onBlur);
|
window.removeEventListener('blur', this.onBlur);
|
||||||
this.down.clear();
|
this.down.clear();
|
||||||
|
this.pressed.clear();
|
||||||
|
this.released.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
isDown(key: string): boolean {
|
isDown(key: string): boolean {
|
||||||
return this.down.has(key);
|
return this.down.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keys that went down since the last frame. */
|
||||||
|
get justPressed(): ReadonlySet<string> {
|
||||||
|
return this.pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys that went up since the last frame. */
|
||||||
|
get justReleased(): ReadonlySet<string> {
|
||||||
|
return this.released;
|
||||||
|
}
|
||||||
|
|
||||||
|
get heldKeys(): ReadonlySet<string> {
|
||||||
|
return this.down;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the per-frame edge sets; call once after all systems consumed them. */
|
||||||
|
endFrame(): void {
|
||||||
|
this.pressed.clear();
|
||||||
|
this.released.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** -1, 0 or 1 for a pair of keys. */
|
/** -1, 0 or 1 for a pair of keys. */
|
||||||
axis(negative: string, positive: string): number {
|
axis(negative: string, positive: string): number {
|
||||||
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);
|
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ export const Theme = {
|
|||||||
viewport: 0x121316,
|
viewport: 0x121316,
|
||||||
grid: 0x1f2126,
|
grid: 0x1f2126,
|
||||||
gizmo: 0x4c8dff,
|
gizmo: 0x4c8dff,
|
||||||
|
/** Behavior symbols and owner links. */
|
||||||
|
behavior: 0xb066ff,
|
||||||
|
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
||||||
|
graphBackground: 0x15171b,
|
||||||
|
node: 0x262a32,
|
||||||
|
nodeBorder: 0x3b404b,
|
||||||
|
nodeSelected: 0x4c8dff,
|
||||||
|
categoryEvent: 0xc0392b,
|
||||||
|
categoryFlow: 0x7f8c8d,
|
||||||
|
categoryData: 0x2e8b57,
|
||||||
|
categoryVariable: 0xb8860b,
|
||||||
|
categoryAction: 0x2f6fd6,
|
||||||
|
portExec: 0xffffff,
|
||||||
|
portNumber: 0x6fcf97,
|
||||||
|
portBoolean: 0xe57373,
|
||||||
|
portString: 0xe6a8ff,
|
||||||
|
portObject: 0x56ccf2,
|
||||||
|
portAny: 0xbdbdbd,
|
||||||
|
hot: 0xffc857,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class ListView extends Widget {
|
|||||||
onSelect?: (id: string | null) => void;
|
onSelect?: (id: string | null) => void;
|
||||||
onActivate?: (id: string) => void;
|
onActivate?: (id: string) => void;
|
||||||
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
|
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
|
||||||
rowHeight = Theme.rowHeight;
|
rowHeight: number = Theme.rowHeight;
|
||||||
emptyText = 'Nothing here yet';
|
emptyText = 'Nothing here yet';
|
||||||
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
|
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||||
import type { AssetType } from '../core/model/AssetTypes';
|
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||||
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
|
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
|
||||||
import { findAsset } from '../core/model/Project';
|
import { findAsset } from '../core/model/Project';
|
||||||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||||
@@ -19,8 +19,13 @@ interface DrawItem {
|
|||||||
/** Draw order (see `Position.z`). */
|
/** Draw order (see `Position.z`). */
|
||||||
z: number;
|
z: number;
|
||||||
props: PropertyBag;
|
props: PropertyBag;
|
||||||
|
/** Id of the owner item (behaviors), empty when none. */
|
||||||
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** World-unit size of the behavior symbol (a diamond). */
|
||||||
|
const BEHAVIOR_SYMBOL = 28;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
|
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
|
||||||
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
|
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
|
||||||
@@ -57,6 +62,8 @@ export class Viewport extends Widget {
|
|||||||
* hide them, no matter what z-order the scene ends up with.
|
* hide them, no matter what z-order the scene ends up with.
|
||||||
*/
|
*/
|
||||||
private readonly overlay = new Container();
|
private readonly overlay = new Container();
|
||||||
|
/** Dashed owner links (behavior → owner), drawn in world units beneath the symbols. */
|
||||||
|
private readonly links = new Graphics();
|
||||||
private readonly contentMask = new Graphics();
|
private readonly contentMask = new Graphics();
|
||||||
private readonly gizmos = new Graphics();
|
private readonly gizmos = new Graphics();
|
||||||
private readonly nodes = new Map<string, Graphics>();
|
private readonly nodes = new Map<string, Graphics>();
|
||||||
@@ -70,7 +77,9 @@ export class Viewport extends Widget {
|
|||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.world.addChild(this.objects, this.overlay);
|
this.world.addChild(this.objects, this.links, this.overlay);
|
||||||
|
// Decorations must not swallow hits meant for the objects beneath them.
|
||||||
|
this.links.eventMode = 'none';
|
||||||
this.content.addChild(this.world, this.gizmos);
|
this.content.addChild(this.world, this.gizmos);
|
||||||
this.addChild(this.bg, this.grid, this.content, this.contentMask);
|
this.addChild(this.bg, this.grid, this.content, this.contentMask);
|
||||||
this.content.mask = this.contentMask;
|
this.content.mask = this.contentMask;
|
||||||
@@ -204,9 +213,10 @@ export class Viewport extends Widget {
|
|||||||
|
|
||||||
private collectItems(): { items: DrawItem[]; background: number } {
|
private collectItems(): { items: DrawItem[]; background: number } {
|
||||||
if (this.playWorld) {
|
if (this.playWorld) {
|
||||||
|
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
|
||||||
return {
|
return {
|
||||||
background: this.playWorld.backgroundColor,
|
background: this.playWorld.backgroundColor,
|
||||||
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props })),
|
items: this.playWorld.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props, ownerId: '' })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const scene = this.state.activeScene;
|
const scene = this.state.activeScene;
|
||||||
@@ -215,7 +225,7 @@ export class Viewport extends Widget {
|
|||||||
for (const object of scene.objects) {
|
for (const object of scene.objects) {
|
||||||
const asset = findAsset(this.state.project, object.assetId);
|
const asset = findAsset(this.state.project, object.assetId);
|
||||||
if (!asset) continue;
|
if (!asset) continue;
|
||||||
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object) });
|
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object), ownerId: object.ownerId });
|
||||||
}
|
}
|
||||||
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
|
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
|
||||||
}
|
}
|
||||||
@@ -223,6 +233,7 @@ export class Viewport extends Widget {
|
|||||||
/** Size of an item's footprint in world units (sprite box or camera frame). */
|
/** Size of an item's footprint in world units (sprite box or camera frame). */
|
||||||
private itemSize(item: DrawItem): { w: number; h: number } {
|
private itemSize(item: DrawItem): { w: number; h: number } {
|
||||||
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
|
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
|
||||||
|
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||||||
return cameraFrame(item.props);
|
return cameraFrame(item.props);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +287,16 @@ export class Viewport extends Widget {
|
|||||||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted });
|
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted });
|
||||||
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
|
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
|
||||||
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
|
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
|
||||||
|
} else if (item.type === 'behavior') {
|
||||||
|
// A diamond with a small "circuit" mark: the logic symbol.
|
||||||
|
const r = BEHAVIOR_SYMBOL / 2;
|
||||||
|
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
|
||||||
|
node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
|
||||||
|
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
|
||||||
|
node.circle(6, 3, 2).fill(Theme.colors.accentText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.drawOwnerLinks(items, editing, view.scale);
|
||||||
for (const [id, node] of this.nodes) {
|
for (const [id, node] of this.nodes) {
|
||||||
if (!seen.has(id)) {
|
if (!seen.has(id)) {
|
||||||
node.destroy();
|
node.destroy();
|
||||||
@@ -307,25 +326,42 @@ export class Viewport extends Widget {
|
|||||||
|
|
||||||
/** Items drawn as editor symbols rather than as scene content. */
|
/** Items drawn as editor symbols rather than as scene content. */
|
||||||
private isSymbol(item: DrawItem): boolean {
|
private isSymbol(item: DrawItem): boolean {
|
||||||
return item.type === 'camera';
|
return isSymbolType(item.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashed line from every behavior symbol to its owner (edit mode only). */
|
||||||
|
private drawOwnerLinks(items: DrawItem[], editing: boolean, scale: number): void {
|
||||||
|
this.links.clear();
|
||||||
|
if (!editing) return;
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type !== 'behavior' || !item.ownerId) continue;
|
||||||
|
const owner = byId.get(item.ownerId);
|
||||||
|
if (!owner) continue;
|
||||||
|
dashedLine(this.links, item.x, item.y, owner.x, owner.y, 8 / scale, 6 / scale);
|
||||||
|
this.links.stroke({ width: 1.5 / scale, color: Theme.colors.behavior, alpha: 0.8 });
|
||||||
|
this.links.circle(owner.x, owner.y, 3 / scale).fill(Theme.colors.behavior);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
|
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
|
||||||
let label = this.labels.get(item.id);
|
let label = this.labels.get(item.id);
|
||||||
if (!show || item.type !== 'camera') {
|
if (!show || !this.isSymbol(item)) {
|
||||||
if (label) label.visible = false;
|
if (label) label.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!label) {
|
if (!label) {
|
||||||
label = new Text({ text: '', style: textStyle('small') });
|
label = new Text({ text: '', style: textStyle('small') });
|
||||||
|
label.eventMode = 'none';
|
||||||
this.labels.set(item.id, label);
|
this.labels.set(item.id, label);
|
||||||
this.overlay.addChild(label);
|
this.overlay.addChild(label);
|
||||||
}
|
}
|
||||||
const frame = cameraFrame(item.props);
|
const size = this.itemSize(item);
|
||||||
label.visible = true;
|
label.visible = true;
|
||||||
label.text = item.name;
|
label.text = item.name;
|
||||||
label.scale.set(1 / scale);
|
label.scale.set(1 / scale);
|
||||||
label.position.set(item.x - frame.w / 2, item.y - frame.h / 2 - 16 / scale);
|
if (item.type === 'camera') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
|
||||||
|
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawGrid(editing: boolean): void {
|
private drawGrid(editing: boolean): void {
|
||||||
@@ -369,3 +405,15 @@ export class Viewport extends Widget {
|
|||||||
this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
|
this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
|
||||||
|
export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
|
||||||
|
const length = Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
if (length === 0) return;
|
||||||
|
const ux = (x2 - x1) / length;
|
||||||
|
const uy = (y2 - y1) / length;
|
||||||
|
for (let d = 0; d < length; d += dash + gap) {
|
||||||
|
const end = Math.min(length, d + dash);
|
||||||
|
g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||||
|
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||||
|
import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||||
|
import { PlaceAssetCommand, SetOwnerCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
|
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
|
import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
|
||||||
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||||
|
import { createEmptyProject } from '../../src/core/model/Project';
|
||||||
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||||
|
|
||||||
|
function entity(name: string, x = 0): EntityHandle {
|
||||||
|
return { id: name, name, x, y: 0, z: 0, props: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: string[]; stopped: boolean } {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const ctx = {
|
||||||
|
self: entity('self'),
|
||||||
|
owner: entity('owner'),
|
||||||
|
variables: { speed: 100 },
|
||||||
|
time: 0,
|
||||||
|
dt: 0.5,
|
||||||
|
logs,
|
||||||
|
stopped: false,
|
||||||
|
findByName: () => null,
|
||||||
|
isKeyDown: () => false,
|
||||||
|
log: (m: string) => logs.push(m),
|
||||||
|
destroy: () => {},
|
||||||
|
stop: () => { ctx.stopped = true; },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */
|
||||||
|
function buildMoverProject() {
|
||||||
|
const project = createEmptyProject('p', 'P');
|
||||||
|
const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
|
||||||
|
project.assets.push(behavior);
|
||||||
|
const stack = new CommandStack(project);
|
||||||
|
stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
|
||||||
|
const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' });
|
||||||
|
const owner = new AddNodeCommand('mover', 'Owner', 0, 100);
|
||||||
|
const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' });
|
||||||
|
const mul = new AddNodeCommand('mover', 'Multiply', 200, 150);
|
||||||
|
const move = new AddNodeCommand('mover', 'Move', 400, 0);
|
||||||
|
for (const c of [held, owner, speed, mul, move]) stack.execute(c);
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' }));
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' }));
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' }));
|
||||||
|
return { project, behavior, stack, nodes: { held, owner, speed, mul, move } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('node registry', () => {
|
||||||
|
it('registers the built-in node set with well-formed ports', () => {
|
||||||
|
const defs = allNodeDefs();
|
||||||
|
expect(defs.length).toBeGreaterThan(20);
|
||||||
|
for (const def of defs) {
|
||||||
|
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||||||
|
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
|
||||||
|
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
||||||
|
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
|
||||||
|
else expect(def.run).toBeTypeOf('function');
|
||||||
|
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('type-checks port connections', () => {
|
||||||
|
expect(portsCompatible('exec', 'exec')).toBe(true);
|
||||||
|
expect(portsCompatible('exec', 'number')).toBe(false);
|
||||||
|
expect(portsCompatible('number', 'any')).toBe(true);
|
||||||
|
expect(portsCompatible('any', 'boolean')).toBe(true);
|
||||||
|
expect(portsCompatible('number', 'boolean')).toBe(false);
|
||||||
|
expect(portsCompatible('object', 'object')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GraphInterpreter', () => {
|
||||||
|
it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => {
|
||||||
|
const { behavior } = buildMoverProject();
|
||||||
|
const interpreter = new GraphInterpreter(behavior.graph);
|
||||||
|
const ctx = context({ variables: { speed: 100 } });
|
||||||
|
const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
|
||||||
|
expect(fired).toBe(1);
|
||||||
|
expect(ctx.owner!.x).toBe(50);
|
||||||
|
expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0);
|
||||||
|
expect(ctx.owner!.x).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses inline values for unconnected inputs', () => {
|
||||||
|
const project = createEmptyProject('p', 'P');
|
||||||
|
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
|
||||||
|
project.assets.push(behavior);
|
||||||
|
const stack = new CommandStack(project);
|
||||||
|
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
|
||||||
|
const self = new AddNodeCommand('b', 'Self', 0, 0);
|
||||||
|
const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 });
|
||||||
|
for (const c of [start, self, move]) stack.execute(c);
|
||||||
|
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
|
||||||
|
stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
|
||||||
|
const ctx = context();
|
||||||
|
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||||
|
expect(ctx.self.x).toBe(7);
|
||||||
|
expect(ctx.self.y).toBe(-3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows Branch, writes variables and logs', () => {
|
||||||
|
const project = createEmptyProject('p', 'P');
|
||||||
|
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
|
||||||
|
project.assets.push(behavior);
|
||||||
|
const stack = new CommandStack(project);
|
||||||
|
stack.execute(new AddVariableCommand('b', 'count', 'number'));
|
||||||
|
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
|
||||||
|
const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' });
|
||||||
|
const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 });
|
||||||
|
const branch = new AddNodeCommand('b', 'Branch', 0, 0);
|
||||||
|
const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 });
|
||||||
|
const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' });
|
||||||
|
const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' });
|
||||||
|
const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' });
|
||||||
|
for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c);
|
||||||
|
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
|
||||||
|
stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
|
||||||
|
connect(start, 'exec', branch, 'exec');
|
||||||
|
connect(get, 'value', cmp, 'a');
|
||||||
|
connect(cmp, 'result', branch, 'condition');
|
||||||
|
connect(branch, 'then', set, 'exec');
|
||||||
|
connect(get, 'value', add, 'a');
|
||||||
|
connect(add, 'result', set, 'value');
|
||||||
|
connect(set, 'exec', logThen, 'exec');
|
||||||
|
connect(branch, 'else', logElse, 'exec');
|
||||||
|
|
||||||
|
const interpreter = new GraphInterpreter(behavior.graph);
|
||||||
|
const ctx = context({ variables: { count: 0 } });
|
||||||
|
for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx);
|
||||||
|
expect(ctx.variables.count).toBe(3);
|
||||||
|
expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports traversed edges and nodes to the trace listener', () => {
|
||||||
|
const { behavior, nodes } = buildMoverProject();
|
||||||
|
const interpreter = new GraphInterpreter(behavior.graph);
|
||||||
|
const edges: string[] = [];
|
||||||
|
const visited: string[] = [];
|
||||||
|
interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
|
||||||
|
interpreter.fire('OnKeyHeld', context(), { dt: 1 });
|
||||||
|
expect(edges).toHaveLength(1);
|
||||||
|
expect(visited).toContain(nodes.mul.node.id);
|
||||||
|
expect(visited).toContain(nodes.move.node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops a runaway flow instead of hanging', () => {
|
||||||
|
const project = createEmptyProject('p', 'P');
|
||||||
|
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
|
||||||
|
project.assets.push(behavior);
|
||||||
|
const stack = new CommandStack(project);
|
||||||
|
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
|
||||||
|
const a = new AddNodeCommand('b', 'Sequence', 0, 0);
|
||||||
|
const b = new AddNodeCommand('b', 'Sequence', 0, 0);
|
||||||
|
for (const c of [start, a, b]) stack.execute(c);
|
||||||
|
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' }));
|
||||||
|
stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' }));
|
||||||
|
stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' }));
|
||||||
|
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('graph commands', () => {
|
||||||
|
it('rejects incompatible, self and duplicate connections', () => {
|
||||||
|
const { stack, nodes } = buildMoverProject();
|
||||||
|
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/);
|
||||||
|
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/);
|
||||||
|
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/);
|
||||||
|
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the edge of a data input and restores it on undo', () => {
|
||||||
|
const { behavior, stack, nodes } = buildMoverProject();
|
||||||
|
const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 });
|
||||||
|
stack.execute(literal);
|
||||||
|
const before = behavior.graph.edges.length;
|
||||||
|
stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' }));
|
||||||
|
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
|
||||||
|
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id);
|
||||||
|
stack.undo();
|
||||||
|
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removing a node drops its edges; undo restores both', () => {
|
||||||
|
const { behavior, stack, nodes } = buildMoverProject();
|
||||||
|
const edgesBefore = behavior.graph.edges.length;
|
||||||
|
stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id));
|
||||||
|
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false);
|
||||||
|
expect(behavior.graph.edges.length).toBe(edgesBefore - 3);
|
||||||
|
stack.undo();
|
||||||
|
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true);
|
||||||
|
expect(behavior.graph.edges.length).toBe(edgesBefore);
|
||||||
|
stack.execute(new DisconnectCommand('mover', behavior.graph.edges[0]!.id));
|
||||||
|
expect(behavior.graph.edges.length).toBe(edgesBefore - 1);
|
||||||
|
stack.undo();
|
||||||
|
expect(behavior.graph.edges.length).toBe(edgesBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces declared params and keeps inline input values', () => {
|
||||||
|
const { behavior, stack, nodes } = buildMoverProject();
|
||||||
|
stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft'));
|
||||||
|
stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4));
|
||||||
|
const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!;
|
||||||
|
const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!;
|
||||||
|
expect(held.params.key).toBe('ArrowLeft');
|
||||||
|
expect(move.params.dy).toBe(4);
|
||||||
|
stack.undo();
|
||||||
|
expect(move.params.dy).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('variables', () => {
|
||||||
|
it('adds, renames (including graph references and overrides) and removes variables', () => {
|
||||||
|
const { project, behavior, stack, nodes } = buildMoverProject();
|
||||||
|
const scene = buildAsset('scene', 'S', 's');
|
||||||
|
project.assets.push(scene);
|
||||||
|
const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 });
|
||||||
|
stack.execute(place);
|
||||||
|
const object = (scene as { objects: { id: string; overrides: Record<string, unknown> }[] }).objects[0]!;
|
||||||
|
object.overrides.speed = 5;
|
||||||
|
|
||||||
|
stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
|
||||||
|
expect(behavior.variables[0]!.key).toBe('velocity');
|
||||||
|
expect(behavior.props).toEqual({ velocity: 0 });
|
||||||
|
expect(object.overrides).toEqual({ velocity: 5 });
|
||||||
|
expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity');
|
||||||
|
stack.undo();
|
||||||
|
expect(behavior.variables[0]!.key).toBe('speed');
|
||||||
|
expect(object.overrides).toEqual({ speed: 5 });
|
||||||
|
|
||||||
|
expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/);
|
||||||
|
expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/);
|
||||||
|
|
||||||
|
stack.execute(new RemoveVariableCommand('mover', 'speed'));
|
||||||
|
expect(behavior.variables).toHaveLength(0);
|
||||||
|
expect(object.overrides).toEqual({});
|
||||||
|
stack.undo();
|
||||||
|
expect(behavior.variables).toHaveLength(1);
|
||||||
|
expect(object.overrides).toEqual({ speed: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner references are validated and cleared when the owner disappears', () => {
|
||||||
|
const project = createEmptyProject('p', 'P');
|
||||||
|
const scene = buildAsset('scene', 'S', 's');
|
||||||
|
const sprite = buildAsset('sprite', 'Box', 'box');
|
||||||
|
const behavior = buildAsset('behavior', 'B', 'b');
|
||||||
|
project.assets.push(scene, sprite, behavior);
|
||||||
|
const stack = new CommandStack(project);
|
||||||
|
const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 });
|
||||||
|
const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
|
||||||
|
stack.execute(placeBox);
|
||||||
|
stack.execute(placeB);
|
||||||
|
expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/);
|
||||||
|
stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId));
|
||||||
|
const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects;
|
||||||
|
expect(objects()[1]!.ownerId).toBe(placeBox.objectId);
|
||||||
|
// Round trip keeps the owner; a dangling owner is dropped by the loader.
|
||||||
|
const loaded = deserializeProject(serializeProject(project));
|
||||||
|
expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId);
|
||||||
|
const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] };
|
||||||
|
raw.assets[0]!.objects![1]!.ownerId = 'ghost';
|
||||||
|
expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('behavior serialization', () => {
|
||||||
|
it('round-trips variables and graph, dropping unknown node types and dangling edges', () => {
|
||||||
|
const { project, behavior } = buildMoverProject();
|
||||||
|
const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
|
||||||
|
expect(loaded.variables).toEqual(behavior.variables);
|
||||||
|
expect(loaded.graph).toEqual(behavior.graph);
|
||||||
|
expect(loaded.props).toEqual({ speed: 0 });
|
||||||
|
|
||||||
|
const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] };
|
||||||
|
raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
|
||||||
|
raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
|
||||||
|
const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset;
|
||||||
|
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
|
||||||
|
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@ describe('serialize', () => {
|
|||||||
name: '',
|
name: '',
|
||||||
position: { x: 5, y: 0, z: 0 },
|
position: { x: 5, y: 0, z: 0 },
|
||||||
overrides: { color: 0x00ff00, width: 1 },
|
overrides: { color: 0x00ff00, width: 1 },
|
||||||
|
ownerId: '',
|
||||||
});
|
});
|
||||||
expect(project.assets[1]!.name).toBe('sprite');
|
expect(project.assets[1]!.name).toBe('sprite');
|
||||||
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
|
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
|
||||||
|
|||||||
+103
-6
@@ -199,7 +199,8 @@ try {
|
|||||||
await shot('07-undo-redo');
|
await shot('07-undo-redo');
|
||||||
|
|
||||||
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
|
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
|
||||||
// origin), arrow keys do nothing, Esc stops without touching the model.
|
// origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate:
|
||||||
|
// holding ArrowRight moves it in the world (speed × dt), Esc stops without touching the model.
|
||||||
await clickText('Renamed crate');
|
await clickText('Renamed crate');
|
||||||
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||||
await clickText('▶ Play');
|
await clickText('▶ Play');
|
||||||
@@ -210,15 +211,24 @@ try {
|
|||||||
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
|
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
|
||||||
await shot('08-playing');
|
await shot('08-playing');
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||||
await sleep(300);
|
await sleep(400);
|
||||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||||
|
const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
|
||||||
|
const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1');
|
||||||
|
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||||
|
await sleep(250);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||||
|
const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1');
|
||||||
|
check('inline dx (−3 per frame) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
|
||||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
s = JSON.parse(await stateJson());
|
s = JSON.parse(await stateJson());
|
||||||
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
||||||
await shot('09-stopped');
|
await shot('09-stopped');
|
||||||
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||||
check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`);
|
const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
|
||||||
|
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
|
||||||
|
|
||||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||||
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
|
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
|
||||||
@@ -234,7 +244,7 @@ try {
|
|||||||
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
|
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
|
||||||
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
|
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 2);
|
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
|
||||||
await clickText('▶ Play');
|
await clickText('▶ Play');
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||||
@@ -246,7 +256,7 @@ try {
|
|||||||
await sleep(300);
|
await sleep(300);
|
||||||
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
|
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 4);
|
||||||
|
|
||||||
// 8. Context menu on the Crate asset → menu shows "Place in scene".
|
// 8. Context menu on the Crate asset → menu shows "Place in scene".
|
||||||
await clickText('Crate', 'right');
|
await clickText('Crate', 'right');
|
||||||
@@ -257,7 +267,7 @@ try {
|
|||||||
await clickText('Place in scene');
|
await clickText('Place in scene');
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
const count = await evaluate('__gm.state.activeScene.objects.length');
|
const count = await evaluate('__gm.state.activeScene.objects.length');
|
||||||
check('placing from the menu adds an object', count === 4, `objects=${count}`);
|
check('placing from the menu adds an object', count === 5, `objects=${count}`);
|
||||||
|
|
||||||
// 9. Create a new asset through the + menu.
|
// 9. Create a new asset through the + menu.
|
||||||
await clickText('+');
|
await clickText('+');
|
||||||
@@ -330,6 +340,93 @@ try {
|
|||||||
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
|
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
|
||||||
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
||||||
|
|
||||||
|
// 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||||
|
await clickText('Mover');
|
||||||
|
await evaluate(`__gm.showTab('graph')`);
|
||||||
|
await sleep(250);
|
||||||
|
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
|
||||||
|
await shot('16-graph');
|
||||||
|
const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse);
|
||||||
|
const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse);
|
||||||
|
|
||||||
|
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||||
|
const mulBefore = await nodeOf('n-mul');
|
||||||
|
const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-mul'))`));
|
||||||
|
await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
|
||||||
|
s = JSON.parse(await stateJson());
|
||||||
|
const mulAfter = await nodeOf('n-mul');
|
||||||
|
check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-mul' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`);
|
||||||
|
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Multiply')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
|
||||||
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
|
await sleep(150);
|
||||||
|
const mulUndone = await nodeOf('n-mul');
|
||||||
|
check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`);
|
||||||
|
|
||||||
|
// 13b. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||||||
|
const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`));
|
||||||
|
const before13 = await graphCounts();
|
||||||
|
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||||
|
await sleep(250);
|
||||||
|
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||||
|
await typeText('log');
|
||||||
|
await sleep(100);
|
||||||
|
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
|
||||||
|
await sleep(250);
|
||||||
|
const after13 = await graphCounts();
|
||||||
|
s = JSON.parse(await stateJson());
|
||||||
|
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
|
||||||
|
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
|
||||||
|
await shot('17-node-added');
|
||||||
|
|
||||||
|
// 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input.
|
||||||
|
const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`));
|
||||||
|
const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`));
|
||||||
|
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
|
||||||
|
const connected = await graphCounts();
|
||||||
|
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
|
||||||
|
// An exec output must not connect to a data input: dragging onto "message" does nothing.
|
||||||
|
const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`));
|
||||||
|
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
|
||||||
|
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
|
||||||
|
await shot('18-connected');
|
||||||
|
|
||||||
|
// 13d. Delete removes the selected node together with its edge; undo brings both back.
|
||||||
|
await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`);
|
||||||
|
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
|
||||||
|
await sleep(150);
|
||||||
|
const deleted = await graphCounts();
|
||||||
|
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
|
||||||
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
|
await sleep(150);
|
||||||
|
const restored = await graphCounts();
|
||||||
|
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
|
||||||
|
|
||||||
|
// 13e. Live highlight: play with the graph open, hold ArrowRight → the exec edge is hot.
|
||||||
|
await clickText('▶ Play');
|
||||||
|
await sleep(200);
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||||
|
await sleep(300);
|
||||||
|
await shot('19-graph-live');
|
||||||
|
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||||
|
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
// 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
|
||||||
|
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
|
||||||
|
await sleep(150);
|
||||||
|
await clickText('+ Variable ▾');
|
||||||
|
await sleep(150);
|
||||||
|
await clickText('Number');
|
||||||
|
await sleep(200);
|
||||||
|
const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
|
||||||
|
check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(','));
|
||||||
|
await shot('20-variable-added');
|
||||||
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
|
await sleep(150);
|
||||||
|
const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
|
||||||
|
check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(','));
|
||||||
|
await evaluate(`__gm.showTab('scene')`);
|
||||||
|
|
||||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||||
ws.close();
|
ws.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+37
-3
@@ -10,6 +10,31 @@ if (!projectId) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Mover" behavior: ArrowRight moves the owner by speed × dt per frame (data flow through
|
||||||
|
* a variable and a Multiply node), ArrowLeft moves it by an inline −3 per frame.
|
||||||
|
*/
|
||||||
|
const moverGraph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'n-right', type: 'OnKeyHeld', x: 0, y: 0, params: { key: 'ArrowRight' } },
|
||||||
|
{ id: 'n-owner', type: 'Owner', x: 0, y: 140, params: {} },
|
||||||
|
{ id: 'n-speed', type: 'GetVariable', x: 0, y: 220, params: { variable: 'speed' } },
|
||||||
|
{ id: 'n-mul', type: 'Multiply', x: 220, y: 180, params: {} },
|
||||||
|
{ id: 'n-move', type: 'Move', x: 440, y: 0, params: { dy: 0 } },
|
||||||
|
{ id: 'n-left', type: 'OnKeyHeld', x: 0, y: 340, params: { key: 'ArrowLeft' } },
|
||||||
|
{ id: 'n-move2', type: 'Move', x: 440, y: 340, params: { dx: -3, dy: 0 } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e1', from: { node: 'n-right', port: 'exec' }, to: { node: 'n-move', port: 'exec' } },
|
||||||
|
{ id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } },
|
||||||
|
{ id: 'e3', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-mul', port: 'a' } },
|
||||||
|
{ id: 'e4', from: { node: 'n-right', port: 'dt' }, to: { node: 'n-mul', port: 'b' } },
|
||||||
|
{ id: 'e5', from: { node: 'n-mul', port: 'result' }, to: { node: 'n-move', port: 'dx' } },
|
||||||
|
{ id: 'e6', from: { node: 'n-left', port: 'exec' }, to: { node: 'n-move2', port: 'exec' } },
|
||||||
|
{ id: 'e7', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move2', port: 'object' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const project = {
|
const project = {
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
id: projectId,
|
id: projectId,
|
||||||
@@ -22,13 +47,22 @@ const project = {
|
|||||||
name: 'Level 1',
|
name: 'Level 1',
|
||||||
props: { backgroundColor: 0x22252c },
|
props: { backgroundColor: 0x22252c },
|
||||||
objects: [
|
objects: [
|
||||||
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {} },
|
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, ownerId: '' },
|
||||||
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff } },
|
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, ownerId: '' },
|
||||||
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {} },
|
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, ownerId: '' },
|
||||||
|
{ id: 'o4', assetId: 'mover', name: '', position: { x: -150, y: 110, z: 0 }, overrides: {}, ownerId: 'o1' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
||||||
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
|
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||||
|
{
|
||||||
|
id: 'mover',
|
||||||
|
type: 'behavior',
|
||||||
|
name: 'Mover',
|
||||||
|
props: { speed: 200 },
|
||||||
|
variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }],
|
||||||
|
graph: moverGraph,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user