Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors

Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.

The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.

Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.

The "Add node..." header button is gone; nodes come from the context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 15:07:07 +02:00
1 parent e9fa592078
commit 854ae2e756
35 files changed
+1044 -101

No files matched your search

+18 -8
View File
@@ -107,7 +107,15 @@ shown on the node and editable in the inspector.
input. Their plurals `Get variables` / `Set variables` do the same for several at once: their settings are a *list* of
variables, one port per entry, so reading a handful is one node instead of a column of five. Those
ports are picked from the behavior's variables rather than named, they take their type from the
declaration, and renaming a variable carries them (and their edges) along. A variable is a
declaration, and renaming a variable carries them (and their edges) along. `Get properties` /
`Set properties` do the same for the object they are handed: a built-in property (`width`,
`color`, `align`, …) is picked from one list covering every object type, each entry named with
its type (*Sprite width*, *Camera zoom* – a key several types share is there once per type), and
keeps its own field and default – an unconnected `color` is a colour field starting white, shown
on the node as `#ffffff`. A name the list does not know (a variable of another behavior's object)
is added as *Custom number/text/…* and typed; typing a built-in name turns it into that property.
The single `Get property` / `Set property` name theirs as text, with the same list one click away
on the button beside the field. A variable is a
**number, a yes/no, a text, a list or a struct**; a list holds numbers, texts, yes/nos, colours or
**structs** (its item kind decides which widget an item gets) and is
compared by its items, never by identity – otherwise every reload would look like an override.
@@ -153,7 +161,7 @@ shown on the node and editable in the inspector.
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
variable`, `Variable`, `Get/Set variables`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
variable`, `Variable`, `Get/Set variables`, `Get/Set property`, `Get/Set properties`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
`Substring`, `Find text`, `Equals`,
lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`,
@@ -217,8 +225,11 @@ shown on the node and editable in the inspector.
object in the scene (see *Cross-object state* below). A behavior that would run itself raises an
error instead of looping; there is one start and one end node per behavior.
Three ways in and out of a call: **drag** a behavior out of the asset panel onto the graph and it
lands as a call where you let go; **double-click** a call node to open the graph it runs. And a
call that cannot do anything **says so on the node** – no behavior picked, the behavior deleted,
lands as a call where you let go; **double-click** a call node to open the graph it runs. The
way down stays above the graph as a **breadcrumb** (`Game › Move › Other`): click a crumb to go
back there; the first one returns to where you started and the bar goes away. Opening a behavior
any other way – the asset panel, the scene, the picker – ends the trail. And a call that cannot
do anything **says so on the node** – no behavior picked, the behavior deleted,
or no start node in it. The last one is the one worth naming: without a start node there is no
way in, so the call is skipped and the flow simply carries on at its `exec` output. A missing
*end* node is not a defect – a call is always synchronous, "done" is the nested flow running out,
@@ -277,8 +288,7 @@ shown on the node and editable in the inspector.
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
this is drawing only: `collapsed` on the node is saved with the project.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
**Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
height – `editor/framing.ts`), and the size of the graph as "N nodes · M links".
- **Snap** (header checkbox, off by default): a drag drops each node it carries on the nearest
24 px grid line – the same grid the background draws. It takes effect on the next drag only, so
@@ -649,7 +659,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
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 542 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 569 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
@@ -720,7 +730,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) that build on each other's
variable nodes, property nodes) 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.
+13 -5
View File
@@ -4,6 +4,7 @@ import {
declaresVariablePorts,
type DeclaredPort,
findNode,
fixedPortKeys,
type Graph,
type GraphEdge,
type GraphNode,
@@ -73,6 +74,12 @@ function carryPort(site: PortSite, from: string, to: string): void {
const INVALID_NAME =
'Port names must start with a letter, contain only letters, digits or _, and "exec" is taken by the flow port';
function checkPortName(node: GraphNode, key: string): void {
if (!isValidPortName(key)) throw new CommandError(INVALID_NAME);
if (fixedPortKeys(requireNodeDef(node.type)).includes(key))
throw new CommandError(`"${key}" is taken by a port of the node itself`);
}
/** Several ports as one step, so seeding a node from a struct is one undo rather than one per field. */
export class AddPortsCommand implements Command {
readonly label: string;
@@ -91,7 +98,7 @@ export class AddPortsCommand implements Command {
const node = requireNode(project, this.assetId, this.nodeId);
const keys = new Set((node.ports ?? []).map((p) => p.key));
for (const port of this.ports) {
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
checkPortName(node, port.key);
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
keys.add(port.key);
}
@@ -187,7 +194,7 @@ export class RenamePortCommand implements Command {
if (from === to) return;
const node = requireNode(project, this.assetId, this.nodeId);
const sites = portSites(project, this.assetId, node);
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
checkPortName(node, to);
const ports = node.ports ?? [];
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
@@ -209,11 +216,11 @@ function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string):
}
/**
* Points one declared port at another variable. Key and kind change together, because on a
* Get/Set variables node the kind is the variable's rather than a choice of its own; the edges
* Points one declared port at another variable or property. Key and kind change together, because
* there the kind is the variable's (the property's) rather than a choice of its own; the edges
* hanging on the port come along, so correcting a pick is not a rewiring.
*/
export class SetVariablePortCommand implements Command {
export class RepointPortCommand implements Command {
readonly label: string;
private previous?: DeclaredPort;
@@ -231,6 +238,7 @@ export class SetVariablePortCommand implements Command {
const ports = node.ports ?? [];
const current = ports.find((p) => p.key === this.from);
if (!current) throw new CommandError(`Port "${this.from}" not found`);
checkPortName(node, this.port.key);
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
throw new CommandError(`"${this.port.key}" already exists`);
this.previous = { ...current };
+6 -1
View File
@@ -1,6 +1,6 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { Graph, GraphNode, PortType } from './Graph';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asList, asNumber, asString, type Value } from './values';
@@ -11,6 +11,8 @@ export interface PortDef {
type: PortType;
/** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */
default?: PropertyValue;
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
field?: PropertyDef;
}
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
@@ -76,6 +78,8 @@ export interface NodeDef {
* is what gives a node the port editor in the inspector.
*/
declaresPorts?: 'inputs' | 'outputs';
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
declaredPort?(port: DeclaredPort): PortDef;
/**
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
@@ -132,6 +136,7 @@ export function inlineInputs(def: NodeDef): PortDef[] {
}
export function inlineInputDef(port: PortDef): PropertyDef {
if (port.field) return { ...port.field, key: port.key, label: port.label };
switch (port.type) {
case 'boolean':
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
+1
View File
@@ -7,6 +7,7 @@ export * from './lookup';
export * from './nested';
export * from './NodeContext';
export * from './NodeRegistry';
export * from './propertyPorts';
export * from './values';
export * from './variablePorts';
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
+13 -1
View File
@@ -9,6 +9,8 @@ export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const GET_VARIABLES = 'GetVariables';
export const SET_VARIABLES = 'SetVariables';
export const GET_PROPERTIES = 'GetProperties';
export const SET_PROPERTIES = 'SetProperties';
export const BEHAVIOR_PARAM = 'behavior';
@@ -21,11 +23,21 @@ export function declaresVariablePorts(type: string): boolean {
return type === GET_VARIABLES || type === SET_VARIABLES;
}
/** Nodes whose declared ports are properties of the object they are handed. */
export function declaresPropertyPorts(type: string): boolean {
return type === GET_PROPERTIES || type === SET_PROPERTIES;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean {
return isValidFieldName(name) && name !== 'exec';
}
/** Keys a declared port cannot take: the node's fixed ports on the side it declares (`object`). */
export function fixedPortKeys(def: NodeDef): string[] {
return (def.declaresPorts === 'inputs' ? def.inputs : def.outputs).map((p) => p.key);
}
/** The single start (or end) node of a graph; a second one is ignored. */
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
return graph.nodes.find((n) => n.type === type);
@@ -56,7 +68,7 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
const own = def.declaresPorts ? (node.ports ?? []).map(portDefOf) : [];
const own = def.declaresPorts ? (node.ports ?? []).map((p) => (def.declaredPort ?? portDefOf)(p)) : [];
const side = (which: 'inputs' | 'outputs') => (def.declaresPorts === which ? own : []);
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
return {
@@ -0,0 +1,28 @@
import { SET_PROPERTIES } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { propertyPort } from '../../propertyPorts';
import { asEntity, writeTyped } from '../../values';
import { exec, obj } from '../shared';
/**
* The plural of Set property: one input per property picked. Like Set variables it writes every
* property it lists each time it runs – an unconnected input writes the value typed on the node.
*/
export const SetProperties: NodeDef = {
type: SET_PROPERTIES,
label: 'Set properties',
category: 'action',
description: 'Writes several properties (width, color, …) of an object at once, one input per property.',
inputs: [exec(), obj('object')],
outputs: [exec()],
params: [],
declaresPorts: 'inputs',
declaredPort: propertyPort,
run: (_ctx, inputs, _params, def) => {
const target = asEntity(inputs.object);
if (target)
for (const port of def.inputs.slice(SetProperties.inputs.length))
writeTyped(target.props, port.key, inputs[port.key]);
return { next: 'exec' };
},
};
+3 -3
View File
@@ -1,8 +1,8 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asEntity, writeTyped } from '../../values';
import { any, exec, obj } from '../shared';
import { any, exec, obj, PROPERTY_PARAM } from '../shared';
import { textOf } from '../../../model/Properties';
export const SetProperty: NodeDef = {
type: 'SetProperty',
label: 'Set property',
@@ -10,7 +10,7 @@ export const SetProperty: NodeDef = {
description: 'Writes a property (width, color, …) of an object.',
inputs: [exec(), obj('object'), any('value')],
outputs: [exec()],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
params: [PROPERTY_PARAM],
run: (_ctx, inputs, params) => {
const target = asEntity(inputs.object);
const key = textOf(params.property ?? '');
@@ -0,0 +1,24 @@
import { GET_PROPERTIES } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { propertyPort } from '../../propertyPorts';
import { asEntity, type Value } from '../../values';
import { obj } from '../shared';
/** The plural of Get property: one output per property picked, the way Get variables reads. */
export const GetProperties: NodeDef = {
type: GET_PROPERTIES,
label: 'Get properties',
category: 'data',
description: 'Reads several properties (width, color, …) of an object at once, one output per property.',
inputs: [obj('object')],
outputs: [],
params: [],
declaresPorts: 'outputs',
declaredPort: propertyPort,
run: (_ctx, inputs, _params, def) => {
const target = asEntity(inputs.object);
const outputs: Record<string, Value> = {};
for (const port of def.outputs) outputs[port.key] = target?.props[port.key] ?? null;
return { outputs };
},
};
+3 -3
View File
@@ -1,8 +1,8 @@
import { textOf } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asEntity } from '../../values';
import { any, obj } from '../shared';
import { any, obj, PROPERTY_PARAM } from '../shared';
import { textOf } from '../../../model/Properties';
export const GetProperty: NodeDef = {
type: 'GetProperty',
label: 'Get property',
@@ -10,7 +10,7 @@ export const GetProperty: NodeDef = {
description: 'Reads a property (width, color, …) of an object.',
inputs: [obj('object')],
outputs: [any('value')],
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
params: [PROPERTY_PARAM],
run: (_ctx, inputs, params) => ({
outputs: { value: asEntity(inputs.object)?.props[textOf(params.property ?? '')] ?? null },
}),
+4
View File
@@ -5,6 +5,7 @@ import { Move } from './action/Move';
import { RestartGame } from './action/RestartGame';
import { SetEnabled } from './action/SetEnabled';
import { SetPosition } from './action/SetPosition';
import { SetProperties } from './action/SetProperties';
import { SetProperty } from './action/SetProperty';
import { SetText } from './action/SetText';
import { Spawn } from './action/Spawn';
@@ -23,6 +24,7 @@ import { Equals } from './data/Equals';
import { FindText } from './data/FindText';
import { Floor } from './data/Floor';
import { GetPosition } from './data/GetPosition';
import { GetProperties } from './data/GetProperties';
import { GetProperty } from './data/GetProperty';
import { IsAsset } from './data/IsAsset';
import { IsEnabled } from './data/IsEnabled';
@@ -125,6 +127,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
IsAsset,
IsEnabled,
GetProperty,
GetProperties,
GetPosition,
// data: literals and math
NumberLiteral,
@@ -182,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Move,
SetPosition,
SetProperty,
SetProperties,
SetEnabled,
SetText,
Spawn,
+10 -1
View File
@@ -1,9 +1,18 @@
import { type PropertyBag, type PropertyValue, textOf } from '../../model/Properties';
import type { PropertyDef } from '../../properties/PropertySchema';
import { OBJECT_PROPERTY_SUGGESTIONS, type PropertyDef } from '../../properties/PropertySchema';
import type { NodeContext } from '../NodeContext';
import type { DerivedShape, NodeDef, PortDef, PortSource, RunResult } from '../NodeRegistry';
import { asBoolean, asNumber, type Value, writeTyped } from '../values';
/** The property a Get/Set property node names: typed freely, or picked from the built-in ones. */
export const PROPERTY_PARAM: PropertyDef = {
key: 'property',
label: 'Property',
kind: 'text',
default: 'width',
suggestions: OBJECT_PROPERTY_SUGGESTIONS,
};
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
+24
View File
@@ -0,0 +1,24 @@
import { OBJECT_PROPERTIES, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort } from './Graph';
import { portDefOf } from './nested';
import type { PortDef } from './NodeRegistry';
import { portKindOf } from './variablePorts';
export function objectPropertyDef(key: string): PropertyDef | undefined {
return OBJECT_PROPERTIES.find((def) => def.key === key);
}
export function propertyPortOf(def: PropertyDef): DeclaredPort {
return { key: def.key, kind: portKindOf(def) };
}
/**
* A port naming a built-in property carries its def: a typed-in value starts at its default and is
* edited with its field. A custom name, or a kind the property does not travel as, stays plain.
*/
export function propertyPort(port: DeclaredPort): PortDef {
const plain = portDefOf(port);
const field = objectPropertyDef(port.key);
if (!field || portKindOf(field) !== port.kind) return plain;
return { ...plain, field, default: field.default };
}
+3 -2
View File
@@ -1,8 +1,9 @@
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
/** Every variable kind is a port kind too; the fallback is for a project file that says otherwise. */
export function variablePortKind(def: PropertyDef): DeclaredPortKind {
/** The port a variable or property travels on. A color is a number; an enum or a texture is text. */
export function portKindOf(def: PropertyDef): DeclaredPortKind {
if (def.kind === 'color') return 'number';
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
+34 -2
View File
@@ -1,5 +1,5 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../model/AssetTypes';
import {
type FieldValue,
isList,
@@ -13,6 +13,12 @@ import {
colorOf,
} from '../model/Properties';
/** A value a text field offers to fill in; the text stays free to type. */
export interface TextSuggestion {
value: string;
label: string;
}
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
@@ -30,7 +36,7 @@ export type PropertyDef =
step?: number;
decimals?: number;
}
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'text'; default: string; suggestions?: readonly TextSuggestion[] }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
@@ -197,6 +203,32 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
]),
) as Record<AssetType, PropertySchema>;
const OBJECT_TYPES = ['sprite', 'text', 'collider', 'camera'] as const;
/** A built-in property with the type it belongs to ("Sprite width"); a shared key appears once per type. */
export const OBJECT_PROPERTY_CHOICES: readonly { label: string; def: PropertyDef }[] = [
...COMMON_PROPS.map((def) => ({ label: `Object ${def.key}`, def })),
...OBJECT_TYPES.flatMap((type) =>
ASSET_SCHEMAS[type].map((def) => ({ label: `${ASSET_TYPE_LABELS[type]} ${def.key}`, def })),
),
];
/**
* Every built-in property once per key – what a Get/Set properties port resolves to. Sprite comes
* first so a shared key (`width`) keeps its def, not the camera's whole-pixel view width.
*/
export const OBJECT_PROPERTIES: PropertySchema = firstPerKey(OBJECT_PROPERTY_CHOICES.map((c) => c.def));
export const OBJECT_PROPERTY_SUGGESTIONS: readonly TextSuggestion[] = OBJECT_PROPERTY_CHOICES.map((c) => ({
value: c.def.key,
label: c.label,
}));
function firstPerKey(defs: readonly PropertyDef[]): PropertyDef[] {
const seen = new Set<string>();
return defs.filter((def) => !seen.has(def.key) && seen.add(def.key));
}
export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
+6 -4
View File
@@ -8,6 +8,7 @@ import {
type GraphEdge,
type GraphNode,
isValidPortName,
fixedPortKeys,
nodeDef,
} from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
@@ -210,9 +211,9 @@ function readField(key: string, f: Record<string, unknown>): StructFieldDef | nu
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
}
function normalizePorts(raw: unknown): DeclaredPort[] {
function normalizePorts(raw: unknown, fixed: readonly string[]): DeclaredPort[] {
const ports: DeclaredPort[] = [];
const seen = new Set<string>();
const seen = new Set(fixed);
for (const item of Array.isArray(raw) ? raw : []) {
const p = asRecord(item);
const key = typeof p.key === 'string' ? p.key : '';
@@ -234,7 +235,8 @@ function normalizeGraph(raw: unknown): Graph {
const n = asRecord(item);
const id = typeof n.id === 'string' ? n.id : '';
const type = typeof n.type === 'string' ? n.type : '';
if (!id || nodeIds.has(id) || !nodeDef(type)) continue;
const def = nodeDef(type);
if (!id || nodeIds.has(id) || !def) continue;
nodeIds.add(id);
// Params carry both settings and inline input values, so there is no schema to coerce against.
const params: PropertyBag = {};
@@ -243,7 +245,7 @@ function normalizeGraph(raw: unknown): Graph {
if (stored !== undefined) params[key] = stored;
}
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
const ports = normalizePorts(n.ports);
const ports = normalizePorts(n.ports, fixedPortKeys(def));
if (ports.length > 0) node.ports = ports;
if (n.collapsed === true) node.collapsed = true;
graph.nodes.push(node);
+5 -8
View File
@@ -37,7 +37,8 @@ export abstract class AssetTabEditor<
A extends Asset,
C extends TabCanvas & Widget = TabCanvas & Widget,
> extends VStack {
protected readonly picker: Dropdown;
/** Public for the check scripts. */
readonly picker: Dropdown;
protected readonly info: Label;
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
canvas!: C;
@@ -53,8 +54,8 @@ export abstract class AssetTabEditor<
this.info.minWidth = options.infoWidth;
}
/** Builds the header around `canvas`, wires the state events and shows the first asset. */
protected mount(canvas: C, extras: readonly Widget[] = []): void {
/** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
this.canvas = canvas;
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
@@ -67,6 +68,7 @@ export abstract class AssetTabEditor<
header.add(this.info);
this.add(header);
if (strip) this.add(strip);
this.add(canvas, 1);
this.state.on('selection', () => this.followSelection());
@@ -83,11 +85,6 @@ export abstract class AssetTabEditor<
return this.canvas.currentAssetId;
}
/** For the check scripts: the picker's width must not follow the name it happens to show. */
get pickerWidth(): number {
return this.picker.w;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
+17 -14
View File
@@ -18,24 +18,24 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
/** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */
const locate = (text: string, last = false): { x: number; y: number } | null => {
let found: Text | null = null;
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
const walk = (node: Container): void => {
if ((found && !last) || !node.visible) return;
if (!node.visible) return;
if (node instanceof Text && node.text === text) {
found = node;
const p = node.getGlobalPosition();
hits.push({ x: p.x + node.width / 2, y: p.y + node.height / 2 });
return;
}
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, hence the disable and the cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!found) return null;
const node = found as Text;
const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => {
@@ -160,6 +160,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
locateAll,
inspectorField: fieldOf,
inspectorInfo: () => inspector.debugInfo(),
textColor,
@@ -207,21 +208,23 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
// The canvas, not the whole tab: a centre measured through the header sits above the
// drawing area.
center: () => centreOf(graphEditor.canvas),
pickerWidth: () => graphEditor.pickerWidth,
pickerWidth: () => graphEditor.picker.w,
pickerAt: () => centreOf(graphEditor.picker),
snapping: () => graphEditor.canvas.snapEnabled,
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
},
/** 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,
texture: {
current: () => textureEditor.currentAssetId,
pickerWidth: () => textureEditor.pickerWidth,
pickerWidth: () => textureEditor.picker.w,
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.pickerWidth,
pickerWidth: () => containerEditor.picker.w,
center: () => centreOf(containerEditor),
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
+74
View File
@@ -0,0 +1,74 @@
import { Graphics } from 'pixi.js';
import { Button, HStack, Label, Theme, type Widget } from '../../ui';
/** Past this a crumb cuts its name short, so a deep trail of long names still fits the tab. */
const CRUMB_MAX_WIDTH = 160;
/** "Game › Move › Other": every crumb but the last one leads back; empty hides the bar. */
export class Breadcrumb extends HStack {
private readonly bg = new Graphics();
private crumbs: Widget[] = [];
onPick: (index: number) => void = () => undefined;
constructor() {
super({ gap: 2, padding: 3, align: 'center' });
this.addChild(this.bg);
this.visible = false;
}
/** Returns whether the bar changed, so the caller relayouts only then. */
show(names: readonly string[]): boolean {
if (names.length === this.crumbs.length && names.every((n, i) => crumbText(this.crumbs[i]!) === n))
return false;
for (const widget of this.items) widget.destroy({ children: true });
this.crumbs = names.map((name, i) => {
if (i > 0) this.add(new Label('›', { style: 'muted' }));
const last = i === names.length - 1;
return this.add(
capped((width) =>
last
? new Label(name, { width, padding: Theme.padding })
: new Button(name, { variant: 'ghost', width, onClick: () => this.onPick(i) }),
),
);
});
this.visible = names.length > 0;
// The bar keeps its size across a rebuild, and `resize` lays out only on a new size.
this.relayout();
return true;
}
/** For the check scripts: what each crumb says, where it sits, and whether it leads anywhere. */
crumbsDrawn(): { name: string; x: number; y: number; left: number; right: number; clickable: boolean }[] {
if (!this.visible) return [];
return this.crumbs.map((widget) => {
const at = widget.getGlobalPosition();
return {
name: crumbText(widget),
x: at.x + widget.w / 2,
y: at.y + widget.h / 2,
left: at.x,
right: at.x + widget.w,
clickable: widget instanceof Button,
};
});
}
override redraw(): void {
super.redraw();
this.bg.clear();
this.bg.rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader);
this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border);
}
}
function crumbText(widget: Widget): string {
return widget instanceof Button || widget instanceof Label ? widget.text : '';
}
function capped(build: (width?: number) => Widget): Widget {
const natural = build();
if (natural.measure().w <= CRUMB_MAX_WIDTH) return natural;
natural.destroy({ children: true });
return build(CRUMB_MAX_WIDTH);
}
+3 -11
View File
@@ -112,6 +112,8 @@ export class GraphCanvas extends Widget {
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
/** 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) {
super();
@@ -173,15 +175,6 @@ export class GraphCanvas extends Widget {
return this.snapping;
}
/** Drops the node in the middle of the view, since a button press names no position. */
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
const centre = this.screenToGraph(this.w / 2, this.h / 2);
new NodePalette(this.entries(), (entry) =>
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
).openBelow(anchor);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
@@ -367,13 +360,12 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view); // raise
}
/** Selecting the asset is what opens it: the graph tab follows the selection. */
private openCalledBehavior(node: GraphNode): boolean {
if (node.type !== RUN_BEHAVIOR) return false;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
if (asset?.type !== 'behavior') return false;
this.state.select({ kind: 'asset', assetId: id });
this.onOpenBehavior(id);
return true;
}
+39 -6
View File
@@ -1,25 +1,59 @@
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { Button, Checkbox, Label } from '../../ui';
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 { 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 addButton: Button;
private readonly snapBox = new Checkbox();
readonly breadcrumb = new Breadcrumb();
private trail: Trail = [];
constructor(state: EditorState) {
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
this.snapBox.onChange = (on) => this.canvas.setSnapping(on);
this.mount(new GraphCanvas(state), [this.addButton, new Label('Snap', { style: 'muted' }), this.snapBox]);
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
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.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
// Edits made while playing would be thrown away with the run.
state.on('mode', () => this.refresh());
}
/**
* The bar goes up before the graph switches: switching fits the view to the canvas, and a bar
* appearing afterwards would cut the bottom of the fitted graph off.
*/
private open(assetId: string, trail: Trail): void {
this.trail = trail;
this.showTrail();
this.state.select({ kind: 'asset', assetId });
this.refresh();
}
protected override refresh(): void {
super.refresh();
const project = this.state.project;
this.trail = follow(this.trail, this.currentAssetId, (id) =>
Boolean(findAssetOf(project, id, 'behavior')),
);
this.showTrail();
}
private showTrail(): void {
const names = this.trail.map((id) => findAsset(this.state.project, id)?.name ?? '');
if (this.breadcrumb.show(names)) this.relayout();
}
protected override selectedId(): string {
return this.state.selectedBehaviorId ?? '';
}
@@ -36,7 +70,6 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
protected override syncExtras(asset: BehaviorAsset | null): void {
const editable = Boolean(asset) && this.state.mode === 'edit';
this.addButton.setEnabled(editable);
this.snapBox.setEnabled(editable);
}
}
+23 -9
View File
@@ -1,9 +1,11 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import {
asNumber,
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputDef,
inlineInputs,
inlineValue,
type NodeCategory,
@@ -15,10 +17,11 @@ import {
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
import { textureRefLabel } from '../../core/properties/textureRef';
import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
@@ -93,6 +96,7 @@ export class NodeView extends Widget {
targetPort: { port: string; isOutput: boolean } | null = null;
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
private inlineTexts = new Map<string, string>();
/** Which sides carry a link – a collapsed node marks those with one dot each. */
private linked = { inputs: false, outputs: false };
@@ -148,6 +152,12 @@ export class NodeView extends Widget {
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
this.inlineTexts = new Map();
for (const port of inlineInputs(this.def)) {
const value = inlineValue(node, port);
if (!this.connected.has(port.key) && value !== null && value !== '')
this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project));
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
@@ -266,11 +276,8 @@ export class NodeView extends Widget {
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
if (input) {
let label = input.label;
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
const inline = this.inlineTexts.get(input.key);
const label = inline === undefined ? input.label : `${input.label} ${inline}`;
add(
label,
input.type === 'exec' ? 'body' : 'muted',
@@ -369,8 +376,6 @@ function formatParam(
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(textOf(value));
if (param.kind === 'asset') return assetRefLabel(project, textOf(value));
if (
param.kind === 'enum' &&
param.key === 'variable' &&
@@ -378,6 +383,15 @@ function formatParam(
!asset.variables.some((v) => v.key === value)
)
return `${asString(value)}?`;
return formatField(param, value, project);
}
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
if (def.kind === 'key') return keyLabel(asString(value));
if (def.kind === 'asset') return assetRefLabel(project, asString(value));
if (def.kind === 'texture') return textureRefLabel(project, asString(value));
if (def.kind === 'color') return toHex(asNumber(value));
return formatValue(value);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The behaviors entered by double-clicking call nodes, root first. Either empty or at least two
* long, and always ending at the behavior the graph shows – anything else opened it otherwise.
*/
export type Trail = readonly string[];
/** Opening `to` from a call node in `from`. A behavior already on the trail is a step back to it. */
export function descend(trail: Trail, from: string, to: string): Trail {
const base = trail.at(-1) === from ? trail : [from];
const known = base.indexOf(to);
return known >= 0 ? backTo(base, known) : [...base, to];
}
/** Clicking crumb `index`; back at the root there is nothing left to show. */
export function backTo(trail: Trail, index: number): Trail {
const next = trail.slice(0, index + 1);
return next.length > 1 ? next : [];
}
/** What survives the graph showing `shown`: only a trail that still leads there, every step still alive. */
export function follow(trail: Trail, shown: string, exists: (id: string) => boolean): Trail {
return trail.at(-1) === shown && trail.every(exists) ? trail : [];
}
+19 -1
View File
@@ -251,7 +251,25 @@ export function createField(
}
case 'text': {
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
const setValue = (v: PropertyValue) => field.setValue(textOf(v));
const suggestions = def.suggestions;
if (!suggestions) return { widget: field, grow: true, setValue };
const pick = new Dropdown('', { variant: 'ghost', width: MARK_WIDTH });
pick.onClick = () => {
const current = field.value.trim();
const items = suggestions.map((s) => ({
label: s.value === current ? `● ${s.label}` : s.label,
onSelect: () => {
setValue(s.value);
handlers.onChange(s.value);
},
}));
new Menu(items, { searchable: true }).openBelow(pick);
};
const combo = new HStack({ gap: Theme.gap, align: 'center' });
combo.add(field, 1);
combo.add(pick);
return { widget: combo, grow: true, setValue };
}
case 'color': {
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
+3 -1
View File
@@ -1,5 +1,6 @@
import {
callProblem,
declaresPropertyPorts,
declaresVariablePorts,
edgesInto,
type GraphNode,
@@ -15,7 +16,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildVariablePortSection } from './portSection';
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { currentNode, nodeParamValue } from './valueSpecs';
@@ -52,6 +53,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
else if (declaresPropertyPorts(node.type)) buildPropertyPortSection(host, assetId, node);
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
+80 -17
View File
@@ -4,7 +4,7 @@ import {
nextPortName,
RemovePortCommand,
RenamePortCommand,
SetVariablePortCommand,
RepointPortCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
@@ -13,10 +13,16 @@ import {
type GraphNode,
isTerminalNode,
missingFieldPorts,
objectPropertyDef,
portKindOf,
propertyPortOf,
structShapeOf,
variablePortKind,
} from '../../core/graph';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import {
OBJECT_PROPERTY_CHOICES,
type PropertyDef,
VARIABLE_KINDS,
} from '../../core/properties/PropertySchema';
import {
Dropdown,
HStack,
@@ -42,44 +48,101 @@ export function buildVariablePortSection(
node: GraphNode,
variables: readonly PropertyDef[],
): void {
const choices = variables.map((def) => ({ label: def.key, def }));
buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices });
}
/**
* The ports of a Get/Set properties node: a built-in property is picked like a variable, and a name
* the list does not know (a variable of another behavior's object) is typed, with a kind of its own.
*/
export function buildPropertyPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: OBJECT_PROPERTY_CHOICES,
custom: true,
});
}
interface PickedPorts {
noun: 'Variable' | 'Property';
choices: readonly { label: string; def: PropertyDef }[];
custom?: boolean;
}
function buildPickedPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
{ noun, choices, custom = false }: PickedPorts,
): void {
const plural = noun === 'Property' ? 'properties' : 'variables';
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) });
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
variables.map((v) => ({
label: v.key,
disabled: taken.has(v.key) && v.key !== own,
onSelect: () => onPick(portOf(v)),
choices.map(({ label, def }) => ({
label,
disabled: taken.has(def.key) && def.key !== own,
onSelect: () => onPick(portOf(def)),
}));
const typed = (): MenuItem[] =>
VARIABLE_KINDS.map((kind) => ({
label: `Custom ${KIND_LABELS[kind].toLowerCase()}`,
onSelect: () =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
}));
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(items).openBelow(add);
new Menu(custom ? [...items, ...typed()] : items, { searchable: custom }).openBelow(add);
};
add.setEnabled(variables.length > taken.size);
add.setEnabled(custom || choices.some(({ def }) => !taken.has(def.key)));
header.add(add);
host.body.add(header);
if (variables.length === 0) addNote(host, 'This behavior has no variables yet.');
else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.');
if (choices.length === 0) addNote(host, `This behavior has no ${plural} yet.`);
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const known = choices.some(({ def }) => def.key === port.key);
if (!known && custom) {
portRow(host, assetId, node, port, customName(host, assetId, node, port));
continue;
}
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)),
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
);
new Menu(items).openBelow(choose);
new Menu(items, { searchable: custom }).openBelow(choose);
};
portRow(host, assetId, node, port, choose);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
/** Typing a built-in property's name turns the port into that property, kind included. */
function customName(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
onChange: (text) => {
const next = text.trim();
const builtIn = objectPropertyDef(next);
host.state.execute(
builtIn
? new RepointPortCommand(assetId, node.id, port.key, propertyPortOf(builtIn))
: new RenamePortCommand(assetId, node.id, port.key, next),
);
},
});
}
/**
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
+42
View File
@@ -49,6 +49,7 @@ const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } =>
};
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
const painted = fakeHandle({ width: 100, text: '' });
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
pointer: { ...NO_POINTER, ...overrides },
@@ -221,6 +222,29 @@ const CASES: Record<string, Case[]> = {
},
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
],
GetProperties: [
{
name: 'reads one output per property it names',
ports: [
{ key: 'width', kind: 'number' },
{ key: 'speed', kind: 'number' },
],
inputs: { object: fakeHandle({ width: 42, speed: 3 }) },
outputs: { width: 42, speed: 3 },
},
{
name: 'an absent property reads as null',
ports: [{ key: 'radius', kind: 'number' }],
inputs: { object: fakeHandle({}) },
outputs: { radius: null },
},
{
name: 'no object reads null for every port',
ports: [{ key: 'width', kind: 'number' }],
inputs: { object: null },
outputs: { width: null },
},
],
GetProperty: [
{
name: 'reads a property',
@@ -617,6 +641,24 @@ const CASES: Record<string, Case[]> = {
outputs: { blockedX: false, blockedY: false },
},
],
SetProperties: [
{
name: 'writes every property it names, each keeping the type it already had',
ports: [
{ key: 'width', kind: 'number' },
{ key: 'text', kind: 'text' },
],
inputs: { object: painted, width: '250', text: 7 },
next: 'exec',
after: () => expect(painted.props).toEqual({ width: 250, text: '7' }),
},
{
name: 'no object is a no-op',
ports: [{ key: 'width', kind: 'number' }],
inputs: { object: null, width: 5 },
next: 'exec',
},
],
SetPosition: [
{
name: 'places the object and continues',
+141
View File
@@ -0,0 +1,141 @@
/**
* Get/Set properties: the plural of Get/Set property, built like Get/Set variables. A port naming a
* built-in property carries that property – its default and its field – while a custom name is a
* plain port for a variable of another behavior's object.
*/
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { AddPortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
import {
GET_PROPERTIES,
GraphInterpreter,
inlineInputDef,
propertyPort,
requireNodeDef,
resolveDef,
SET_PROPERTIES,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { OBJECT_PROPERTIES, OBJECT_PROPERTY_CHOICES } from '../../src/core/properties/PropertySchema';
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
/** Self's `width` and `color` are set with nothing wired into them, then read back into `seen`. */
function typedIn(params: Record<string, number> = {}) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['seen']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const set = edit.addNode(SET_PROPERTIES, params);
const get = edit.addNode(GET_PROPERTIES);
const store = edit.addNode('SetVariable', { variable: 'seen' });
for (const key of ['width', 'color'])
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'width', kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[self, 'object', set, 'object'],
[set, 'exec', store, 'exec'],
[self, 'object', get, 'object'],
[get, 'width', store, 'value'],
]);
return { fixture, behavior, set, get };
}
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>) {
const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
const ctx = fakeContext({ self, variables: { seen: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
return { props: self.props, seen: ctx.variables.seen };
}
describe('a node that reads or writes several properties at once', () => {
it("writes an unconnected input as the property's own default, not as a bare 0", () => {
const { fixture, behavior } = typedIn();
expect(run(behavior, fixture)).toEqual({ props: { width: 100, color: 0xffffff }, seen: 100 });
});
it('writes the value typed on the node once there is one', () => {
const { fixture, behavior } = typedIn({ width: 64 });
expect(run(behavior, fixture).props.width).toBe(64);
});
it('edits a typed-in value with the field of the property it names', () => {
expect(inlineInputDef(propertyPort({ key: 'color', kind: 'number' })).kind).toBe('color');
expect(inlineInputDef(propertyPort({ key: 'align', kind: 'text' })).kind).toBe('enum');
expect(inlineInputDef(propertyPort({ key: 'speed', kind: 'number' })).kind).toBe('number');
// A kind the property does not travel as (a hand-edited file) is left a plain port.
expect(propertyPort({ key: 'width', kind: 'text' }).field).toBeUndefined();
});
it('keeps the fixed object input its own, so no declared port can take its key', () => {
const { fixture, behavior, set, get } = typedIn();
const add = (nodeId: string) =>
new AddPortCommand(behavior.id, nodeId, { key: 'object', kind: 'number' });
expect(() => fixture.stack.execute(add(set.id))).toThrow(CommandError);
expect(() =>
fixture.stack.execute(new RenamePortCommand(behavior.id, set.id, 'width', 'object')),
).toThrow(CommandError);
// Get properties has no *output* called object, so there the name is free.
fixture.stack.execute(add(get.id));
const keys = resolveDef(set, behavior.graph, () => null)!.inputs.map((p) => p.key);
expect(keys).toEqual(['exec', 'object', 'width', 'color']);
});
it('drops a port from a file that would shadow the object input', () => {
const { fixture, behavior, set } = typedIn();
set.ports = [...(set.ports ?? []), { key: 'object', kind: 'number' }];
const loaded = roundTrip(fixture.project);
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
const node = asset.graph.nodes.find((n) => n.id === set.id)!;
expect((node.ports ?? []).map((p) => p.key)).toEqual(['width', 'color']);
});
it('does not follow a variable that happens to share a property name', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['width']);
const set = graphEditor(fixture, behavior).addNode(SET_PROPERTIES);
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'width', kind: 'number' }));
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'width', 'w'));
expect((set.ports ?? []).map((p) => p.key)).toEqual(['width']);
});
});
describe('the properties on offer', () => {
it('lists each key once, with the def of a sprite where types share it', () => {
const keys = OBJECT_PROPERTIES.map((d) => d.key);
expect(new Set(keys).size).toBe(keys.length);
expect(OBJECT_PROPERTIES.find((d) => d.key === 'width')).toMatchObject({ label: 'Width', min: 1 });
expect(keys).toEqual(expect.arrayContaining(['enabled', 'color', 'texture', 'text', 'zoom', 'radius']));
// The asset's own switches and a scene's background are not properties of a placed object.
expect(keys).not.toContain('runInEditor');
expect(keys).not.toContain('backgroundColor');
});
it('names each entry with the type it belongs to, a shared key once per type', () => {
const labels = OBJECT_PROPERTY_CHOICES.map((c) => c.label);
expect(labels).toEqual(
expect.arrayContaining([
'Object enabled',
'Sprite width',
'Collider width',
'Camera width',
'Text align',
]),
);
expect(new Set(labels).size).toBe(labels.length);
expect(OBJECT_PROPERTY_CHOICES.find((c) => c.label === 'Camera width')?.def.key).toBe('width');
});
it('offers the same entries as suggestions for the property a single Get/Set property names', () => {
for (const type of ['GetProperty', 'SetProperty']) {
const param = requireNodeDef(type).params.find((p) => p.key === 'property');
expect(param?.kind === 'text' && param.suggestions).toContainEqual({
value: 'width',
label: 'Camera width',
});
}
});
});
+2 -2
View File
@@ -4,7 +4,7 @@
* it names when that is renamed or swapped, and take its edges along.
*/
import { describe, expect, it } from 'vitest';
import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands';
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
@@ -90,7 +90,7 @@ describe('a node that reads or writes several variables at once', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(
new SetVariablePortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
new RepointPortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
);
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { backTo, descend, follow } from '../../src/editor/graph/trail';
const alive = () => true;
describe('breadcrumb trail', () => {
it('starts at the graph the call was opened from', () => {
expect(descend([], 'game', 'move')).toEqual(['game', 'move']);
});
it('grows by one per call opened further down', () => {
expect(descend(['game', 'move'], 'move', 'other')).toEqual(['game', 'move', 'other']);
});
it('starts over when the call sits in a graph the trail does not end at', () => {
expect(descend(['game', 'move'], 'jump', 'other')).toEqual(['jump', 'other']);
});
it('steps back instead of repeating a behavior already on the trail', () => {
expect(descend(['game', 'move', 'other'], 'other', 'move')).toEqual(['game', 'move']);
expect(descend(['game', 'move'], 'move', 'game')).toEqual([]);
});
it('leaves the trail alone when a behavior calls itself', () => {
expect(descend(['game', 'move'], 'move', 'move')).toEqual(['game', 'move']);
});
it('cuts back to the crumb clicked, and disappears at the root', () => {
const trail = ['game', 'move', 'other'];
expect(backTo(trail, 1)).toEqual(['game', 'move']);
expect(backTo(trail, 0)).toEqual([]);
});
it('survives only while it still leads to the graph shown', () => {
const trail = ['game', 'move'];
expect(follow(trail, 'move', alive)).toEqual(trail);
expect(follow(trail, 'game', alive)).toEqual([]);
expect(follow(trail, '', alive)).toEqual([]);
});
it('is dropped when a behavior along it was deleted', () => {
expect(follow(['game', 'move', 'other'], 'other', (id) => id !== 'move')).toEqual([]);
});
});
+12
View File
@@ -0,0 +1,12 @@
beim build kommt immer folgender output:
```
../dist/assets/index-CPWPKF3g.js 578.97 kB │ gzip: 180.43 kB │ map: 2,777.11 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
```
- was bedeutet das?
- kann man das "fixen"?
@@ -2,3 +2,9 @@ falls man im graph ein sub-behavior klick, wäre eine breadcrumb navigation gut,
sobald man in der hierachie (links im panel, assets/scene objects) wieder ein neues behavior auswählt bzw. über das dropdown im graph, soll das breadcrumb wieder verschwinden.
kurz gefasst: breadcrumb anzeigen sobald behaviour in graph geöffnet wird (doppelklick).
im breadcrump selbst, beispiel: "Game > Move > Other" - hier soll man im prinzip auf "Move" oder auch "Game" klicken können. in dem beispiel wenn man auf "Game" klickt, ist man wieder "Root" d.h. breadcrumb verschwindet wieder. klickt man auf "Move" wird aus dem Breadcrumb "Game > Move"
---
Umgesetzt (Regel in `src/editor/graph/trail.ts`):
- Oeffnet ein Doppelklick ein Behavior, das schon im Pfad steht, ist das ein Schritt zurueck dorthin statt einer Wiederholung.
- Wird ein Behavior im Pfad geloescht, verschwindet der Breadcrumb. Beim Umbenennen aendert sich nur der Eintrag.
- Der Breadcrumb erscheint vor dem Umschalten des Graphen, sonst schneidet er den Graph ab, der gerade auf die Flaeche eingepasst wurde.
+1
View File
@@ -305,6 +305,7 @@ export async function graph(t) {
Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)),
readout,
);
check('the header has no Add node button – the right-click palette is the way', !(await find('Add node…')));
await select(null);
await sleep(100);
check(
+100 -2
View File
@@ -266,9 +266,16 @@ export async function nestedBehavior(t) {
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
);
// 19j. Double-clicking a call opens the behavior it runs.
// 19j. Double-clicking a call opens the behavior it runs. Double calls Empty in turn, so the
// breadcrumb below gets a third level. The call sits far down: only a graph tall enough to
// fill the canvas shows whether it was fitted to the canvas before or after the bar.
await mutate(`
const double = __gm.state.project.assets.find((a) => a.id === 'nb-double');
double.graph.nodes.push(${JSON.stringify(node('nb-call2', 'RunBehavior', 240, 900, { behavior: 'nb-empty' }))});
`);
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0);
await t.doubleClick(callNode.x, callNode.y);
await sleep(400);
const opened = await evaluate('__gm.graph.current()');
@@ -278,7 +285,98 @@ export async function nestedBehavior(t) {
`current=${JSON.stringify(opened)}`,
);
// 19k. Clean up: the three behaviors and the box are gone again.
// 19k. The breadcrumb: every behavior entered by double-click, each earlier one a way back.
const crumbs = () => json('__gm.graph.crumbs()');
const trail = async () => (await crumbs()).map((c) => `${c.name}${c.clickable ? '' : '*'}`).join(' › ');
check('opening a call shows the way back', (await trail()) === 'Caller › Double*', await trail());
const cut = await json(`(() => {
const c = __gm.graph.canvasRect();
return __gm.state.project.assets.find((a) => a.id === 'nb-double').graph.nodes
.map((n) => ({ id: n.id, r: __gm.graph.nodeRect(n.id) }))
.filter(({ r }) => r.y < c.y || r.y + r.h > c.y + c.h)
.map(({ id }) => id);
})()`);
check('the graph opened is fitted below the bar, not under it', cut.length === 0, `cut off: ${cut}`);
const inner = await json(`__gm.graph.nodeAt('nb-call2')`);
await click(inner.x, inner.y);
// Past the double-click window: the pair below must count as its own two presses.
await sleep(500);
check(
'selecting a node inside keeps the breadcrumb',
(await trail()) === 'Caller › Double*',
await trail(),
);
await t.doubleClick(inner.x, inner.y);
await sleep(400);
check(
'each call opened further down adds a crumb',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*',
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// A width of its own too: crumbs never laid out are all 0 wide at one spot, and overlap nothing.
const overlaps = (await crumbs()).filter(
(c, i, all) => c.right - c.left < 10 || (i > 0 && c.left < all[i - 1].right),
);
check(
'the crumbs sit side by side, not on top of each other',
overlaps.length === 0,
JSON.stringify((await crumbs()).map((c) => [c.name, Math.round(c.left), Math.round(c.right)])),
);
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Twice'`);
check('a renamed behavior renames its crumb', (await trail()) === 'Caller › Twice › Empty*', await trail());
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Double'`);
await shot('39b-breadcrumb');
const clickCrumb = async (name) => {
const crumb = (await crumbs()).find((c) => c.name === name);
await click(crumb.x, crumb.y);
await sleep(300);
};
await clickCrumb('Double');
await shot('39c-breadcrumb-back');
check(
'a crumb leads back, and cuts the trail there',
(await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*',
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
await clickCrumb('Caller');
check(
'back at the root, the breadcrumb is gone',
(await evaluate('__gm.graph.current()')) === 'nb-caller' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// Opening another behavior any other way ends the trail: the asset panel, then the picker.
const enterDouble = async () => {
const call = await json(`__gm.graph.nodeAt('nb-call')`);
await t.doubleClick(call.x, call.y);
await sleep(400);
};
await enterDouble();
const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
await click(emptyAgain.x, emptyAgain.y);
await sleep(300);
check(
'choosing another behavior in the asset panel ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'nb-caller' })`);
await sleep(200);
await enterDouble();
const picker = await json('__gm.graph.pickerAt()');
await click(picker.x, picker.y);
await sleep(150);
const emptyItem = await locate('Empty', true);
await click(emptyItem.x, emptyItem.y);
await sleep(300);
check(
'choosing another behavior in the picker ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// 19l. Clean up: the three behaviors and the box are gone again.
await evaluate(`__gm.state.select(null)`);
await mutate(`
const p = __gm.state.project;
+218
View File
@@ -0,0 +1,218 @@
import { edge, node, port, variable } from '../lib/builders.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Get properties / Set properties: several properties of an object in one node, picked the way
* Get/Set variables pick theirs. A built-in property keeps its own field and default; a name the
* list does not know is typed. The step brings its own box and behavior and removes them again.
*/
/** `On update → Set properties{width 40, color red} on the owner`, then its width read back into `seen`. */
const logicGraph = {
nodes: [
node('pp-update', 'OnUpdate', 0, 0),
node('pp-owner', 'Owner', 0, 240),
node('pp-set', 'SetProperties', 260, 0, { width: 40, color: 0xff0000 }, [
port('width', 'number'),
port('color', 'number'),
]),
node('pp-get', 'GetProperties', 260, 300, {}, [port('width', 'number')]),
node('pp-store', 'SetVariable', 560, 0, { variable: 'seen' }),
node('pp-single', 'SetProperty', 560, 300, { property: 'width' }),
],
edges: [
edge('pp-e1', 'pp-update', 'exec', 'pp-set', 'exec'),
edge('pp-e2', 'pp-owner', 'object', 'pp-set', 'object'),
edge('pp-e3', 'pp-set', 'exec', 'pp-store', 'exec'),
edge('pp-e4', 'pp-owner', 'object', 'pp-get', 'object'),
edge('pp-e5', 'pp-get', 'width', 'pp-store', 'value'),
],
};
export async function propertyNodes(t) {
const { check, sleep, evaluate, json, shot, click, doubleClick, locate, find, undo, entity, asset } = t;
const { mutate, stop, typeText, press } = t;
const portsOf = async (nodeId) =>
((await asset('pp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? []).map(
(p) => `${p.key}:${p.kind}`,
);
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'pp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
);
await sleep(300);
};
/** The inspector is drawn over the graph, so the last match is the row rather than the port. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
await sleep(200);
};
const addProperty = async (search) => {
await clickLast('+ Property');
await typeText(search);
await sleep(150);
await press('Enter');
await sleep(300);
};
/** The name field, not the node's port or the unconnected-input row below it that say the same. */
const rename = async (from, to) => {
const canvas = await json('__gm.graph.canvasRect()');
const hits = await json(`__gm.locateAll(${JSON.stringify(from)})`);
const field = hits.find((p) => p.x > canvas.x + canvas.w);
await doubleClick(field.x, field.y);
await typeText(to);
await press('Enter');
await sleep(300);
};
// 24a. A box wearing a behavior that reshapes it through one node.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const a = structuredClone(p.assets.find((x) => x.id === 'mover'));
Object.assign(a, {
id: 'pp-logic',
name: 'Reshape',
props: { enabled: true, runInEditor: false, seen: 0 },
variables: ${JSON.stringify([variable('seen', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
${MAKE_OBJECT}
scene.objects.push(make('pp-box', 'crate', '', 900, 'Reshaped box'), make('pp-brain', 'pp-logic', 'pp-box', 900, 'Reshape'));
`);
await sleep(400);
// 24b. The run: one node writes both properties, another reads one of them back.
await t.play();
await sleep(400);
const box = (await entity('pp-box'))?.props;
const seen = (await entity('pp-brain'))?.props.seen;
await stop();
await sleep(300);
check(
'one node writes several properties, another reads them back',
box?.width === 40 && box?.color === 0xff0000 && seen === 40,
JSON.stringify({ width: box?.width, color: box?.color, seen }),
);
// 24c. A typed-in value reads in the words of its property: a color as its hex.
await select('pp-set');
check(
'the node shows a typed-in color as a color',
(await find('color #ff0000')) !== null,
JSON.stringify(await json(`__gm.graph.nodeLabel('pp-set')`)),
);
await shot('01-property-ports');
// 24d. The list names the type each property belongs to; a key several types share is there once
// per type, and taken for all of them once picked.
await clickLast('+ Property');
const offered = await json('__gm.overlayTexts()');
await press('Escape');
await sleep(200);
check(
'the list names each property with its type',
['Object enabled', 'Sprite height', 'Camera zoom', 'Text align'].every((l) => offered.includes(l)) &&
!offered.includes('height'),
offered.join(' | '),
);
// 24e. Picked from the built-in properties, and an unconnected one starts at that property's
// default – a height of 100, not a bare 0 that would flatten the box.
await addProperty('height');
const grown = await portsOf('pp-set');
check(
'picking a property adds it as a port',
grown.join(',') === 'width:number,color:number,height:number',
grown.join(','),
);
check(
"and its typed-in value starts at the property's own default",
(await find('height 100')) !== null,
JSON.stringify(await find('height 0')),
);
await shot('02-property-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('pp-set')).length === 2);
// 24f. A name the list does not know – a variable of another behavior's object – is typed, with
// a kind of its own. Typing a built-in name turns it into that property, kind and all.
await addProperty('custom text');
check('a custom port is named and typed by hand', (await portsOf('pp-set'))[2] === 'property1:text');
await rename('property1', 'speed');
check(
'and renamed by typing',
(await portsOf('pp-set'))[2] === 'speed:text',
(await portsOf('pp-set')).join(),
);
await rename('speed', 'radius');
check(
'typing a built-in name makes it that property, with its kind',
(await portsOf('pp-set'))[2] === 'radius:number',
(await portsOf('pp-set')).join(),
);
await shot('03-custom-renamed');
await addProperty('custom number');
await evaluate("__gm.state.notice = ''");
await rename('property1', 'object');
const notice = await json('__gm.state.notice');
check(
'the object input keeps its key: a port cannot take it',
(await portsOf('pp-set'))[3] === 'property1:number' && notice.includes('"object" is taken'),
JSON.stringify({ ports: await portsOf('pp-set'), notice }),
);
// 24g. Get properties grows outputs from the same list.
await select('pp-get');
await addProperty('color');
check(
'Get properties picks from the same list, as outputs',
(await portsOf('pp-get')).join(',') === 'width:number,color:number' &&
Boolean(await json(`__gm.graph.portAt('pp-get', 'color', true)`)),
(await portsOf('pp-get')).join(','),
);
// 24h. The single Set property names its property by text; the button beside it suggests the
// same list, and picking an entry writes its key.
await select('pp-single');
const param = async () =>
(await asset('pp-logic')).graph.nodes.find((n) => n.id === 'pp-single').params.property;
const field = await json(`__gm.inspectorField('Property', true)`);
await click(2 * field.x - field.left - 14, field.y);
await sleep(200);
const suggested = await json('__gm.overlayTexts()');
check(
'the property setting suggests the built-in properties, the current one marked',
suggested.includes('● Sprite width') && suggested.includes('Text align'),
suggested.join(' | '),
);
await shot('04-property-suggestions');
await typeText('text align');
await sleep(150);
await press('Enter');
await sleep(300);
check(
'picking a suggestion writes its key',
(await param()) === 'align' && (await find('Property: align')) !== null,
JSON.stringify(await param()),
);
await undo();
await sleep(300);
check('undo takes it back', (await param()) === 'width', JSON.stringify(await param()));
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'pp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'pp-box' && o.id !== 'pp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "pp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+2
View File
@@ -26,6 +26,7 @@ import { keyRepeat } from './20-key-repeat.mjs';
import { listTab } from './21-list.mjs';
import { structVariable } from './22-struct.mjs';
import { variableNodes } from './23-variable-nodes.mjs';
import { propertyNodes } from './24-property-nodes.mjs';
export const STEPS = [
hierarchy,
@@ -51,4 +52,5 @@ export const STEPS = [
listTab,
structVariable,
variableNodes,
propertyNodes,
];