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:
1 parent
9d62dfea7b
commit
f60358c951
32 files changed
+1083
-253
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)) } };
|
||||
},
|
||||
};
|
||||
@@ -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' };
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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]) });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user