State machines: asset, tab, State branch and Set state nodes (#51)
A state machine is an asset whose graph holds one `State` node per state (no ports, name as title), edited in a new "State machine" tab that reuses the graph canvas: right-click places "State n", snap toggle shared with the Behavior tab via SnapToggle. `statemachine` is a value kind like texture (port, literal node, variable, struct field, tagged list item). Behaviors use it through `State branch` (one exec output per state) and `Set state` (one exec input per state; the interpreter now hands `run` the entry port). States live per behavior instance in NodeContext.states, with no initial state. Ports derive from the inline picker or a wired `State machine` literal; BehaviorLookup became GraphLookup with behavior(id) and machine(id). The Graph tab is labelled "Behavior" (key unchanged). Unit tests (state-machine, kinds, catalog, ports), e2e step 26 and README added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce42d16657
commit
e7d82bd1a9
60 files changed
+1269
-213
No files matched your search
@@ -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`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`) and typed properties. Assets have **no position**. |
|
||||
| **Texture** | An uploaded image. Scenes, textures and animations are the three types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See *Textures* below. |
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`, `statemachine`) and typed properties. Assets have **no position**. |
|
||||
| **Texture** | An uploaded image. Scenes, textures, animations and state machines are the four types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See *Textures* below. |
|
||||
| **State machine** | A named set of **states**, edited as nodes in its own tab. A behavior puts it into a state with `Set state` and branches on it with `State branch`; each behavior instance keeps its own current state. See *State machines* below. |
|
||||
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so a `Properties` node of type Text with `text` switched to Set changes it while playing. |
|
||||
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. From a graph, `Properties` with `enabled` switched to Set does it, and `Is enabled` asks (including the parents). |
|
||||
| **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 **parent** (another object of the scene). Scenes cannot be placed. |
|
||||
@@ -91,7 +92,7 @@ get:
|
||||
|
||||
## Behaviors (node graphs)
|
||||
|
||||
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
|
||||
A behavior asset holds a Blueprint-style graph edited in the **Behavior** tab of the centre
|
||||
area (the **Scene** tab shows the editable scene, the **Game** tab the running game). Ports come in two kinds: **exec** (white
|
||||
arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`,
|
||||
`object`, `list`, `struct`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls
|
||||
@@ -119,8 +120,8 @@ shown on the node and editable in the inspector.
|
||||
width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its
|
||||
type as a setting row (*Type: Sprite*, plus *Behavior: Game* under that type). Textures and
|
||||
scenes are no type here: they are not objects in a running game. A variable is a
|
||||
**number, a text, a yes/no, a colour, a texture, a list or a struct**; a list declares **nothing** about its items –
|
||||
each one is a number, a text, a yes/no, a colour, a texture or a struct and says so itself, so one list may
|
||||
**number, a text, a yes/no, a colour, a texture, a state machine, a list or a struct**; a list declares **nothing** about its items –
|
||||
each one is a number, a text, a yes/no, a colour, a texture, a state machine or a struct and says so itself, so one list may
|
||||
hold several kinds at once. A list is
|
||||
compared by its items, never by identity – otherwise every reload would look like an override.
|
||||
There is no list *of objects*: a list is stored in the project file and an object is a live
|
||||
@@ -139,16 +140,18 @@ shown on the node and editable in the inspector.
|
||||
value, every such change is an ordinary **value edit**: one command, one undo step, and no
|
||||
neighbouring item touched. A **struct item** carries its own shape the same way: its fields are
|
||||
renamed, retyped, added (`+ Field ▾`) and removed on its own rows, which grows that one item and
|
||||
not the list. Two kinds need saying, because JSON cannot tell them from the value beside them: a
|
||||
**colour** is a number and a **texture** is an id, which is a text. Such a *tagged* kind wears its
|
||||
not the list. Three kinds need saying, because JSON cannot tell them from the value beside them: a
|
||||
**colour** is a number, and a **texture** or a **state machine** is an id, which is a text. Such a *tagged* kind wears its
|
||||
name as the one key of a small object – `{"$color": 16711680}`, `{"$texture": "walk-up"}` (the
|
||||
picker a sprite's `texture` property has – a whole picture, one frame of a sheet, or an animation).
|
||||
picker a sprite's `texture` property has – a whole picture, one frame of a sheet, or an animation),
|
||||
`{"$statemachine": "doors"}`.
|
||||
A field name cannot start with `$` (`isValidFieldName`), so a tag can never collide with a struct
|
||||
item of one field. Either reads as what it wraps on a data
|
||||
edge (`Item at`), the graph having neither colours nor pictures of its own, and `Set item` leaves
|
||||
the slot the kind it was. A **texture is a kind wherever a kind is picked** – a variable, a struct
|
||||
field, a list item, an Any port of `Properties`, a field of `Pack` – and the `Texture` node hands
|
||||
one into the graph, as `Number` and `Text` do a number and a text. **Every kind is one row of one
|
||||
one into the graph, as `Number` and `Text` do a number and a text; a **state machine** is the same
|
||||
kind of kind, with the `State machine` node. **Every kind is one row of one
|
||||
table** (`core/properties/kinds.ts`): its word and mark, what it travels as on an edge, its empty
|
||||
value, how a value is read as it, whether it is tagged and where it may be picked. The kind lists,
|
||||
the `+ …` menus, the tags, the port fields and the literal nodes all follow from the row, so a new
|
||||
@@ -218,7 +221,7 @@ shown on the node and editable in the inspector.
|
||||
fields it declares: what arrives fills the ones it names, the rest stay put (`mergeStruct`), so
|
||||
one node can set part of a bundle without erasing it – "the same piece, one row lower" is a
|
||||
`Pack` of that one field. Depth is bounded by the *value*, not by convention: a list item is a primitive, a
|
||||
tagged colour or texture, or a struct of flat ones (`listItems.ts`), a struct field says what it holds
|
||||
tagged colour, texture or state machine, or a struct of flat ones (`listItems.ts`), a struct field says what it holds
|
||||
(`StructFieldKind`, primitives plus `list`), and the list a *field* holds takes flat items only.
|
||||
Forbidding just that one combination is what keeps the shapes finite:
|
||||
`list → struct → primitive` and `struct → list → primitive` are the two deepest a stored value
|
||||
@@ -329,7 +332,7 @@ shown on the node and editable in the inspector.
|
||||
all) leaves the tab on the behavior you were editing, because checking a sprite's position
|
||||
must not empty your work. Only a behavior that no longer exists is dropped, and then the tab
|
||||
falls back to another one – the picker is how you switch on purpose.
|
||||
- **Live view**: Play always switches to the Game tab; switch to Graph while playing and
|
||||
- **Live view**: Play always switches to the Game tab; switch to Behavior while playing and
|
||||
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
|
||||
this costs nothing extra. Each fire of an exec edge also sends a bead along it (0.3 s, at most
|
||||
one per 0.15 s and edge): a key press is a single bead, `On update` an even chain. Beads run
|
||||
@@ -559,13 +562,50 @@ frame without freezing the animation on frame one. The system ticks before the b
|
||||
with the sprite as `object`. Like collisions it is heard by the behaviors of the sprite's owner
|
||||
subtree – the sprite itself, or a sprite placed next to the behavior below the same object – and
|
||||
by a behavior without an owner for every sprite; its *Animation* setting narrows it to one.
|
||||
There is no node to restart an animation that is already running; that and a state machine are
|
||||
left for when a game needs them.
|
||||
There is no node to restart an animation that is already running; that is left for when a game
|
||||
needs it.
|
||||
|
||||
Deleting a texture counts the animations that play its frames besides the sprites wearing it;
|
||||
deleting an animation counts the sprites wearing it. Either leaves dangling references that draw
|
||||
the plain colour and come back on undo.
|
||||
|
||||
### State machines
|
||||
|
||||
A **state machine asset** is a named set of states and nothing more, yet: its graph holds one
|
||||
`State` node per state (a title and a *Name* setting, no ports) and no edges. Transitions,
|
||||
events like "on state entered" and a highlight of the current state while playing are later
|
||||
steps. The states are edited in the **State machine** tab, which is the graph canvas the Behavior
|
||||
tab uses – so placing, dragging, the header's **Snap** checkbox, renaming in the inspector,
|
||||
`Delete` and undo are the graph's.
|
||||
Selecting the asset opens the tab (a behavior, texture or container asset does not switch tabs –
|
||||
a machine has nothing to show anywhere else). Right-click on its canvas offers a `State` and
|
||||
nothing else, named by its number (`State 3` for the third); no behavior palette offers one.
|
||||
|
||||
**A behavior reads it through two nodes**, both with a `machine` input that is a picker while
|
||||
unconnected (or takes a `State machine` node, the literal every kind has):
|
||||
|
||||
- **`State branch`** grows **one exec output per state**, named after the state, and continues
|
||||
along the one the machine is in. Before `Set state` has run the machine is in **no state**, and
|
||||
the flow ends there – there is no start state. A state the machine no longer has (deleted, or of
|
||||
another machine) ends it too.
|
||||
- **`Set state`** is the mirror image: **one exec input per state**, one exec output. The input the
|
||||
flow *arrives through* names the new state – so `OnStart → Set state (Open)` and `On key down →
|
||||
Set state (Closed)` are two edges into one node. The interpreter hands every node the exec input
|
||||
a pulse entered through (`run(…, entry)`); this is the first node to read it.
|
||||
|
||||
The ports are keyed by the **state's node id** and labelled by its name: renaming a state keeps
|
||||
every edge, deleting one leaves its edges dangling like any port that is gone. Which machine the
|
||||
ports belong to is read off the graph, not off a running value – the inline pick, or a
|
||||
`State machine` literal wired in; a variable or an `Item at` feeding the input yields no state
|
||||
ports, exactly as `Run behavior` knows its behavior only as a setting. Picking another machine
|
||||
orphans the edges of the old states, the way any setting that decides a node's ports does.
|
||||
|
||||
**The current state is the behavior instance's own**, kept beside its variables
|
||||
(`NodeContext.states`, machine id → state id): two crates running the same behavior are in
|
||||
states of their own, and a behavior run through `Run behavior` starts in no state, as it starts
|
||||
with fresh variables. A state machine is a **kind** like a texture – a variable, a struct field,
|
||||
a list item (tagged `{"$statemachine": id}`), a port – and a `statemachine` asset is never placed.
|
||||
|
||||
**Limits.** nginx caps request bodies at 1 MB, so the editor shrinks images over 2048 px per
|
||||
side and re-encodes to WebP when they are still too big — but an image that already fits is
|
||||
uploaded byte for byte, because re-encoding pixel art would destroy it. Anything still too
|
||||
@@ -592,7 +632,7 @@ a scene asset and a container asset are both `ObjectHolder`s, and every object c
|
||||
included) and the size of the template as "N objects". Placing needs no button of its own: an
|
||||
asset dragged from the panel onto this view goes into the template, and a container dragged onto
|
||||
its own template is refused while it hovers rather than at the drop.
|
||||
Like the Graph tab it follows the selection without being bound to it – the container asset, one of
|
||||
Like the Behavior tab it follows the selection without being bound to it – the container asset, one of
|
||||
its placed copies or one of its template objects shows it, anything else leaves the tab on what you
|
||||
were editing, and only a deleted container makes it fall back to another one. Selecting a template
|
||||
*object* also switches to the tab, the rule a graph node and a texture frame already follow: it
|
||||
@@ -666,7 +706,7 @@ selected and switched back on.
|
||||
measures from it – only what is drawn is shortened – and a subclass that puts something else
|
||||
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
|
||||
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
|
||||
the Graph, Texture, Animation and Container pickers keep their size whatever they show; the tab
|
||||
the Behavior, Texture, Animation and Container pickers keep their size whatever they show; the tab
|
||||
bar uses 90 px as a floor. A button that can draw its label bold – on an accent fill: primary,
|
||||
danger, a toggled tab – is measured bold, or a dialog's `Delete` comes out as `Del…`.
|
||||
The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph
|
||||
@@ -684,11 +724,12 @@ selected and switched back on.
|
||||
|
||||
```
|
||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||
src/core/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where), texture (frames and the slice grid), animation (which frame at time t), behaviorPrograms (what `Run behavior` needs)
|
||||
src/core/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where), texture (frames and the slice grid), animation (which frame at time t), graphLookup (what a graph sees of the project: behaviors to run, state machines to read)
|
||||
src/core/properties/ PropertySchema, kinds (one row per value kind), listItems (an item's kind, read off its value), resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry, aspect, enabled, editorRun, assetRef/textureRef (how a reference reads)
|
||||
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
|
||||
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter, dropTarget, nested (behaviors as nodes),
|
||||
variablePorts, propertyPorts, lookup (behavior programs), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
|
||||
stateMachine (the State node, a machine's states, the ports they become), variablePorts, propertyPorts,
|
||||
lookup (what a node may look up: behaviors, state machines), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
|
||||
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore
|
||||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
@@ -696,7 +737,7 @@ src/viewport/ view (transforms), items (what to draw), symbols (how to
|
||||
textureFill, TextureLibrary, activeCamera – no editor concepts, so tests reach it freely
|
||||
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
|
||||
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
|
||||
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the three asset tabs)
|
||||
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the asset tabs)
|
||||
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
|
||||
src/editor/inspector/ Inspector panel, host (what a section builds into), field factory, previewHandlers (scrub → preview → command), valueSpecs
|
||||
(where a value lives – shared with the List tab),
|
||||
@@ -709,6 +750,7 @@ src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a Sce
|
||||
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths),
|
||||
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
|
||||
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
|
||||
src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas)
|
||||
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
|
||||
api/ PHP backend data/projects/ persisted projects
|
||||
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
|
||||
@@ -741,7 +783,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 633 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 657 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
@@ -790,7 +832,7 @@ to touch it.
|
||||
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
|
||||
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
|
||||
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
|
||||
variable nodes, property nodes, animations) that build on each other's
|
||||
variable nodes, property nodes, animations, state machines) that build on each other's
|
||||
scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`,
|
||||
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
|
||||
exercises.
|
||||
|
||||
@@ -34,6 +34,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'container', objects: [] };
|
||||
case 'animation':
|
||||
return { ...base, type: 'animation', frames: [] };
|
||||
case 'statemachine':
|
||||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
} from '../graph/Graph';
|
||||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { behaviorLookup } from '../model/behaviorPrograms';
|
||||
import { graphLookup } from '../model/graphLookup';
|
||||
import { newId } from '../ids';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
import { requireGraph, requireNode, requireNodeDef } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
export class AddNodeCommand implements Command {
|
||||
@@ -44,16 +44,16 @@ export class AddNodeCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
requireNodeDef(this.node.type);
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
// The two terminals give the behavior its signature; a second one of either would make
|
||||
// "which ports does this behavior have" ambiguous.
|
||||
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||||
throw new CommandError(`This behavior already has a ${nodeDef(this.node.type)?.label} node`);
|
||||
throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`);
|
||||
graph.nodes.push(this.node);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
graph.nodes = removeById(graph.nodes, this.node.id);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class RemoveNodeCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
|
||||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||||
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
|
||||
@@ -79,7 +79,7 @@ export class RemoveNodeCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
restoreAt(graph.nodes, [this.removed.node]);
|
||||
restoreAt(graph.edges, this.removed.edges);
|
||||
}
|
||||
@@ -134,9 +134,9 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||||
|
||||
override execute(project: Project): void {
|
||||
super.execute(project);
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const def = resolveDef(node, graph, behaviorLookup(project));
|
||||
const def = resolveDef(node, graph, graphLookup(project));
|
||||
if (!def) return;
|
||||
const outputs = new Set(def.outputs.map((p) => p.key));
|
||||
const inputs = new Set(def.inputs.map((p) => p.key));
|
||||
@@ -150,7 +150,7 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||||
|
||||
override undo(project: Project): void {
|
||||
super.undo(project);
|
||||
restoreAt(requireBehavior(project, this.assetId).graph.edges, this.orphans);
|
||||
restoreAt(requireGraph(project, this.assetId).edges, this.orphans);
|
||||
}
|
||||
|
||||
protected override resolveTarget(project: Project): GraphNode {
|
||||
@@ -183,12 +183,12 @@ export class ConnectCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
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 lookup = behaviorLookup(project);
|
||||
const lookup = graphLookup(project);
|
||||
const out = resolveDef(fromNode, graph, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = resolveDef(toNode, graph, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
@@ -201,7 +201,7 @@ export class ConnectCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
graph.edges = removeById(graph.edges, this.edge.id);
|
||||
restoreAt(graph.edges, this.replaced);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export class FoldEdgeCommand implements Command {
|
||||
}
|
||||
|
||||
private edge(project: Project): GraphEdge {
|
||||
const edge = requireBehavior(project, this.assetId).graph.edges.find((e) => e.id === this.edgeId);
|
||||
const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId);
|
||||
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||
return edge;
|
||||
}
|
||||
@@ -282,13 +282,13 @@ export class DisconnectCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const graph = requireGraph(project, this.assetId);
|
||||
this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId);
|
||||
if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
restoreAt(requireBehavior(project, this.assetId).graph.edges, this.removed);
|
||||
restoreAt(requireGraph(project, this.assetId).edges, this.removed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/** Lookups that throw a `CommandError` when the target does not exist. */
|
||||
import { findNode, type GraphNode } from '../graph/Graph';
|
||||
import { findNode, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset } from '../model/Asset';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import { findAsset, findAssetOf, findObjectHolder, type ObjectHolder, type Project } from '../model/Project';
|
||||
import {
|
||||
findAsset,
|
||||
findAssetOf,
|
||||
findGraphAsset,
|
||||
findObjectHolder,
|
||||
type ObjectHolder,
|
||||
type Project,
|
||||
} from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
@@ -64,8 +71,15 @@ export function requireBehavior(project: Project, assetId: string): BehaviorAsse
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** The graph of a behavior or of a state machine – what every graph command edits. */
|
||||
export function requireGraph(project: Project, assetId: string): Graph {
|
||||
const asset = findGraphAsset(project, assetId);
|
||||
if (!asset) throw new CommandError(`Behavior or state machine ${assetId} not found`);
|
||||
return asset.graph;
|
||||
}
|
||||
|
||||
export function requireNode(project: Project, assetId: string, nodeId: string): GraphNode {
|
||||
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
|
||||
const node = findNode(requireGraph(project, assetId), nodeId);
|
||||
if (!node) throw new CommandError(`Node ${nodeId} not found`);
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import { type BehaviorLookup, NO_BEHAVIORS } from './lookup';
|
||||
import { type GraphLookup, NO_LOOKUP } from './lookup';
|
||||
import { BEHAVIOR_END, BEHAVIOR_START, resolveDef, terminalNode } from './nested';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import {
|
||||
@@ -58,7 +58,7 @@ export class GraphInterpreter {
|
||||
|
||||
constructor(
|
||||
readonly graph: Graph,
|
||||
private readonly behaviors: BehaviorLookup = NO_BEHAVIORS,
|
||||
private readonly lookup: GraphLookup = NO_LOOKUP,
|
||||
) {
|
||||
this.frame = emptyFrame(graph);
|
||||
}
|
||||
@@ -93,11 +93,11 @@ export class GraphInterpreter {
|
||||
const target = this.node(edge.to.node);
|
||||
if (!target) continue;
|
||||
this.trace?.onEdge(edge.id);
|
||||
this.execute(target, ctx, depth + 1);
|
||||
this.execute(target, ctx, depth + 1, edge.to.port);
|
||||
}
|
||||
}
|
||||
|
||||
private execute(node: GraphNode, ctx: NodeContext, depth: number): void {
|
||||
private execute(node: GraphNode, ctx: NodeContext, depth: number, entry: string): void {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE)
|
||||
throw new Error('Behavior graph exceeded the step limit (endless loop?)');
|
||||
if (depth > MAX_DEPTH)
|
||||
@@ -105,7 +105,7 @@ export class GraphInterpreter {
|
||||
const def = this.def(node);
|
||||
if (!def?.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params, def);
|
||||
const result = def.run(ctx, inputs, node.params, def, entry);
|
||||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||
const produced = { ...result.outputs, ...returned };
|
||||
if (!def.read) this.frame.outputCache.set(node.id, produced);
|
||||
@@ -122,7 +122,7 @@ export class GraphInterpreter {
|
||||
* a call and not an inlining: nothing of it survives the return but the values it hands back.
|
||||
*/
|
||||
private call(call: CallResult, ctx: NodeContext, depth: number): Record<string, Value> {
|
||||
const program = this.behaviors(call.behaviorId);
|
||||
const program = this.lookup.behavior(call.behaviorId);
|
||||
if (!program) return {};
|
||||
if (this.callStack.includes(call.behaviorId)) throw new Error(`Behavior "${program.name}" runs itself`);
|
||||
const start = terminalNode(program.graph, BEHAVIOR_START);
|
||||
@@ -135,7 +135,8 @@ export class GraphInterpreter {
|
||||
this.calledBehaviors.add(call.behaviorId);
|
||||
try {
|
||||
this.trace?.onNode(start.id);
|
||||
this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults } }, depth + 1);
|
||||
// Fresh variables and fresh states alike: a call keeps nothing, and sees nothing of the caller's.
|
||||
this.follow(start, 'exec', { ...ctx, variables: { ...program.defaults }, states: {} }, depth + 1);
|
||||
const end = terminalNode(program.graph, BEHAVIOR_END);
|
||||
return (end && this.frame.outputCache.get(end.id)) ?? {};
|
||||
} finally {
|
||||
@@ -196,7 +197,7 @@ export class GraphInterpreter {
|
||||
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.frame.dataCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def).outputs ?? {};
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def, '').outputs ?? {};
|
||||
this.frame.dataCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
this.record(nodeId, outputs);
|
||||
@@ -220,7 +221,7 @@ export class GraphInterpreter {
|
||||
}
|
||||
|
||||
private def(node: GraphNode) {
|
||||
return resolveDef(node, this.frame.graph, this.behaviors);
|
||||
return resolveDef(node, this.frame.graph, this.lookup);
|
||||
}
|
||||
|
||||
private node(id: string): GraphNode | undefined {
|
||||
|
||||
@@ -62,6 +62,11 @@ export interface NodeContext {
|
||||
/** The behavior's parent object – the one the graph acts on. */
|
||||
owner: EntityHandle | null;
|
||||
variables: PropertyBag;
|
||||
/**
|
||||
* The current state of each state machine this instance has set, keyed by machine asset id,
|
||||
* as the state node's id. A machine not in here is in no state: `State branch` follows nothing.
|
||||
*/
|
||||
states: Record<string, string>;
|
||||
/** Seconds since the world started and since the last tick. */
|
||||
time: number;
|
||||
dt: number;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { asList, type Value } from './values';
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface PortDef {
|
||||
field?: PropertyDef;
|
||||
}
|
||||
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||||
/** `state` is the one category no behavior graph offers: its nodes are the states of a state machine. */
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state';
|
||||
|
||||
/**
|
||||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||||
@@ -45,11 +46,11 @@ export interface RunResult {
|
||||
call?: CallResult;
|
||||
}
|
||||
|
||||
/** What a node may compute its ports from: itself, the graph it sits in, and the behaviors around it. */
|
||||
/** What a node may compute its ports from: itself, the graph it sits in, and the project around it. */
|
||||
export interface PortSource {
|
||||
node: GraphNode;
|
||||
graph: Graph;
|
||||
behaviors: BehaviorLookup;
|
||||
lookup: GraphLookup;
|
||||
}
|
||||
|
||||
/** Ports a single node has on top of the fixed ones of its type, and a label naming what it does. */
|
||||
@@ -95,9 +96,16 @@ export interface NodeDef {
|
||||
read?(ctx: NodeContext, params: PropertyBag, input: (key: string) => Value): Record<string, Value>;
|
||||
/**
|
||||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||||
* hand, the only way a node with derived ports knows how many it has.
|
||||
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
|
||||
* the pulse arrived through – what a node with one exec input per meaning reads.
|
||||
*/
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
|
||||
run?(
|
||||
ctx: NodeContext,
|
||||
inputs: Record<string, Value>,
|
||||
params: PropertyBag,
|
||||
def: NodeDef,
|
||||
entry: string,
|
||||
): RunResult;
|
||||
}
|
||||
|
||||
const defs = new Map<string, NodeDef>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { edgesInto, findNode, type Graph, type PortType, portsCompatible } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { resolveDef } from './nested';
|
||||
import type { NodeDef } from './NodeRegistry';
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface PortSide {
|
||||
isOutput: boolean;
|
||||
}
|
||||
|
||||
export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined {
|
||||
export function portTypeOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortType | undefined {
|
||||
const node = findNode(graph, side.nodeId);
|
||||
const def = node && resolveDef(node, graph, lookup);
|
||||
const list = side.isOutput ? def?.outputs : def?.inputs;
|
||||
@@ -18,7 +18,7 @@ export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup)
|
||||
}
|
||||
|
||||
/** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */
|
||||
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: BehaviorLookup): boolean {
|
||||
export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: GraphLookup): boolean {
|
||||
if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false;
|
||||
const fromType = portTypeOf(graph, from, lookup);
|
||||
const toType = portTypeOf(graph, to, lookup);
|
||||
@@ -36,7 +36,7 @@ export function dropTargetPort(
|
||||
graph: Graph,
|
||||
from: PortSide,
|
||||
nodeId: string,
|
||||
lookup: BehaviorLookup,
|
||||
lookup: GraphLookup,
|
||||
): PortSide | null {
|
||||
if (nodeId === from.nodeId) return null;
|
||||
const node = findNode(graph, nodeId);
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './nested';
|
||||
export * from './NodeContext';
|
||||
export * from './NodeRegistry';
|
||||
export * from './propertyPorts';
|
||||
export * from './stateMachine';
|
||||
export * from './values';
|
||||
export * from './variablePorts';
|
||||
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
|
||||
@@ -8,6 +8,24 @@ export interface BehaviorProgram {
|
||||
defaults: PropertyBag;
|
||||
}
|
||||
|
||||
export type BehaviorLookup = (assetId: string) => BehaviorProgram | null;
|
||||
/** One state of a machine: the node id is what a port and the runtime hold, the name is the label. */
|
||||
export interface MachineState {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const NO_BEHAVIORS: BehaviorLookup = () => null;
|
||||
export interface StateMachine {
|
||||
name: string;
|
||||
states: MachineState[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a node may look up in the project around its graph: the behaviors it can run and the
|
||||
* state machines it can read. One handle, so no caller can resolve a node with half of it.
|
||||
*/
|
||||
export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
}
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null };
|
||||
@@ -2,7 +2,7 @@ import { textOf } from '../model/Properties';
|
||||
import { isTaggedKind } from '../properties/kinds';
|
||||
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
|
||||
export const BEHAVIOR_START = 'BehaviorStart';
|
||||
@@ -92,14 +92,14 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
|
||||
* The node definition as it applies to *this* node: identical to the registered one, unless the
|
||||
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
|
||||
*/
|
||||
export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined {
|
||||
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
|
||||
const own = (side: 'inputs' | 'outputs') =>
|
||||
declaredOn(def, node.ports ?? [], side).map((p) =>
|
||||
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
|
||||
);
|
||||
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
|
||||
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
|
||||
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
|
||||
return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
|
||||
}
|
||||
@@ -113,12 +113,12 @@ function execFirst(ports: PortDef[]): PortDef[] {
|
||||
* Why this call cannot do anything, or null if it can. A call is the one node that depends on a
|
||||
* *different* graph, so it is the one node that can look perfectly healthy and still be dead.
|
||||
*/
|
||||
export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string | null {
|
||||
export function callProblem(node: GraphNode, lookup: GraphLookup, ownerId = ''): string | null {
|
||||
if (node.type !== RUN_BEHAVIOR) return null;
|
||||
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
|
||||
if (!id) return 'No behavior picked';
|
||||
if (id === ownerId) return 'A behavior cannot run itself';
|
||||
const program = lookup(id);
|
||||
const program = lookup.behavior(id);
|
||||
if (!program) return 'That behavior is gone';
|
||||
if (!terminalNode(program.graph, BEHAVIOR_START)) return 'No start node – does nothing';
|
||||
return null;
|
||||
@@ -128,7 +128,7 @@ export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = '
|
||||
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
|
||||
* changes – a port list that is derived rather than fixed has no other way of being noticed.
|
||||
*/
|
||||
export function portSignature(node: GraphNode, graph: Graph, lookup: BehaviorLookup, ownerId = ''): string {
|
||||
export function portSignature(node: GraphNode, graph: Graph, lookup: GraphLookup, ownerId = ''): string {
|
||||
const def = resolveDef(node, graph, lookup);
|
||||
if (!def) return 'missing';
|
||||
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
|
||||
|
||||
@@ -30,6 +30,11 @@ export const TextureLiteral = literal(
|
||||
'texture',
|
||||
'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
|
||||
);
|
||||
export const StateMachineLiteral = literal(
|
||||
'StateMachine',
|
||||
'statemachine',
|
||||
'A constant state machine, for a "State branch" or a "Set state" to work on.',
|
||||
);
|
||||
export const ListLiteral = literal(
|
||||
'List',
|
||||
'list',
|
||||
|
||||
@@ -18,8 +18,8 @@ export const RunBehavior: NodeDef = {
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }],
|
||||
derivePorts: ({ node, behaviors }) => {
|
||||
const program = behaviors(textOf(node.params[BEHAVIOR_PARAM] ?? ''));
|
||||
derivePorts: ({ node, lookup }) => {
|
||||
const program = lookup.behavior(textOf(node.params[BEHAVIOR_PARAM] ?? ''));
|
||||
return {
|
||||
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
|
||||
label: program?.name,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/** The mirror image of `State branch`: the input the flow arrives through names the new state. */
|
||||
export const SetState: NodeDef = {
|
||||
type: 'SetState',
|
||||
label: 'Set state',
|
||||
category: 'action',
|
||||
description:
|
||||
'Puts the machine into the state whose input the flow arrived through, then continues. Each ' +
|
||||
'behavior instance keeps its own states.',
|
||||
inputs: [machinePort()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
derivePorts: (source) => ({ inputs: statePorts(machineOf(source)) }),
|
||||
run: (ctx, inputs, _params, def, entry) => {
|
||||
const machine = asString(inputs.machine);
|
||||
if (machine && def.inputs.some((p) => p.type === 'exec' && p.key === entry)) ctx.states[machine] = entry;
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { machineOf, machinePort, statePorts } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/** No state set yet, or a state of another machine: the flow ends here, along no output. */
|
||||
export const StateBranch: NodeDef = {
|
||||
type: 'StateBranch',
|
||||
label: 'State branch',
|
||||
category: 'flow',
|
||||
description:
|
||||
'Continues along the output of the state the machine is in. Before "Set state" has run, the ' +
|
||||
'machine is in no state and nothing continues.',
|
||||
inputs: [exec(), machinePort()],
|
||||
outputs: [],
|
||||
params: [],
|
||||
derivePorts: (source) => ({ outputs: statePorts(machineOf(source)) }),
|
||||
run: (ctx, inputs, _params, def) => {
|
||||
const current = ctx.states[asString(inputs.machine)];
|
||||
return def.outputs.some((p) => p.key === current) ? { next: current } : {};
|
||||
},
|
||||
};
|
||||
@@ -26,7 +26,14 @@ import { IsTouching } from './data/IsTouching';
|
||||
import { Join } from './data/Join';
|
||||
import { KeyIsDown } from './data/KeyIsDown';
|
||||
import { Length } from './data/Length';
|
||||
import { BooleanLiteral, ListLiteral, NumberLiteral, StringLiteral, TextureLiteral } from './data/literals';
|
||||
import {
|
||||
BooleanLiteral,
|
||||
ListLiteral,
|
||||
NumberLiteral,
|
||||
StateMachineLiteral,
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
} from './data/literals';
|
||||
import { Max } from './data/Max';
|
||||
import { Min } from './data/Min';
|
||||
import { Modulo } from './data/Modulo';
|
||||
@@ -76,6 +83,9 @@ import { Branch } from './flow/Branch';
|
||||
import { Repeat } from './flow/Repeat';
|
||||
import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { SetState } from './flow/SetState';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
|
||||
/**
|
||||
@@ -99,6 +109,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Sequence,
|
||||
Repeat,
|
||||
RunBehavior,
|
||||
StateBranch,
|
||||
SetState,
|
||||
BehaviorEnd,
|
||||
Variables,
|
||||
Self,
|
||||
@@ -114,6 +126,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
BooleanLiteral,
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
StateMachineLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
@@ -168,3 +181,5 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
];
|
||||
|
||||
for (const def of BUILT_IN_NODES) registerNode(def);
|
||||
// Registered so a machine's graph loads and draws, but not built in: no behavior graph offers it.
|
||||
registerNode(State);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import { edgesInto, type Graph, type GraphNode } from './Graph';
|
||||
import type { MachineState, StateMachine } from './lookup';
|
||||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||||
|
||||
export const STATE = 'State';
|
||||
export const STATE_MACHINE = 'StateMachine';
|
||||
export const STATE_NAME_PARAM = 'name';
|
||||
/** The input naming the machine on `State branch` and `Set state`. */
|
||||
export const MACHINE_PORT = 'machine';
|
||||
|
||||
/**
|
||||
* One state of a state machine: a node with no ports whose name is its title. Its graph is the
|
||||
* machine asset's, never a behavior's – the palette leaves the `state` category out.
|
||||
*/
|
||||
export const State: NodeDef = {
|
||||
type: STATE,
|
||||
label: 'State',
|
||||
category: 'state',
|
||||
description: 'One state of a state machine.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
|
||||
derivePorts: ({ node }) => ({ label: stateName(node) }),
|
||||
};
|
||||
|
||||
export function isStateNode(node: GraphNode): boolean {
|
||||
return node.type === STATE;
|
||||
}
|
||||
|
||||
export function stateName(node: GraphNode): string {
|
||||
return textOf(node.params[STATE_NAME_PARAM] ?? '') || State.label;
|
||||
}
|
||||
|
||||
/** The states of a machine's graph in node order – what its ports and the runtime name. */
|
||||
export function statesOf(graph: Graph): MachineState[] {
|
||||
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
|
||||
}
|
||||
|
||||
/** What a state placed from the palette is called: "State 3" for the third. */
|
||||
export function nextStateName(graph: Graph): string {
|
||||
return `${State.label} ${statesOf(graph).length + 1}`;
|
||||
}
|
||||
|
||||
/** The `machine` input; unconnected, it shows the machine picker. */
|
||||
export const machinePort = (): PortDef => ({
|
||||
key: MACHINE_PORT,
|
||||
label: 'machine',
|
||||
type: 'string',
|
||||
field: flatDef(MACHINE_PORT, 'statemachine', { label: 'Machine' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* The machine a node's `machine` input names while the graph is drawn: the inline pick, or the
|
||||
* `State machine` literal an edge brings in. A port is derived from the graph, not from a running
|
||||
* value, so a variable or a list item feeding the input yields no state ports.
|
||||
*/
|
||||
export function machineOf({ node, graph, lookup }: PortSource): StateMachine | null {
|
||||
const edge = edgesInto(graph, node.id, MACHINE_PORT)[0];
|
||||
const source = edge && graph.nodes.find((n) => n.id === edge.from.node);
|
||||
const id = edge
|
||||
? source?.type === STATE_MACHINE
|
||||
? textOf(source.params.value ?? '')
|
||||
: ''
|
||||
: textOf(node.params[MACHINE_PORT] ?? '');
|
||||
return id ? lookup.machine(id) : null;
|
||||
}
|
||||
|
||||
/** One exec port per state, keyed by the state's id – a rename keeps every edge. */
|
||||
export function statePorts(machine: StateMachine | null): PortDef[] {
|
||||
return (machine?.states ?? []).map((state) => ({ key: state.id, label: state.name, type: 'exec' }));
|
||||
}
|
||||
+18
-1
@@ -82,6 +82,15 @@ export interface AnimationAsset extends AssetBase {
|
||||
frames: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph holds one `State` node per state and nothing else; a behavior reads the machine's
|
||||
* current state through `State branch` and moves it through `Set state`.
|
||||
*/
|
||||
export interface StateMachineAsset extends AssetBase {
|
||||
type: 'statemachine';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
| SpriteAsset
|
||||
| CameraAsset
|
||||
@@ -91,7 +100,15 @@ export type Asset =
|
||||
| ColliderAsset
|
||||
| ContainerAsset
|
||||
| TextureAsset
|
||||
| AnimationAsset;
|
||||
| AnimationAsset
|
||||
| StateMachineAsset;
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset;
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
return asset.type === 'behavior' || asset.type === 'statemachine';
|
||||
}
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
return asset.type === 'scene';
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ASSET_TYPES = [
|
||||
'container',
|
||||
'texture',
|
||||
'animation',
|
||||
'statemachine',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -22,13 +23,17 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
container: 'Container',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
statemachine: 'State machine',
|
||||
};
|
||||
|
||||
/** Textures and animations are not placeable: they are pictures a sprite wears through `texture`. */
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture' | 'animation'>;
|
||||
/**
|
||||
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`.
|
||||
* A state machine is not either: a behavior refers to it by id.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture' | 'animation' | 'statemachine'>;
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
return type !== 'scene' && type !== 'texture' && type !== 'animation';
|
||||
return type !== 'scene' && type !== 'texture' && type !== 'animation' && type !== 'statemachine';
|
||||
}
|
||||
|
||||
export function isAssetType(value: unknown): value is AssetType {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
|
||||
import {
|
||||
type Asset,
|
||||
type ContainerAsset,
|
||||
type GraphAsset,
|
||||
isGraphAsset,
|
||||
isSceneAsset,
|
||||
type SceneAsset,
|
||||
} from './Asset';
|
||||
|
||||
/** 2: a placed container carries no copy of its template. */
|
||||
export const PROJECT_FORMAT_VERSION = 2;
|
||||
@@ -38,6 +45,11 @@ export function listScenes(project: Project): SceneAsset[] {
|
||||
return project.assets.filter(isSceneAsset);
|
||||
}
|
||||
|
||||
export function findGraphAsset(project: Project, assetId: string): GraphAsset | undefined {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset && isGraphAsset(asset) ? asset : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that holds placed objects. A container's `objects` are a template, so anything
|
||||
* walking "all placed objects" must cover both or it silently skips the templates.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { BehaviorLookup } from '../graph/lookup';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* What a `Run behavior` node needs from the project. `defaults` are the asset's variable values;
|
||||
* every call copies them, so a nested behavior never carries state from one call to the next.
|
||||
*/
|
||||
export function behaviorLookup(project: Project): BehaviorLookup {
|
||||
return (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { statesOf } from '../graph/stateMachine';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* What a graph may see of the project around it. A behavior's `defaults` are its variable values;
|
||||
* every call copies them, so a nested behavior never carries state from one call to the next.
|
||||
*/
|
||||
export function graphLookup(project: Project): GraphLookup {
|
||||
return {
|
||||
behavior: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
return asset ? { name: asset.name, states: statesOf(asset.graph) } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -155,6 +155,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||
statemachine: [],
|
||||
collider: [
|
||||
{
|
||||
key: 'shape',
|
||||
|
||||
@@ -92,6 +92,20 @@ export const KINDS = [
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
/** A state machine asset's id – a text, so it is tagged in a list like a texture. */
|
||||
{
|
||||
kind: 'statemachine',
|
||||
label: 'State machine',
|
||||
mark: '◈',
|
||||
travels: 'string',
|
||||
empty: '',
|
||||
coerce: text,
|
||||
tagged: true,
|
||||
reference: true,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
|
||||
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
|
||||
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isValidPortName,
|
||||
fixedPortKeys,
|
||||
nodeDef,
|
||||
STATE,
|
||||
} from '../graph';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
@@ -89,6 +90,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'statemachine') {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, STATE) });
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else if (type === 'animation') {
|
||||
@@ -242,7 +245,8 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea
|
||||
return ports;
|
||||
}
|
||||
|
||||
function normalizeGraph(raw: unknown): Graph {
|
||||
/** `only` restricts the node types kept: a state machine's graph holds states and nothing else. */
|
||||
function normalizeGraph(raw: unknown, only?: string): Graph {
|
||||
const g = asRecord(raw);
|
||||
const graph = createEmptyGraph();
|
||||
const nodeIds = new Set<string>();
|
||||
@@ -251,7 +255,7 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
const id = typeof n.id === 'string' ? n.id : '';
|
||||
const type = typeof n.type === 'string' ? n.type : '';
|
||||
const def = nodeDef(type);
|
||||
if (!id || nodeIds.has(id) || !def) continue;
|
||||
if (!id || nodeIds.has(id) || !def || (only !== undefined && type !== only)) continue;
|
||||
nodeIds.add(id);
|
||||
// Params carry both settings and inline input values, so there is no schema to coerce against.
|
||||
const params: PropertyBag = {};
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface AssetTabOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Graph, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||||
* The Behavior, State machine, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||||
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
|
||||
*/
|
||||
export abstract class AssetTabEditor<
|
||||
|
||||
+25
-5
@@ -17,19 +17,31 @@ import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
import type { EditorState } from './state';
|
||||
import { TextureEditor } from './texture/TextureEditor';
|
||||
import { installTextureImport } from './textureImport';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'animation' | 'container' | 'list';
|
||||
export type CentreTab =
|
||||
'scene' | 'graph' | 'statemachine' | 'game' | 'texture' | 'animation' | 'container' | 'list';
|
||||
|
||||
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'animation', 'container', 'list'] as const;
|
||||
const TAB_ORDER = [
|
||||
'scene',
|
||||
'game',
|
||||
'graph',
|
||||
'statemachine',
|
||||
'texture',
|
||||
'animation',
|
||||
'container',
|
||||
'list',
|
||||
] as const;
|
||||
|
||||
const TAB_LABELS: Record<CentreTab, string> = {
|
||||
scene: 'Scene',
|
||||
game: 'Game',
|
||||
graph: 'Graph',
|
||||
graph: 'Behavior',
|
||||
statemachine: 'State machine',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
container: 'Container',
|
||||
@@ -52,6 +64,7 @@ export class Editor implements PlayHost {
|
||||
/** Game tab: the running world through its camera. */
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
readonly stateMachineEditor: StateMachineEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly animationEditor: AnimationEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
@@ -88,6 +101,7 @@ export class Editor implements PlayHost {
|
||||
this.viewport = new SceneViewport(state);
|
||||
this.gameView = new GameViewport(state);
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.stateMachineEditor = new StateMachineEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.animationEditor = new AnimationEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
@@ -132,6 +146,7 @@ export class Editor implements PlayHost {
|
||||
scene: this.viewport,
|
||||
game: this.gameView,
|
||||
graph: this.graphEditor,
|
||||
statemachine: this.stateMachineEditor,
|
||||
texture: this.textureEditor,
|
||||
animation: this.animationEditor,
|
||||
container: this.containerEditor,
|
||||
@@ -141,6 +156,7 @@ export class Editor implements PlayHost {
|
||||
scene: this.tabButton('scene'),
|
||||
game: this.tabButton('game'),
|
||||
graph: this.tabButton('graph'),
|
||||
statemachine: this.tabButton('statemachine'),
|
||||
texture: this.tabButton('texture'),
|
||||
animation: this.tabButton('animation'),
|
||||
container: this.tabButton('container'),
|
||||
@@ -182,10 +198,14 @@ export class Editor implements PlayHost {
|
||||
});
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// A node, a frame and a template object exist only in their own tab, so selecting one
|
||||
// switches to it; selecting the behavior, texture or container asset itself does not.
|
||||
// switches to it; selecting the behavior, texture or container asset itself does not. A state
|
||||
// machine asset does switch (user's choice): its tab is the only place it shows anything.
|
||||
state.on('selection', () => {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
const machine = state.selectedStateMachineId !== null;
|
||||
if (s?.kind === 'node' && !machine && this.tab !== 'graph') this.showTab('graph');
|
||||
if ((s?.kind === 'node' || s?.kind === 'asset') && machine && this.tab !== 'statemachine')
|
||||
this.showTab('statemachine');
|
||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
if (
|
||||
s?.kind === 'object' &&
|
||||
|
||||
@@ -230,6 +230,16 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
snapping: () => graphEditor.canvas.snapEnabled,
|
||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||
},
|
||||
stateMachine: {
|
||||
current: () => editor.stateMachineEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.stateMachineEditor.picker),
|
||||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
|
||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||
},
|
||||
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
||||
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
||||
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
||||
|
||||
@@ -17,17 +17,18 @@ import {
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
firstPortFor,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
type BehaviorLookup,
|
||||
type GraphLookup,
|
||||
type PortSide,
|
||||
type PortType,
|
||||
portTypeOf,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
} from '../../core/graph';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { beadsInFlight, launchBead } from './beads';
|
||||
import {
|
||||
@@ -69,8 +70,8 @@ const DRAG_THRESHOLD = 3;
|
||||
const LIVE_VALUE_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
|
||||
* The node graph of one behavior or state machine asset. Which one that is comes from the outside
|
||||
* (`show`), so the canvas never has an opinion about the selection – the tab around it does.
|
||||
*/
|
||||
export class GraphCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
@@ -123,7 +124,11 @@ export class GraphCanvas extends Widget {
|
||||
/** A double-clicked call node; the tab opens the behavior and records the step. */
|
||||
onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId });
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
/** Names the asset kind in the empty state: "No behavior selected …". */
|
||||
private readonly noun = 'behavior',
|
||||
) {
|
||||
super();
|
||||
this.canvas.addChild(this.edgeLayer, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||||
@@ -187,20 +192,19 @@ export class GraphCanvas extends Widget {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
private behaviors(): BehaviorLookup {
|
||||
return behaviorLookup(this.state.project);
|
||||
private behaviors(): GraphLookup {
|
||||
return graphLookup(this.state.project);
|
||||
}
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
private asset(): GraphAsset | null {
|
||||
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
|
||||
}
|
||||
|
||||
/** Rebuilds node views from the model (cheap: views are reused by node id). */
|
||||
private sync(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) {
|
||||
// A behavior that was deleted (or undone away) must not linger as a graph.
|
||||
// An asset that was deleted (or undone away) must not linger as a graph.
|
||||
this.assetId = '';
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
@@ -208,7 +212,7 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lookup = behaviorLookup(this.state.project);
|
||||
const lookup = graphLookup(this.state.project);
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = resolveDef(node, asset.graph, lookup);
|
||||
if (!def) continue;
|
||||
@@ -435,7 +439,8 @@ export class GraphCanvas extends Widget {
|
||||
const local = this.toLocal(e.global);
|
||||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||||
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
|
||||
if (!inside || !this.assetId || asset?.type !== 'behavior') {
|
||||
// Only a behavior graph runs other behaviors; a state machine has nothing to drop in.
|
||||
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') {
|
||||
this.setIncoming(null);
|
||||
return;
|
||||
}
|
||||
@@ -631,10 +636,7 @@ export class GraphCanvas extends Widget {
|
||||
this.state.select(nodeSelection(this.assetId, [add.node.id]));
|
||||
}
|
||||
|
||||
private openLinks(
|
||||
pick: (graph: BehaviorAsset['graph']) => BehaviorAsset['graph']['edges'],
|
||||
at: { x: number; y: number },
|
||||
): void {
|
||||
private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void {
|
||||
const graph = this.asset()?.graph;
|
||||
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
|
||||
}
|
||||
@@ -652,7 +654,7 @@ export class GraphCanvas extends Widget {
|
||||
const asset = this.asset();
|
||||
this.canvas.visible = asset !== null;
|
||||
if (!asset) {
|
||||
this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.';
|
||||
this.hint.text = `No ${this.noun} selected – pick one above, or add a ${this.noun} asset.`;
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset, findAssetOf } from '../../core/model/Project';
|
||||
import { Checkbox, Label } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { Breadcrumb } from './Breadcrumb';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
import { SnapToggle } from './SnapToggle';
|
||||
import { backTo, descend, follow, type Trail } from './trail';
|
||||
|
||||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
private readonly snapBox = new Checkbox();
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly breadcrumb = new Breadcrumb();
|
||||
private trail: Trail = [];
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
|
||||
this.snapBox.onChange = (on) => this.canvas.setSnapping(on);
|
||||
this.breadcrumb.onPick = (index) => this.open(this.trail[index]!, backTo(this.trail, index));
|
||||
this.mount(
|
||||
new GraphCanvas(state),
|
||||
[new Label('Snap', { style: 'muted' }), this.snapBox],
|
||||
this.breadcrumb,
|
||||
);
|
||||
this.mount(new GraphCanvas(state), this.snap.widgets, this.breadcrumb);
|
||||
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
@@ -69,6 +64,6 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: BehaviorAsset | null): void {
|
||||
this.snapBox.setEnabled(Boolean(asset));
|
||||
this.snap.setEnabled(Boolean(asset));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { NodeCategory, NodeDef } from '../../core/graph';
|
||||
import { ListView, Popup, ScrollContainer, TextField, Theme, ui } from '../../ui';
|
||||
import type { PaletteEntry } from './paletteEntries';
|
||||
|
||||
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action'];
|
||||
const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'action', 'state'];
|
||||
|
||||
/** Searchable list of node types; right-click menu of the graph editor. */
|
||||
export class NodePalette extends Popup {
|
||||
|
||||
@@ -49,6 +49,8 @@ export function categoryColor(category: NodeCategory): number {
|
||||
return c.categoryVariable;
|
||||
case 'action':
|
||||
return c.categoryAction;
|
||||
case 'state':
|
||||
return c.categoryState;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Checkbox, Label, type Widget } from '../../ui';
|
||||
import type { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
/** The "Snap" checkbox every tab on the graph canvas carries in its header. */
|
||||
export class SnapToggle {
|
||||
private readonly box = new Checkbox();
|
||||
readonly widgets: readonly Widget[];
|
||||
|
||||
constructor(canvas: () => GraphCanvas) {
|
||||
this.box.onChange = (on) => canvas().setSnapping(on);
|
||||
this.widgets = [new Label('Snap', { style: 'muted' }), this.box];
|
||||
}
|
||||
|
||||
setEnabled(on: boolean): void {
|
||||
this.box.setEnabled(on);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,16 @@ import {
|
||||
BEHAVIOR_PARAM,
|
||||
createEmptyGraph,
|
||||
isTerminalType,
|
||||
nextStateName,
|
||||
type NodeDef,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
State,
|
||||
STATE_NAME_PARAM,
|
||||
terminalNode,
|
||||
} from '../../core/graph';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
|
||||
@@ -25,17 +28,22 @@ export interface PaletteEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* The node types offerable in `asset`'s graph. Every other behavior appears under its own name
|
||||
* instead of behind a generic "Run behavior"; the two terminals drop out once the graph has them.
|
||||
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
|
||||
* new one named by its number. In a behavior, every other behavior appears under its own name
|
||||
* instead of behind a generic "Run behavior", and the two terminals drop out once the graph has them.
|
||||
*/
|
||||
export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteEntry[] {
|
||||
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
|
||||
if (asset.type === 'statemachine') {
|
||||
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
|
||||
return [{ id: State.type, hint: State.category, def: State, params }];
|
||||
}
|
||||
const entries: PaletteEntry[] = [];
|
||||
for (const def of allNodeDefs()) {
|
||||
if (def.type === RUN_BEHAVIOR) continue;
|
||||
if (def.type === RUN_BEHAVIOR || def.category === 'state') continue;
|
||||
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
|
||||
entries.push({ id: def.type, hint: def.category, def, params: {} });
|
||||
}
|
||||
const lookup = behaviorLookup(project);
|
||||
const lookup = graphLookup(project);
|
||||
for (const other of project.assets) {
|
||||
// A behavior running itself is an endless loop, so it is not on offer.
|
||||
if (other.type !== 'behavior' || other.id === asset.id) continue;
|
||||
|
||||
@@ -231,6 +231,12 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
})),
|
||||
},
|
||||
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices },
|
||||
statemachine: {
|
||||
none: NO_ASSET_LABEL,
|
||||
label: assetRefLabel,
|
||||
choices: (project) =>
|
||||
assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })),
|
||||
},
|
||||
};
|
||||
|
||||
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
|
||||
@@ -355,6 +361,7 @@ export function createField(
|
||||
}
|
||||
case 'asset':
|
||||
case 'texture':
|
||||
case 'statemachine':
|
||||
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
portSignature,
|
||||
resolveDef,
|
||||
} from '../../core/graph';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Label } from '../../ui';
|
||||
@@ -24,11 +25,11 @@ import { currentNode, nodeParamValue } from './valueSpecs';
|
||||
|
||||
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
||||
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
const asset = findGraphAsset(project, assetId);
|
||||
if (!asset) return 'missing';
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'missing';
|
||||
const lookup = behaviorLookup(project);
|
||||
const lookup = graphLookup(project);
|
||||
const def = resolveDef(node, asset.graph, lookup);
|
||||
const connected = def
|
||||
? inlineInputs(def)
|
||||
@@ -38,23 +39,28 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
|
||||
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
|
||||
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
|
||||
asset,
|
||||
)
|
||||
.map((v) => v.key)
|
||||
.join(',')}|${target}`;
|
||||
}
|
||||
|
||||
/** A state machine declares no variables; its one node type has none to name. */
|
||||
const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
||||
|
||||
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
||||
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
||||
const asset = findAssetOf(host.state.project, assetId, 'behavior');
|
||||
const asset = findGraphAsset(host.state.project, assetId);
|
||||
if (!asset) return;
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
|
||||
const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project));
|
||||
if (!node || !def) return;
|
||||
|
||||
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
||||
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
||||
addNote(host, def.description);
|
||||
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
|
||||
const problem = callProblem(node, graphLookup(host.state.project), assetId);
|
||||
if (problem) addNote(host, problem, 'warning');
|
||||
const addSetting = (param: PropertyDef) => {
|
||||
const row = createFieldRow(
|
||||
@@ -67,9 +73,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
host.body.add(row.row);
|
||||
};
|
||||
const ownSettings = declaresPropertyPorts(node.type);
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, variablesOf(asset));
|
||||
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
|
||||
|
||||
if (!ownSettings && def.params.length > 0) {
|
||||
addTitle(host, 'SETTINGS');
|
||||
@@ -94,7 +100,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
||||
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
||||
const found = currentNode(host.state.project, assetId, nodeId);
|
||||
const def = found && resolveDef(found.node, found.graph, behaviorLookup(host.state.project));
|
||||
const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project));
|
||||
if (!found || !def) return false;
|
||||
for (const [key, row] of host.fields) {
|
||||
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { findAsset, findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import type { EditorState } from '../state';
|
||||
import { currentProps, currentRef } from './objectQueries';
|
||||
import type { PreviewedValue } from './previewHandlers';
|
||||
@@ -57,7 +57,7 @@ export function currentNode(
|
||||
assetId: string,
|
||||
nodeId: string,
|
||||
): { node: GraphNode; graph: Graph } | undefined {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
const asset = findGraphAsset(project, assetId);
|
||||
if (!asset) return undefined;
|
||||
const node = findNode(asset.graph, nodeId);
|
||||
return node && { node, graph: asset.graph };
|
||||
|
||||
@@ -6,7 +6,7 @@ import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
@@ -181,7 +181,7 @@ function objectOwner(
|
||||
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
|
||||
const found = currentNode(state.project, slot.assetId, slot.nodeId);
|
||||
if (!found) return null;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const nodeDef = resolveDef(found.node, found.graph, graphLookup(state.project));
|
||||
const def =
|
||||
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
|
||||
if (!nodeDef || !def) return null;
|
||||
|
||||
+15
-5
@@ -7,6 +7,7 @@ import {
|
||||
createEmptyProject,
|
||||
findAsset,
|
||||
findAssetOf,
|
||||
findGraphAsset,
|
||||
findObjectHolder,
|
||||
findScene,
|
||||
type Project,
|
||||
@@ -313,8 +314,8 @@ export class EditorState {
|
||||
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
|
||||
}
|
||||
if (s.kind === 'node') {
|
||||
const asset = findAssetOf(this.project, s.assetId, 'behavior');
|
||||
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
|
||||
const asset = findGraphAsset(this.project, s.assetId);
|
||||
// Fall back to the asset so the graph stays open when its node vanished (undo).
|
||||
if (!asset) this.select(null);
|
||||
else {
|
||||
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
|
||||
@@ -346,12 +347,21 @@ export class EditorState {
|
||||
|
||||
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
|
||||
get selectedBehaviorId(): string | null {
|
||||
return this.selectedGraphId('behavior');
|
||||
}
|
||||
|
||||
/** The state machine the current selection refers to: the asset, or one of its states. */
|
||||
get selectedStateMachineId(): string | null {
|
||||
return this.selectedGraphId('statemachine');
|
||||
}
|
||||
|
||||
private selectedGraphId(type: 'behavior' | 'statemachine'): string | null {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'node') return s.assetId;
|
||||
if (s.kind === 'frame') return null;
|
||||
const asset = this.assetBehind(s);
|
||||
return asset && asset.type === 'behavior' ? asset.id : null;
|
||||
// A node names its asset; which tab that is depends on what the asset is.
|
||||
const asset = s.kind === 'node' ? findAsset(this.project, s.assetId) : this.assetBehind(s);
|
||||
return asset && asset.type === type ? asset.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { statesOf } from '../../core/graph';
|
||||
import type { Asset, StateMachineAsset } from '../../core/model/Asset';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Room kept for the "N states" readout. */
|
||||
const INFO_WIDTH = 90;
|
||||
|
||||
/**
|
||||
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node,
|
||||
* so placing (right-click), moving, snapping, renaming, deleting and undo are the graph's.
|
||||
* Transitions are a later step.
|
||||
*/
|
||||
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'State machine', infoWidth: INFO_WIDTH });
|
||||
this.mount(new GraphCanvas(state, 'state machine'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedStateMachineId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is StateMachineAsset {
|
||||
return asset.type === 'statemachine';
|
||||
}
|
||||
|
||||
protected override infoText(asset: StateMachineAsset): string {
|
||||
const count = statesOf(asset.graph).length;
|
||||
return `${count} ${count === 1 ? 'state' : 'states'}`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: StateMachineAsset | null): void {
|
||||
this.snap.setEnabled(Boolean(asset));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
@@ -41,6 +41,8 @@ interface Instance {
|
||||
interpreter: GraphInterpreter;
|
||||
trace: Omit<Trace, 'values'>;
|
||||
values: Map<Graph, Map<string, Produced>>;
|
||||
/** Current state per state machine – this instance's alone, like its variables. */
|
||||
states: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +127,7 @@ export class BehaviorSystem implements System {
|
||||
if (!asset) continue;
|
||||
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
|
||||
const values = new Map<Graph, Map<string, Produced>>();
|
||||
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(this.project));
|
||||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project));
|
||||
interpreter.trace = {
|
||||
onEdge: (id) => trace.edges.set(id, world.time),
|
||||
onNode: (id) => trace.nodes.set(id, world.time),
|
||||
@@ -135,7 +137,7 @@ export class BehaviorSystem implements System {
|
||||
byNode.set(id, { time: world.time, values: produced });
|
||||
},
|
||||
} satisfies TraceListener;
|
||||
const instance = { entity, interpreter, trace, values };
|
||||
const instance: Instance = { entity, interpreter, trace, values, states: {} };
|
||||
this.instances.push(instance);
|
||||
this.run(instance, (i, ctx) => i.fire('OnStart', ctx), 0);
|
||||
}
|
||||
@@ -275,7 +277,7 @@ export class BehaviorSystem implements System {
|
||||
): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
const ctx = this.context(instance.entity, world, dt);
|
||||
const ctx = this.context(instance, world, dt);
|
||||
try {
|
||||
body(instance.interpreter, ctx);
|
||||
} catch (error) {
|
||||
@@ -286,7 +288,7 @@ export class BehaviorSystem implements System {
|
||||
}
|
||||
}
|
||||
|
||||
private context(entity: Entity, world: World, dt: number): NodeContext {
|
||||
private context({ entity, states }: Instance, world: World, dt: number): NodeContext {
|
||||
// Handles arriving from the graph are the entities themselves, or test doubles with the same id.
|
||||
const resolve = (handle: EntityHandle | null): Entity | undefined =>
|
||||
handle ? world.entities.find((e) => e === handle || e.id === handle.id) : undefined;
|
||||
@@ -294,6 +296,7 @@ export class BehaviorSystem implements System {
|
||||
self: entity,
|
||||
owner: entity.parent,
|
||||
variables: entity.props,
|
||||
states,
|
||||
time: world.time,
|
||||
dt,
|
||||
findByName: (name) => world.findByName(name) ?? null,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const Theme = {
|
||||
categoryData: 0x2e8b57,
|
||||
categoryVariable: 0xb8860b,
|
||||
categoryAction: 0x2f6fd6,
|
||||
categoryState: 0x8e5ad6,
|
||||
portExec: 0xffffff,
|
||||
portNumber: 0x6fcf97,
|
||||
portBoolean: 0xe57373,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
inlineInputs,
|
||||
resolveDef,
|
||||
} from '../../src/core/graph';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
@@ -23,7 +23,7 @@ function sequenceFixture() {
|
||||
}
|
||||
|
||||
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
|
||||
resolveDef(node, behavior.graph, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
|
||||
resolveDef(node, behavior.graph, graphLookup(fixture.project))!.outputs.map((p) => p.key);
|
||||
|
||||
const edgeKeys = (behavior: BehaviorAsset) =>
|
||||
behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort();
|
||||
@@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => {
|
||||
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
|
||||
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.logs).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => {
|
||||
// One edge into the call, where two numbers would need two.
|
||||
expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1);
|
||||
const ctx = fakeContext({ variables: { result: 0 } });
|
||||
new GraphInterpreter(caller.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables.result).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-10
@@ -19,7 +19,7 @@ import {
|
||||
dropTargetPort,
|
||||
findNode,
|
||||
firstPortFor,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
type PortType,
|
||||
portsCompatible,
|
||||
} from '../../src/core/graph';
|
||||
@@ -72,7 +72,8 @@ describe('node registry', () => {
|
||||
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);
|
||||
// An event is seeded by the runtime, a state is a name in a machine: neither runs.
|
||||
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
|
||||
else expect(def.run).toBeTypeOf('function');
|
||||
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||||
}
|
||||
@@ -522,7 +523,7 @@ describe('dropping an edge on a node', () => {
|
||||
it('picks the first compatible input', () => {
|
||||
const { graph, number, clamp } = fixtureWithClamp();
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toEqual({
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toEqual({
|
||||
nodeId: clamp.id,
|
||||
port: 'value',
|
||||
isOutput: false,
|
||||
@@ -534,7 +535,7 @@ describe('dropping an edge on a node', () => {
|
||||
const other = edit.addNode('Number');
|
||||
edit.connect(other, 'value', clamp, 'value');
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('min');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('min');
|
||||
});
|
||||
|
||||
it('falls back to the last compatible input once every one is taken', () => {
|
||||
@@ -542,7 +543,7 @@ describe('dropping an edge on a node', () => {
|
||||
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('max');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('max');
|
||||
});
|
||||
|
||||
it('keeps offering an exec input, which is never exclusive', () => {
|
||||
@@ -553,26 +554,26 @@ describe('dropping an edge on a node', () => {
|
||||
const move = edit.addNode('SetPosition');
|
||||
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
|
||||
const from = { nodeId: held.id, port: 'exec', isOutput: true };
|
||||
expect(dropTargetPort(behavior.graph, from, move.id, NO_BEHAVIORS)?.port).toBe('exec');
|
||||
expect(dropTargetPort(behavior.graph, from, move.id, NO_LOOKUP)?.port).toBe('exec');
|
||||
});
|
||||
|
||||
it('refuses a node that offers nothing compatible, and the node itself', () => {
|
||||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||||
const boolean = edit.addNode('Boolean');
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, boolean.id, NO_LOOKUP)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toBeNull();
|
||||
// A pair that is already wired is not offered again either.
|
||||
edit.connect(number, 'value', clamp, 'value');
|
||||
edit.connect(number, 'value', clamp, 'min');
|
||||
edit.connect(number, 'value', clamp, 'max');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a drag started at an input to an output', () => {
|
||||
const { graph, number, clamp } = fixtureWithClamp();
|
||||
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
|
||||
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toEqual({
|
||||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toEqual({
|
||||
nodeId: number.id,
|
||||
port: 'value',
|
||||
isOutput: true,
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('the literal nodes', () => {
|
||||
['Boolean', 'boolean'],
|
||||
['String', 'text'],
|
||||
['Texture', 'texture'],
|
||||
['StateMachine', 'statemachine'],
|
||||
['List', 'list'],
|
||||
] as const) {
|
||||
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TraceListener,
|
||||
type Value,
|
||||
} from '../../src/core/graph';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
@@ -44,7 +44,7 @@ describe('live values', () => {
|
||||
[add, 'result', count, 'count'],
|
||||
]);
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
@@ -75,7 +75,7 @@ describe('live values', () => {
|
||||
const call = outer.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: 21 });
|
||||
outer.connect(onStart, 'exec', call, 'exec');
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
interpreter.fire('OnStart', fakeContext());
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
terminalNode,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
@@ -55,7 +55,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
|
||||
}
|
||||
|
||||
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
|
||||
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
|
||||
const ctx = fakeContext({ variables });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
return { ctx, interpreter };
|
||||
@@ -73,7 +73,7 @@ describe('a behavior used as a node', () => {
|
||||
const callee = doubler(fixture);
|
||||
const caller = callerOf(fixture, callee, 1);
|
||||
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
|
||||
const def = resolveDef(call, caller.graph, behaviorLookup(fixture.project))!;
|
||||
const def = resolveDef(call, caller.graph, graphLookup(fixture.project))!;
|
||||
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
|
||||
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
|
||||
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
|
||||
@@ -185,7 +185,7 @@ describe('a behavior used as a node', () => {
|
||||
describe('a call that cannot do anything says so', () => {
|
||||
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
|
||||
const fixture = makeProject();
|
||||
const lookup = () => behaviorLookup(fixture.project);
|
||||
const lookup = () => graphLookup(fixture.project);
|
||||
const callee = doubler(fixture);
|
||||
const caller = makeBehavior(fixture, 'Caller');
|
||||
const edit = graphEditor(fixture, caller);
|
||||
@@ -214,11 +214,11 @@ describe('a call that cannot do anything says so', () => {
|
||||
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
|
||||
const caller = makeBehavior(fixture, 'Caller');
|
||||
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||||
const signature = () => portSignature(call, caller.graph, behaviorLookup(fixture.project), caller.id);
|
||||
const signature = () => portSignature(call, caller.graph, graphLookup(fixture.project), caller.id);
|
||||
|
||||
const healthy = signature();
|
||||
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
|
||||
expect(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare');
|
||||
expect(resolveDef(call, caller.graph, graphLookup(fixture.project))!.label).toBe('Bare');
|
||||
expect(signature()).not.toBe(healthy);
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('a call that cannot do anything says so', () => {
|
||||
]);
|
||||
|
||||
// A missing end node is not a defect, so nothing is reported.
|
||||
expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null);
|
||||
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
|
||||
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
|
||||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
|
||||
});
|
||||
@@ -293,7 +293,7 @@ describe('declared ports', () => {
|
||||
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
|
||||
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
|
||||
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
|
||||
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
|
||||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
|
||||
const ctx = fakeContext({ variables: { result: 0 } });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
expect(ctx.variables.result).toBe(12);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import {
|
||||
createEmptyGraph,
|
||||
type DeclaredPort,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
type NodeContext,
|
||||
resolveDef,
|
||||
type RunResult,
|
||||
@@ -29,6 +29,9 @@ interface Case {
|
||||
outputs?: Record<string, Value>;
|
||||
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
|
||||
derivedOutputs?: string[];
|
||||
/** Exec inputs grown the same way, and the one the pulse arrives through (`exec` unless said). */
|
||||
derivedInputs?: string[];
|
||||
entry?: string;
|
||||
/** Expected exec continuation; `null` asserts the flow ends here. */
|
||||
next?: string | string[] | null;
|
||||
/** Expected loop request; `null` asserts the node does not loop. */
|
||||
@@ -42,10 +45,14 @@ interface Case {
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
|
||||
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type);
|
||||
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
|
||||
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_LOOKUP)! : requireNodeDef(type);
|
||||
const execPorts = (keys: string[] = []) => keys.map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = {
|
||||
...registered,
|
||||
inputs: [...execPorts(c.derivedInputs), ...registered.inputs],
|
||||
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
|
||||
};
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec'), ctx };
|
||||
};
|
||||
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
@@ -88,6 +95,49 @@ const CASES: Record<string, Case[]> = {
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
StateBranch: [
|
||||
{ name: 'in no state yet, the flow ends', inputs: { machine: 'm' }, derivedOutputs: ['s1'], next: null },
|
||||
{
|
||||
name: 'follows the output of the current state',
|
||||
inputs: { machine: 'm' },
|
||||
context: { states: { m: 's1' } },
|
||||
derivedOutputs: ['s1', 's2'],
|
||||
next: 's1',
|
||||
},
|
||||
{
|
||||
name: 'a state that is not among its outputs (another machine, a deleted state) ends the flow',
|
||||
inputs: { machine: 'm' },
|
||||
context: { states: { m: 'gone' } },
|
||||
derivedOutputs: ['s1'],
|
||||
next: null,
|
||||
},
|
||||
],
|
||||
SetState: [
|
||||
{
|
||||
name: 'puts the machine into the state whose input the flow arrived through',
|
||||
inputs: { machine: 'm' },
|
||||
derivedInputs: ['s1', 's2'],
|
||||
entry: 's2',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
|
||||
},
|
||||
{
|
||||
name: 'arriving through no state input changes nothing',
|
||||
inputs: { machine: 'm' },
|
||||
derivedInputs: ['s1'],
|
||||
entry: 'exec',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({}),
|
||||
},
|
||||
{
|
||||
name: 'without a machine there is nothing to set',
|
||||
inputs: { machine: '' },
|
||||
derivedInputs: ['s1'],
|
||||
entry: 's1',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({}),
|
||||
},
|
||||
],
|
||||
Repeat: [
|
||||
{
|
||||
name: 'asks for as many body rounds as it was given, then continues at done',
|
||||
@@ -227,6 +277,10 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
|
||||
{ name: 'unset is no picture', outputs: { value: '' } },
|
||||
],
|
||||
StateMachine: [
|
||||
{ name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } },
|
||||
{ name: 'unset is no machine', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
type DeclaredPort,
|
||||
GraphInterpreter,
|
||||
inlineInputDef,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_TYPE_PARAM,
|
||||
propertyPort,
|
||||
resolveDef,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
@@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) {
|
||||
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, props: PropertyBag) {
|
||||
const self = { ...fakeEntity('self'), props: { ...props } };
|
||||
const ctx = fakeContext({ self, variables: { seen: 0 } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
return { props: self.props, seen: ctx.variables.seen };
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('a node that reads and writes properties of an object', () => {
|
||||
[add, 'result', coins, 'coins'],
|
||||
]);
|
||||
const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ }));
|
||||
expect(self_.props.coins).toBe(7);
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe('a node that reads and writes properties of an object', () => {
|
||||
it('has flow pins only while a property is set, and they sit above the object input', () => {
|
||||
const { behavior, props } = onSelf([set('width'), { key: 'height', kind: 'number' }]);
|
||||
const keys = () => {
|
||||
const def = resolveDef(props, behavior.graph, NO_BEHAVIORS)!;
|
||||
const def = resolveDef(props, behavior.graph, NO_LOOKUP)!;
|
||||
return { inputs: def.inputs.map((p) => p.key), outputs: def.outputs.map((p) => p.key) };
|
||||
};
|
||||
expect(keys()).toEqual({ inputs: ['exec', 'object', 'width'], outputs: ['exec', 'width', 'height'] });
|
||||
@@ -147,7 +147,7 @@ describe('what a port carries, by the type of the node', () => {
|
||||
const edit = graphEditor(fixture, makeBehavior(fixture, 'B'));
|
||||
const hidden = (params: PropertyBag) => {
|
||||
const node = edit.properties([], params);
|
||||
return resolveDef(node, { nodes: [node], edges: [] }, NO_BEHAVIORS)!.hiddenParams?.(node.params);
|
||||
return resolveDef(node, { nodes: [node], edges: [] }, NO_LOOKUP)!.hiddenParams?.(node.params);
|
||||
};
|
||||
expect(hidden(SPRITE)).toEqual([OBJECT_BEHAVIOR_PARAM]);
|
||||
expect(hidden({})).toEqual([OBJECT_BEHAVIOR_PARAM]);
|
||||
@@ -166,7 +166,7 @@ describe('what a port carries, by the type of the node', () => {
|
||||
edit.properties([width], SPRITE),
|
||||
edit.properties([], SPRITE),
|
||||
];
|
||||
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_BEHAVIORS)!.label);
|
||||
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_LOOKUP)!.label);
|
||||
expect(new Set(titles())).toEqual(new Set(['Properties']));
|
||||
fixture.stack.execute(
|
||||
new CollapseNodesCommand(
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* A state machine is an asset whose graph holds one `State` node per state. A behavior reads it
|
||||
* through `State branch` (one exec output per state) and moves it through `Set state` (one exec
|
||||
* input per state); the current state lives with the behavior instance, like its variables.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand, ConnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
GraphInterpreter,
|
||||
MACHINE_PORT,
|
||||
nextStateName,
|
||||
resolveDef,
|
||||
STATE,
|
||||
STATE_NAME_PARAM,
|
||||
statesOf,
|
||||
} from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import type { StateMachineAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||
import {
|
||||
fakeContext,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type ProjectFixture,
|
||||
roundTrip,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
|
||||
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
|
||||
const create = new CreateAssetCommand('statemachine', 'Doors');
|
||||
fixture.stack.execute(create);
|
||||
const ids = names.map((name) => {
|
||||
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
||||
fixture.stack.execute(add);
|
||||
return add.node.id;
|
||||
});
|
||||
return {
|
||||
id: create.asset.id,
|
||||
asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!,
|
||||
ids,
|
||||
};
|
||||
}
|
||||
|
||||
describe('a state machine asset', () => {
|
||||
it('is created empty, is not placeable, and takes states as nodes', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
expect(isPlaceableType('statemachine')).toBe(false);
|
||||
expect(statesOf(machine.asset().graph)).toEqual([
|
||||
{ id: machine.ids[0], name: 'Open' },
|
||||
{ id: machine.ids[1], name: 'Closed' },
|
||||
]);
|
||||
f.stack.undo();
|
||||
expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']);
|
||||
});
|
||||
|
||||
it('names a state by its setting, and falls back to "State" for an empty name', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f, ['Open']);
|
||||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, ''));
|
||||
expect(statesOf(machine.asset().graph)[0]?.name).toBe('State');
|
||||
const node = machine.asset().graph.nodes[0]!;
|
||||
expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State');
|
||||
});
|
||||
|
||||
it('survives a save and load, keeping only its states', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine');
|
||||
expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph));
|
||||
|
||||
// A node of any other type has no business in a machine and is dropped on load.
|
||||
const smuggled = deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'm',
|
||||
type: 'statemachine',
|
||||
name: 'M',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } },
|
||||
{ id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const asset = findAsset(smuggled, 'm') as StateMachineAsset;
|
||||
expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]);
|
||||
});
|
||||
|
||||
it('keeps its State node out of every behavior palette: it is registered, not built in', () => {
|
||||
expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false);
|
||||
expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers one palette entry, a State named by its number', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const entries = paletteEntries(f.project, machine.asset());
|
||||
expect(entries.map((e) => e.def.type)).toEqual([STATE]);
|
||||
expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' });
|
||||
expect(nextStateName(machine.asset().graph)).toBe('State 3');
|
||||
f.stack.undo();
|
||||
expect(nextStateName(machine.asset().graph)).toBe('State 2');
|
||||
});
|
||||
|
||||
it('is a kind of variable holding the asset id', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||||
const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior');
|
||||
expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']);
|
||||
expect(back?.props.doors).toBe(machine.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the state ports of State branch and Set state', () => {
|
||||
const setup = () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const ports = (node: { id: string }, side: 'inputs' | 'outputs') =>
|
||||
resolveDef(
|
||||
behavior.graph.nodes.find((n) => n.id === node.id)!,
|
||||
behavior.graph,
|
||||
graphLookup(f.project),
|
||||
)!
|
||||
[side].filter((p) => p.type === 'exec')
|
||||
.map((p) => `${p.key}:${p.label}`);
|
||||
return { f, machine, behavior, edit, ports };
|
||||
};
|
||||
|
||||
it('are one exec port per state, keyed by the state id and labelled by its name', () => {
|
||||
const { machine, edit, ports } = setup();
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`];
|
||||
expect(ports(branch, 'outputs')).toEqual(expected);
|
||||
expect(ports(set, 'inputs')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('follow a State machine literal wired into the machine input', () => {
|
||||
const { machine, edit, ports } = setup();
|
||||
const literal = edit.addNode('StateMachine', { value: machine.id });
|
||||
const branch = edit.addNode('StateBranch');
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
edit.connect(literal, 'value', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]);
|
||||
});
|
||||
|
||||
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
|
||||
const { f, machine, behavior, edit, ports } = setup();
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||||
const variables = edit.variables(['doors']);
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
edit.connect(variables, 'doors', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
// Only the State machine literal is followed – a text holding the very same id is not.
|
||||
const text = edit.addNode('String', { value: machine.id });
|
||||
edit.connect(text, 'value', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
});
|
||||
|
||||
it('follow a rename, and lose the edges of a state that is deleted', () => {
|
||||
const { f, machine, behavior, edit, ports } = setup();
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
edit.connect(start, 'exec', set, machine.ids[0]!);
|
||||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar'));
|
||||
expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`);
|
||||
expect(behavior.graph.edges).toHaveLength(1);
|
||||
// Picking another machine orphans the edge, as any param that decides the ports does.
|
||||
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
|
||||
expect(behavior.graph.edges).toHaveLength(0);
|
||||
f.stack.undo();
|
||||
expect(behavior.graph.edges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a running state machine', () => {
|
||||
/** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */
|
||||
function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) {
|
||||
const behavior = makeBehavior(f, 'Door');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId });
|
||||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||||
const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||||
edit.wire([
|
||||
[start, 'exec', setOpen, open!],
|
||||
[update, 'exec', branch, 'exec'],
|
||||
[branch, open!, logOpen, 'exec'],
|
||||
[logOpen, 'exec', setClosed, closed!],
|
||||
[branch, closed!, logClosed, 'exec'],
|
||||
]);
|
||||
return behavior;
|
||||
}
|
||||
|
||||
it('is in no state until Set state ran, then follows the state it was put in', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = build(f, machine.id, machine.ids);
|
||||
const ctx = fakeContext();
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
expect(ctx.logs).toEqual([]);
|
||||
interpreter.fire('OnStart', ctx);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
expect(ctx.logs).toEqual(['open', 'closed']);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||||
});
|
||||
|
||||
it('keeps one state per behavior instance', () => {
|
||||
const f = makeSpriteScene();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = build(f, machine.id, machine.ids);
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 10, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
// Shared state would let the second instance find the door the first one just closed.
|
||||
expect(started.world.log).toEqual(['open', 'open']);
|
||||
started.frame();
|
||||
expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']);
|
||||
});
|
||||
|
||||
it('starts a called behavior in no state, whatever the caller set', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const inner = makeBehavior(f, 'Inner');
|
||||
const innerEdit = graphEditor(f, inner);
|
||||
const innerStart = innerEdit.addNode(BEHAVIOR_START);
|
||||
const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
const log = innerEdit.addNode('Log', { message: 'inner saw open' });
|
||||
innerEdit.wire([
|
||||
[innerStart, 'exec', branch, 'exec'],
|
||||
[branch, machine.ids[0]!, log, 'exec'],
|
||||
]);
|
||||
const outer = makeBehavior(f, 'Outer');
|
||||
const edit = graphEditor(f, outer);
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id });
|
||||
edit.wire([
|
||||
[start, 'exec', set, machine.ids[0]!],
|
||||
[set, 'exec', call, 'exec'],
|
||||
]);
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||||
expect(ctx.logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a connection to a state port the machine does not have', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
expect(() =>
|
||||
f.stack.execute(
|
||||
new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }),
|
||||
),
|
||||
).toThrow('Port not found');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCom
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||||
import { graphEditor, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('seeding a node from that shape', () => {
|
||||
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
|
||||
stack.execute(new AddPortsCommand(behavior.id, pack.id, ports));
|
||||
|
||||
const inputs = resolveDef(pack, behavior.graph, behaviorLookup(fixture.project))!.inputs;
|
||||
const inputs = resolveDef(pack, behavior.graph, graphLookup(fixture.project))!.inputs;
|
||||
expect(inputs.map((p) => `${p.key}:${p.type}`)).toEqual([
|
||||
'shape:number',
|
||||
'name:string',
|
||||
|
||||
@@ -104,11 +104,12 @@ describe('the literal nodes', () => {
|
||||
/** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */
|
||||
it('are labelled by the kind they hand out', () => {
|
||||
const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label;
|
||||
expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([
|
||||
expect(['Number', 'Boolean', 'String', 'Texture', 'StateMachine', 'List'].map(label)).toEqual([
|
||||
'Number',
|
||||
'Boolean',
|
||||
'Text',
|
||||
'Texture',
|
||||
'State machine',
|
||||
'List',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
|
||||
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
|
||||
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { type GraphNode, GraphInterpreter, inlineValue, resolveDef } from '../../src/core/graph';
|
||||
import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
function setup(variables = ['count', 'copy']) {
|
||||
@@ -19,12 +19,12 @@ function run(
|
||||
variables: Record<string, number> = { count: 5, copy: 0 },
|
||||
) {
|
||||
const ctx = fakeContext({ variables: { ...variables } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
return ctx.variables;
|
||||
}
|
||||
|
||||
const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
||||
resolveDef(node, behavior.graph, () => null)![side].map((p) => p.key);
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => p.key);
|
||||
|
||||
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('the Variables node', () => {
|
||||
const start = edit.addNode('OnStart');
|
||||
const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } });
|
||||
edit.connect(start, 'exec', node, 'exec');
|
||||
const carried = resolveDef(node, behavior.graph, () => null)!
|
||||
const carried = resolveDef(node, behavior.graph, NO_LOOKUP)!
|
||||
.inputs.filter((p) => p.type !== 'exec')
|
||||
.map((p) => [p.key, inlineValue(node, p)]);
|
||||
expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] });
|
||||
@@ -237,7 +237,7 @@ describe('switching Set on a Variables node', () => {
|
||||
|
||||
describe('Variables node titles', () => {
|
||||
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
|
||||
resolveDef(node, behavior.graph, () => null)!.label;
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)!.label;
|
||||
|
||||
it('follow a renamed variable once collapsed', () => {
|
||||
const { fixture, behavior, edit } = setup();
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph';
|
||||
import { type GraphNode, GraphInterpreter, NO_LOOKUP, resolveDef, VARIABLES } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
|
||||
@@ -41,14 +41,14 @@ function run(
|
||||
fixture: ReturnType<typeof makeProject>,
|
||||
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
|
||||
) {
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
const ctx = fakeContext({ variables });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
return ctx.variables;
|
||||
}
|
||||
|
||||
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
||||
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => `${p.key}:${p.type}`);
|
||||
|
||||
describe('a node that reads or writes several variables at once', () => {
|
||||
it('writes each variable it names and reads each one back', () => {
|
||||
@@ -139,7 +139,7 @@ describe('a node that reads or writes several variables at once', () => {
|
||||
]);
|
||||
|
||||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -181,6 +181,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
self: fakeEntity('self'),
|
||||
owner: fakeEntity('owner'),
|
||||
variables: { speed: 100 },
|
||||
states: {},
|
||||
time: 0,
|
||||
dt: 0.5,
|
||||
logs,
|
||||
@@ -213,9 +214,10 @@ export function runDef(
|
||||
ctx: NodeContext,
|
||||
inputs: Record<string, Value>,
|
||||
params: PropertyBag = {},
|
||||
entry = 'exec',
|
||||
): RunResult {
|
||||
const def = requireNodeDef(type);
|
||||
return def.run!(ctx, inputs, params, def);
|
||||
return def.run!(ctx, inputs, params, def, entry);
|
||||
}
|
||||
|
||||
/** Runs one node definition in isolation and returns its data outputs. */
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function graph(t) {
|
||||
};
|
||||
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
|
||||
|
||||
// 6a. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||
// 6a. Selecting the Mover asset binds the graph; the Behavior tab shows it.
|
||||
await clickText('Mover');
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
|
||||
@@ -88,7 +88,8 @@ export async function listTab(t) {
|
||||
await sleep(150);
|
||||
check(
|
||||
'a list of its own offers every kind, the struct included',
|
||||
(await json('__gm.list.info()')).offers.join(',') === 'number,text,boolean,color,texture,struct',
|
||||
(await json('__gm.list.info()')).offers.join(',') ===
|
||||
'number,text,boolean,color,texture,statemachine,struct',
|
||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||
);
|
||||
await pickKind('Text');
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* State machines: an asset made from the + menu, its states edited as nodes in the State machine
|
||||
* tab, read by a `State branch` and moved by a `Set state` in a behavior. The step removes
|
||||
* everything it made.
|
||||
*/
|
||||
|
||||
export async function stateMachine(t) {
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
json,
|
||||
evaluate,
|
||||
shot,
|
||||
mutate,
|
||||
press,
|
||||
click,
|
||||
clickText,
|
||||
locate,
|
||||
has,
|
||||
doubleClick,
|
||||
drag,
|
||||
} = t;
|
||||
const { typeText, undo, redo, world, play, stop } = t;
|
||||
const machines = () =>
|
||||
json(`__gm.state.project.assets
|
||||
.filter((a) => a.type === 'statemachine')
|
||||
.map((a) => ({ id: a.id, name: a.name, states: a.graph.nodes.map((n) => ({ id: n.id, name: n.params.name, x: n.x, y: n.y })) }))`);
|
||||
const states = async () => (await machines())[0]?.states ?? [];
|
||||
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
|
||||
const clickLast = async (text) => {
|
||||
const p = await locate(text, true);
|
||||
await click(p.x, p.y);
|
||||
await sleep(200);
|
||||
};
|
||||
const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`);
|
||||
const selection = () => json('__gm.state.selection');
|
||||
const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? [];
|
||||
const inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? [];
|
||||
const selectNode = async (nodeId) => {
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
|
||||
);
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
await press('Escape');
|
||||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await sleep(200);
|
||||
|
||||
// 26a. The + menu creates a state machine; selected, it opens its own tab.
|
||||
await clickText('+');
|
||||
await sleep(200);
|
||||
await clickLast('State machine');
|
||||
await sleep(400);
|
||||
const made = (await machines())[0];
|
||||
check(
|
||||
'the + menu creates a state machine asset',
|
||||
Boolean(made) && (await machines()).length === 1,
|
||||
JSON.stringify(await machines()),
|
||||
);
|
||||
const picked = await selection();
|
||||
check(
|
||||
'the new machine is selected and its tab opens on it',
|
||||
picked?.kind === 'asset' &&
|
||||
picked.assetId === made?.id &&
|
||||
(await json('__gm.tab()')) === 'statemachine' &&
|
||||
(await json('__gm.stateMachine.current()')) === made?.id,
|
||||
JSON.stringify({
|
||||
picked,
|
||||
tab: await json('__gm.tab()'),
|
||||
current: await json('__gm.stateMachine.current()'),
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'the tab button has room for its label',
|
||||
(await json("__gm.shownLabel('State machine')")) === 'State machine',
|
||||
String(await json("__gm.shownLabel('State machine')")),
|
||||
);
|
||||
|
||||
// 26b. Right-click places a state where the pointer is, numbered and selected; no header button.
|
||||
const canvas = await json('__gm.stateMachine.canvasRect()');
|
||||
const addState = async (dy) => {
|
||||
await click(canvas.x + 80, canvas.y + dy, 'right');
|
||||
await sleep(300);
|
||||
await clickLast('State');
|
||||
await sleep(300);
|
||||
};
|
||||
await addState(60);
|
||||
await addState(220);
|
||||
const two = await states();
|
||||
check(
|
||||
'right-click → State adds a state each time, named by its number',
|
||||
two.map((s) => s.name).join(',') === 'State 1,State 2',
|
||||
JSON.stringify(two),
|
||||
);
|
||||
check('the tab header has no + State button', !(await has('+ State')));
|
||||
check(
|
||||
'the new state is selected and its node shows its name',
|
||||
(await selection())?.nodeId === two[1]?.id && (await stateLabel(two[1]?.id)) === 'State 2',
|
||||
JSON.stringify({ selection: await selection(), label: await stateLabel(two[1]?.id) }),
|
||||
);
|
||||
const rects = await Promise.all(
|
||||
two.map((s) => json(`__gm.stateMachine.nodeRect(${JSON.stringify(s.id)})`)),
|
||||
);
|
||||
check(
|
||||
'each state sits where its right-click was, the second below the first',
|
||||
rects[0] && rects[1] && rects[0].y + rects[0].h <= rects[1].y,
|
||||
JSON.stringify(rects),
|
||||
);
|
||||
await shot('01-two-states');
|
||||
|
||||
// 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid.
|
||||
const GRID = 24;
|
||||
const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN };
|
||||
check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false);
|
||||
const snapLabel = await locate('Snap');
|
||||
const preSnap = JSON.stringify(await states());
|
||||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
check(
|
||||
'the Snap toggle switches it on without moving a state',
|
||||
(await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap,
|
||||
JSON.stringify(await states()),
|
||||
);
|
||||
// The selected state: dragging another would move the selection off it, and 26c renames the selected one.
|
||||
const dragged = two[1]?.id;
|
||||
const from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`);
|
||||
const wasAt = await stateXY(dragged);
|
||||
await drag(from.x, from.y, from.x + 50, from.y + 50);
|
||||
await sleep(200);
|
||||
const nowAt = await stateXY(dragged);
|
||||
check(
|
||||
'a dragged state lands on the grid, not where the pointer let go',
|
||||
nowAt.x % GRID === 0 && nowAt.y % GRID === 0 && (nowAt.x !== wasAt.x || nowAt.y !== wasAt.y),
|
||||
`${wasAt.x},${wasAt.y} → ${nowAt.x},${nowAt.y}`,
|
||||
);
|
||||
await undo();
|
||||
const backAt = await stateXY(dragged);
|
||||
check(
|
||||
'one undo takes the snapped drag back',
|
||||
backAt.x === wasAt.x && backAt.y === wasAt.y,
|
||||
`${backAt.x},${backAt.y}`,
|
||||
);
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false);
|
||||
|
||||
// 26c. The inspector renames the selected state; the node follows.
|
||||
const nameField = await json("__gm.inspectorField('Name', true)");
|
||||
check('the inspector shows a Name field for the state', Boolean(nameField), JSON.stringify(nameField));
|
||||
await doubleClick(nameField.x, nameField.y);
|
||||
await typeText('Open');
|
||||
await press('Enter');
|
||||
await sleep(300);
|
||||
const renamed = (await states())[1];
|
||||
check(
|
||||
'typing in the Name field renames the state, on the node too',
|
||||
renamed?.name === 'Open' && (await stateLabel(renamed?.id)) === 'Open',
|
||||
JSON.stringify({ renamed, label: await stateLabel(renamed?.id) }),
|
||||
);
|
||||
|
||||
// 26d. Undo and redo walk the rename and the additions back and forth.
|
||||
await undo();
|
||||
const afterOne = (await states()).map((s) => s.name).join(',');
|
||||
await undo();
|
||||
const afterTwo = (await states()).map((s) => s.name).join(',');
|
||||
await redo();
|
||||
await redo();
|
||||
const back = (await states()).map((s) => s.name).join(',');
|
||||
check(
|
||||
'undo takes the rename and then the state back, redo brings both',
|
||||
afterOne === 'State 1,State 2' && afterTwo === 'State 1' && back === 'State 1,Open',
|
||||
JSON.stringify({ afterOne, afterTwo, back }),
|
||||
);
|
||||
|
||||
// 26e. The canvas palette offers a state and nothing else.
|
||||
await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right');
|
||||
await sleep(300);
|
||||
const offered = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'right-click offers a State and no behavior node',
|
||||
offered.includes('State') && !offered.some((text) => /branch|Set state|Number|On start/i.test(text)),
|
||||
JSON.stringify(offered),
|
||||
);
|
||||
await press('Escape');
|
||||
await sleep(200);
|
||||
|
||||
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
|
||||
const [first, second] = (await states()).map((s) => s.id);
|
||||
const rig = await scaffoldBehavior(t, {
|
||||
assetId: 'sm-logic',
|
||||
name: 'Doors logic',
|
||||
boxId: 'sm-box',
|
||||
boxName: 'State box',
|
||||
logicId: 'sm-brain',
|
||||
y: LANES.stateMachine,
|
||||
graph: {
|
||||
nodes: [
|
||||
node('sm-start', 'OnStart', 0, 0),
|
||||
node('sm-set', 'SetState', 300, 0, { machine: made.id }),
|
||||
node('sm-update', 'OnUpdate', 0, 240),
|
||||
node('sm-branch', 'StateBranch', 300, 240, { machine: made.id }),
|
||||
node('sm-log', 'Log', 600, 200, { message: 'first state' }),
|
||||
node('sm-log2', 'Log', 600, 360, { message: 'second state' }),
|
||||
],
|
||||
edges: [
|
||||
edge('sm-e1', 'sm-start', 'exec', 'sm-set', first),
|
||||
edge('sm-e2', 'sm-update', 'exec', 'sm-branch', 'exec'),
|
||||
edge('sm-e3', 'sm-branch', first, 'sm-log', 'exec'),
|
||||
edge('sm-e4', 'sm-branch', second, 'sm-log2', 'exec'),
|
||||
],
|
||||
},
|
||||
});
|
||||
await selectNode('sm-branch');
|
||||
check(
|
||||
'selecting a behavior node opens the Behavior tab on that behavior',
|
||||
(await json('__gm.tab()')) === 'graph' && (await json('__gm.graph.current()')) === 'sm-logic',
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), current: await json('__gm.graph.current()') }),
|
||||
);
|
||||
const branchOutputs = await outputsOf('sm-branch');
|
||||
const setInputs = await inputsOf('sm-set');
|
||||
const names = (lines) => lines.map((line) => line.trim());
|
||||
check(
|
||||
'State branch grows one output per state, named after it',
|
||||
names(branchOutputs).includes('State 1') && names(branchOutputs).includes('Open'),
|
||||
JSON.stringify(branchOutputs),
|
||||
);
|
||||
check(
|
||||
'Set state grows one input per state, named after it',
|
||||
names(setInputs).includes('State 1') && names(setInputs).includes('Open'),
|
||||
JSON.stringify(setInputs),
|
||||
);
|
||||
await shot('02-behavior-ports');
|
||||
|
||||
await play();
|
||||
await sleep(600);
|
||||
const log = (await world())?.log ?? [];
|
||||
check(
|
||||
'in play the branch follows the state Set state put the machine in',
|
||||
log.includes('first state') && !log.includes('second state'),
|
||||
JSON.stringify([...new Set(log)]),
|
||||
);
|
||||
await stop();
|
||||
|
||||
// 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
|
||||
await mutate(`
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');
|
||||
a.graph.nodes.push(${JSON.stringify(node('sm-fresh', 'StateBranch', 300, 520))});
|
||||
`);
|
||||
await selectNode('sm-fresh');
|
||||
check('without a machine a State branch has no state outputs', (await outputsOf('sm-fresh')).length === 0);
|
||||
// The row is named after the port, as the node body names it.
|
||||
const machineField = await json("__gm.inspectorField('machine', true)");
|
||||
await click(machineField.x, machineField.y);
|
||||
await sleep(300);
|
||||
const choices = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'the Machine picker offers the machine by name',
|
||||
choices.includes(made.name),
|
||||
JSON.stringify(choices),
|
||||
);
|
||||
await clickLast(made.name);
|
||||
await sleep(400);
|
||||
check(
|
||||
'picking it grows the state outputs',
|
||||
names(await outputsOf('sm-fresh')).includes('Open'),
|
||||
JSON.stringify(await outputsOf('sm-fresh')),
|
||||
);
|
||||
await shot('03-machine-picked');
|
||||
|
||||
// 26h. Everything the step made goes away again.
|
||||
const left = await rig.remove();
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)});
|
||||
`);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await sleep(300);
|
||||
check(
|
||||
'the step leaves nothing behind',
|
||||
left.objects === 0 && left.assets === 0 && (await machines()).length === 0,
|
||||
JSON.stringify({ left, machines: await machines() }),
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { structVariable } from './22-struct.mjs';
|
||||
import { variableNodes } from './23-variable-nodes.mjs';
|
||||
import { propertyNodes } from './24-property-nodes.mjs';
|
||||
import { animation } from './25-animation.mjs';
|
||||
import { stateMachine } from './26-state-machine.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -55,4 +56,5 @@ export const STEPS = [
|
||||
variableNodes,
|
||||
propertyNodes,
|
||||
animation,
|
||||
stateMachine,
|
||||
];
|
||||
@@ -14,6 +14,7 @@ export const LANES = {
|
||||
pause: 300,
|
||||
nested: 700,
|
||||
keyRepeat: -300,
|
||||
stateMachine: -700,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user