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:
rootandClaude Fable 5.1 committed 2026-09-12 18:00:23 +02:00
1 parent ce42d16657
commit e7d82bd1a9
60 files changed
+1269 -213

No files matched your search

+18 -1
View File
@@ -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 -3
View File
@@ -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 {
+13 -1
View File
@@ -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.
-15
View File
@@ -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) };
};
}
+22
View File
@@ -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;
},
};
}