Let a list hold structs, and read or write variables in bundles

A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 14:01:56 +02:00
1 parent 9d62dfea7b
commit f60358c951
32 files changed
+1083 -253

No files matched your search

+23 -9
View File
@@ -91,15 +91,20 @@ shown on the node and editable in the inspector.
- **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they
appear in the inspector, can be overridden per placed instance, and each running instance
gets its own mutable copy. `Get variable` / `Set variable` read and write them. A variable is a
**number, a yes/no, a text, a list or a struct**; a list holds numbers, texts, yes/nos or colours (its
item kind decides which widget an item gets) and is
gets its own mutable copy. `Get variable` / `Set variable` read and write them, and 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
**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.
There is no list *of objects*: a list is stored in the project file and an object is a live
handle that no reload survives. A set of objects is a list of their names, walked with `Repeat`
and `Object by name`. A list property is shown as a button saying how many items it holds;
clicking it opens the **List** tab, which gives each item a row with the same widget the
inspector would use for that kind. The inspector stays visible beside the tab, so what the list
inspector would use for that kind – for a list of structs that is the inspector's own struct
block, so a row is the bundle's fields under each other. The inspector stays visible beside the tab, so what the list
belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object
override or node setting) on the editor state, because no tab may reach into the `Editor` to
switch itself on. A **struct** variable is the other way round: few fields, so they fit under
@@ -152,10 +157,12 @@ shown on the node and editable in the inspector.
behavior port may be declared `struct` – which is how a bundle crosses a call – and a
**variable** may be one, which is how a bundle is stored. Writing a struct variable keeps the
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. Fields are
primitives or lists, never another struct, for the same reason a list is flat; a *declared*
field is a primitive – a field holding a list would need a nested editor, which is the same
step "a list of structs" waits for. A live entity is
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 *declaration*, not by convention: a list says
what its items are (`ListItemKind`, struct included), a struct field says what it holds
(`FieldKind`, primitives only), so `list → struct → primitive` is as deep as a stored value
goes and the deep compare and the editor stay finite. A declared field holding a list is the
one step still missing. A live entity is
an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct
field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead),
or a struct would read as `[object Object]`. A struct port has no inline value (its parts come
@@ -580,7 +587,7 @@ npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
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 # 450 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 456 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
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
@@ -663,6 +670,13 @@ above and below it. What the player sees is a pool of objects the graphs address
destroyed while playing. Every frame tries exactly **one** move (turning beats moving beats
falling), which is why the game needs only a single "does it fit?" test instead of one per key.
What is *not* loose numbers is the piece. `piece` and the candidate `next` are **structs** of
shape/rot/x/y, and the shape table is a **list of `{x, y}` cells** – so `Next move` hands back a
whole candidate for `Fits` to judge and the master to accept in one write, rather than three
parallel variables copied back and forth, and each strand packs only the field it changes
(`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that
list instead of the same index doubled and read twice.
It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each
step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`,
`Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where
+66
View File
@@ -1,6 +1,7 @@
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
declaresVariablePorts,
type DeclaredPort,
findNode,
type Graph,
@@ -190,6 +191,71 @@ export class RenamePortCommand implements Command {
}
}
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
const ports = node.ports ?? [];
if (from === to || !ports.some((p) => p.key === from)) return;
const site: PortSite = {
graph,
nodeId: node.id,
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
};
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
if (site.asInput) edge.to.port = to;
else edge.from.port = to;
}
if (site.asInput) renameParam(site, from, to);
}
/**
* 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
* hanging on the port come along, so correcting a pick is not a rewiring.
*/
export class SetVariablePortCommand implements Command {
readonly label: string;
private previous?: DeclaredPort;
constructor(
private readonly assetId: string,
private readonly nodeId: string,
private readonly from: string,
private readonly port: DeclaredPort,
) {
this.label = `Point ${from} at ${port.key}`;
}
execute(project: Project): void {
const node = requirePortNode(project, this.assetId, this.nodeId);
const ports = node.ports ?? [];
const current = ports.find((p) => p.key === this.from);
if (!current) throw new CommandError(`Port "${this.from}" not found`);
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 };
this.swap(project, node, this.from, this.port);
}
undo(project: Project): void {
if (!this.previous) return;
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
}
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
}
}
/**
* Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
* values move with it – the port key *is* the variable name there.
*/
export function renameVariablePorts(graph: Graph, from: string, to: string): void {
for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
}
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
export function nextPortName(node: GraphNode, base: string): string {
const taken = new Set((node.ports ?? []).map((p) => p.key));
+2
View File
@@ -18,6 +18,7 @@ import {
} from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { renameVariablePorts } from './portCommands';
import { requireBehavior } from './require';
/** The variable's default value is written to `props` as well. */
@@ -133,6 +134,7 @@ export class RenameVariableCommand implements Command {
}
// Every node that picks a variable stores it under the param key `variable`.
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
renameVariablePorts(asset.graph, from, to);
}
}
+11
View File
@@ -6,9 +6,20 @@ import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
export const BEHAVIOR_START = 'BehaviorStart';
export const BEHAVIOR_END = 'BehaviorEnd';
export const RUN_BEHAVIOR = 'RunBehavior';
export const GET_VARIABLES = 'GetVariables';
export const SET_VARIABLES = 'SetVariables';
export const BEHAVIOR_PARAM = 'behavior';
/**
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
* Renaming a variable therefore has to reach them, and the inspector picks their ports from a list
* instead of letting them be typed.
*/
export function declaresVariablePorts(type: string): boolean {
return type === GET_VARIABLES || type === SET_VARIABLES;
}
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
export function isValidPortName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
+6 -3
View File
@@ -1,3 +1,4 @@
import { sameValue } from '../../../model/Properties';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asListItem } from '../../values';
import { any, list, num } from '../shared';
@@ -10,7 +11,9 @@ export const IndexOf: NodeDef = {
inputs: [list('list'), any('value')],
outputs: [num('index')],
params: [],
run: (_ctx, inputs) => ({
outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) },
}),
// Compared by content, not by identity: two equal structs are never the same object.
run: (_ctx, inputs) => {
const wanted = asListItem(inputs.value);
return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } };
},
};
+4
View File
@@ -82,7 +82,9 @@ import { Repeat } from './flow/Repeat';
import { RunBehavior } from './flow/RunBehavior';
import { Sequence } from './flow/Sequence';
import { GetVariable } from './variable/GetVariable';
import { GetVariables } from './variable/GetVariables';
import { SetVariable } from './variable/SetVariable';
import { SetVariables } from './variable/SetVariables';
/**
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
@@ -109,7 +111,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
BehaviorEnd,
// variables
GetVariable,
GetVariables,
SetVariable,
SetVariables,
// data: references and properties
Self,
Owner,
@@ -0,0 +1,24 @@
import type { NodeDef } from '../../NodeRegistry';
import { GET_VARIABLES } from '../../nested';
import type { Value } from '../../values';
/**
* The plural of Get variable: one output per variable picked, so a graph reading a handful of them
* is one node instead of a column of five. The ports are declared on the node, the way Expand
* declares the fields of a struct.
*/
export const GetVariables: NodeDef = {
type: GET_VARIABLES,
label: 'Get variables',
category: 'variable',
description: "Reads several of this behavior's variables at once, one output per variable.",
inputs: [],
outputs: [],
params: [],
declaresPorts: 'outputs',
run: (ctx, _inputs, _params, def) => {
const outputs: Record<string, Value> = {};
for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null;
return { outputs };
},
};
@@ -0,0 +1,25 @@
import type { NodeDef } from '../../NodeRegistry';
import { SET_VARIABLES } from '../../nested';
import { writeTyped } from '../../values';
import { exec } from '../shared';
/**
* The plural of Set variable: one input per variable picked. An input left unconnected writes the
* value typed on the node, exactly as it does on the singular node – the node writes every variable
* it lists, every time it runs.
*/
export const SetVariables: NodeDef = {
type: SET_VARIABLES,
label: 'Set variables',
category: 'variable',
description: "Writes several of this behavior's variables at once, one input per variable.",
inputs: [exec()],
outputs: [exec()],
params: [],
declaresPorts: 'inputs',
run: (ctx, inputs, _params, def) => {
for (const port of def.inputs)
if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]);
return { next: 'exec' };
},
};
+11 -6
View File
@@ -39,16 +39,21 @@ export function asList(value: Value | undefined): ListValue {
return [value];
}
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
export function asListItem(value: Value | undefined): number | string | boolean {
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
if (value === null || value === undefined) return 0;
return asString(value);
/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */
export function asListItem(value: Value | undefined): number | string | boolean | StructValue {
if (isStructValue(value)) return value;
return asPrimitive(value);
}
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
export function asField(value: Value | undefined): FieldValue {
return isList(value) ? value : asListItem(value);
return isList(value) ? value : asPrimitive(value);
}
function asPrimitive(value: Value | undefined): number | string | boolean {
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
if (value === null || value === undefined) return 0;
return asString(value);
}
export function asBoolean(value: Value | undefined): boolean {
+8 -7
View File
@@ -1,10 +1,12 @@
/**
* A list is deliberately flat – a list of lists would need a nested editor and a deep compare on
* every override, and nothing so far wants one: a board is addressed as `row · width + column`.
* A list holds primitives or structs. Depth stops there and it stops in the declaration, not by
* convention: a list says what its items are (`ListItemKind`, struct included), a struct field
* says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest
* a stored value goes, which is what keeps the deep compare and the editor finite.
*/
export type ListValue = readonly (number | string | boolean)[];
export type ListValue = readonly (number | string | boolean | StructValue)[];
/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
export type FieldValue = number | string | boolean | ListValue;
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
@@ -30,7 +32,7 @@ export const isStruct = (value: unknown): value is StructValue =>
*/
export function textOf(value: PropertyValue | undefined): string {
if (value === undefined) return '';
if (isList(value)) return value.join(', ');
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
if (isStruct(value))
return Object.entries(value)
.map(([field, item]) => `${field}: ${textOf(item)}`)
@@ -42,12 +44,11 @@ export function textOf(value: PropertyValue | undefined): string {
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
if (isList(a) || isList(b)) {
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
return a.every((item, i) => item === b[i]);
return a.every((item, i) => sameValue(item, b[i]));
}
if (isStruct(a) || isStruct(b)) {
if (!isStruct(a) || !isStruct(b)) return false;
const keys = Object.keys(a);
// Fields are never structs, so this recurses exactly one level deep.
return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
}
return a === b;
+40 -13
View File
@@ -49,20 +49,32 @@ export type PropertyDef =
* list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the
* project file, and an object is a live handle that no reload survives.
*/
| { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind }
| {
key: string;
label: string;
kind: 'list';
default: ListValue;
items: ListItemKind;
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
fields?: readonly StructFieldDef[];
}
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`).
*/
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const;
/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */
export interface StructFieldDef {
key: string;
kind: ListItemKind;
kind: FieldKind;
}
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
@@ -200,14 +212,19 @@ export function variableDef(
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list':
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? (fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
label: key,
kind,
default: isList(defaultValue) ? defaultValue : [],
items: items ?? 'number',
default: raw.map((entry) => coerceListItem(entry, item, shape)),
items: item,
...(shape ? { fields: shape } : {}),
};
}
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
@@ -216,14 +233,14 @@ export function variableDef(
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<ListItemKind, number | string | boolean> = {
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
text: '',
boolean: false,
color: 0,
};
export function emptyField(kind: ListItemKind): number | string | boolean {
export function emptyField(kind: FieldKind): number | string | boolean {
return EMPTY_FIELD[kind];
}
@@ -236,7 +253,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
const value: Record<string, FieldValue> = {};
for (const field of fields)
value[field.key] =
field.key in source ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind);
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
return value;
}
@@ -266,14 +283,24 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default;
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
case 'struct':
return structOf(def.fields, value);
}
}
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
if (items === 'text') return isList(value) ? value.join(', ') : textOf(value);
/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */
export function coerceListItem(
value: PropertyValue,
items: ListItemKind,
fields?: readonly StructFieldDef[],
): number | string | boolean | StructValue {
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
}
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value);
if (items === 'boolean') {
if (typeof value === 'boolean') return value;
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
+27 -17
View File
@@ -13,7 +13,7 @@ import {
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties';
import type { FieldValue, PropertyBag, PropertyValue, StructValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile, normalizeFrameRect } from '../model/texture';
@@ -21,6 +21,8 @@ import { hasParentCycle } from '../model/walk';
import {
coerceValue,
COMMON_PROPS,
FIELD_KINDS,
type FieldKind,
isCommonProp,
LIST_ITEM_KINDS,
type ListItemKind,
@@ -192,9 +194,9 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
const f = asRecord(item);
const key = typeof f.key === 'string' ? f.key : '';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
if (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue;
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
seen.add(key);
fields.push({ key, kind: f.kind as ListItemKind });
fields.push({ key, kind: f.kind as FieldKind });
}
return fields;
}
@@ -317,27 +319,35 @@ function asRecord(value: unknown): Record<string, unknown> {
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
*/
function asStoredValue(value: unknown): PropertyValue | undefined {
const field = asFieldValue(value);
if (field !== undefined) return field;
if (!value || typeof value !== 'object') return undefined;
if (isPrimitive(value)) return value;
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
return asStoredStruct(value, asStoredField);
}
const isPrimitive = (value: unknown): value is number | string | boolean =>
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
/** A list item: a primitive or a struct of primitives. */
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
isPrimitive(value) ? value : asStoredStruct(value, (item) => (isPrimitive(item) ? item : undefined));
/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */
const asStoredField = (value: unknown): FieldValue | undefined =>
isPrimitive(value) ? value : Array.isArray(value) ? value.filter(isPrimitive) : undefined;
function asStoredStruct(
value: unknown,
ofField: (item: unknown) => FieldValue | undefined,
): StructValue | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const fields: Record<string, FieldValue> = {};
for (const [key, item] of Object.entries(value)) {
const stored = asFieldValue(item);
const stored = ofField(item);
if (stored !== undefined) fields[key] = stored;
}
return fields;
}
function asFieldValue(value: unknown): FieldValue | undefined {
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
if (Array.isArray(value))
return value.filter(
(item): item is number | string | boolean =>
typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean',
);
return undefined;
}
function toNumber(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
+1 -1
View File
@@ -226,7 +226,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
list: {
info: () => editor.listEditor.debugInfo(),
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
fieldAt: (index: number) => editor.listEditor.fieldAt(index),
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
},
world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(),
+5 -5
View File
@@ -16,8 +16,8 @@ import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import {
LIST_ITEM_KINDS,
type ListItemKind,
FIELD_KINDS,
type FieldKind,
type PropertyDef,
schemaForAsset,
type StructDef,
@@ -62,7 +62,7 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
struct: '⊞',
};
const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
@@ -71,7 +71,7 @@ const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
const FIELD_INDENT = 12;
const FIELD_KIND_MARKS: Record<ListItemKind, string> = {
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
@@ -264,7 +264,7 @@ function buildStructVariable(
head.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
const items = LIST_ITEM_KINDS.map((kind) => ({
const items = FIELD_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
+6 -3
View File
@@ -8,7 +8,7 @@ import {
} from '../../core/properties/assetRef';
import {
emptyField,
type ListItemKind,
type FieldKind,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
@@ -179,10 +179,12 @@ interface Field {
/** Whether the widget should take the remaining row width. */
grow: boolean;
setValue(value: PropertyValue): void;
/** The per-field widgets of a struct, so a caller can address one of them. */
parts?: ReadonlyMap<string, Widget>;
}
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
export function primitiveDef(key: string, kind: ListItemKind, label = key): PrimitiveDef {
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) {
case 'number':
return { key, label, kind: 'number', default: 0 };
@@ -195,7 +197,7 @@ export function primitiveDef(key: string, kind: ListItemKind, label = key): Prim
}
}
export type PrimitiveDef = Extract<PropertyDef, { kind: ListItemKind }>;
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
@@ -265,6 +267,7 @@ export function createField(
setValue: (v) => {
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
},
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
};
}
case 'boolean': {
+4 -2
View File
@@ -1,6 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
callProblem,
declaresVariablePorts,
type Graph,
edgesInto,
type GraphNode,
@@ -16,7 +17,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection } from './portSection';
import { buildPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
@@ -51,7 +52,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
addNote(host, def.description);
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
if (def.declaresPorts) buildPortSection(host, assetId, node);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
else if (def.declaresPorts) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
+73 -1
View File
@@ -3,15 +3,18 @@ import {
nextPortName,
RemovePortCommand,
RenamePortCommand,
SetVariablePortCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type GraphNode,
isTerminalNode,
} from '../../core/graph';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui';
import { addNote, type InspectorHost } from './host';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
@@ -32,6 +35,75 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
struct: '⊞',
};
/**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
* from that list rather than named, and its kind follows the variable's declaration.
*/
export function buildVariablePortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[],
): void {
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 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)),
}));
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(items).openBelow(add);
};
add.setEnabled(variables.length > taken.size);
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.');
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
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)),
);
new Menu(items).openBelow(choose);
};
row.add(choose, 1);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
/** Every variable kind is a port kind too; the fallback is for a project file that says otherwise. */
function variablePortKind(def: PropertyDef): DeclaredPortKind {
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
}
/**
* 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
+2 -2
View File
@@ -1,5 +1,5 @@
import {
coerceListItem,
coerceField,
type PropertyDef,
type StructDef,
type StructFieldDef,
@@ -45,7 +45,7 @@ export function structFieldsOf<Target>(
...spec,
toModel: (input, target) => ({
...structOf(def.fields, spec.read(target)),
[field.key]: coerceListItem(input, field.kind),
[field.key]: coerceField(input, field.kind),
}),
});
}
+31 -20
View File
@@ -4,9 +4,11 @@
* tab, so what the list belongs to is still on screen while it is edited.
*/
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory';
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
import { type ListAccess, type ListDef, resolveList } from './slot';
const INDEX_WIDTH = 40;
@@ -19,8 +21,8 @@ export class ListEditor extends VStack {
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
private setters: ((value: number | string | boolean) => void)[] = [];
private fields: Widget[] = [];
private setters: ((value: PropertyValue) => void)[] = [];
private fields: { widget: Widget; parts?: ReadonlyMap<string, Widget> }[] = [];
constructor(private readonly state: EditorState) {
super({ gap: 0 });
@@ -48,8 +50,9 @@ export class ListEditor extends VStack {
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number): { x: number; y: number } | null {
const widget = this.fields[index];
fieldAt(index: number, field = ''): { x: number; y: number } | null {
const found = this.fields[index];
const widget = field ? found?.parts?.get(field) : found?.widget;
if (!widget) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -61,7 +64,7 @@ export class ListEditor extends VStack {
if (!access) return { title: '', items: [], kind: '' };
return {
title: access.title,
items: access.value.map((item) => String(item)),
items: access.value.map((item) => textOf(item)),
kind: access.def.items,
};
}
@@ -71,9 +74,11 @@ export class ListEditor extends VStack {
return slot ? resolveList(this.state, slot) : null;
}
/** Everything that decides how many rows there are and of what kind. */
/** Everything that decides how many rows there are and of what kind – a struct's fields included. */
private signatureOf(access: ListAccess | null): string {
return access ? `${access.title}|${access.def.items}|${access.value.length}` : '';
if (!access) return '';
const fields = (access.def.fields ?? []).map((f) => `${f.key}:${f.kind}`).join(',');
return `${access.title}|${access.def.items}|${fields}|${access.value.length}`;
}
private refresh(): void {
@@ -109,18 +114,16 @@ export class ListEditor extends VStack {
this.relayout();
}
private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
const tall = access.def.items === 'struct';
const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' });
if (!tall) row.minHeight = Theme.rowHeight;
const label = new Label(String(index), { style: 'small', align: 'right' });
label.minWidth = INDEX_WIDTH;
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const field = createField(
this.ctx,
primitiveDef(String(index), access.def.items),
item,
access.itemHandlers(index),
);
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
@@ -131,12 +134,20 @@ export class ListEditor extends VStack {
}),
);
this.setters.push(field.setValue);
this.fields.push(field.widget);
this.fields.push({ widget: field.widget, parts: field.parts });
return row;
}
private addItem(): void {
const access = this.access();
if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]);
if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]);
}
}
/** Item `index` as a definition, so the inspector's own widgets render a row of the list. */
function itemDef(def: ListDef, index: number): PrimitiveDef | StructDef {
const key = String(index);
return def.items === 'struct'
? { key, label: key, kind: 'struct', fields: def.fields ?? [], default: structOf(def.fields ?? [], {}) }
: primitiveDef(key, def.items);
}
+34 -5
View File
@@ -12,13 +12,16 @@ import { resolveDef } from '../../core/graph/nested';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
coerceField,
coerceListItem,
type PropertyDef,
type PropertySchema,
schemaForAsset,
structOf,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
@@ -53,9 +56,13 @@ const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
return def?.kind === 'list' ? def : null;
};
const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue =>
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
list.map((value, i) => (i === index ? item : value));
/** The item at `index` in the shape the declaration promises, so a field write never invents one. */
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
structOf(def.fields ?? [], list[index]);
/**
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
* the next keystroke has to build on what is in there now, and the commit on what the restore
@@ -72,11 +79,33 @@ function access<Target>(
def,
title,
value: current(),
itemHandlers: (index) =>
previewingHandlers<Target>(
itemHandlers: (index) => ({
...previewingHandlers<Target>(
{ state },
{ ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) },
{
...spec,
toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)),
},
),
// A struct item is edited field by field, and each write rebuilds the whole item from what
// is in the list at that moment -- the same rule as a row, one level deeper.
...(def.items === 'struct'
? {
structFieldHandlers: (field: StructFieldDef) =>
previewingHandlers<Target>(
{ state },
{
...spec,
toModel: (input) =>
withItem(current(), index, {
...structItemAt(current(), index, def),
[field.key]: coerceField(input, field.kind),
}),
},
),
}
: {}),
}),
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
// A preview does not, which is why `toModel` above has to.
replace: (next) => state.execute(spec.commit(next)),
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import { Fill } from '../../src/core/graph/nodes/data/Fill';
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
import { sameValue, textOf } from '../../src/core/model/Properties';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
const XY = [
{ key: 'x', kind: 'number' },
{ key: 'y', kind: 'number' },
] as const;
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
/** A `list` variable whose items are structs, declared the way a seeder writes it. */
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never, 'struct', XY);
/** The node interface only needs `run`; nothing here reads the context. */
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
(def.run as (ctx: unknown, i: Record<string, unknown>) => { outputs: Record<string, unknown> })({}, inputs)
.outputs;
describe('a list whose items are structs', () => {
it('forces every item into the declared shape, filling and dropping fields', () => {
const def = shapesDef([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
expect(def.default).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 0 },
{ x: 4, y: 5 },
]);
});
it('coerces the field values themselves, so text out of a file becomes numbers', () => {
expect(coerceValue(shapesDef([]), [{ x: '3', y: '4' }])).toEqual([{ x: 3, y: 4 }]);
});
it('makes a non-struct item into the empty shape rather than dropping the item', () => {
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 2 },
]);
});
it('survives a save and load with its items intact', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
const asset = f.project.assets.find((a) => a.id === behavior.id);
if (asset?.type !== 'behavior') throw new Error('behavior expected');
asset.variables = [shapesDef(cells([0, 1], [1, 1]))];
asset.props.shapes = cells([2, 3], [4, 5]);
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
const def = loaded.variables.find((v) => v.key === 'shapes');
expect(def?.kind === 'list' ? def.items : '').toBe('struct');
expect(def?.kind === 'list' ? def.fields : []).toEqual([...XY]);
expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5]));
});
it('compares by content, so an unchanged list is not reported as an override', () => {
expect(sameValue(cells([1, 2]), cells([1, 2]))).toBe(true);
expect(sameValue(cells([1, 2]), cells([1, 3]))).toBe(false);
expect(sameValue(cells([1, 2]), cells([1, 2], [3, 4]))).toBe(false);
});
it('reads as its items rather than as "[object Object]"', () => {
expect(textOf(cells([1, 2], [3, 4]))).toBe('x: 1, y: 2, x: 3, y: 4');
});
});
/**
* Node params have no schema to coerce against, so the loader alone decides how deep a value may
* be. The bound has to be the model's own: a list of structs of primitives, and no further.
*/
describe('an untyped slot on load', () => {
const paramsAfterLoad = (params: Record<string, unknown>) => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
const asset = f.project.assets.find((a) => a.id === behavior.id);
if (asset?.type !== 'behavior') throw new Error('behavior expected');
asset.graph.nodes.push({ id: 'n1', type: 'List', x: 0, y: 0, params: params as never });
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
return loaded.graph.nodes.find((n) => n.id === 'n1')?.params ?? {};
};
it('keeps a list of structs', () => {
expect(paramsAfterLoad({ items: cells([1, 2], [3, 4]) }).items).toEqual(cells([1, 2], [3, 4]));
});
it('still keeps a struct holding a flat list, which it always did', () => {
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
});
it('drops a struct inside a struct, which no declaration can ask for', () => {
expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 });
});
it('drops a list inside a list item, the level below what a field may hold', () => {
expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]);
});
});
describe('the list nodes with struct items', () => {
it('hands a whole item out of the list', () => {
expect(run(ItemAt, { list: cells([1, 2], [3, 4]), index: 1 })).toEqual({ item: { x: 3, y: 4 } });
});
it('puts a struct back into a list instead of turning it into text', () => {
expect(run(SetItem, { list: cells([1, 2], [3, 4]), index: 0, value: { x: 9, y: 9 } })).toEqual({
result: cells([9, 9], [3, 4]),
});
});
it('finds a struct by its content, since two equal structs are never the same object', () => {
expect(run(IndexOf, { list: cells([1, 2], [3, 4]), value: { x: 3, y: 4 } })).toEqual({ index: 1 });
expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 });
});
it('fills a list with a struct', () => {
expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) });
});
});
+39 -2
View File
@@ -4,7 +4,16 @@
*/
import { describe, expect, it } from 'vitest';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
import {
createEmptyGraph,
type DeclaredPort,
NO_BEHAVIORS,
type NodeContext,
requireNodeDef,
resolveDef,
type RunResult,
type Value,
} from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
@@ -14,6 +23,8 @@ interface Case {
inputs?: Record<string, Value>;
params?: PropertyBag;
context?: Partial<NodeContext>;
/** Ports the node declares for itself – for the types whose ports are variables it names. */
ports?: DeclaredPort[];
/** Expected data outputs, compared exactly. */
outputs?: Record<string, Value>;
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
@@ -30,7 +41,8 @@ interface Case {
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context);
const registered = requireNodeDef(type);
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 };
@@ -98,6 +110,31 @@ const CASES: Record<string, Case[]> = {
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
{ name: 'no variable chosen reads as null', outputs: { value: null } },
],
GetVariables: [
{
name: 'reads one output per variable it names',
ports: [
{ key: 'speed', kind: 'number' },
{ key: 'nope', kind: 'number' },
],
outputs: { speed: 100, nope: null },
},
{ name: 'without a variable it reads nothing', outputs: {} },
],
SetVariables: [
{
name: 'writes every variable it names, each keeping its declared type',
ports: [{ key: 'speed', kind: 'number' }],
inputs: { speed: '250' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'without a variable it writes nothing',
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
SetVariable: [
{
name: 'writes a variable, keeping the declared type',
+122
View File
@@ -0,0 +1,122 @@
/**
* Get/Set variables: the two nodes whose declared ports are variables of the behavior around them,
* rather than names of their own. What that costs is the wiring – a port has to follow the variable
* 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 {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import {
GET_VARIABLES,
type GraphNode,
GraphInterpreter,
resolveDef,
SET_VARIABLES,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
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`. */
function bothWays() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 });
const get = edit.addNode(GET_VARIABLES);
const add = edit.addNode('Add');
const store = edit.addNode('SetVariable', { variable: 'sum' });
for (const node of [set, get])
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[set, 'exec', store, 'exec'],
[get, 'px', add, 'a'],
[get, 'py', add, 'b'],
[add, 'result', store, 'value'],
]);
return { fixture, behavior, set, get, edit };
}
function run(
behavior: BehaviorAsset,
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
) {
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(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}`);
describe('a node that reads or writes several variables at once', () => {
it('writes each variable it names and reads each one back', () => {
const { fixture, behavior } = bothWays();
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('takes the port type from the variable, so a number output stays a number', () => {
const { fixture, behavior, get, set } = bothWays();
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
// The exec port is the node's own; only the variables come from the declaration.
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
});
it('follows a renamed variable, edge and all', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 });
fixture.stack.undo();
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('keeps the edge when a port is pointed at another variable', () => {
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' }),
);
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);
fixture.stack.undo();
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
});
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
// Left standing rather than dropped: taking the port would have to take its edge too, and
// undo could not put either back. The inspector marks it as naming no variable.
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
fixture.stack.undo();
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('survives a save and load, which is what makes the pick more than a session', () => {
const { fixture, behavior } = bothWays();
const loaded = roundTrip(fixture.project);
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!;
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
});
});
+2
View File
@@ -0,0 +1,2 @@
beim expand und pack node habe ich mir eigentlich auch vorgestellt, dass man sich eine struct variable auswählt und automatisch die felder erzeugt werden.
wir können das so lassen wie es ist (eigene felder definieren), aber eine helfer auswahl von structs das die felder erstellt wäre gut.
+16 -10
View File
@@ -4,6 +4,13 @@ Drei Punkte, die beim Bau von List-Tab und Struct-Variablen bewusst liegen gebli
standen am Ende von `done/nodes-mit-dynamischer-port-zahl.md` und sind hierher gezogen, damit sie
nicht mit der erledigten Notiz verschwinden.
**Punkt 3 ist inzwischen gebaut** (siehe unten). Offen sind noch 1 und 2.
Fehlender Nebenpunkt, der dabei aufgefallen ist: eine Listen-Variable kann ihre **Item-Art im
Editor gar nicht wählen** — `+ Variable ▸ List` legt immer `items: 'number'` an, und für
`items: 'struct'` gibt es keinen Feld-Editor. Eine Liste aus Structs entsteht deshalb heute nur
aus einer Projektdatei (Seeder); bearbeiten lässt sie sich danach normal.
## 1. Der unverbundene Eingang zeigt das falsche Feld
`inlineInputDef` (`NodeRegistry.ts:131`) kennt nur `boolean`, `string`/`any` und *alles andere* —
@@ -21,15 +28,14 @@ Ein deklariertes Struct-Feld ist heute ein Primitiv (Zahl, Text, Ja/Nein, Farbe)
einen **Pfad in der Slot-Adresse** (`ListSlot` in `state.ts` hat bewusst keinen). Die Naht ist die
eine Funktion `resolveList` — eine Signaturänderung, kein Umbau.
## 3. Liste aus Structs
## 3. Liste aus Structs — ERLEDIGT
Der teuerste der drei. `ListValue` ist heute `readonly (number | string | boolean)[]` und
`FieldValue` hält fest, dass kein Feld selbst ein Struct ist (`Properties.ts:5-8`); `sameValue`
verlässt sich darauf ("recurses exactly one level deep", `Properties.ts:50`), und
`LIST_ITEM_KINDS` macht eine Liste homogen.
Gebaut. Zwei Annahmen dieser Notiz waren dabei **falsch**, und beide haben es billiger gemacht:
Structs in Listen machen `ListValue` und `StructValue` **gegenseitig rekursiv**: beliebige
Schachtelungstiefe, echter Deep-Compare, und die Items brauchen eine gemeinsame deklarierte
Feldform — die im Typmodell keine Heimat hat, solange sie nur an *einer* Property hängt.
Setzt Punkt 2 voraus (Pfad in der Adresse).
- Es wird **nichts gegenseitig rekursiv**. Die Tiefe steckt in der *Deklaration*, nicht in einer
Konvention: `FieldKind` (Primitive) sagt, was ein Struct-**Feld** hält, `ListItemKind`
(= `FieldKind` + `struct`) sagt, was eine **Liste** hält. Damit ist `list → struct → primitiv`
die tiefste gespeicherte Form — kein beliebiges Schachteln, `sameValue` bleibt endlich.
- Es setzt **Punkt 2 nicht voraus**. Ein Struct-Item braucht keinen Pfad in der Adresse, weil es
**inline** gerendert wird — dieselbe Begründung, aus der ein Struct im Inspector ein Block ist
und keine eigene Navigation bekommt. `structFieldHandlers` trägt die Feld-Adresse.
@@ -1 +1,2 @@
Tetris Game mit Structs neu bauen
...und mit neuen `Get variables` / `Set variables` node
+77 -1
View File
@@ -110,8 +110,84 @@ export async function listTab(t) {
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty');
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'celly',
type: 'behavior',
name: 'Celly',
props: { enabled: true, runInEditor: false, cells: [{ x: 30, y: 11 }, { x: 44, y: 55 }] },
variables: [
{
key: 'cells',
label: 'cells',
kind: 'list',
default: [],
items: 'struct',
fields: [{ key: 'x', kind: 'number' }, { key: 'y', kind: 'number' }],
},
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`);
await sleep(250);
info = await json('__gm.list.info()');
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
check(
'and a row reads as its fields, not as an object',
info.items[0] === 'x: 30, y: 11',
JSON.stringify(info.items),
);
check('the tab knows its items are structs', info.kind === 'struct', info.kind);
check('the field labels of a bundle are on screen', (await find('x')) !== null);
await shot('05-struct-rows');
// Editing one field must leave the others and the neighbouring item alone – the whole item is
// rewritten on every keystroke, so a snapshot anywhere would silently revive an old value.
const firstField = await json("__gm.list.fieldAt(0, 'x')");
await click(firstField.x, firstField.y);
await typeText('77');
await press('Enter');
await sleep(250);
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'editing one field of one item leaves every other value untouched',
JSON.stringify(cells) ===
JSON.stringify([
{ x: 77, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(cells),
);
await undo();
await sleep(200);
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check(
'and undo takes back just that field',
JSON.stringify(back) ===
JSON.stringify([
{ x: 30, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(back),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(200);
await mutate('__gm.state.openList(null)');
await sleep(200);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "listy").length');
const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length',
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+162
View File
@@ -0,0 +1,162 @@
import { edge, node, port, variable } from '../lib/builders.mjs';
/**
* Get variables / Set variables: one node for several of them. Their ports are the behavior's
* variables, so the step checks all three sides of that – the run, the picking of a port from the
* variable list, and pointing an existing port at another variable without losing its edge.
*
* The step brings its own box and behavior; everything it adds is removed at the end.
*/
const SCALE = 100;
/**
* `On update → Set variables{vx = 2, vy = 3} → Move(owner, dx = vx, dy = vy)`, where the two
* numbers reach Move through the *variables* – one node writes both, one node reads both back.
* Neither speed is on the Move node, so a node that dropped a port would leave the box still.
*/
const logicGraph = {
nodes: [
node('vp-update', 'OnUpdate', 0, 0),
node('vp-owner', 'Owner', 0, 300),
node('vp-set', 'SetVariables', 260, 0, { vx: 2, vy: 3 }, [port('vx', 'number'), port('vy', 'number')]),
node('vp-get', 'GetVariables', 260, 180, {}, [port('vx', 'number'), port('vy', 'number')]),
node('vp-move', 'Move', 560, 0, { scale: SCALE }),
],
edges: [
edge('vp-e1', 'vp-update', 'exec', 'vp-set', 'exec'),
edge('vp-e2', 'vp-set', 'exec', 'vp-move', 'exec'),
edge('vp-e3', 'vp-get', 'vx', 'vp-move', 'dx'),
edge('vp-e4', 'vp-get', 'vy', 'vp-move', 'dy'),
edge('vp-e5', 'vp-update', 'dt', 'vp-move', 'dt'),
edge('vp-e6', 'vp-owner', 'object', 'vp-move', 'object'),
],
};
export async function variableNodes(t) {
const { check, sleep, evaluate, json, shot, click, locate, undo, entity, asset, mutate, stop } = t;
const portAt = (nodeId, key, isOutput) =>
json(`__gm.graph.portAt(${JSON.stringify(nodeId)}, ${JSON.stringify(key)}, ${isOutput})`);
const portsOf = async (nodeId) =>
(await asset('vp-logic')).graph.nodes.find((n) => n.id === nodeId).ports ?? [];
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'vp-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);
};
// 23a. A box wearing a behavior whose two speeds travel through its variables.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const proto = scene.objects[0];
const mover = p.assets.find((a) => a.id === 'mover');
const a = structuredClone(mover);
Object.assign(a, {
id: 'vp-logic',
name: 'Speeds',
props: { enabled: true, runInEditor: false, vx: 0, vy: 0, vz: 0 },
variables: ${JSON.stringify([variable('vx', 'number', 0), variable('vy', 'number', 0), variable('vz', 'number', 0)])},
graph: ${JSON.stringify(logicGraph)},
});
p.assets.unshift(a);
const make = (id, assetId, parentId, name) => {
const o = structuredClone(proto);
Object.assign(o, {
id, assetId, name,
position: { x: 0, y: 900, z: 0 },
parentId, overrides: {}, instanceEdits: {},
});
return o;
};
scene.objects.push(make('vp-box', 'crate', '', 'Speedy box'), make('vp-brain', 'vp-logic', 'vp-box', 'Speeds'));
`);
await sleep(400);
// 23b. The run: both variables are written by one node and read back by another, and the two
// speeds stay apart – 2 to the right for every 3 down.
await t.play();
await sleep(300);
const before = await entity('vp-box');
await sleep(1000);
const after = await entity('vp-box');
const dx = after.x - before.x;
const dy = after.y - before.y;
check(
'one node writes both variables, another reads both back, and the run uses them',
dx > SCALE * 1.4 && dx < SCALE * 2.6 && dy > SCALE * 2.4 && dy < SCALE * 3.6,
`moved (${dx.toFixed(1)}, ${dy.toFixed(1)}) px in ~1 s – (200, 300) means both ports carried their own variable`,
);
await stop();
await sleep(300);
// 23c. The node draws one port per variable it names, on the side its type says.
await select('vp-set');
const vxIn = await portAt('vp-set', 'vx', false);
const vyIn = await portAt('vp-set', 'vy', false);
const vxOut = await portAt('vp-get', 'vx', true);
check(
'the picked variables are ports: inputs on Set, outputs on Get',
Boolean(vxIn) && Boolean(vyIn) && vyIn.y > vxIn.y && Boolean(vxOut),
JSON.stringify({ vxIn, vyIn, vxOut }),
);
// 23d. The inspector picks a port from the behavior's variables instead of having it typed.
const add = await locate('+ Variable');
check('the node offers its variables in the inspector', Boolean(add), JSON.stringify(add));
await shot('01-variable-ports');
await click(add.x, add.y);
await sleep(200);
await clickLast('vz');
await sleep(300);
const grown = await portsOf('vp-set');
check(
'picking one adds it as a port, typed from the variable',
grown.length === 3 && grown[2].key === 'vz' && grown[2].kind === 'number',
JSON.stringify(grown),
);
check('and the node grew that input', Boolean(await portAt('vp-set', 'vz', false)));
await shot('02-variable-added');
await undo();
await sleep(300);
check('undo takes it back', (await portsOf('vp-set')).length === 2);
// 23e. Pointing a port at another variable keeps the edge hanging on it – a correction, not a
// rewiring. `vp-e3` runs from this port to Move's dx.
await select('vp-get');
await clickLast('vx');
await sleep(200);
await clickLast('vz');
await sleep(300);
const swapped = await portsOf('vp-get');
const edgeStill = await json(
`(__gm.state.project.assets.find((a) => a.id === 'vp-logic').graph.edges.find((e) => e.id === 'vp-e3') || {}).from`,
);
check(
'a port pointed at another variable keeps its edge',
swapped[0].key === 'vz' && edgeStill.port === 'vz',
JSON.stringify({ swapped, edgeStill }),
);
await undo();
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'vp-logic');
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'vp-box' && o.id !== 'vp-brain');
__gm.state.select(null);
__gm.state.preview();
`);
await sleep(300);
const left = await json('__gm.state.project.assets.filter((a) => a.id === "vp-logic").length');
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+2
View File
@@ -25,6 +25,7 @@ import { nestedBehavior } from './19-nested-behavior.mjs';
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';
export const STEPS = [
hierarchy,
@@ -49,4 +50,5 @@ export const STEPS = [
keyRepeat,
listTab,
structVariable,
variableNodes,
];
+9 -2
View File
@@ -46,11 +46,18 @@ export const node = (id, type, x, y, params = {}, ports) => ({
/** A port a behavior's start or end node declares. */
export const port = (key, kind) => ({ key, kind });
/** A behavior variable declaration. `items` is the item kind of a `list`, ignored otherwise. */
export const variable = (key, kind, value, items) => ({
/**
* A behavior variable declaration. `items` is the item kind of a `list`, `fields` the shape of a
* `struct` – or of the items of a `list` whose items are structs.
*/
export const variable = (key, kind, value, items, fields) => ({
key,
label: key,
kind,
default: value,
...(items ? { items } : {}),
...(fields ? { fields } : {}),
});
/** A struct field declaration, and the same shape a list of structs declares for its items. */
export const field = (key, kind = 'number') => ({ key, kind });
+112 -134
View File
@@ -7,6 +7,11 @@
* empty row joined in front of everything above and below it – with no text encoding in between,
* so the numbers in the inspector are the numbers the game means.
*
* What travels as a bundle rather than as loose numbers: the falling piece (`piece`) and the one
* move being tried on it (`next`) are structs of shape/rot/x/y, and `shapes` is a list of `{x, y}`
* cells. That is what lets `Next move` hand back a whole candidate for `Fits` to judge and the
* master to accept in a single write, and it halves the index arithmetic in `Piece cell`.
*
* The game is ten behaviors rather than one graph. `Tetris` owns the state and does none of the
* work: every step is a behavior of its own, called as a node – `Spawn piece`, `Next move`,
* `Fits`, `Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell`
@@ -19,7 +24,7 @@
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-tetris.mjs [projectId] [baseUrl]
*/
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
import { edge, field, node, port, sceneObject, variable } from './lib/builders.mjs';
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
@@ -123,21 +128,25 @@ const SHAPES = [
const turned = (cells, size) => cells.map(([x, y]) => [size - 1 - y, x]);
/**
* All 28 rotations in one list, eight numbers each (x and y of the four cells), so one rotation
* starts at (shape·4 + rotation)·8 and its n-th cell two numbers further along.
* All 28 rotations in one list, four cells each, so one rotation starts at (shape·4 + rotation)·4
* and its n-th cell is one entry further along.
*/
function shapeTable() {
const table = [];
for (const shape of SHAPES) {
let cells = shape.cells;
for (let rotation = 0; rotation < 4; rotation++) {
for (const [x, y] of cells) table.push(x, y);
for (const [x, y] of cells) table.push({ x, y });
cells = turned(cells, shape.size);
}
}
return table;
}
/** The four numbers that say which piece is where. */
const PIECE_FIELDS = [field('shape'), field('rot'), field('x'), field('y')];
const NO_PIECE = { shape: 0, rot: 0, x: 0, y: 0 };
/** The column a shape starts in, so every piece appears centred over the well. */
const spawnColumns = () => SHAPES.map((s) => Math.floor((COLS - s.size) / 2));
@@ -224,41 +233,36 @@ function pieceCellGraph() {
const g = graphBuilder();
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('piece', 'struct'),
port('corner', 'number'),
port('baseX', 'number'),
port('baseY', 'number'),
]);
g.at('x4', 'Multiply', 1, 2, { b: 4 });
g.at('plus', 'Add', 2, 2);
g.at('x8', 'Multiply', 3, 2, { b: 8 });
g.at('c2', 'Multiply', 1, 5, { b: 2 });
g.at('ix', 'Add', 4, 2);
g.at('iy', 'Add', 4, 5, { b: 1 });
g.at('ax', 'ItemAt', 5, 1);
g.at('ay', 'ItemAt', 5, 5);
g.at('sx', 'Add', 6, 1);
g.at('sy', 'Add', 6, 5);
g.at('end', 'BehaviorEnd', 7, 0, {}, [port('x', 'number'), port('y', 'number')]);
// The whole piece arrives as one bundle and is taken apart here, where its parts are needed.
g.at('of', 'ExpandStruct', 1, 2, {}, PIECE_FIELDS);
g.at('x4', 'Multiply', 2, 2, { b: 4 });
g.at('plus', 'Add', 3, 2);
g.at('x4b', 'Multiply', 4, 2, { b: 4 });
g.at('index', 'Add', 5, 2);
g.at('cell', 'ItemAt', 6, 2);
g.at('at', 'ExpandStruct', 7, 2, {}, [field('x'), field('y')]);
g.at('sx', 'Add', 8, 1);
g.at('sy', 'Add', 8, 4);
g.at('end', 'BehaviorEnd', 9, 0, {}, [port('x', 'number'), port('y', 'number')]);
g.link('start', 'shape', 'x4', 'a');
g.link('start', 'piece', 'of', 'value');
g.link('of', 'shape', 'x4', 'a');
g.link('x4', 'result', 'plus', 'a');
g.link('start', 'rot', 'plus', 'b');
g.link('plus', 'result', 'x8', 'a');
g.link('start', 'corner', 'c2', 'a');
g.link('x8', 'result', 'ix', 'a');
g.link('c2', 'result', 'ix', 'b');
g.link('ix', 'result', 'iy', 'a');
g.link('start', 'shapes', 'ax', 'list');
g.link('ix', 'result', 'ax', 'index');
g.link('start', 'shapes', 'ay', 'list');
g.link('iy', 'result', 'ay', 'index');
g.link('ax', 'item', 'sx', 'a');
g.link('start', 'baseX', 'sx', 'b');
g.link('ay', 'item', 'sy', 'a');
g.link('start', 'baseY', 'sy', 'b');
g.link('of', 'rot', 'plus', 'b');
g.link('plus', 'result', 'x4b', 'a');
g.link('x4b', 'result', 'index', 'a');
g.link('start', 'corner', 'index', 'b');
g.link('start', 'shapes', 'cell', 'list');
g.link('index', 'result', 'cell', 'index');
g.link('cell', 'item', 'at', 'value');
g.link('at', 'x', 'sx', 'a');
g.link('of', 'x', 'sx', 'b');
g.link('at', 'y', 'sy', 'a');
g.link('of', 'y', 'sy', 'b');
g.link('sx', 'result', 'end', 'x');
g.link('sy', 'result', 'end', 'y');
g.flow('start', 'end');
@@ -274,10 +278,7 @@ function fitsGraph() {
g.at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('grid', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('x', 'number'),
port('y', 'number'),
port('piece', 'struct'),
]);
g.set('ok', 'ok', 1, 0, true);
g.at('loop', 'Repeat', 2, 0, { times: 4 });
@@ -285,10 +286,8 @@ function fitsGraph() {
g.at('cell', 'RunBehavior', 3, 0, { behavior: 'piece-cell' });
g.link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'shape', 'rot']) g.link('start', key, 'cell', key);
for (const key of ['shapes', 'piece']) g.link('start', key, 'cell', key);
g.link('loop', 'index', 'cell', 'corner');
g.link('start', 'x', 'cell', 'baseX');
g.link('start', 'y', 'cell', 'baseY');
g.at('left', 'Compare', 4, 3, { op: '<', b: 0 });
g.at('right', 'Compare', 4, 5, { op: '>', b: COLS - 1 });
@@ -446,12 +445,16 @@ function spawnPieceGraph() {
g.at('random', 'Random', 1, 2, { min: 0, max: SHAPES.length });
g.at('shape', 'Floor', 2, 2);
g.at('column', 'ItemAt', 3, 2);
g.at('end', 'BehaviorEnd', 4, 0, {}, [port('shape', 'number'), port('x', 'number')]);
// Handed back as a finished piece rather than as parts the caller has to assemble: the two
// fields it does not choose are the ones a new piece is defined to start at.
g.at('piece', 'PackStruct', 4, 2, { rot: 0, y: 0 }, PIECE_FIELDS);
g.at('end', 'BehaviorEnd', 5, 0, {}, [port('piece', 'struct')]);
g.link('random', 'result', 'shape', 'value');
g.link('start', 'spawnX', 'column', 'list');
g.link('shape', 'result', 'column', 'index');
g.link('shape', 'result', 'end', 'shape');
g.link('column', 'item', 'end', 'x');
g.link('shape', 'result', 'piece', 'shape');
g.link('column', 'item', 'piece', 'x');
g.link('piece', 'value', 'end', 'piece');
g.flow('start', 'end');
return g.done();
}
@@ -466,9 +469,7 @@ function nextMoveGraph() {
const { at, link, flow, get, set } = g;
at('start', 'BehaviorStart', 0, 0, {}, [
port('px', 'number'),
port('py', 'number'),
port('rot', 'number'),
port('piece', 'struct'),
port('wantDx', 'number'),
port('wantRot', 'number'),
port('fall', 'number'),
@@ -476,21 +477,21 @@ function nextMoveGraph() {
port('stepTime', 'number'),
]);
// The candidate starts out as where the piece already is, and no wish is spent yet.
set('i-nx', 'nx', 1, 0);
set('i-ny', 'ny', 2, 0);
set('i-nrot', 'nrot', 3, 0);
set('i-kind', 'kind', 4, 0, '');
set('i-fall', 'outFall', 5, 0);
set('i-dx', 'outDx', 6, 0);
set('i-rot', 'outRot', 7, 0);
link('start', 'px', 'i-nx', 'value');
link('start', 'py', 'i-ny', 'value');
link('start', 'rot', 'i-nrot', 'value');
// The candidate starts out as the piece itself – one write, because it is one value. Each
// strand below then packs only the field it changes: writing a bundle keeps the fields it
// does not name, so "the same piece, one row lower" needs no copying.
at('of', 'ExpandStruct', 1, 3, {}, PIECE_FIELDS);
link('start', 'piece', 'of', 'value');
set('i-next', 'next', 1, 0);
set('i-kind', 'kind', 2, 0, '');
set('i-fall', 'outFall', 3, 0);
set('i-dx', 'outDx', 4, 0);
set('i-rot', 'outRot', 5, 0);
link('start', 'piece', 'i-next', 'value');
link('start', 'fall', 'i-fall', 'value');
link('start', 'wantDx', 'i-dx', 'value');
link('start', 'wantRot', 'i-rot', 'value');
flow('start', 'i-nx', 'i-ny', 'i-nrot', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
flow('start', 'i-next', 'i-kind', 'i-fall', 'i-dx', 'i-rot', 't-br');
// Turning wins: a quarter turn clockwise is one step around the four rotations of the shape.
g.strand();
@@ -498,15 +499,17 @@ function nextMoveGraph() {
at('t-br', 'Branch', 0, 0);
at('t-plus1', 'Add', 1, 3, { b: 1 });
at('t-wrap', 'Modulo', 2, 3, { b: 4 });
set('t-set', 'nrot', 1, 0);
at('t-pack', 'PackStruct', 3, 3, {}, [field('rot')]);
set('t-set', 'next', 1, 0);
set('t-kind', 'kind', 2, 0, 'turn');
set('t-used', 'outRot', 3, 0, 0);
link('start', 'wantRot', 't-want', 'a');
link('t-want', 'result', 't-br', 'condition');
link('t-br', 'then', 't-set', 'exec');
link('start', 'rot', 't-plus1', 'a');
link('of', 'rot', 't-plus1', 'a');
link('t-plus1', 'result', 't-wrap', 'a');
link('t-wrap', 'result', 't-set', 'value');
link('t-wrap', 'result', 't-pack', 'rot');
link('t-pack', 'value', 't-set', 'value');
flow('t-set', 't-kind', 't-used', 'end');
// Then sideways, one column per key press.
@@ -514,7 +517,8 @@ function nextMoveGraph() {
at('m-moving', 'Not', 5, 3);
at('m-br', 'Branch', 5, 0);
at('m-shift', 'Add', 6, 3);
set('m-set', 'nx', 6, 0);
at('m-pack', 'PackStruct', 7, 3, {}, [field('x')]);
set('m-set', 'next', 6, 0);
set('m-kind', 'kind', 7, 0, 'move');
set('m-used', 'outDx', 8, 0, 0);
link('t-br', 'else', 'm-br', 'exec');
@@ -522,9 +526,10 @@ function nextMoveGraph() {
link('m-still', 'result', 'm-moving', 'value');
link('m-moving', 'result', 'm-br', 'condition');
link('m-br', 'then', 'm-set', 'exec');
link('start', 'px', 'm-shift', 'a');
link('of', 'x', 'm-shift', 'a');
link('start', 'wantDx', 'm-shift', 'b');
link('m-shift', 'result', 'm-set', 'value');
link('m-shift', 'result', 'm-pack', 'x');
link('m-pack', 'value', 'm-set', 'value');
flow('m-set', 'm-kind', 'm-used', 'end');
// And otherwise the clock: holding the down key lets it run ten times as fast.
@@ -550,7 +555,8 @@ function nextMoveGraph() {
at('f-minus', 'Subtract', 7, 3);
set('f-rest', 'outFall', 6, 0);
at('f-down1', 'Add', 7, 5, { b: 1 });
set('f-ny', 'ny', 7, 0);
at('f-pack', 'PackStruct', 8, 5, {}, [field('y')]);
set('f-ny', 'next', 7, 0);
set('f-kind', 'kind', 8, 0, 'fall');
flow('f-set', 'f-br');
link('f-fall', 'value', 'f-due', 'a');
@@ -560,8 +566,9 @@ function nextMoveGraph() {
link('f-fall', 'value', 'f-minus', 'a');
link('start', 'stepTime', 'f-minus', 'b');
link('f-minus', 'result', 'f-rest', 'value');
link('start', 'py', 'f-down1', 'a');
link('f-down1', 'result', 'f-ny', 'value');
link('of', 'y', 'f-down1', 'a');
link('f-down1', 'result', 'f-pack', 'y');
link('f-pack', 'value', 'f-ny', 'value');
flow('f-rest', 'f-ny', 'f-kind', 'end');
// Not due yet: nothing moves, and "kind" stays empty so the fit test below changes nothing.
link('f-br', 'else', 'end', 'exec');
@@ -569,18 +576,14 @@ function nextMoveGraph() {
// Whatever the frame decided is handed back – the caller owns the variables, not this behavior.
g.strand();
at('end', 'BehaviorEnd', 2, 0, {}, [
port('nx', 'number'),
port('ny', 'number'),
port('nrot', 'number'),
port('next', 'struct'),
port('kind', 'text'),
port('fall', 'number'),
port('wantDx', 'number'),
port('wantRot', 'number'),
]);
const back = [
['e-nx', 'nx', 'nx'],
['e-ny', 'ny', 'ny'],
['e-nrot', 'nrot', 'nrot'],
['e-next', 'next', 'next'],
['e-kind', 'kind', 'kind'],
['e-fall', 'outFall', 'fall'],
['e-dx', 'outDx', 'wantDx'],
@@ -604,20 +607,18 @@ function drawPieceGraph() {
at('start', 'BehaviorStart', 0, 0, {}, [
port('shapes', 'list'),
port('shape', 'number'),
port('rot', 'number'),
port('px', 'number'),
port('py', 'number'),
port('piece', 'struct'),
port('colors', 'list'),
]);
at('loop', 'Repeat', 1, 0, { times: 4 });
at('cell', 'RunBehavior', 2, 0, { behavior: 'piece-cell' });
flow('start', 'loop');
link('loop', 'body', 'cell', 'exec');
for (const key of ['shapes', 'shape', 'rot']) link('start', key, 'cell', key);
for (const key of ['shapes', 'piece']) link('start', key, 'cell', key);
link('loop', 'index', 'cell', 'corner');
link('start', 'px', 'cell', 'baseX');
link('start', 'py', 'cell', 'baseY');
// Only the shape number is wanted here, for the colour.
at('of', 'ExpandStruct', 2, 10, {}, [field('shape')]);
link('start', 'piece', 'of', 'value');
const object = objectOfRound(g, 'p', 'loop', 'p', 3, 12);
at('x', 'Multiply', 3, 4, { b: CELL });
@@ -630,7 +631,7 @@ function drawPieceGraph() {
link('cell', 'y', 'y', 'a');
link('y', 'result', 'aty', 'a');
link('start', 'colors', 'color', 'list');
link('start', 'shape', 'color', 'index');
link('of', 'shape', 'color', 'index');
at('show', 'SetEnabled', 6, 0, { enabled: true });
at('place', 'SetPosition', 7, 0);
@@ -756,26 +757,20 @@ function tetrisGraph() {
// 3a) No piece: ask for one and write down what came back.
at('n-spawn', 'RunBehavior', 2, 5, { behavior: 'spawn-piece' });
get('n-columns', 'spawnX', 2, 8);
set('n-shape', 'shape', 3, 5);
set('n-rot', 'rot', 4, 5, 0);
set('n-px', 'px', 5, 5);
set('n-py', 'py', 6, 5, 0);
set('n-fall', 'fall', 7, 5, 0);
set('n-has', 'hasPiece', 8, 5, true);
set('n-piece', 'piece', 3, 5);
set('n-fall', 'fall', 4, 5, 0);
set('n-has', 'hasPiece', 5, 5, true);
link('u-br', 'else', 'n-spawn', 'exec');
link('n-columns', 'value', 'n-spawn', 'spawnX');
link('n-spawn', 'shape', 'n-shape', 'value');
link('n-spawn', 'x', 'n-px', 'value');
flow('n-spawn', 'n-shape', 'n-rot', 'n-px', 'n-py', 'n-fall', 'n-has');
link('n-spawn', 'piece', 'n-piece', 'value');
flow('n-spawn', 'n-piece', 'n-fall', 'n-has');
// 3b) A piece is falling: one behavior decides which single move this frame attempts.
strand();
at('c-move', 'RunBehavior', 0, 0, { behavior: 'next-move' });
link('u-br', 'then', 'c-move', 'exec');
const moveIn = [
['px', 'px'],
['py', 'py'],
['rot', 'rot'],
['piece', 'piece'],
['wantDx', 'wantDx'],
['wantRot', 'wantRot'],
['fall', 'fall'],
@@ -788,9 +783,7 @@ function tetrisGraph() {
link('u-update', 'dt', 'c-move', 'dt');
const moveOut = [
['nx', 'nx'],
['ny', 'ny'],
['nrot', 'nrot'],
['next', 'next'],
['kind', 'kind'],
['fall', 'fall'],
['wantDx', 'wantDx'],
@@ -807,56 +800,45 @@ function tetrisGraph() {
at('t-fits', 'RunBehavior', 0, 0, { behavior: 'fits' });
get('t-shapes', 'shapes', 0, 3);
get('t-grid', 'grid', 0, 5);
get('t-shape', 'shape', 0, 7);
get('t-nrot', 'nrot', 0, 9);
get('t-nx', 'nx', 0, 11);
get('t-ny', 'ny', 0, 13);
get('t-next', 'next', 0, 7);
flow('c-out-wantRot', 't-fits');
link('t-shapes', 'value', 't-fits', 'shapes');
link('t-grid', 'value', 't-fits', 'grid');
link('t-shape', 'value', 't-fits', 'shape');
link('t-nrot', 'value', 't-fits', 'rot');
link('t-nx', 'value', 't-fits', 'x');
link('t-ny', 'value', 't-fits', 'y');
link('t-next', 'value', 't-fits', 'piece');
// 5) It fits: the candidate becomes the piece. It does not: only a fall means anything – the
// piece has landed (or, still at the very top, the well is full).
at('m-br', 'Branch', 1, 0);
get('m-nx', 'nx', 2, 3);
get('m-ny', 'ny', 3, 3);
get('m-nrot', 'nrot', 4, 3);
set('m-px', 'px', 2, 0);
set('m-py', 'py', 3, 0);
set('m-rot', 'rot', 4, 0);
get('m-next', 'next', 2, 3);
set('m-piece', 'piece', 2, 0);
flow('t-fits', 'm-br');
link('t-fits', 'ok', 'm-br', 'condition');
link('m-br', 'then', 'm-px', 'exec');
link('m-nx', 'value', 'm-px', 'value');
link('m-ny', 'value', 'm-py', 'value');
link('m-nrot', 'value', 'm-rot', 'value');
flow('m-px', 'm-py', 'm-rot');
link('m-br', 'then', 'm-piece', 'exec');
link('m-next', 'value', 'm-piece', 'value');
get('m-kind', 'kind', 2, 7);
at('m-landed', 'Equals', 3, 7, { b: 'fall' });
at('m-brland', 'Branch', 3, 5);
get('m-py2', 'py', 4, 8);
at('m-attop', 'Compare', 5, 8, { op: '<=', b: 0 });
get('m-py2', 'piece', 4, 8);
at('m-pieceY', 'ExpandStruct', 5, 10, {}, [field('y')]);
at('m-attop', 'Compare', 6, 8, { op: '<=', b: 0 });
at('m-brover', 'Branch', 5, 5);
at('m-stop', 'StopGame', 6, 5);
link('m-br', 'else', 'm-brland', 'exec');
link('m-kind', 'value', 'm-landed', 'a');
link('m-landed', 'result', 'm-brland', 'condition');
link('m-brland', 'then', 'm-brover', 'exec');
link('m-py2', 'value', 'm-attop', 'a');
link('m-py2', 'value', 'm-pieceY', 'value');
link('m-pieceY', 'y', 'm-attop', 'a');
link('m-attop', 'result', 'm-brover', 'condition');
link('m-brover', 'then', 'm-stop', 'exec');
// 6) Wherever the piece ended up – newly spawned or moved – it is drawn there.
strand();
at('d-draw', 'RunBehavior', 0, 0, { behavior: 'draw-piece' });
flow('m-rot', 'd-draw');
flow('m-piece', 'd-draw');
flow('n-has', 'd-draw');
['shapes', 'shape', 'rot', 'px', 'py', 'colors'].forEach((key, i) => {
['shapes', 'piece', 'colors'].forEach((key, i) => {
get(`d-${key}`, key, 0, i * 2 + 3);
link(`d-${key}`, 'value', 'd-draw', key);
});
@@ -865,11 +847,13 @@ function tetrisGraph() {
strand();
at('l-lock', 'RunBehavior', 0, 0, { behavior: 'lock-piece' });
get('l-grid', 'grid', 0, 3);
get('l-shape', 'shape', 0, 5);
get('l-piece', 'piece', 0, 5);
at('l-shape', 'ExpandStruct', 0, 7, {}, [field('shape')]);
set('l-setgrid', 'grid', 1, 0);
link('m-brover', 'else', 'l-lock', 'exec');
link('l-grid', 'value', 'l-lock', 'grid');
link('l-shape', 'value', 'l-lock', 'shape');
link('l-piece', 'value', 'l-shape', 'value');
link('l-shape', 'shape', 'l-lock', 'shape');
link('l-lock', 'grid', 'l-setgrid', 'value');
flow('l-lock', 'l-setgrid');
@@ -910,16 +894,12 @@ function behavior(id, name, variables, graph) {
const tetrisVariables = [
variable('grid', 'list', EMPTY_GRID, 'number'),
variable('shapes', 'list', shapeTable(), 'number'),
variable('shapes', 'list', shapeTable(), 'struct', [field('x'), field('y')]),
variable('colors', 'list', SHAPE_COLORS, 'color'),
variable('spawnX', 'list', spawnColumns(), 'number'),
variable('shape', 'number', 0),
variable('rot', 'number', 0),
variable('px', 'number', 3),
variable('py', 'number', 0),
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
// The falling piece and the one move being tried on it – four numbers each, and never apart.
variable('piece', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('hasPiece', 'boolean', false),
variable('fall', 'number', 0),
@@ -984,9 +964,7 @@ const project = {
'next-move',
'Next move',
[
variable('nx', 'number', 0),
variable('ny', 'number', 0),
variable('nrot', 'number', 0),
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
variable('kind', 'text', ''),
variable('outFall', 'number', 0),
variable('outDx', 'number', 0),
+12 -10
View File
@@ -27,16 +27,16 @@ const OBJECT_COUNT = STORED_COUNT + 1 + COLS * ROWS + 4;
/** The master graph and the nine behaviors the game is split into. */
const GRAPHS = {
tetris: { nodes: 84, edges: 94 },
'spawn-piece': { nodes: 5, edges: 6 },
'next-move': { nodes: 44, edges: 63 },
fits: { nodes: 18, edges: 29 },
'draw-piece': { nodes: 14, edges: 26 },
tetris: { nodes: 69, edges: 76 },
'spawn-piece': { nodes: 6, edges: 7 },
'next-move': { nodes: 44, edges: 61 },
fits: { nodes: 18, edges: 26 },
'draw-piece': { nodes: 15, edges: 24 },
'lock-piece': { nodes: 20, edges: 23 },
'clear-lines': { nodes: 29, edges: 36 },
'show-lines': { nodes: 5, edges: 5 },
'draw-board': { nodes: 11, edges: 17 },
'piece-cell': { nodes: 12, edges: 19 },
'piece-cell': { nodes: 11, edges: 17 },
};
/** The steps the master calls. `piece-cell` is missing on purpose: three of these call it. */
@@ -82,6 +82,8 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
/** The variables of the Tetris behavior, live from the running world. */
const vars = async () => (await entity('ogame')).props;
/** The falling piece as the game now keeps it: one bundle, not four loose numbers. */
const piece = async () => (await vars()).piece;
const filledCells = (grid) => grid.filter((c) => c !== EMPTY).length;
const pieceCells = async () => {
const w = await world();
@@ -151,7 +153,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
check(
'playing puts a piece into the well',
spawned.hasPiece === true && cells.every((c) => c && c.props.enabled === true),
JSON.stringify({ hasPiece: spawned.hasPiece, shape: spawned.shape, px: spawned.px }),
JSON.stringify({ hasPiece: spawned.hasPiece, piece: spawned.piece }),
);
const start = await piecePosition();
check(
@@ -184,10 +186,10 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
back.every((c, i) => c.column === beforeMove[i].column),
back.map((c) => c.column).join(','),
);
const beforeTurn = (await vars()).rot;
const beforeTurn = (await piece()).rot;
await press('ArrowUp');
await sleep(150);
const afterTurn = (await vars()).rot;
const afterTurn = (await piece()).rot;
check(
'the up arrow turns the piece',
afterTurn === (beforeTurn + 1) % 4,
@@ -266,7 +268,7 @@ await runChecks({ projectId, baseUrl, port: 9337, shotPrefix: 'gm-tetris' }, asy
over
? JSON.stringify({
mode,
py: over.py,
piece: over.piece,
hasPiece: over.hasPiece,
kind: over.kind,
lines: over.lines,