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:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+39 -7
View File
@@ -10,8 +10,9 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| Term | Meaning |
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) 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. |
| **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 + 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 (↺). |
| **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. |
@@ -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
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):
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
`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)
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/viewport/ scene renderer: model in edit mode, world in play mode
src/editor/ App composition, state, panels, inspector field factory
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
api/ PHP backend data/projects/ persisted projects
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),
`↑`/`↓` 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,
touch-optimised layout.
Textures on sprites, physics/collisions, spawning from graphs, multi-select, auth, editing
while playing, touch-optimised layout, graph comments/groups.
+22 -4
View File
@@ -1,9 +1,10 @@
import { createEmptyGraph } from '../graph/Graph';
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findUsages, type Project } from '../model/Project';
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 { type Command, CommandError } from './Command';
@@ -16,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'camera' };
case '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;
private removed?: { asset: Asset; index: number };
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {
@@ -62,9 +66,18 @@ export class DeleteAssetCommand implements Command {
object,
index: scene.objects.indexOf(object),
}));
this.clearedOwners = [];
for (const usage of this.removedUsages) {
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 };
project.assets.splice(index, 1);
@@ -83,6 +96,11 @@ export class DeleteAssetCommand implements Command {
const scene = findAsset(project, usage.sceneId);
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;
}
}
@@ -114,7 +132,7 @@ export class SetAssetPropertyCommand implements Command {
execute(project: Project): void {
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}`);
this.previous = asset.props[this.key];
asset.props[this.key] = coerceValue(def, this.value);
@@ -138,7 +156,7 @@ export class ResetAssetPropertyCommand implements Command {
execute(project: Project): void {
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}`);
this.previous = asset.props[this.key];
asset.props[this.key] = def.default;
+173
View File
@@ -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;
}
+40 -4
View File
@@ -2,7 +2,7 @@ import { isPlaceableType } from '../model/AssetTypes';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaFor } from '../properties/PropertySchema';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
import { requireAsset } from './assetCommands';
@@ -13,7 +13,7 @@ function requireScene(project: Project, sceneId: string) {
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);
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
return object;
@@ -42,6 +42,7 @@ export class PlaceAssetCommand implements Command {
export class RemoveSceneObjectCommand implements Command {
readonly label = 'Remove object';
private removed?: { object: SceneObject; index: number };
private clearedOwners: 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`);
this.removed = { object, index };
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 {
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 {
const object = requireObject(project, this.sceneId, this.objectId);
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}`);
this.previous = object.overrides[this.key];
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;
}
}
/** 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;
}
}
+132
View File
@@ -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);
}
+70
View File
@@ -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';
}
+112
View File
@@ -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);
}
}
+154
View File
@@ -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;
}
+6
View File
@@ -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';
+256
View File
@@ -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
View File
@@ -1,3 +1,5 @@
import type { Graph } from '../graph/Graph';
import type { PropertyDef } from '../properties/PropertySchema';
import type { AssetType } from './AssetTypes';
import type { PropertyBag } from './Properties';
import type { SceneObject } from './SceneObject';
@@ -24,8 +26,23 @@ export interface SceneAsset extends AssetBase {
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 {
return asset.type === 'scene';
}
export function isBehaviorAsset(asset: Asset): asset is BehaviorAsset {
return asset.type === 'behavior';
}
+10 -2
View File
@@ -1,5 +1,5 @@
/** All asset types known in phase 1. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite'] as const;
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior'] as const;
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 {
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 -1
View File
@@ -13,9 +13,21 @@ export interface SceneObject {
/** World position plus draw order (`z`). */
position: Position;
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. */
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: '',
};
}
+40 -2
View File
@@ -1,3 +1,4 @@
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
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: 'text'; default: string }
| { 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 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. */
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
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: '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. */
@@ -36,16 +47,38 @@ export const SCENE_OBJECT_SCHEMA: PropertySchema = [
{ 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 {
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 {
return defaultsOf(ASSET_SCHEMAS[type]);
}
export function defaultsOf(schema: PropertySchema): 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;
}
/** 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. */
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
switch (def.kind) {
@@ -63,6 +96,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
case 'boolean':
return Boolean(value);
case 'text':
case 'key':
return String(value);
case 'enum': {
const s = String(value);
return def.options.some((o) => o.value === s) ? s : def.default;
}
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import type { Asset } from '../model/Asset';
import type { PropertyBag, PropertyValue } from '../model/Properties';
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.
@@ -9,7 +9,7 @@ import { schemaFor } from './PropertySchema';
*/
export function resolveAssetProps(asset: Asset): PropertyBag {
const bag: PropertyBag = {};
for (const def of schemaFor(asset.type)) {
for (const def of schemaForAsset(asset)) {
const own = asset.props[def.key];
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 {
const bag = resolveAssetProps(asset);
for (const def of schemaFor(asset.type)) {
for (const def of schemaForAsset(asset)) {
const override = object.overrides[def.key];
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.
*/
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;
const own = asset.props[key];
return own !== undefined && own !== def.default;
+82 -15
View File
@@ -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 AssetType, isAssetType } from '../model/AssetTypes';
import { isAssetType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
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
@@ -14,22 +16,34 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const r = asRecord(raw);
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.
const types = new Map<string, AssetType>();
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
// 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) {
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.
const assets: Asset[] = [];
for (const a of rawAssets) {
const id = typeof a.id === 'string' ? a.id : '';
const type = types.get(id);
if (!type) continue;
const schema = schemas.get(id);
if (!schema || !isAssetType(a.type)) continue;
const type = a.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') {
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 {
assets.push({ id, type, name, props });
}
@@ -55,29 +69,82 @@ export function serializeProject(project: Project): unknown {
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[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const o = asRecord(item);
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
const type = types.get(o.assetId);
if (!type || type === 'scene') continue; // dangling reference or illegal scene-in-scene
const schema = schemas.get(o.assetId);
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
const p = asRecord(o.position);
objects.push({
id: o.id,
assetId: o.assetId,
name: typeof o.name === 'string' ? o.name : '',
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;
}
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 bag: PropertyBag = {};
for (const def of schemaFor(type)) {
for (const def of schema) {
const value = r[def.key];
if (value === undefined || value === null) {
if (!sparse) bag[def.key] = def.default;
+94 -9
View File
@@ -1,13 +1,17 @@
import { type Application, Container, Text } from 'pixi.js';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { listScenes } from '../core/model/Project';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
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 { 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 { attachAutosave } from './autosave';
import { GraphEditor } from './graph/GraphEditor';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Inspector } from './panels/Inspector';
@@ -16,12 +20,14 @@ import { EditorState } from './state';
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> {
const state = new EditorState();
const store: ProjectStore = new HttpProjectStore(API_ENDPOINT);
const editor = new Editor(app, root, state, store);
installDebugHook(app, state, editor.viewport);
installDebugHook(app, state, editor);
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
* 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 p = viewport.getGlobalPosition();
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();
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 {
readonly viewport: Viewport;
readonly graphEditor: GraphEditor;
tab: CentreTab = 'scene';
private readonly leftColumn: VStack;
private readonly inspector: Inspector;
private readonly leftSplitter = new Splitter('x');
private readonly rightSplitter = new Splitter('x');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabButtons: Record<CentreTab, Button>;
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
private leftWidth = 240;
private rightWidth = 300;
@@ -74,11 +106,30 @@ class Editor {
this.workspace = new HStack();
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.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
this.leftColumn.add(new HierarchyPanel(state), 1);
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.inspector.minWidth = this.rightWidth;
this.leftSplitter.onDrag = (delta) => {
@@ -93,7 +144,7 @@ class Editor {
};
this.workspace.add(this.leftColumn);
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.inspector);
root.add(this.workspace, 1);
@@ -103,6 +154,20 @@ class Editor {
this.installShortcuts();
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
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 ---------------------------------------------------------------
@@ -185,25 +250,44 @@ class Editor {
const scene = this.state.activeScene;
if (!scene) return;
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.input.attach();
this.behaviors = new BehaviorSystem(this.state.project, this.input);
this.world.addSystem(this.behaviors);
this.viewport.setWorld(this.world);
this.state.setMode('play');
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 {
this.world?.destroy();
const world = this.world;
this.world = null;
this.behaviors = null;
this.input.detach();
world?.destroy();
this.viewport.setWorld(null);
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 {
if (!this.world) return;
this.world.tick(Math.min(dt, 0.1));
this.input.endFrame();
if (this.world.stopRequested) {
this.stop();
return;
}
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 {
@@ -238,6 +322,7 @@ class Editor {
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = this.state.selection;
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') {
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.', [
+396
View File
@@ -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);
}
+55
View File
@@ -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);
}
}
+191
View File
@@ -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 + '…';
}
+112 -39
View File
@@ -1,6 +1,6 @@
import type { PropertyValue } from '../../core/model/Properties';
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 {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -20,6 +20,8 @@ export interface FieldRow {
setModified(modified: boolean): void;
}
export const FIELD_LABEL_WIDTH = 64;
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* 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' });
row.minHeight = Theme.rowHeight;
const label = new Label(def.label, { style: 'muted' });
label.minWidth = 64;
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
let setValue: (v: PropertyValue) => void;
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,
});
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;
}
}
const field = createField(def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
let reset: Button | null = null;
if (onReset) {
@@ -78,7 +47,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
return {
row,
setValue,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
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' : '');
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
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. */
export class AssetPanel extends Panel {
+3 -1
View File
@@ -43,10 +43,12 @@ export class HierarchyPanel extends Panel {
(scene?.objects ?? []).map((object) => {
const asset = findAsset(this.state.project, object.assetId);
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 {
id: object.id,
label: object.name || asset?.name || '?',
hint: asset?.type ?? '',
hint: owner ? `→ ${ownerName}` : asset?.type ?? '',
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
};
}),
+240 -36
View File
@@ -1,21 +1,31 @@
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetOwnerCommand,
} 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 { 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 { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
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
* 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 {
private readonly scroll = new ScrollContainer();
@@ -23,6 +33,8 @@ export class Inspector extends Panel {
private fields = new Map<string, FieldRow>();
private nameField: TextField | null = 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) {
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.fields = new Map();
this.nameField = null;
this.signature = this.currentSignature();
const s = this.state.selection;
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') {
this.buildAsset(s.assetId);
} else {
} else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
} else {
this.buildNode(s.assetId, s.nodeId);
}
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 {
const asset = findAsset(this.state.project, assetId);
if (!asset) return;
@@ -66,37 +105,87 @@ export class Inspector extends Panel {
});
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.
const values = resolveAssetProps(asset);
for (const def of schemaFor(asset.type)) {
let original: PropertyValue | undefined;
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)),
);
for (const def of schemaForAsset(asset)) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, 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 {
const scene = findScene(this.state.project, sceneId);
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 assetLabel = new Label('Asset', { style: 'muted' });
assetLabel.minWidth = 64;
assetLabel.minWidth = FIELD_LABEL_WIDTH;
assetRow.add(assetLabel);
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
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).
this.body.add(new Label('POSITION', { style: 'title' }));
for (const axis of ['x', 'y', 'z'] as const) {
@@ -143,10 +234,12 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
// Asset properties with overrides.
this.body.add(new Label('PROPERTIES', { style: 'title' }));
// Asset properties (or behavior variables) with overrides.
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);
for (const def of schemaFor(asset.type)) {
for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
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, …). */
private refreshValues(): void {
const s = this.state.selection;
if (!sameTarget(s, this.builtFor)) {
if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
this.rebuild();
return;
}
@@ -201,6 +388,17 @@ export class Inspector extends Panel {
}
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 asset = object && findAsset(this.state.project, object.assetId);
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('z')?.setValue(object.position.z);
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);
row?.setValue(values[def.key] ?? def.default);
row?.setModified(isOverridden(object, def.key));
@@ -221,11 +419,16 @@ export class Inspector extends Panel {
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 {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
label.minWidth = 64;
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
row.add(field, 1);
return row;
@@ -237,5 +440,6 @@ function sameTarget(a: Selection, b: Selection): boolean {
if (a.kind !== b.kind) return false;
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 === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+19
View File
@@ -6,6 +6,8 @@ import { createEmptyProject, findScene, type Project } from '../core/model/Proje
export type Selection =
| { kind: 'asset'; assetId: string }
| { kind: 'object'; sceneId: string; objectId: string }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
| null;
export type EditorMode = 'edit' | 'play';
@@ -129,6 +131,22 @@ export class EditorState {
const scene = findScene(this.project, s.sceneId);
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 -------------------------------------------------------------------
@@ -156,5 +174,6 @@ function sameSelection(a: Selection, b: Selection): boolean {
if (!a || !b || a.kind !== b.kind) return false;
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 === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+3
View File
@@ -17,5 +17,8 @@ export interface Entity {
y: number;
/** Draw order; higher is drawn on top. */
z: number;
/** For behaviors: the resolved variables (mutable state). For others: resolved properties. */
props: PropertyBag;
/** Scene object id of the owner (behaviors), empty when none. */
ownerObjectId: string;
}
+14
View File
@@ -10,6 +10,10 @@ import type { System } from './systems/System';
export class World {
readonly entities: Entity[] = [];
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 elapsed = 0;
@@ -36,6 +40,7 @@ export class World {
y: object.position.y,
z: object.position.z,
props: resolveSceneObjectProps(asset, object),
ownerObjectId: object.ownerId,
});
}
return world;
@@ -61,10 +66,19 @@ export class World {
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 {
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 {
for (const system of this.systems) system.stop?.(this);
this.systems.length = 0;
+128
View File
@@ -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),
};
}
}
+36 -3
View File
@@ -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 {
private readonly down = new Set<string>();
private readonly pressed = new Set<string>();
private readonly released = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
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);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key);
private readonly onBlur = () => this.down.clear();
private readonly onKeyUp = (e: KeyboardEvent) => {
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 {
window.addEventListener('keydown', this.onKeyDown);
@@ -20,12 +31,34 @@ export class Input {
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
this.down.clear();
this.pressed.clear();
this.released.clear();
}
isDown(key: string): boolean {
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. */
axis(negative: string, positive: string): number {
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);
+19
View File
@@ -30,6 +30,25 @@ export const Theme = {
viewport: 0x121316,
grid: 0x1f2126,
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;
+1 -1
View File
@@ -25,7 +25,7 @@ export class ListView extends Widget {
onSelect?: (id: string | null) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
rowHeight = Theme.rowHeight;
rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
+56 -8
View File
@@ -1,6 +1,6 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
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 { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
@@ -19,8 +19,13 @@ interface DrawItem {
/** Draw order (see `Position.z`). */
z: number;
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
* 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.
*/
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 gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
@@ -70,7 +77,9 @@ export class Viewport extends Widget {
constructor(private readonly state: EditorState) {
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.addChild(this.bg, this.grid, this.content, this.contentMask);
this.content.mask = this.contentMask;
@@ -204,9 +213,10 @@ export class Viewport extends Widget {
private collectItems(): { items: DrawItem[]; background: number } {
if (this.playWorld) {
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
return {
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;
@@ -215,7 +225,7 @@ export class Viewport extends Widget {
for (const object of scene.objects) {
const asset = findAsset(this.state.project, object.assetId);
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) };
}
@@ -223,6 +233,7 @@ export class Viewport extends Widget {
/** Size of an item's footprint in world units (sprite box or camera frame). */
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 === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
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(-12, -8, 24, 16).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) {
if (!seen.has(id)) {
node.destroy();
@@ -307,25 +326,42 @@ export class Viewport extends Widget {
/** Items drawn as editor symbols rather than as scene content. */
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 {
let label = this.labels.get(item.id);
if (!show || item.type !== 'camera') {
if (!show || !this.isSymbol(item)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
label.eventMode = 'none';
this.labels.set(item.id, label);
this.overlay.addChild(label);
}
const frame = cameraFrame(item.props);
const size = this.itemSize(item);
label.visible = true;
label.text = item.name;
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 {
@@ -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 };
}
}
/** 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);
}
}
+289
View File
@@ -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);
});
});
+1
View File
@@ -52,6 +52,7 @@ describe('serialize', () => {
name: '',
position: { x: 5, y: 0, z: 0 },
overrides: { color: 0x00ff00, width: 1 },
ownerId: '',
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
+103 -6
View File
@@ -199,7 +199,8 @@ try {
await shot('07-undo-redo');
// 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');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
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);
await shot('08-playing');
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 });
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 sleep(300);
s = JSON.parse(await stateJson());
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
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.
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 keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
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 sleep(300);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
@@ -246,7 +256,7 @@ try {
await sleep(300);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
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".
await clickText('Crate', 'right');
@@ -257,7 +267,7 @@ try {
await clickText('Place in scene');
await sleep(200);
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.
await clickText('+');
@@ -330,6 +340,93 @@ try {
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
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(' | '));
ws.close();
} catch (error) {
+37 -3
View File
@@ -10,6 +10,31 @@ if (!projectId) {
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 = {
formatVersion: 1,
id: projectId,
@@ -22,13 +47,22 @@ const project = {
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {} },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff } },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, 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 }, ownerId: '' },
{ 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: '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,
},
],
};