Drop the per-list item declaration; every item carries its own kind
A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
e147dd6d21
commit
11c900c998
34 files changed
+1218
-640
No files matched your search
@@ -123,26 +123,37 @@ shown on the node and editable in the inspector.
|
||||
`Properties` nodes, typed by the first object type holding every key, else the one behavior
|
||||
declaring them, else Any – which is also what a node only about `enabled` gets, since every
|
||||
object has it. 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
|
||||
**number, a yes/no, a text, a list or a struct**; a list declares **nothing** about its items –
|
||||
each one is a number, a text, a yes/no, a colour or a struct and says so itself, so one list may
|
||||
hold several kinds at once. A list is
|
||||
compared by its items, never by identity – otherwise every reload would look like an override.
|
||||
There is no list *of objects*: a list is stored in the project file and an object is a live
|
||||
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 – for a list of structs a row is the item's fields as
|
||||
labelled rows under each other. The inspector stays visible beside the tab, so what the list
|
||||
inspector would use for that kind – a struct item is its fields as rows 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, plus the `field` of a struct that holds one) on the editor state,
|
||||
because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input**
|
||||
of a node is the same button and the same tab: its inline value lives in the very same params
|
||||
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list
|
||||
variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡✓`, `≡◧`, `≡⊞`), which on the asset doubles
|
||||
as the chooser – a click opens the menu, with no chevron, since the mark has to stay as narrow as
|
||||
its column; changing it runs the declared default, the behavior's own value and every override
|
||||
through the new declaration in one command. A list of structs then declares its item shape with
|
||||
the same `+ Field` control a struct variable has – names and kinds only, since the values are
|
||||
edited row by row in the tab. A **struct** variable is the other way round: few fields, so they fit under
|
||||
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What an item holds
|
||||
is **its own** and is chosen there: the **kind mark** in front of a row (`#`, `Aa`, `✓`, `◧`, `▦`, `⊞`)
|
||||
is the picker – a click opens the menu, with no chevron, since the mark has to stay as narrow as
|
||||
its column – and `+ Item ▾` asks for the kind of the one it appends. Because the kind lives in the
|
||||
value, every such change is an ordinary **value edit**: one command, one undo step, and no
|
||||
neighbouring item touched. A **struct item** carries its own shape the same way: its fields are
|
||||
renamed, retyped, added (`+ Field ▾`) and removed on its own rows, which grows that one item and
|
||||
not the list. Two kinds need saying, because JSON cannot tell them from the value beside them: a
|
||||
**colour** is a number (`{"$color": 16711680}`) and a **texture** is an id, which is a text
|
||||
(`{"$texture": "walk-up"}`, the picker a sprite's `texture` property has – a whole picture, one
|
||||
frame of a sheet, or an animation). A field name cannot start with `$` (`isValidFieldName`), so a
|
||||
tag can never collide with a struct item of one field. Either reads as what it wraps on a data
|
||||
edge (`Item at`), the graph having neither colours nor pictures of its own, and `Set item` leaves
|
||||
the slot the kind it was. A texture is a **list item only**: in a flat position – a struct item's
|
||||
field, or a list a struct field holds – it keeps its id as plain text, and the tab does not offer
|
||||
it there. A file from before this carried the item kind on the list (`items: 'color'`); on load
|
||||
every such value is converted once, which is the only thing that keeps a colour written under the
|
||||
old rule a colour. A **struct** variable is the other way round: few fields, so they fit under
|
||||
each other right in the inspector. Its head row renames it and adds fields (`+ Field`), each
|
||||
field row below carries its own name, its value and a remove – the port editor `Pack` already
|
||||
has, one level down and with a value column – and a field may itself be a **list**, which is
|
||||
@@ -197,18 +208,21 @@ shown on the node and editable in the inspector.
|
||||
per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, a
|
||||
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. A `Pack`/`Expand` can take its fields
|
||||
from a variable that already declares the shape (**From variable**, a struct variable or the item
|
||||
shape of a list of structs) instead of having them typed again; that adds the fields it does not
|
||||
from a variable that already has the shape (**From variable**: a struct variable, or the first
|
||||
struct item a list holds – a list declares nothing, so its value is the only source) instead of
|
||||
having them typed again; that adds the fields it does not
|
||||
have yet as one undo step and leaves the ones it has alone. 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 – "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
|
||||
(`StructFieldKind`, primitives plus `list`), and the list a *field* holds takes primitives only
|
||||
(`ListFieldDef`). Forbidding just that one combination is what keeps the shapes finite:
|
||||
`Pack` of that one field. Depth is bounded by the *value*, not by convention: a list item is a primitive, a
|
||||
tagged colour or a struct of flat ones (`listItems.ts`), a struct field says what it holds
|
||||
(`StructFieldKind`, primitives plus `list`), and the list a *field* holds takes flat items only.
|
||||
Forbidding just that one combination is what keeps the shapes finite:
|
||||
`list → struct → primitive` and `struct → list → primitive` are the two deepest a stored value
|
||||
goes – exactly what `asStoredValue` lets through an untyped slot – so the deep compare and the
|
||||
editor stay finite. A live entity is
|
||||
editor stay finite. The List tab **offers only the kinds its slot can hold** (`ListAccess.kinds`):
|
||||
on a list of its own all five, on a list a struct field holds the four flat ones. Offering the
|
||||
struct there would add an item the next write drops – which reads as the menu entry doing nothing. 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
|
||||
@@ -724,13 +738,13 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 618 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 631 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
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
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 19 checks: the walk demo (the seed uploads its own sheet)
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 20 checks: the walk demo (the seed uploads its own sheet)
|
||||
```
|
||||
|
||||
**A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and
|
||||
@@ -856,9 +870,10 @@ sheet into the 96 cells of a 12×8 grid. That is the usual RPG-Maker layout –
|
||||
three columns (left foot / standing / right foot) by four rows (down, left, right, up) – and the demo
|
||||
wears the one in the top left corner. Walking is four **ping-pong** animations of three frames, which
|
||||
is exactly the original walk cycle; standing is no animation at all but the middle frame of the row.
|
||||
The graph builds what the sprite wears **by name**, pasting `walk-` or `idle-` in front of the
|
||||
direction it faces, which is why the standing frames carry named ids. Which way it looks is the key
|
||||
pressed last, corrected whenever only one axis is held – otherwise letting go of one key of a
|
||||
The graph picks what the sprite wears the way the editor does: two **list variables** hold the four
|
||||
walk animations and the four standing frames, every item chosen with the texture picker, and
|
||||
`facing` is the row of the sheet, which indexes both (`Item at`). No id is spelled anywhere in it,
|
||||
so the whole graph can be rebuilt by hand. Which way it looks is the key pressed last, corrected whenever only one axis is held – otherwise letting go of one key of a
|
||||
diagonal would leave it walking backwards. It is the worked example for animations, for a seed that
|
||||
brings its own texture, and for `Axis input`, whose vector is already normalized so a diagonal is not
|
||||
faster than a straight line. `tools/smoke-actor.mjs` walks it in a headless browser; that the
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
type ListValue,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
@@ -10,13 +8,9 @@ import {
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
coerceValue,
|
||||
emptyFieldValue,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
type ListItemKind,
|
||||
type PrimitiveFieldDef,
|
||||
primitiveFieldsOf,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
@@ -145,10 +139,8 @@ export class RenameVariableCommand implements Command {
|
||||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||||
|
||||
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
|
||||
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
|
||||
interface FieldOwner {
|
||||
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
|
||||
readonly inList: boolean;
|
||||
fields(): readonly StructFieldDef[];
|
||||
setFields(next: readonly StructFieldDef[]): void;
|
||||
}
|
||||
@@ -159,24 +151,13 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
const def = variableOf(project, assetId, key);
|
||||
if (def?.kind === 'struct')
|
||||
return {
|
||||
inList: false,
|
||||
fields: () => def.fields,
|
||||
setFields: (next) => (def.fields = next),
|
||||
};
|
||||
if (def?.kind === 'list' && def.items === 'struct')
|
||||
return {
|
||||
inList: true,
|
||||
fields: () => def.fields ?? [],
|
||||
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
|
||||
};
|
||||
throw new CommandError(`Variable "${key}" has no fields`);
|
||||
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
|
||||
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One struct-carrying value of a variable (default, own, override, or list item); a field change hits
|
||||
* all of them at once so none keeps a stale shape. `id` pairs a site up again on undo.
|
||||
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
|
||||
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
@@ -185,36 +166,16 @@ interface StructSite {
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const def = variableOf(project, assetId, key);
|
||||
const holders = valueHolders(project, assetId, key).filter(
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
|
||||
);
|
||||
const sites: StructSite[] = [];
|
||||
for (const holder of holders) {
|
||||
if (def?.kind !== 'list') {
|
||||
sites.push({
|
||||
return (
|
||||
valueHolders(project, assetId, key)
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
.filter((h) => !h.optional || isStruct(h.read()))
|
||||
.map((holder) => ({
|
||||
id: holder.id,
|
||||
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
||||
write: (value) => holder.write(value),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const list = holder.read();
|
||||
if (!isList(list)) continue;
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
sites.push({
|
||||
id: `${holder.id}#${index}`,
|
||||
read: () => {
|
||||
const item = (holder.read() as ListValue)[index];
|
||||
return isStruct(item) ? item : {};
|
||||
},
|
||||
write: (value) =>
|
||||
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
|
||||
});
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
write: (value: StructValue) => holder.write(value),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
|
||||
@@ -278,8 +239,6 @@ export class AddStructFieldCommand implements Command {
|
||||
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
||||
if (owner.fields().some((f) => f.key === this.field.key))
|
||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||
if (owner.inList && this.field.kind === 'list')
|
||||
throw new CommandError('A list field cannot sit in the items of a list');
|
||||
owner.setFields([...owner.fields(), { ...this.field }]);
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
||||
@@ -367,71 +326,3 @@ export class RenameStructFieldCommand implements Command {
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes what a list variable holds. The declaration is the only place that says it, so every
|
||||
* value has to follow at once – each is run through `coerceValue` against the new declaration,
|
||||
* which is the same conversion an item edited in the List tab goes through.
|
||||
*/
|
||||
export class SetListItemsCommand implements Command {
|
||||
readonly label: string;
|
||||
private before?: {
|
||||
items: ListItemKind;
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
values: Map<string, PropertyValue>;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly items: ListItemKind,
|
||||
) {
|
||||
this.label = `Set ${key} items`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
if (def.items === this.items)
|
||||
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
|
||||
const values = new Map<string, PropertyValue>();
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) values.set(holder.id, value);
|
||||
}
|
||||
this.before = { items: def.items, fields: def.fields, values };
|
||||
def.items = this.items;
|
||||
// Fields belong to struct items alone: kept while leaving so an undo has them back.
|
||||
if (this.items === 'struct' && !def.fields) def.fields = [];
|
||||
this.recoerce(project);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.before) return;
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
def.items = this.before.items;
|
||||
if (this.before.fields) def.fields = this.before.fields;
|
||||
else delete def.fields;
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = this.before.values.get(holder.id);
|
||||
if (value !== undefined) holder.write(value);
|
||||
}
|
||||
}
|
||||
|
||||
private recoerce(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) holder.write(coerceValue(def, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireListDef(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
key: string,
|
||||
): Extract<PropertyDef, { kind: 'list' }> {
|
||||
const def = variableOf(project, assetId, key);
|
||||
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
|
||||
return def;
|
||||
}
|
||||
@@ -148,16 +148,8 @@ export function inlineInputDef(port: PortDef): PropertyDef {
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||||
// No port declares an item kind, so an inline list reads as numbers – the same default
|
||||
// `+ Variable ▸ List` picks. The graph types a list as a list either way.
|
||||
case 'list':
|
||||
return {
|
||||
key: port.key,
|
||||
label: port.label,
|
||||
kind: 'list',
|
||||
default: asList(port.default),
|
||||
items: 'number',
|
||||
};
|
||||
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
|
||||
default:
|
||||
return {
|
||||
key: port.key,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sameValue } from '../../../model/Properties';
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
@@ -11,9 +12,12 @@ export const IndexOf: NodeDef = {
|
||||
inputs: [list('list'), any('value')],
|
||||
outputs: [num('index')],
|
||||
params: [],
|
||||
// Compared by content, not by identity: two equal structs are never the same object.
|
||||
// Compared by content, not by identity: two equal structs are never the same object. A color item
|
||||
// is compared as its number, or searching a list of colors with one would never find it.
|
||||
run: (_ctx, inputs) => {
|
||||
const wanted = asListItem(inputs.value);
|
||||
return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } };
|
||||
const wanted = itemValue(asListItem(inputs.value));
|
||||
return {
|
||||
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
@@ -9,10 +10,11 @@ export const ItemAt: NodeDef = {
|
||||
description: 'The item at an index, counting from 0. Out of range gives 0.',
|
||||
inputs: [list('list'), num('index')],
|
||||
outputs: [any('item')],
|
||||
// A color item reads as its number: the graph has no color of its own (see `portKindOf`).
|
||||
run: (_ctx, inputs) => {
|
||||
const items = asList(inputs.list);
|
||||
const i = Math.floor(asNumber(inputs.index));
|
||||
return { outputs: { item: items[i] ?? 0 } };
|
||||
return { outputs: { item: itemValue(items[i]) ?? 0 } };
|
||||
},
|
||||
params: [],
|
||||
};
|
||||
@@ -6,9 +6,9 @@ export const ListLiteral: NodeDef = {
|
||||
type: 'List',
|
||||
label: 'List',
|
||||
category: 'data',
|
||||
description: 'A constant list, edited item by item in the List tab.',
|
||||
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
|
||||
inputs: [],
|
||||
outputs: [list('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'list', default: [], items: 'number' }],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { itemLike } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
@@ -14,7 +15,9 @@ export const SetItem: NodeDef = {
|
||||
const i = Math.floor(asNumber(inputs.index));
|
||||
if (i < 0 || i >= items.length) return { outputs: { result: items } };
|
||||
const next = [...items];
|
||||
next[i] = asListItem(inputs.value);
|
||||
// The slot keeps its kind: writing a number into a color item leaves a color behind, the same
|
||||
// way writing into a number variable leaves a number (see `asTypeOf`).
|
||||
next[i] = itemLike(items[i], asListItem(inputs.value));
|
||||
return { outputs: { result: next } };
|
||||
},
|
||||
params: [],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { itemKindOf, structItemFields } from '../properties/listItems';
|
||||
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
|
||||
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
|
||||
|
||||
@@ -15,14 +16,15 @@ export function fieldPortKind(field: StructFieldDef): DeclaredPortKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle shape a variable declares, if it declares one: a struct variable holds it directly, a
|
||||
* list of structs declares the shape of every item. Both are what a Pack builds and an Expand takes
|
||||
* apart, so both can seed a node's fields.
|
||||
* The bundle shape of a variable, if it has one: a struct variable declares it, a list takes it from
|
||||
* the first struct item its default holds – items declare nothing, so the value is the only source.
|
||||
* Both are what a Pack builds and an Expand takes apart, so both can seed a node's fields.
|
||||
*/
|
||||
export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | undefined {
|
||||
if (def.kind === 'struct') return def.fields;
|
||||
if (def.kind === 'list' && def.items === 'struct') return def.fields ?? [];
|
||||
return undefined;
|
||||
if (def.kind !== 'list') return undefined;
|
||||
const item = def.default.find((entry) => itemKindOf(entry) === 'struct');
|
||||
return item === undefined ? undefined : structItemFields(item);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** The declarations cap depth: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
|
||||
export type FieldValue = number | string | boolean | ListValue;
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged colour. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem;
|
||||
|
||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||
export type StructValue = { readonly [field: string]: FieldValue };
|
||||
@@ -14,6 +14,44 @@ export type PropertyBag = Record<string, PropertyValue>;
|
||||
|
||||
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Inside a list a color and a texture have to say so: a list declares nothing about its items, and
|
||||
* there 0xRRGGBB is a plain number and an asset id a plain text. A field name cannot start with `$`
|
||||
* (`isValidFieldName`), so a tag can never collide with a struct item of one field.
|
||||
*/
|
||||
export const COLOR_TAG = '$color';
|
||||
export const TEXTURE_TAG = '$texture';
|
||||
|
||||
/** Indexable like any struct, so the one place that reads fields by name needs no special case. */
|
||||
export interface ColorItem {
|
||||
readonly $color: number;
|
||||
readonly [field: string]: FieldValue;
|
||||
}
|
||||
|
||||
/** A picture a sprite can wear: a texture, one of its frames, or an animation – all one id. */
|
||||
export interface TextureItem {
|
||||
readonly $texture: string;
|
||||
readonly [field: string]: FieldValue;
|
||||
}
|
||||
|
||||
const isTagged = (value: unknown, tag: string): boolean =>
|
||||
isStruct(value) && tag in value && Object.keys(value).length === 1;
|
||||
|
||||
export const isColorItem = (value: unknown): value is ColorItem => isTagged(value, COLOR_TAG);
|
||||
|
||||
export const isTextureItem = (value: unknown): value is TextureItem =>
|
||||
isTagged(value, TEXTURE_TAG) && typeof (value as TextureItem).$texture === 'string';
|
||||
|
||||
export const colorItem = (color: number): ColorItem => ({ $color: color });
|
||||
|
||||
export const textureItem = (id: string): TextureItem => ({ $texture: id });
|
||||
|
||||
/** The number behind a tagged color. */
|
||||
export const colorItemValue = (item: ColorItem): number => colorOf(item.$color, 0);
|
||||
|
||||
/** The id behind a tagged texture. */
|
||||
export const textureItemValue = (item: TextureItem): string => item.$texture;
|
||||
|
||||
/**
|
||||
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
|
||||
* the handle's brand (see `values.ts`). Nothing in a PropertyBag is ever a handle.
|
||||
@@ -28,6 +66,9 @@ export const isStruct = (value: unknown): value is StructValue =>
|
||||
export function textOf(value: PropertyValue | undefined): string {
|
||||
if (value === undefined) return '';
|
||||
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
|
||||
// A tagged value reads as what it wraps, the same as the matching property does.
|
||||
if (isColorItem(value)) return String(colorItemValue(value));
|
||||
if (isTextureItem(value)) return textureItemValue(value);
|
||||
if (isStruct(value))
|
||||
return Object.entries(value)
|
||||
.map(([field, item]) => `${field}: ${textOf(item)}`)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
booleanOf,
|
||||
colorOf,
|
||||
} from '../model/Properties';
|
||||
import { coerceField, flatListOf, listItemOf } from './listItems';
|
||||
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
export type PropertyDef =
|
||||
@@ -47,19 +48,12 @@ export type PropertyDef =
|
||||
*/
|
||||
| { key: string; label: string; kind: 'texture'; default: string }
|
||||
/**
|
||||
* `items` only decides how the inspector reads and writes the line; the graph types a list as a
|
||||
* 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.
|
||||
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
|
||||
* and the graph types a list as a 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;
|
||||
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
}
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||||
/**
|
||||
* `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`).
|
||||
@@ -70,8 +64,8 @@ export type PropertyDef =
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'texture', 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
/** What a field of a struct property holds – a primitive plus `list`. */
|
||||
@@ -83,11 +77,10 @@ export interface PrimitiveFieldDef {
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
|
||||
/** A list held by a struct field. Its items declare nothing either, but they stay flat. */
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
items: FieldKind;
|
||||
}
|
||||
|
||||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
|
||||
@@ -242,7 +235,6 @@ export function variableDef(
|
||||
key: string,
|
||||
kind: VariableKind,
|
||||
defaultValue?: PropertyValue,
|
||||
items?: ListItemKind,
|
||||
fields?: readonly StructFieldDef[],
|
||||
): PropertyDef {
|
||||
switch (kind) {
|
||||
@@ -252,19 +244,8 @@ 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': {
|
||||
const item = items ?? 'number';
|
||||
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
|
||||
const raw = isList(defaultValue) ? defaultValue : [];
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
kind,
|
||||
default: raw.map((entry) => coerceListItem(entry, item, shape)),
|
||||
items: item,
|
||||
...(shape ? { fields: shape } : {}),
|
||||
};
|
||||
}
|
||||
case 'list':
|
||||
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
|
||||
case 'struct': {
|
||||
const declared = fields ?? [];
|
||||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||||
@@ -272,10 +253,6 @@ export function variableDef(
|
||||
}
|
||||
}
|
||||
|
||||
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
|
||||
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
|
||||
}
|
||||
|
||||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||||
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
number: 0,
|
||||
@@ -324,32 +301,14 @@ 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.fields)) : def.default;
|
||||
// Each item keeps the kind it arrived as; there is no declaration to force it into.
|
||||
return isList(value) ? value.map(listItemOf) : def.default;
|
||||
case 'struct':
|
||||
return structOf(def.fields, 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);
|
||||
}
|
||||
|
||||
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
|
||||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||||
if (field.kind !== 'list') return coerceField(value, field.kind);
|
||||
return isList(value) ? value.map((item) => coerceField(item, field.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') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (items === 'color') return colorOf(value, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||||
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
|
||||
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
|
||||
*
|
||||
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
|
||||
* and so does a list inside a struct field. A texture is a list item only; in a flat position it
|
||||
* keeps its id as text rather than vanishing.
|
||||
*/
|
||||
import {
|
||||
booleanOf,
|
||||
type ColorItem,
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
colorOf,
|
||||
type FieldValue,
|
||||
isColorItem,
|
||||
isList,
|
||||
isStruct,
|
||||
isTextureItem,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
} from '../model/Properties';
|
||||
import type { FieldKind, ListItemKind } from './PropertySchema';
|
||||
|
||||
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
||||
export type ListItem = number | string | boolean | StructValue;
|
||||
|
||||
/** What a flat position holds: no struct except a tagged color, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | ColorItem;
|
||||
|
||||
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
||||
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
||||
if (isColorItem(item)) return 'color';
|
||||
if (isTextureItem(item)) return 'texture';
|
||||
if (isStruct(item) || isList(item)) return 'struct';
|
||||
if (typeof item === 'string') return 'text';
|
||||
if (typeof item === 'boolean') return 'boolean';
|
||||
return 'number';
|
||||
}
|
||||
|
||||
/** Flat positions hold no struct and no texture, so either of them reads as its text. */
|
||||
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
|
||||
const kind = itemKindOf(value);
|
||||
return kind === 'struct' || kind === 'texture' ? 'text' : kind;
|
||||
}
|
||||
|
||||
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
|
||||
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
|
||||
if (isColorItem(item)) return colorItemValue(item);
|
||||
if (isTextureItem(item)) return textureItemValue(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** `value` as an item of `kind`, which is how a row changes what it holds. */
|
||||
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
|
||||
switch (kind) {
|
||||
case 'color':
|
||||
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
|
||||
return colorItem(colorOf(itemValue(value) ?? 0, 0));
|
||||
case 'texture':
|
||||
return textureItem(textOf(itemValue(value)));
|
||||
case 'struct':
|
||||
return structItemOf(value);
|
||||
default:
|
||||
return coerceField(itemValue(value), kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
|
||||
export function itemLike(current: PropertyValue | undefined, value: PropertyValue | undefined): ListItem {
|
||||
return makeItem(itemKindOf(current), value);
|
||||
}
|
||||
|
||||
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
|
||||
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
|
||||
return kind === 'color'
|
||||
? colorItem(colorOf(itemValue(value) ?? 0, 0))
|
||||
: coerceField(itemValue(value), kind);
|
||||
}
|
||||
|
||||
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
|
||||
export function fieldLike(current: PropertyValue | undefined, value: PropertyValue | undefined): FlatItem {
|
||||
return makeFlatItem(fieldKindOf(current), value);
|
||||
}
|
||||
|
||||
/** Any value as a valid item; the one coercion a list value goes through. */
|
||||
export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||||
return makeItem(itemKindOf(value), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat position holds a primitive or a tagged color. Anything deeper is dropped rather than
|
||||
* flattened – the same bound the declarations used to draw, and the one the store writes.
|
||||
*/
|
||||
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
if (isTextureItem(value)) return textureItemValue(value);
|
||||
if (isList(value) || isStruct(value)) return undefined;
|
||||
return coerceField(value, fieldKindOf(value));
|
||||
}
|
||||
|
||||
/** A struct item keeps the fields it carries, each as the kind its own value says. */
|
||||
export function structItemOf(value: PropertyValue | undefined): StructValue {
|
||||
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
|
||||
const fields: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const flat = flatItemOf(item);
|
||||
if (flat !== undefined) fields[key] = flat;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The items of a list sitting in a struct field: flat ones only. */
|
||||
export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[] {
|
||||
if (!isList(value)) return [];
|
||||
return value.map(flatItemOf).filter((item): item is FlatItem => item !== undefined);
|
||||
}
|
||||
|
||||
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
||||
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
|
||||
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
|
||||
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
|
||||
}
|
||||
|
||||
/** A primitive as one of the four kinds. The spelling of a text counts, see `booleanOf`. */
|
||||
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
|
||||
if (kind === 'text') return textOf(value);
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (kind === 'color') return colorOf(value ?? 0, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
+86
-16
@@ -13,13 +13,28 @@ import {
|
||||
} from '../graph';
|
||||
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, StructValue } from '../model/Properties';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit, overrideSites } from '../model/instance';
|
||||
import {
|
||||
type ColorItem,
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
type FieldValue,
|
||||
isTextureItem,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
isColorItem,
|
||||
isList,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
setBagValue,
|
||||
type 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';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import { upgradePropertyNodes, upgradeVariableNodes } from './legacyNodes';
|
||||
import { makeItem } from '../properties/listItems';
|
||||
import {
|
||||
coerceValue,
|
||||
COMMON_PROPS,
|
||||
@@ -32,6 +47,7 @@ import {
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaFor,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
VARIABLE_KINDS,
|
||||
variableDef,
|
||||
@@ -49,10 +65,13 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
|
||||
const schemas = new Map<string, PropertySchema>();
|
||||
const variables = new Map<string, PropertyDef[]>();
|
||||
const legacyLists = new Map<string, Map<string, DeclaredItems>>();
|
||||
for (const a of rawAssets) {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
if (a.type === 'behavior') {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
const declared = new Map<string, DeclaredItems>();
|
||||
const vars = normalizeVariables(a.variables, declared);
|
||||
if (declared.size > 0) legacyLists.set(a.id, declared);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||||
} else {
|
||||
@@ -107,13 +126,48 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
activeSceneId = first ? first.id : '';
|
||||
}
|
||||
|
||||
return {
|
||||
const project: Project = {
|
||||
formatVersion: PROJECT_FORMAT_VERSION,
|
||||
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
|
||||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||
assets,
|
||||
activeSceneId,
|
||||
};
|
||||
migrateDeclaredLists(project, legacyLists);
|
||||
return project;
|
||||
}
|
||||
|
||||
/** What a list variable declared before its items carried their own kind – see `migrateDeclaredLists`. */
|
||||
interface DeclaredItems {
|
||||
kind: ListItemKind;
|
||||
fields: readonly StructFieldDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A list used to declare what it held, so its stored items did not have to say: a `color` list held
|
||||
* plain numbers, and a list of structs could hold an item missing a declared field. Items carry
|
||||
* their own kind now, so every value of such a variable is converted here – the declaration is read
|
||||
* one last time and then gone.
|
||||
*/
|
||||
function migrateDeclaredLists(project: Project, legacy: Map<string, Map<string, DeclaredItems>>): void {
|
||||
const convert =
|
||||
({ kind, fields }: DeclaredItems) =>
|
||||
(value: PropertyValue | undefined): PropertyValue | undefined =>
|
||||
isList(value)
|
||||
? value.map((item) => (kind === 'struct' ? structOf(fields, item) : makeItem(kind, item)))
|
||||
: value;
|
||||
for (const [assetId, declared] of legacy) {
|
||||
const asset = project.assets.find((a) => a.id === assetId);
|
||||
if (asset?.type !== 'behavior') continue;
|
||||
for (const [key, items] of declared) {
|
||||
const as = convert(items);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
if (def) def.default = as(def.default) ?? def.default;
|
||||
setBagValue(asset.props, key, as(asset.props[key]));
|
||||
for (const site of overrideSites(project, (id) => id === assetId))
|
||||
setBagValue(site.bag, key, as(site.bag[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
|
||||
@@ -179,7 +233,8 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
|
||||
return edits;
|
||||
}
|
||||
|
||||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */
|
||||
function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>): PropertyDef[] {
|
||||
const defs: PropertyDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
@@ -191,16 +246,20 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
// Common property keys are reserved – a variable of that name would shadow the switch.
|
||||
if (!isValidVariableName(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
|
||||
? (v.items as ListItemKind)
|
||||
: undefined;
|
||||
const def = variableDef(key, kind, undefined, items, normalizeStructFields(v.fields));
|
||||
const fields = normalizeStructFields(v.fields);
|
||||
if (kind === 'list' && (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items)))
|
||||
legacy?.set(key, { kind: v.items as ListItemKind, fields: primitivesOf(fields) });
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** A declared item shape held primitives only; a list field of one would have been a level too deep. */
|
||||
const primitivesOf = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
|
||||
fields.filter((f) => f.kind !== 'list');
|
||||
|
||||
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
|
||||
function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
const fields: StructFieldDef[] = [];
|
||||
@@ -219,8 +278,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
|
||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||
if (f.kind === 'list')
|
||||
return { key, kind: 'list', items: isFieldKind(f.items) ? (f.items as FieldKind) : 'number' };
|
||||
if (f.kind === 'list') return { key, kind: 'list' };
|
||||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||||
}
|
||||
|
||||
@@ -352,13 +410,25 @@ function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||
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 primitive, or the one object a flat position may hold: a tagged color. A tagged texture is a
|
||||
* list item only, so here it keeps its id as text – the same degrade `flatItemOf` makes.
|
||||
*/
|
||||
const asFlatItem = (value: unknown): number | string | boolean | ColorItem | undefined => {
|
||||
if (isPrimitive(value)) return value;
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
return isTextureItem(value) ? textureItemValue(value) : undefined;
|
||||
};
|
||||
|
||||
/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */
|
||||
/** A list item: a tagged texture, a flat one, or a struct of flat ones. */
|
||||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||||
isTextureItem(value)
|
||||
? textureItem(textureItemValue(value))
|
||||
: (asFlatItem(value) ?? asStoredStruct(value, asFlatItem));
|
||||
|
||||
/** A struct field: a flat item 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;
|
||||
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
|
||||
|
||||
function asStoredStruct(
|
||||
value: unknown,
|
||||
|
||||
@@ -260,6 +260,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
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, field = '') => editor.listEditor.fieldAt(index, field),
|
||||
/** The kind mark of a row, or of one field of a struct item – both are pickers. */
|
||||
markAt: (index: number, field = '') => editor.listEditor.markAt(index, field),
|
||||
nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
playInfo: () => editor.playInfo(),
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import { structShapeOf } from '../../core/graph';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
@@ -34,9 +32,7 @@ import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { assetValue } from './valueSpecs';
|
||||
|
||||
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
|
||||
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind });
|
||||
|
||||
/**
|
||||
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a
|
||||
@@ -48,14 +44,11 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
const fields = (structShapeOf(def) ?? []).map((f) => `${f.key}:${f.kind}`).join('+');
|
||||
// A list's item kind decides whether it has field rows at all, so it is structure too.
|
||||
const items = def.kind === 'list' ? `/${def.items}` : '';
|
||||
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
|
||||
// Only a struct has rows of its own; a list is one row whatever its items are.
|
||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** The fields a struct declares, or the item shape of a list of structs. */
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
export function buildAssetSection(host: InspectorHost, assetId: string): void {
|
||||
const asset = findAsset(host.state.project, assetId);
|
||||
@@ -164,7 +157,6 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
const edit: VariableEditing = {
|
||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
|
||||
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
|
||||
setItems: (items) => run(new SetListItemsCommand(asset.id, def.key, items)),
|
||||
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
|
||||
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
addField: (kind) =>
|
||||
@@ -172,7 +164,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
new AddStructFieldCommand(
|
||||
asset.id,
|
||||
def.key,
|
||||
newField(nextFieldName(structShapeOf(def) ?? []), kind),
|
||||
newField(nextFieldName(def.kind === 'struct' ? def.fields : []), kind),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -203,6 +204,11 @@ export function removeButton(onClick: () => void): Button {
|
||||
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
||||
|
||||
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
|
||||
/** The def a list item draws with: the flat kinds plus `texture`, which only a list item may be. */
|
||||
export function itemDef(key: string, kind: Exclude<ListItemKind, 'struct'>, label = key): PropertyDef {
|
||||
return kind === 'texture' ? { key, label, kind: 'texture', default: '' } : primitiveDef(key, kind, label);
|
||||
}
|
||||
|
||||
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
@@ -221,7 +227,7 @@ export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||||
/** A struct field as a property definition – a list field becomes the same row a list property has. */
|
||||
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
||||
return field.kind === 'list'
|
||||
? { key: field.key, label, kind: 'list', default: [], items: field.items }
|
||||
? { key: field.key, label, kind: 'list', default: [] }
|
||||
: primitiveDef(field.key, field.kind, label);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { DeclaredPortKind } from '../../core/graph';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
STRUCT_FIELD_KINDS,
|
||||
@@ -11,8 +9,18 @@ import {
|
||||
type StructFieldKind,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
|
||||
import type { ListDef } from '../list/slot';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import {
|
||||
addValue,
|
||||
attachRowActions,
|
||||
@@ -38,6 +46,7 @@ const KIND_MARKS: Record<ShownKind, string> = {
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
texture: '▦',
|
||||
list: '≡',
|
||||
struct: '⊞',
|
||||
object: '◇',
|
||||
@@ -48,19 +57,12 @@ export const KIND_LABELS: Record<ShownKind, string> = {
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
texture: 'Texture',
|
||||
list: 'List',
|
||||
struct: 'Struct',
|
||||
object: 'Object',
|
||||
};
|
||||
|
||||
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
|
||||
number: 'Numbers',
|
||||
boolean: 'Booleans',
|
||||
text: 'Texts',
|
||||
color: 'Colors',
|
||||
struct: 'Structs',
|
||||
};
|
||||
|
||||
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
|
||||
export function kindMark(kind: string): string {
|
||||
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
|
||||
@@ -70,7 +72,6 @@ export function kindMark(kind: string): string {
|
||||
export interface VariableEditing {
|
||||
rename: (next: string) => void;
|
||||
remove: () => void;
|
||||
setItems: (items: ListItemKind) => void;
|
||||
renameField: (field: StructFieldDef, next: string) => void;
|
||||
removeField: (field: StructFieldDef) => void;
|
||||
addField: (kind: StructFieldKind) => void;
|
||||
@@ -95,7 +96,7 @@ export function buildVariable(
|
||||
): FieldRow {
|
||||
const { edit, actions } = options;
|
||||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
||||
const head = gridRow(0, markOf(def, edit), name);
|
||||
const head = gridRow(0, markLabel(kindMark(def.kind)), name);
|
||||
let setValue: (next: PropertyValue) => void = () => {};
|
||||
if (def.kind === 'struct') {
|
||||
head.add(new Spacer(), 1);
|
||||
@@ -108,7 +109,6 @@ export function buildVariable(
|
||||
host.body.add(head);
|
||||
|
||||
if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit);
|
||||
if (def.kind === 'list' && def.items === 'struct' && edit) itemShapeRows(host, def, edit);
|
||||
|
||||
return {
|
||||
row: head,
|
||||
@@ -147,24 +147,6 @@ function structFieldRows(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The item shape of a list of structs: names and kinds only (primitives, see `ListFieldDef`). The
|
||||
* values are edited in the List tab, one row per item, which is why there is no value column.
|
||||
*/
|
||||
function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing): void {
|
||||
for (const field of def.fields ?? []) {
|
||||
const row = fieldRow(field, edit);
|
||||
row.add(new Spacer(), 1);
|
||||
row.add(removeButton(() => edit.removeField(field)));
|
||||
host.body.add(row);
|
||||
}
|
||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
foot.minHeight = Theme.rowHeight;
|
||||
foot.add(new Spacer(), 1);
|
||||
foot.add(addFieldMenu(FIELD_KINDS, edit));
|
||||
host.body.add(foot);
|
||||
}
|
||||
|
||||
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
|
||||
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
|
||||
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
|
||||
@@ -192,19 +174,14 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
|
||||
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
|
||||
}
|
||||
|
||||
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
|
||||
if (def.kind !== 'list') return markLabel(kindMark(def.kind));
|
||||
const glyph = `≡${KIND_MARKS[def.items]}`;
|
||||
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
|
||||
}
|
||||
|
||||
export function markLabel(glyph: string): Label {
|
||||
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
|
||||
}
|
||||
|
||||
/**
|
||||
* A kind mark that can be clicked. The label may take the button's whole width: the column is
|
||||
* sized for its widest glyph, and the usual inset would cut `≡Aa` to `≡A…`.
|
||||
* A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may
|
||||
* take the button's whole width: the column is sized for its widest glyph, and the usual inset
|
||||
* would cut `Aa` to `A…`.
|
||||
*/
|
||||
class MarkButton extends Button {
|
||||
protected override labelSlot(): number {
|
||||
@@ -212,20 +189,10 @@ class MarkButton extends Button {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the asset, the mark of a list doubles as the chooser for what it holds – it is the one place
|
||||
* that already says it. Changing it runs every value through the new declaration.
|
||||
*/
|
||||
function itemKindPicker(glyph: string, current: ListItemKind, edit: VariableEditing): Button {
|
||||
/** The mark of a kind, as a button that offers the others. */
|
||||
export function markPicker(glyph: string, items: MenuItem[]): Button {
|
||||
const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' });
|
||||
picker.onClick = () => {
|
||||
new Menu(
|
||||
LIST_ITEM_KINDS.filter((items) => items !== current).map((items) => ({
|
||||
label: LIST_ITEM_LABELS[items],
|
||||
onSelect: () => edit.setItems(items),
|
||||
})),
|
||||
).openBelow(picker);
|
||||
};
|
||||
picker.onClick = () => new Menu(items).openBelow(picker);
|
||||
return picker;
|
||||
}
|
||||
|
||||
|
||||
+222
-34
@@ -2,34 +2,89 @@
|
||||
* Edits one list, a row per item, with the same field widgets the inspector builds -- a value that
|
||||
* can be hundreds long has no room in an inspector row. The inspector stays visible beside this
|
||||
* tab, so what the list belongs to is still on screen while it is edited.
|
||||
*
|
||||
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
|
||||
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit.
|
||||
*/
|
||||
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
|
||||
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
|
||||
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Dropdown,
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
ScrollContainer,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { type FieldValue, type ListValue, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { nextFieldName } from '../../core/commands/naming';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
type ListItem,
|
||||
itemKindOf,
|
||||
itemValue,
|
||||
makeFlatItem,
|
||||
makeItem,
|
||||
structItemFields,
|
||||
structItemOf,
|
||||
} from '../../core/properties/listItems';
|
||||
import {
|
||||
addValue,
|
||||
createField,
|
||||
type FieldContext,
|
||||
NAME_WIDTH,
|
||||
itemDef,
|
||||
primitiveDef,
|
||||
type PrimitiveDef,
|
||||
READ_ONLY,
|
||||
removeButton,
|
||||
} from '../inspector/fieldFactory';
|
||||
import { KIND_LABELS, kindMark, markPicker } from '../inspector/variableRows';
|
||||
import type { EditorState } from '../state';
|
||||
import { type ListAccess, type ListDef, resolveList } from './slot';
|
||||
import { type ListAccess, resolveList } from './slot';
|
||||
|
||||
const INDEX_WIDTH = 40;
|
||||
|
||||
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
|
||||
interface RowWidgets {
|
||||
value: Widget;
|
||||
mark: Widget;
|
||||
parts: Map<string, Widget>;
|
||||
marks: Map<string, Widget>;
|
||||
names: Map<string, Widget>;
|
||||
}
|
||||
|
||||
const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
|
||||
mark,
|
||||
value,
|
||||
parts: new Map(),
|
||||
marks: new Map(),
|
||||
names: new Map(),
|
||||
});
|
||||
|
||||
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
|
||||
if (!widget) return null;
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
};
|
||||
|
||||
export class ListEditor extends VStack {
|
||||
private readonly ctx: FieldContext;
|
||||
private readonly title = new Label('', { style: 'bodyBold' });
|
||||
private readonly count = new Label('', { style: 'small', align: 'right' });
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly addButton: Button;
|
||||
private readonly addButton: Dropdown;
|
||||
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: PropertyValue) => void)[] = [];
|
||||
private fields: { widget: Widget; parts?: ReadonlyMap<string, Widget> }[] = [];
|
||||
private setters: ((item: PropertyValue) => void)[] = [];
|
||||
private rowWidgets: RowWidgets[] = [];
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ gap: 0 });
|
||||
@@ -45,7 +100,14 @@ export class ListEditor extends VStack {
|
||||
this.add(this.scroll, 1);
|
||||
|
||||
const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
this.addButton = new Button('+ Item', { variant: 'ghost', onClick: () => this.addItem() });
|
||||
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
|
||||
// Built on click, not once: which kinds fit depends on the list the tab is showing.
|
||||
this.addButton.onClick = () => {
|
||||
const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS;
|
||||
new Menu(
|
||||
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => this.addItem(kind) })),
|
||||
).openBelow(this.addButton);
|
||||
};
|
||||
footer.add(this.addButton);
|
||||
footer.add(new Spacer(), 1);
|
||||
this.add(footer);
|
||||
@@ -58,21 +120,30 @@ 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, 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 };
|
||||
const row = this.rowWidgets[index];
|
||||
return centreOf(field ? row?.parts.get(field) : row?.value);
|
||||
}
|
||||
|
||||
/** For the check scripts: the kind mark of a row, or of one field of a struct item. */
|
||||
markAt(index: number, field = ''): { x: number; y: number } | null {
|
||||
const row = this.rowWidgets[index];
|
||||
return centreOf(field ? row?.marks.get(field) : row?.mark);
|
||||
}
|
||||
|
||||
/** For the check scripts: the name field of one field of a struct item. */
|
||||
nameAt(index: number, field: string): { x: number; y: number } | null {
|
||||
return centreOf(this.rowWidgets[index]?.names.get(field));
|
||||
}
|
||||
|
||||
/** For the check scripts: what the tab shows and how its rows read right now. */
|
||||
debugInfo(): { title: string; items: string[]; kind: string } {
|
||||
debugInfo(): { title: string; items: string[]; kinds: ListItemKind[]; offers: ListItemKind[] } {
|
||||
const access = this.access();
|
||||
if (!access) return { title: '', items: [], kind: '' };
|
||||
if (!access) return { title: '', items: [], kinds: [], offers: [] };
|
||||
return {
|
||||
title: access.title,
|
||||
items: access.value.map((item) => textOf(item)),
|
||||
kind: access.def.items,
|
||||
kinds: access.value.map(itemKindOf),
|
||||
offers: [...access.kinds],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,11 +152,18 @@ export class ListEditor extends VStack {
|
||||
return slot ? resolveList(this.state, slot) : null;
|
||||
}
|
||||
|
||||
/** Everything that decides how many rows there are and of what kind – a struct's fields included. */
|
||||
/** Everything that decides how many rows there are and of what kind – a struct item's fields included. */
|
||||
private signatureOf(access: ListAccess | null): string {
|
||||
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}`;
|
||||
const shape = access.value
|
||||
.map(
|
||||
(item) =>
|
||||
`${itemKindOf(item)}${structItemFields(item)
|
||||
.map((f) => `:${f.key}:${f.kind}`)
|
||||
.join('')}`,
|
||||
)
|
||||
.join(',');
|
||||
return `${access.title}|${shape}|${access.value.length}`;
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
@@ -105,7 +183,7 @@ export class ListEditor extends VStack {
|
||||
this.rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
this.scroll.setContent(this.rows);
|
||||
this.setters = [];
|
||||
this.fields = [];
|
||||
this.rowWidgets = [];
|
||||
|
||||
this.title.setText(access ? access.title : 'No list open');
|
||||
this.count.setText(access ? `${access.value.length} items` : '');
|
||||
@@ -122,32 +200,142 @@ export class ListEditor extends VStack {
|
||||
}
|
||||
|
||||
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
|
||||
const kind = itemKindOf(item);
|
||||
// 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 tall = kind === '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, itemDef(access.def, index), item, access.itemHandlers(index));
|
||||
addValue(row, field);
|
||||
const mark = markPicker(
|
||||
kindMark(kind),
|
||||
access.kinds
|
||||
.filter((next) => next !== kind)
|
||||
.map((next) => ({
|
||||
label: KIND_LABELS[next],
|
||||
// Through `itemValue`, so a color keeps its number when it becomes one.
|
||||
onSelect: () => this.replaceItem(access, index, makeItem(next, itemValue(item))),
|
||||
})),
|
||||
);
|
||||
if (tall) mark.minHeight = Theme.rowHeight;
|
||||
row.add(mark);
|
||||
if (tall) this.addStructItem(access, row, mark, item, index);
|
||||
else {
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
itemDef(String(index), kind),
|
||||
itemValue(item) ?? '',
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
addValue(row, field);
|
||||
this.setters.push((next) => field.setValue(itemValue(next) ?? ''));
|
||||
this.rowWidgets.push(rowWidgets(mark, field.widget));
|
||||
}
|
||||
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
|
||||
this.setters.push(field.setValue);
|
||||
this.fields.push({ widget: field.widget, parts: field.parts });
|
||||
return row;
|
||||
}
|
||||
|
||||
private addItem(): void {
|
||||
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
|
||||
private addStructItem(
|
||||
access: ListAccess,
|
||||
row: HStack,
|
||||
mark: Widget,
|
||||
item: PropertyValue,
|
||||
index: number,
|
||||
): void {
|
||||
const bundle = structItemOf(item);
|
||||
const fields = structItemFields(item);
|
||||
const stack = new VStack({ gap: Theme.gap });
|
||||
const widgets = rowWidgets(mark, stack);
|
||||
const setters = new Map<string, (value: PropertyValue) => void>();
|
||||
const handlers = access.itemHandlers(index);
|
||||
for (const field of fields) {
|
||||
const line = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
line.minHeight = Theme.rowHeight;
|
||||
const fieldMark = markPicker(
|
||||
kindMark(field.kind),
|
||||
FIELD_KINDS.filter((next) => next !== field.kind).map((next) => ({
|
||||
label: KIND_LABELS[next],
|
||||
onSelect: () =>
|
||||
this.replaceItem(access, index, {
|
||||
...bundle,
|
||||
[field.key]: makeFlatItem(next, itemValue(bundle[field.key])),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const name = nameField(field.key, (to) => {
|
||||
if (to && to !== field.key) this.replaceItem(access, index, renamedField(bundle, field.key, to));
|
||||
});
|
||||
line.add(fieldMark);
|
||||
line.add(name);
|
||||
const sub = createField(
|
||||
this.ctx,
|
||||
primitiveDef(field.key, field.kind),
|
||||
itemValue(bundle[field.key]) ?? '',
|
||||
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
|
||||
);
|
||||
setters.set(field.key, addValue(line, sub));
|
||||
widgets.parts.set(field.key, sub.widget);
|
||||
widgets.marks.set(field.key, fieldMark);
|
||||
widgets.names.set(field.key, name);
|
||||
line.add(removeButton(() => this.replaceItem(access, index, withoutField(bundle, field.key))));
|
||||
stack.add(line);
|
||||
}
|
||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
foot.minHeight = Theme.rowHeight;
|
||||
foot.add(addFieldMenu((kind) => this.addField(access, index, bundle, kind)));
|
||||
foot.add(new Spacer(), 1);
|
||||
stack.add(foot);
|
||||
row.add(stack, 1);
|
||||
this.setters.push((next) => {
|
||||
const now = structItemOf(next);
|
||||
for (const field of fields) setters.get(field.key)?.(itemValue(now[field.key]) ?? '');
|
||||
});
|
||||
this.rowWidgets.push(widgets);
|
||||
}
|
||||
|
||||
private addField(access: ListAccess, index: number, bundle: PropertyValue, kind: FieldKind): void {
|
||||
const fields = structItemFields(bundle);
|
||||
const key = nextFieldName(fields);
|
||||
this.replaceItem(access, index, { ...structItemOf(bundle), [key]: makeFlatItem(kind, undefined) });
|
||||
}
|
||||
|
||||
/** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */
|
||||
private replaceItem(access: ListAccess, index: number, item: ListItem): void {
|
||||
access.replace(access.value.map((value, i) => (i === index ? item : value)));
|
||||
}
|
||||
|
||||
private addItem(kind: ListItemKind): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]);
|
||||
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. */
|
||||
function nameField(key: string, rename: (next: string) => void): TextField {
|
||||
return new TextField(key, { width: NAME_WIDTH, onChange: (next) => rename(next.trim()) });
|
||||
}
|
||||
|
||||
function addFieldMenu(add: (kind: FieldKind) => void): Dropdown {
|
||||
const button = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
button.onClick = () => {
|
||||
new Menu(FIELD_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => add(kind) }))).openBelow(
|
||||
button,
|
||||
);
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
const withoutField = (bundle: PropertyValue, key: string): ListItem => {
|
||||
const next = { ...structItemOf(bundle) };
|
||||
delete next[key];
|
||||
return next;
|
||||
};
|
||||
|
||||
const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem => {
|
||||
const next: Record<string, FieldValue> = {};
|
||||
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
|
||||
return next;
|
||||
};
|
||||
+31
-26
@@ -10,13 +10,14 @@ import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
|
||||
import {
|
||||
coerceFieldValue,
|
||||
coerceListItem,
|
||||
FIELD_KINDS,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||||
@@ -31,6 +32,12 @@ export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
|
||||
export interface ListAccess {
|
||||
readonly def: ListDef;
|
||||
readonly value: ListValue;
|
||||
/**
|
||||
* The kinds this list may hold. A list in a struct field takes primitives only – `struct → list →
|
||||
* struct` is past the depth the stored value goes, so offering it would add an item that the next
|
||||
* write silently drops (`flatListOf`).
|
||||
*/
|
||||
readonly kinds: readonly ListItemKind[];
|
||||
/** What the tab header says the list belongs to. */
|
||||
readonly title: string;
|
||||
/** One item, previewed and committed exactly as the inspector does it. */
|
||||
@@ -57,13 +64,9 @@ export function resolveList(state: EditorState, slot: ListSlot): ListAccess | nu
|
||||
|
||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
|
||||
const withItem = (list: ListValue, index: number, item: ListItem): 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]);
|
||||
|
||||
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
|
||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
|
||||
if (field === undefined)
|
||||
@@ -86,7 +89,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string
|
||||
});
|
||||
return access<Target>(
|
||||
state,
|
||||
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
|
||||
{ key: field, label: field, kind: 'list', default: [] },
|
||||
`${owner.title} · ${field}`,
|
||||
() => asList(structOf(fields, owner.current())[field]),
|
||||
{
|
||||
@@ -96,6 +99,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string
|
||||
remove: owner.spec.remove,
|
||||
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
|
||||
},
|
||||
FIELD_KINDS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,9 +114,11 @@ function access<Target>(
|
||||
title: string,
|
||||
current: () => ListValue,
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||||
kinds: readonly ListItemKind[] = LIST_ITEM_KINDS,
|
||||
): ListAccess {
|
||||
return {
|
||||
def,
|
||||
kinds,
|
||||
title,
|
||||
value: current(),
|
||||
itemHandlers: (index) => ({
|
||||
@@ -120,27 +126,26 @@ function access<Target>(
|
||||
{ state },
|
||||
{
|
||||
...spec,
|
||||
toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)),
|
||||
// The row keeps the kind it has: a color item written from its field stays a color.
|
||||
toModel: (input) => withItem(current(), index, itemLike(current()[index], input)),
|
||||
},
|
||||
),
|
||||
// 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]: coerceFieldValue(input, field),
|
||||
}),
|
||||
},
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
structFieldHandlers: (field: { key: string }) =>
|
||||
previewingHandlers<Target>(
|
||||
{ state },
|
||||
{
|
||||
...spec,
|
||||
toModel: (input) => {
|
||||
const item = structItemOf(current()[index]);
|
||||
return withItem(current(), index, {
|
||||
...item,
|
||||
[field.key]: fieldLike(item[field.key], input),
|
||||
});
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||||
// A preview does not, which is why `toModel` above has to.
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* What a list variable holds, chosen in the editor rather than only in a project file. Two things
|
||||
* matter: changing the item kind drags every value along (declared default, own value, overrides),
|
||||
* and a list of structs declares its item shape through the same field commands a struct does.
|
||||
* A list declares nothing about what it holds: every item carries its own kind. Two things matter –
|
||||
* the kind is read back off the value (and a color says so, or it would be a plain number), and a
|
||||
* project written under the old rule, where the list declared one kind for all of them, still loads
|
||||
* with the values it had.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
RemoveStructFieldCommand,
|
||||
RenameStructFieldCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { colorItem, type PropertyValue, textureItem } from '../../src/core/model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { itemKindOf, itemValue, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
const findObject = (project: Project, id: string) =>
|
||||
@@ -20,120 +18,175 @@ const findObject = (project: Project, id: string) =>
|
||||
.flatMap((holder) => holder.objects)
|
||||
.find((o) => o.id === id);
|
||||
|
||||
const listDef = (project: Project, assetId: string, key: string) => {
|
||||
const asset = findAsset(project, assetId);
|
||||
const def = asset?.type === 'behavior' ? asset.variables.find((v) => v.key === key) : undefined;
|
||||
return def?.kind === 'list' ? def : undefined;
|
||||
};
|
||||
|
||||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||||
function withList() {
|
||||
function withList(value: unknown = [1, 2]) {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'stops', 'list'));
|
||||
const asset = findAsset(f.project, behavior.id)!;
|
||||
asset.props.stops = [1, 2];
|
||||
asset.props.stops = value as never;
|
||||
const object = place(f, behavior.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'stops', [3]));
|
||||
return { f, behaviorId: behavior.id, object };
|
||||
}
|
||||
|
||||
describe('what a list variable holds', () => {
|
||||
it('starts on numbers, the one kind `+ Variable ▸ List` can pick', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
|
||||
describe('the kind of one list item', () => {
|
||||
it('is read off the item itself, a color through its tag', () => {
|
||||
expect([7, 'a', true, colorItem(0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||||
'number',
|
||||
'text',
|
||||
'boolean',
|
||||
'color',
|
||||
'struct',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes every value with it when the kind changes, and gives them back on undo', () => {
|
||||
const { f, behaviorId, object } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'text'));
|
||||
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('text');
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual(['1', '2']);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual(['3']);
|
||||
|
||||
f.stack.undo();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([1, 2]);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual([3]);
|
||||
it('lets one list hold all of them at once, through a write and a read back', () => {
|
||||
const mixed = [7, 'tee', true, colorItem(0xff0000), { x: 1, y: 'two' }];
|
||||
const { f, behaviorId } = withList(mixed);
|
||||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed);
|
||||
});
|
||||
|
||||
/** Bare numbers have no shape, so they become the empty item rather than disappearing. */
|
||||
it('turns loose items into the empty shape when it becomes a list of structs', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([]);
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
|
||||
/** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */
|
||||
it('keeps a color apart from the number beside it', () => {
|
||||
const { f, behaviorId } = withList([colorItem(0xff0000), 0xff0000]);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
|
||||
expect(back.map(itemValue)).toEqual([0xff0000, 0xff0000]);
|
||||
});
|
||||
|
||||
it('refuses a change to the kind it already holds', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
expect(() => f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'number'))).toThrow(
|
||||
/already holds/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the item shape of a list of structs', () => {
|
||||
function withStructList() {
|
||||
const { f, behaviorId, object } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'x', kind: 'number' }));
|
||||
return { f, behaviorId, object };
|
||||
}
|
||||
|
||||
it('grows a field in the declaration and in every item of every value at once', () => {
|
||||
const { f, behaviorId, object } = withStructList();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([{ key: 'x', kind: 'number' }]);
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{ x: 0 }, { x: 0 }]);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual([{ x: 0 }]);
|
||||
|
||||
f.stack.undo();
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
|
||||
/**
|
||||
* The second kind JSON cannot tell apart on its own: an asset id is a text like any other. What a
|
||||
* sprite wears is one id whether it names a texture, one of its frames or an animation.
|
||||
*/
|
||||
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
|
||||
const { f, behaviorId } = withList([textureItem('anim-walk'), 'anim-walk']);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
|
||||
expect(back.map(itemValue)).toEqual(['anim-walk', 'anim-walk']);
|
||||
});
|
||||
|
||||
it('keeps the values of the items it does not touch when one is renamed or removed', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
it('turns a text into a texture item and back without losing the id', () => {
|
||||
expect(makeItem('texture', 'frame-26')).toEqual(textureItem('frame-26'));
|
||||
expect(makeItem('text', textureItem('frame-26'))).toBe('frame-26');
|
||||
});
|
||||
|
||||
/** A texture is a list item only: a flat position keeps the id, but as the text it looks like. */
|
||||
it('degrades to its id inside a struct item, where no texture kind exists', () => {
|
||||
const { f, behaviorId } = withList([{ pic: textureItem('frame-26'), n: 1 }]);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back).toEqual([{ pic: 'frame-26', n: 1 }]);
|
||||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||||
'pic:text',
|
||||
'n:number',
|
||||
]);
|
||||
});
|
||||
|
||||
it('changes for one item without touching its neighbours', () => {
|
||||
const { f, behaviorId } = withList([1, 2]);
|
||||
const asset = findAsset(f.project, behaviorId)!;
|
||||
asset.props.stops = [{ x: 5 }, { x: 6 }];
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'y', kind: 'number' }));
|
||||
f.stack.execute(new RenameStructFieldCommand(behaviorId, 'stops', 'x', 'col'));
|
||||
expect(asset.props.stops).toEqual([
|
||||
{ col: 5, y: 0 },
|
||||
{ col: 6, y: 0 },
|
||||
]);
|
||||
const list = asset.props.stops as PropertyValue[];
|
||||
asset.props.stops = [makeItem('text', list[0]), list[1]] as never;
|
||||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(['1', 2]);
|
||||
});
|
||||
|
||||
f.stack.execute(new RemoveStructFieldCommand(behaviorId, 'stops', 'y'));
|
||||
expect(asset.props.stops).toEqual([{ col: 5 }, { col: 6 }]);
|
||||
|
||||
f.stack.undo();
|
||||
expect(asset.props.stops).toEqual([
|
||||
{ col: 5, y: 0 },
|
||||
{ col: 6, y: 0 },
|
||||
it('gives a struct item a shape of its own, each field with its own kind', () => {
|
||||
const { f, behaviorId } = withList([{ x: 1, label: 'a' }, { x: 2 }]);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back.map((item) => structItemFields(item).map((field) => `${field.key}:${field.kind}`))).toEqual([
|
||||
['x:number', 'label:text'],
|
||||
['x:number'],
|
||||
]);
|
||||
});
|
||||
|
||||
/** `list → struct → list → primitive` is one level deeper than anything the model stores. */
|
||||
it('refuses a list field, which a struct variable does allow', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
const listField = { key: 'inner', kind: 'list', items: 'number' } as const;
|
||||
expect(() => f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', listField))).toThrow(
|
||||
/cannot sit in the items of a list/,
|
||||
);
|
||||
|
||||
f.stack.execute(new AddVariableCommand(behaviorId, 'route', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'route', listField));
|
||||
expect(findAsset(f.project, behaviorId)?.props.route).toEqual({ inner: [] });
|
||||
it('drops a list inside a struct item, the one level too deep', () => {
|
||||
const { f, behaviorId } = withList([{ x: 1, deep: [1, 2] }]);
|
||||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([{ x: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('survives being written out and read back in', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
findAsset(f.project, behaviorId)!.props.stops = [{ x: 5 }, { x: 6 }];
|
||||
const back = roundTrip(f.project);
|
||||
expect(listDef(back, behaviorId, 'stops')).toMatchObject({
|
||||
items: 'struct',
|
||||
fields: [{ key: 'x', kind: 'number' }],
|
||||
/**
|
||||
* A node parameter meets no schema on the way in – `asStoredValue` is the only rule it passes. It is
|
||||
* therefore the one place where the store itself has to know the tags.
|
||||
*/
|
||||
describe('a list in a node parameter, where nothing is declared', () => {
|
||||
const loadParam = (value: unknown) =>
|
||||
deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'b',
|
||||
type: 'behavior',
|
||||
name: 'B',
|
||||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value } }], edges: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(findAsset(back, behaviorId)?.props.stops).toEqual([{ x: 5 }, { x: 6 }]);
|
||||
const paramOf = (value: unknown) => {
|
||||
const asset = findAsset(loadParam(value), 'b');
|
||||
return asset?.type === 'behavior' ? asset.graph.nodes[0]?.params.value : undefined;
|
||||
};
|
||||
|
||||
it('keeps a tagged texture an item of its own', () => {
|
||||
expect(paramOf([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']);
|
||||
});
|
||||
|
||||
it('but degrades one inside a struct item to its id, where no texture kind exists', () => {
|
||||
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([{ pic: 'frame-26', n: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a list that still declares its item kind', () => {
|
||||
/** A project file from before the change: the declaration said `color`, the items were numbers. */
|
||||
const loadDeclared = (items: string, values: unknown, fields?: unknown) =>
|
||||
deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'b',
|
||||
type: 'behavior',
|
||||
name: 'B',
|
||||
props: { stops: values },
|
||||
variables: [{ key: 'stops', label: 'stops', kind: 'list', default: values, items, fields }],
|
||||
},
|
||||
{
|
||||
id: 's',
|
||||
type: 'scene',
|
||||
name: 'S',
|
||||
objects: [{ id: 'o', assetId: 'b', overrides: { stops: values } }],
|
||||
},
|
||||
],
|
||||
activeSceneId: 's',
|
||||
});
|
||||
|
||||
it('tags the colors it held, in the default, the own value and the override', () => {
|
||||
const project = loadDeclared('color', [0xff0000, '#00ff00']);
|
||||
const asset = findAsset(project, 'b');
|
||||
const tagged = [colorItem(0xff0000), colorItem(0x00ff00)];
|
||||
expect(asset?.props.stops).toEqual(tagged);
|
||||
expect(asset?.type === 'behavior' && asset.variables[0]?.default).toEqual(tagged);
|
||||
expect(findObject(project, 'o')?.overrides.stops).toEqual(tagged);
|
||||
});
|
||||
|
||||
it('converts the other kinds the same way, so a value stays what it was meant to be', () => {
|
||||
expect(findAsset(loadDeclared('text', [1, true]), 'b')?.props.stops).toEqual(['1', 'true']);
|
||||
expect(findAsset(loadDeclared('number', ['1', '2']), 'b')?.props.stops).toEqual([1, 2]);
|
||||
expect(findAsset(loadDeclared('boolean', ['false', 'yes']), 'b')?.props.stops).toEqual([false, true]);
|
||||
});
|
||||
|
||||
/** An item could be missing a declared field, and the declaration was what filled it in. */
|
||||
it('fills a struct item from the shape the list declared', () => {
|
||||
const fields = [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'y', kind: 'number' },
|
||||
];
|
||||
expect(findAsset(loadDeclared('struct', [{ x: 3 }], fields), 'b')?.props.stops).toEqual([{ x: 3, y: 0 }]);
|
||||
});
|
||||
|
||||
it('and says nothing about its items once it has been read back out', () => {
|
||||
const written = roundTrip(loadDeclared('color', [0xff0000]));
|
||||
const asset = findAsset(written, 'b');
|
||||
expect(asset?.type === 'behavior' && asset.variables[0]).not.toHaveProperty('items');
|
||||
expect(asset?.props.stops).toEqual([colorItem(0xff0000)]);
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,14 @@ 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 { colorItem, sameValue, textOf, textureItem } 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);
|
||||
/** A `list` variable holding struct items, the way a seeder writes it. */
|
||||
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never);
|
||||
|
||||
/** The node interface only needs `run`; nothing here reads the context. */
|
||||
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||||
@@ -23,24 +18,18 @@ const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||||
.outputs;
|
||||
|
||||
describe('a list whose items are structs', () => {
|
||||
it('forces every item into the declared shape, filling and dropping fields', () => {
|
||||
/** Each item keeps the fields it carries: there is no declaration left to fill or drop any. */
|
||||
it('keeps every item the shape it arrived with', () => {
|
||||
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 },
|
||||
]);
|
||||
expect(def.default).toEqual([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
|
||||
});
|
||||
|
||||
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('keeps a field value as the kind it is, text included', () => {
|
||||
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('leaves a number beside them a number, since an item answers for itself', () => {
|
||||
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([7, { x: 1, y: 2 }]);
|
||||
});
|
||||
|
||||
it('survives a save and load with its items intact', () => {
|
||||
@@ -54,8 +43,8 @@ describe('a list whose items are structs', () => {
|
||||
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(def?.kind).toBe('list');
|
||||
expect(def?.default).toEqual(cells([0, 1], [1, 1]));
|
||||
expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5]));
|
||||
});
|
||||
|
||||
@@ -104,6 +93,34 @@ describe('an untyped slot on load', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A tagged item is an editor thing: the graph has no colours and no pictures, only numbers and
|
||||
* texts. Reading one out therefore hands over what it wraps – the id a sprite can be made to wear.
|
||||
*/
|
||||
describe('the list nodes with tagged items', () => {
|
||||
it('hands a texture item out as the plain id it holds', () => {
|
||||
expect(run(ItemAt, { list: [textureItem('walk-up'), textureItem('fr-0-1')], index: 1 })).toEqual({
|
||||
item: 'fr-0-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('and a colour item as its number', () => {
|
||||
expect(run(ItemAt, { list: [colorItem(0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||||
});
|
||||
|
||||
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
|
||||
it('finds a texture item by the id beside it', () => {
|
||||
expect(run(IndexOf, { list: [textureItem('a'), textureItem('b')], value: 'b' })).toEqual({ index: 1 });
|
||||
});
|
||||
|
||||
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
|
||||
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
|
||||
expect(run(SetItem, { list: [textureItem('a')], index: 0, value: 'b' })).toEqual({
|
||||
result: [textureItem('b')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 } });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { colorItem, sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { coerceField, coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { coerceField } from '../../src/core/properties/listItems';
|
||||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||||
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
@@ -77,25 +78,18 @@ describe('a list as a value', () => {
|
||||
expect(deviatesFromAsset(placed, asset, 'row')).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces a loaded item to its declared kind, kind by kind', () => {
|
||||
expect(coerceValue(variableDef('n', 'list', [], 'number'), ['1', 2, 'x'])).toEqual([1, 2, 0]);
|
||||
expect(coerceValue(variableDef('t', 'list', [], 'text'), [1, true])).toEqual(['1', 'true']);
|
||||
// Spelling has to count here: Boolean('false') would be true.
|
||||
expect(coerceValue(variableDef('b', 'list', [], 'boolean'), ['true', 'false', '0', ''])).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(coerceValue(variableDef('c', 'list', [], 'color'), ['#ff0000', 0x00ff00])).toEqual([
|
||||
0xff0000, 0x00ff00,
|
||||
]);
|
||||
it('keeps a loaded item as the kind it is, kind by kind', () => {
|
||||
const def = variableDef('v', 'list', []);
|
||||
expect(coerceValue(def, ['1', 2, true])).toEqual(['1', 2, true]);
|
||||
expect(coerceValue(def, [colorItem(0xff0000)])).toEqual([colorItem(0xff0000)]);
|
||||
// A colour only keeps its kind through the tag; as a bare number it is one.
|
||||
expect(coerceValue(def, [0xff0000])).toEqual([0xff0000]);
|
||||
});
|
||||
|
||||
it('a value that is not a list falls back to the default rather than becoming one', () => {
|
||||
const def = variableDef('grid', 'list', [7], 'number');
|
||||
const def = variableDef('grid', 'list', [7]);
|
||||
expect(coerceValue(def, 'nonsense')).toEqual([7]);
|
||||
expect(coerceValue(def, ['1', 2])).toEqual([1, 2]);
|
||||
expect(coerceValue(def, ['1', 2])).toEqual(['1', 2]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/**
|
||||
* Seeding the fields of a Pack/Expand from a variable that already declares the shape. The node
|
||||
* keeps declaring its own ports – this only copies a shape that exists elsewhere in the behavior.
|
||||
* Seeding the fields of a Pack/Expand from a variable that already has the shape: a struct declares
|
||||
* it, a list shows it in the struct items it holds. The node keeps declaring its own ports – this
|
||||
* only copies a shape that exists elsewhere in the behavior.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
type DeclaredPortKind,
|
||||
fieldPortKind,
|
||||
@@ -26,7 +23,7 @@ const PIECE: readonly StructFieldDef[] = [
|
||||
{ key: 'name', kind: 'text' },
|
||||
{ key: 'held', kind: 'boolean' },
|
||||
{ key: 'tint', kind: 'color' },
|
||||
{ key: 'cells', kind: 'list', items: 'number' },
|
||||
{ key: 'cells', kind: 'list' },
|
||||
];
|
||||
|
||||
function behaviorWithPiece() {
|
||||
@@ -40,13 +37,12 @@ function behaviorWithPiece() {
|
||||
const varOf = (behavior: BehaviorAsset, key: string) => behavior.variables.find((v) => v.key === key)!;
|
||||
|
||||
describe('the shape a variable declares', () => {
|
||||
it('is the fields of a struct variable and the item fields of a list of structs', () => {
|
||||
it('is the fields of a struct variable and the shape of the struct items a list holds', () => {
|
||||
const fixture = behaviorWithPiece();
|
||||
const { behavior, stack } = fixture;
|
||||
stack.execute(new AddVariableCommand(behavior.id, 'shapes', 'list'));
|
||||
stack.execute(new SetListItemsCommand(behavior.id, 'shapes', 'struct'));
|
||||
for (const key of ['x', 'y'])
|
||||
stack.execute(new AddStructFieldCommand(behavior.id, 'shapes', { key, kind: 'number' }));
|
||||
// No declaration to read: the shape is the one the items in there actually have.
|
||||
varOf(behavior, 'shapes').default = [{ x: 0, y: 0 }];
|
||||
|
||||
expect(structShapeOf(varOf(behavior, 'piece'))?.map((f) => f.key)).toEqual([
|
||||
'shape',
|
||||
@@ -58,11 +54,12 @@ describe('the shape a variable declares', () => {
|
||||
expect(structShapeOf(varOf(behavior, 'shapes'))?.map((f) => f.key)).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
it('is absent for a variable that declares no bundle', () => {
|
||||
it('is absent for a number, and for a list with no struct item in it', () => {
|
||||
const fixture = behaviorWithPiece();
|
||||
const { behavior, stack } = fixture;
|
||||
stack.execute(new AddVariableCommand(behavior.id, 'score', 'number'));
|
||||
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
|
||||
varOf(behavior, 'names').default = ['a', 'b'];
|
||||
|
||||
expect(structShapeOf(varOf(behavior, 'score'))).toBeUndefined();
|
||||
expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined();
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('a variable of type struct', () => {
|
||||
});
|
||||
|
||||
it('coerces a stored value to the shape its declaration promises', () => {
|
||||
const def = variableDef('piece', 'struct', undefined, undefined, [
|
||||
const def = variableDef('piece', 'struct', undefined, [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'on', kind: 'boolean' },
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { colorItem } from '../../src/core/model/Properties';
|
||||
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||
import { variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { resolveList } from '../../src/editor/list/slot';
|
||||
@@ -35,9 +36,9 @@ describe('the address a list is edited through', () => {
|
||||
|
||||
const list = () => resolveList(f.state, slot)!;
|
||||
|
||||
it('finds the list, its item kind and what it belongs to', () => {
|
||||
it('finds the list and what it belongs to', () => {
|
||||
expect(list().value).toEqual([3, 1, 4]);
|
||||
expect(list().def.items).toBe('number');
|
||||
expect(list().def.kind).toBe('list');
|
||||
expect(list().title).toBe('B · nums');
|
||||
});
|
||||
|
||||
@@ -72,20 +73,24 @@ describe('the address a list is edited through', () => {
|
||||
expect(list().value).toEqual([10, 1, 20]);
|
||||
});
|
||||
|
||||
it('coerces what it is given to the declared item kind', () => {
|
||||
/** The row keeps the kind of the item in it: a number field may only ever hand back a number. */
|
||||
it('writes what it is given as the kind the item already is', () => {
|
||||
list().itemHandlers(1).onChange('42');
|
||||
expect(list().value).toEqual([3, 42, 4]);
|
||||
|
||||
list().replace(['5', 'nonsense']);
|
||||
expect(list().value).toEqual([5, 0]);
|
||||
list().itemHandlers(1).onChange('nonsense');
|
||||
expect(list().value).toEqual([3, 0, 4]);
|
||||
});
|
||||
|
||||
it('coerces a node setting the same way, where the value is not schema-checked on read', () => {
|
||||
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
|
||||
list().replace(['5', true, colorItem(0xff0000)]);
|
||||
expect(list().value).toEqual(['5', true, colorItem(0xff0000)]);
|
||||
|
||||
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' };
|
||||
resolveList(f.state, node)!.replace(['5', 'nonsense']);
|
||||
expect(resolveList(f.state, node)!.value).toEqual([5, 0]);
|
||||
expect(resolveList(f.state, node)!.value).toEqual(['5', 'nonsense']);
|
||||
});
|
||||
|
||||
/** A preview writes past the command, so it is the one write that has to coerce for itself. */
|
||||
@@ -117,7 +122,7 @@ describe('the address a list is edited through', () => {
|
||||
|
||||
it('keeps text items as text rather than coercing them to numbers', () => {
|
||||
const behavior = findAsset(f.state.project, f.behavior.id) as BehaviorAsset;
|
||||
behavior.variables.push(variableDef('words', 'list', [], 'text'));
|
||||
behavior.variables.push(variableDef('words', 'list', []));
|
||||
const words: ListSlot = { kind: 'asset', assetId: f.behavior.id, key: 'words' };
|
||||
resolveList(f.state, words)!.replace(['red', 'green']);
|
||||
resolveList(f.state, words)!.itemHandlers(1).onChange('blue');
|
||||
@@ -129,7 +134,7 @@ describe('the address a list is edited through', () => {
|
||||
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'list' };
|
||||
expect(resolveList(f.state, port)!.def.items).toBe('number');
|
||||
expect(resolveList(f.state, port)!.def.kind).toBe('list');
|
||||
expect(resolveList(f.state, port)!.title).toBe('Count · list');
|
||||
|
||||
resolveList(f.state, port)!.replace([1, 2, 3]);
|
||||
@@ -187,11 +192,9 @@ describe('a list a struct field holds', () => {
|
||||
const id = f.behavior.id;
|
||||
expect(f.state.execute(new AddVariableCommand(id, 'route', 'struct'))).toBe(true);
|
||||
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'name', kind: 'text' }))).toBe(true);
|
||||
expect(
|
||||
f.state.execute(
|
||||
new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list', items: 'number' }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe(
|
||||
true,
|
||||
);
|
||||
slot = { kind: 'asset', assetId: id, key: 'route', field: 'stops' };
|
||||
});
|
||||
|
||||
@@ -200,7 +203,7 @@ describe('a list a struct field holds', () => {
|
||||
|
||||
it('starts empty and says where it sits', () => {
|
||||
expect(list().value).toEqual([]);
|
||||
expect(list().def.items).toBe('number');
|
||||
expect(list().def.kind).toBe('list');
|
||||
expect(list().title).toBe('B · route · stops');
|
||||
});
|
||||
|
||||
@@ -240,18 +243,26 @@ describe('a list a struct field holds', () => {
|
||||
expect(asset.variables.find((v) => v.key === 'route')).toMatchObject({
|
||||
fields: [
|
||||
{ key: 'name', kind: 'text' },
|
||||
{ key: 'stops', kind: 'list', items: 'number' },
|
||||
{ key: 'stops', kind: 'list' },
|
||||
],
|
||||
});
|
||||
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
|
||||
});
|
||||
|
||||
/** The one shape the model does not store: a list inside a struct inside a list. */
|
||||
it('is refused inside the struct items of a list', () => {
|
||||
const def = variableDef('rows', 'list', [], 'struct', [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'inner', kind: 'list', items: 'number' },
|
||||
]);
|
||||
expect(def.kind === 'list' && def.fields).toEqual([{ key: 'x', kind: 'number' }]);
|
||||
it('is dropped inside the struct items of a list', () => {
|
||||
const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]);
|
||||
expect(def.default).toEqual([{ x: 1 }]);
|
||||
});
|
||||
|
||||
/**
|
||||
* And because they are dropped, the tab must not offer them: an item added here would vanish on
|
||||
* the next write, which reads as the menu entry doing nothing at all. A texture goes the same
|
||||
* way – flat, it would keep only its id and stop being a picture.
|
||||
*/
|
||||
it('offers the flat kinds only, where the whole list has all six', () => {
|
||||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color']);
|
||||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||||
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ const PLAIN: PropertyDef[] = [
|
||||
{ key: 'speed', label: 'speed', kind: 'number', default: 0 },
|
||||
{ key: 'title', label: 'title', kind: 'text', default: '' },
|
||||
{ key: 'alive', label: 'alive', kind: 'boolean', default: true },
|
||||
{ key: 'cells', label: 'cells', kind: 'list', default: [], items: 'text' },
|
||||
{ key: 'cells', label: 'cells', kind: 'list', default: [] },
|
||||
{ key: 'a_rather_long_variable_name', label: 'a_rather_long_variable_name', kind: 'number', default: 0 },
|
||||
];
|
||||
|
||||
@@ -44,7 +44,6 @@ const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHand
|
||||
const EDIT: VariableEditing = {
|
||||
rename: () => {},
|
||||
remove: () => {},
|
||||
setItems: () => {},
|
||||
renameField: () => {},
|
||||
removeField: () => {},
|
||||
addField: () => {},
|
||||
@@ -90,7 +89,7 @@ describe('the column grid', () => {
|
||||
expect(new Set(xs), JSON.stringify(xs)).toEqual(new Set([xs[0]]));
|
||||
});
|
||||
|
||||
it('and at that same x on the asset, where names are fields and the list mark is a picker', () => {
|
||||
it('and at that same x on the asset, where the names are editable fields', () => {
|
||||
const placed = valueX(buildAll(), 'speed');
|
||||
const host = buildAll(EDIT);
|
||||
const xs = NAMES.map((name) => valueX(host, name));
|
||||
|
||||
@@ -3,3 +3,8 @@ Ein im Play neu angelegter `On start`-Node feuert nicht für Behaviors, die scho
|
||||
Stand: seit der Graph im Play bearbeitbar ist, wirken Änderungen ab dem nächsten Frame — nur `On start` nicht, weil der Start schon vorbei ist.
|
||||
|
||||
Abzuklären: nachträglich feuern (einmal pro laufender Instanz) oder bewusst so lassen und im Editor darauf hinweisen.
|
||||
|
||||
EDIT auch noch abzuklären: Wann feuert `On start`:
|
||||
- nur beim ersten Frame?
|
||||
- muss das behavior enabled sein?
|
||||
- wenn ein behavior später enabled wird, feuert `On start` dann noch einmalig?
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
1. es gibt das Node "String", der typ heißt aber "Text", das ist inkonsistent
|
||||
2. "Item at" oder "Set item" node: index hat ".000" nachkommastellen im inspector, ist aber ein integer
|
||||
3. neuer typ "Texture" momentan nur in einer liste hinzufügbar => es gibt kein "Texture" node, man kann den typ auch nicht bei variablen aussuchen
|
||||
4. alle project seeds überarbeiten, sodass keine migration mehr notwendig ist und reseeden.
|
||||
5. checken ob es projects gibt (ohne seed) die auch noch eine migration erfordern.
|
||||
6. migration code entfernen
|
||||
7. unnötige comments auch wieder entfernen
|
||||
+179
-36
@@ -7,6 +7,16 @@ export async function listTab(t) {
|
||||
|
||||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||||
|
||||
/**
|
||||
* A kind menu entry, always the *last* drawn one: a menu is drawn on top, and the kind names are
|
||||
* ordinary words ("Text") that the panels behind it may carry too – which is what made this click
|
||||
* land in the asset list once the whole suite ran and those panels had something in them.
|
||||
*/
|
||||
const pickKind = async (label) => {
|
||||
const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
|
||||
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its
|
||||
// items are two digits so that no value can be confused with a row's index label.
|
||||
await mutate(`
|
||||
@@ -16,7 +26,7 @@ export async function listTab(t) {
|
||||
type: 'behavior',
|
||||
name: 'Listy',
|
||||
props: { enabled: true, runInEditor: false, nums: [30, 11, 44] },
|
||||
variables: [{ key: 'nums', label: 'nums', kind: 'list', default: [], items: 'number' }],
|
||||
variables: [{ key: 'nums', label: 'nums', kind: 'list', default: [] }],
|
||||
graph: { nodes: [], edges: [] },
|
||||
});
|
||||
__gm.state.select({ kind: 'asset', assetId: 'listy' });
|
||||
@@ -73,10 +83,23 @@ export async function listTab(t) {
|
||||
JSON.stringify(shortRows),
|
||||
);
|
||||
|
||||
// 5. `+ Item` asks for a kind, since the list does not hold one for it.
|
||||
await clickText('+ Item');
|
||||
await sleep(150);
|
||||
check(
|
||||
'a list of its own offers every kind, the struct included',
|
||||
(await json('__gm.list.info()')).offers.join(',') === 'number,text,boolean,color,texture,struct',
|
||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||
);
|
||||
await pickKind('Text');
|
||||
await sleep(200);
|
||||
info = await json('__gm.list.info()');
|
||||
check('+ Item appends one', info.items.length === 4, JSON.stringify(info.items));
|
||||
check('+ Item appends one of the kind picked', info.items.length === 4, JSON.stringify(info.items));
|
||||
check(
|
||||
'and that row holds that kind',
|
||||
info.kinds.join(',') === 'number,number,number,text',
|
||||
info.kinds.join(','),
|
||||
);
|
||||
await undo();
|
||||
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
|
||||
await shot('03-rows');
|
||||
@@ -92,39 +115,128 @@ export async function listTab(t) {
|
||||
await sleep(200);
|
||||
check('and the count follows the value', (await find('2 items')) !== null);
|
||||
|
||||
// 6b. What a list holds is a choice in the inspector, not only in a project file: the kind mark
|
||||
// doubles as the picker, and changing it runs every value through the new declaration.
|
||||
// 6b. A list declares nothing about its items, so the kind sits on the row: the mark in front of
|
||||
// it is the picker, and picking changes that one item and no other.
|
||||
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
|
||||
await clickText('\u2261#');
|
||||
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||||
await sleep(200);
|
||||
let mark = await json('__gm.list.markAt(0)');
|
||||
await click(mark.x, mark.y);
|
||||
await sleep(150);
|
||||
await clickText('Texts');
|
||||
await pickKind('Text');
|
||||
await sleep(250);
|
||||
let listy = await numsOf();
|
||||
check(
|
||||
'the kind mark picks what the list holds',
|
||||
listy.variables[0].items === 'text',
|
||||
listy.variables[0].items,
|
||||
);
|
||||
check('and the mark itself says what it holds now', (await find('≡Aa')) !== null);
|
||||
check(
|
||||
'and every value follows the declaration',
|
||||
JSON.stringify(listy.props.nums) === '["1","2"]',
|
||||
'the row mark changes that item alone',
|
||||
JSON.stringify(listy.props.nums) === '["1",2]',
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
check(
|
||||
'and the tab reads the two kinds off the values',
|
||||
(await json('__gm.list.info()')).kinds.join(',') === 'text,number',
|
||||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||||
);
|
||||
check('the variable itself says nothing about items', listy.variables[0].items === undefined);
|
||||
await undo();
|
||||
await sleep(250);
|
||||
listy = await numsOf();
|
||||
check(
|
||||
'undo puts the kind and the values back',
|
||||
listy.variables[0].items === 'number' && JSON.stringify(listy.props.nums) === '[1,2]',
|
||||
'undo puts the item back as a number',
|
||||
JSON.stringify(listy.props.nums) === '[1,2]',
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
// The picker sits in the mark column, so it may not push the value off the inspector's grid.
|
||||
|
||||
// 6c. A colour is the one kind a stored number cannot say for itself, so it is tagged.
|
||||
mark = await json('__gm.list.markAt(1)');
|
||||
await click(mark.x, mark.y);
|
||||
await sleep(150);
|
||||
await pickKind('Color');
|
||||
await sleep(250);
|
||||
listy = await numsOf();
|
||||
check(
|
||||
'a colour item keeps its number under a tag',
|
||||
JSON.stringify(listy.props.nums) === '[1,{"$color":2}]',
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
check(
|
||||
'and is read back as a colour, its neighbour still a number',
|
||||
(await json('__gm.list.info()')).kinds.join(',') === 'number,color',
|
||||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||||
);
|
||||
await shot('03b-kinds');
|
||||
await undo();
|
||||
await sleep(200);
|
||||
|
||||
// 6d. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
|
||||
// The row carries the picker the `texture` property has, frames of a sheet included.
|
||||
// The pictures the earlier steps imported are set aside for this one: the menu lists every sheet
|
||||
// with all of its frames, and a long one would put the entry to click outside the drawn menu.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const isPicture = (a) => a.type === 'texture' || a.type === 'animation';
|
||||
__gm.__pictures = p.assets.filter(isPicture);
|
||||
p.assets = p.assets.filter((a) => !isPicture(a));
|
||||
p.assets.push({
|
||||
id: 'lt-sheet', type: 'texture', name: 'Sheet', props: { filter: 'nearest' },
|
||||
file: '', width: 32, height: 32,
|
||||
frames: [{ id: 'lt-f1', name: 'Frame 1', x: 0, y: 0, w: 16, h: 16 }],
|
||||
});
|
||||
__gm.state.preview();
|
||||
`);
|
||||
await sleep(200);
|
||||
mark = await json('__gm.list.markAt(0)');
|
||||
await click(mark.x, mark.y);
|
||||
await sleep(150);
|
||||
await pickKind('Texture');
|
||||
await sleep(250);
|
||||
// Switching kind carries the value along, here into an id that resolves to nothing – the same as
|
||||
// a number turning into the text "1". What matters is that it is tagged and no longer a number.
|
||||
check(
|
||||
'a texture item is tagged, so it is no plain text beside one',
|
||||
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]',
|
||||
JSON.stringify((await numsOf()).props.nums),
|
||||
);
|
||||
const pic = await json('__gm.list.fieldAt(0)');
|
||||
await click(pic.x, pic.y);
|
||||
await sleep(250);
|
||||
check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null);
|
||||
await pickKind('Sheet / Frame 1');
|
||||
await sleep(250);
|
||||
check(
|
||||
'and picking a frame of a sheet writes that id under the tag',
|
||||
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"lt-f1"},2]',
|
||||
JSON.stringify((await numsOf()).props.nums),
|
||||
);
|
||||
check(
|
||||
'which the tab reads back as a texture, its neighbour still a number',
|
||||
(await json('__gm.list.info()')).kinds.join(',') === 'texture,number',
|
||||
JSON.stringify((await json('__gm.list.info()')).kinds),
|
||||
);
|
||||
await shot('03c-texture');
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(250);
|
||||
check(
|
||||
'undo puts the plain number back',
|
||||
JSON.stringify((await numsOf()).props.nums) === '[1,2]',
|
||||
JSON.stringify((await numsOf()).props.nums),
|
||||
);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = [...p.assets.filter((a) => a.id !== 'lt-sheet'), ...__gm.__pictures];
|
||||
delete __gm.__pictures;
|
||||
__gm.state.preview();
|
||||
`);
|
||||
await sleep(200);
|
||||
|
||||
await mutate('__gm.state.openList(null)');
|
||||
await sleep(200);
|
||||
// The mark of a list is plain now, and still may not push the value off the inspector's grid.
|
||||
const lefts = await json(
|
||||
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`,
|
||||
);
|
||||
check(
|
||||
'the picking mark keeps the list on the column every row shares',
|
||||
'the list mark keeps its row on the column every row shares',
|
||||
lefts[0] !== null && lefts[0] === lefts[1],
|
||||
JSON.stringify(lefts),
|
||||
);
|
||||
@@ -153,16 +265,7 @@ export async function listTab(t) {
|
||||
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' }],
|
||||
},
|
||||
],
|
||||
variables: [{ key: 'cells', label: 'cells', kind: 'list', default: [] }],
|
||||
graph: { nodes: [], edges: [] },
|
||||
});
|
||||
__gm.state.select({ kind: 'asset', assetId: 'celly' });
|
||||
@@ -176,7 +279,11 @@ export async function listTab(t) {
|
||||
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 tab reads both items as structs',
|
||||
info.kinds.join(',') === 'struct,struct',
|
||||
info.kinds.join(','),
|
||||
);
|
||||
check('the field labels of a bundle are on screen', (await find('x')) !== null);
|
||||
await shot('05-struct-rows');
|
||||
|
||||
@@ -210,34 +317,68 @@ export async function listTab(t) {
|
||||
JSON.stringify(back),
|
||||
);
|
||||
|
||||
// 8b. The item shape is declared in the inspector as well, with the control a struct has – and
|
||||
// 8b. A struct item carries its own shape, so it is named, typed and grown on its own row – and
|
||||
// primitives only, because a list inside a list item is the one shape the model does not store.
|
||||
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
|
||||
check('a list of structs carries the add-a-field control', (await find('+ Field')) !== null);
|
||||
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
await clickText('Boolean');
|
||||
await pickKind('Boolean');
|
||||
await sleep(250);
|
||||
check(
|
||||
'+ Field grows every item of the list at once',
|
||||
'+ Field grows the one item it belongs to, not every item',
|
||||
JSON.stringify(await cellsOf()) ===
|
||||
JSON.stringify([
|
||||
{ x: 30, y: 11, field1: false },
|
||||
{ x: 44, y: 55, field1: false },
|
||||
{ x: 44, y: 55 },
|
||||
]),
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
info = await json('__gm.list.info()');
|
||||
check(
|
||||
'and the tab shows the new field on every row',
|
||||
'and the tab shows the new field on that row',
|
||||
info.items[0] === 'x: 30, y: 11, field1: false',
|
||||
info.items[0],
|
||||
);
|
||||
// Asked in the page: `undefined` comes back over the wire as `null`.
|
||||
check(
|
||||
'the inspector has no item shape to show any more',
|
||||
await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`),
|
||||
);
|
||||
await shot('06-item-field');
|
||||
|
||||
// A field of a struct item is renamed and retyped where it sits, both being plain value edits.
|
||||
const nameBox = await json("__gm.list.nameAt(0, 'y')");
|
||||
await click(nameBox.x, nameBox.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('row');
|
||||
await press('Enter');
|
||||
await sleep(250);
|
||||
check(
|
||||
'renaming a field of one item leaves the other item alone',
|
||||
JSON.stringify(await cellsOf()) ===
|
||||
JSON.stringify([
|
||||
{ x: 30, row: 11, field1: false },
|
||||
{ x: 44, y: 55 },
|
||||
]),
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
const fieldMark = await json("__gm.list.markAt(0, 'x')");
|
||||
await click(fieldMark.x, fieldMark.y);
|
||||
await sleep(150);
|
||||
await pickKind('Color');
|
||||
await sleep(250);
|
||||
check(
|
||||
'and a field of a struct item can be a colour too, tagged like any item',
|
||||
JSON.stringify((await cellsOf())[0]) === JSON.stringify({ x: { $color: 30 }, row: 11, field1: false }),
|
||||
JSON.stringify((await cellsOf())[0]),
|
||||
);
|
||||
await undo();
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(250);
|
||||
check(
|
||||
'undo takes it out of every item again',
|
||||
'and three undos take the shape back to what it was',
|
||||
JSON.stringify(await cellsOf()) ===
|
||||
JSON.stringify([
|
||||
{ x: 30, y: 11 },
|
||||
@@ -264,6 +405,8 @@ export async function listTab(t) {
|
||||
info = await json('__gm.list.info()');
|
||||
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title);
|
||||
await clickText('+ Item');
|
||||
await sleep(150);
|
||||
await pickKind('Number');
|
||||
await sleep(250);
|
||||
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
|
||||
check(
|
||||
|
||||
+16
-1
@@ -6,6 +6,12 @@ export async function structVariable(t) {
|
||||
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, select, undo } = t;
|
||||
|
||||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||||
|
||||
/** A kind menu entry, always the last drawn one: the panels behind carry those words too. */
|
||||
const pickKind = async (label) => {
|
||||
const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
const piece = () => json("__gm.state.project.assets.find((a) => a.id === 'structy').props.piece");
|
||||
/** Left edge of each row's control – one column grid means one number, in the browser's own font. */
|
||||
const leftsOf = (labels) =>
|
||||
@@ -80,7 +86,7 @@ export async function structVariable(t) {
|
||||
// 4. Adding a field reaches the declaration and the value at once.
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
await clickText('Boolean');
|
||||
await pickKind('Boolean');
|
||||
await sleep(250);
|
||||
const grown = await piece();
|
||||
check('+ Field grows the bundle', 'field1' in grown && grown.field1 === false, JSON.stringify(grown));
|
||||
@@ -125,7 +131,16 @@ export async function structVariable(t) {
|
||||
const info = await json('__gm.list.info()');
|
||||
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
|
||||
|
||||
// The depth the stored value goes ends here: this list sits in a struct field, so a struct item
|
||||
// would be dropped by the next write. It is left out of the menu rather than doing nothing.
|
||||
await clickText('+ Item');
|
||||
await sleep(150);
|
||||
check(
|
||||
'the menu of a list in a struct field leaves the struct kind out',
|
||||
(await find('Number')) !== null && (await find('Struct')) === null,
|
||||
JSON.stringify((await json('__gm.list.info()')).offers),
|
||||
);
|
||||
await pickKind('Number');
|
||||
await sleep(250);
|
||||
check(
|
||||
'and an item added there leaves the neighbouring fields alone',
|
||||
|
||||
@@ -238,21 +238,16 @@ export async function variableNodes(t) {
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic');
|
||||
a.variables.push(
|
||||
${JSON.stringify(
|
||||
variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, undefined, [
|
||||
variable('piece', 'struct', { shape: 0, tint: 0, cells: [] }, [
|
||||
{ key: 'shape', kind: 'number' },
|
||||
{ key: 'tint', kind: 'color' },
|
||||
{ key: 'cells', kind: 'list', items: 'number' },
|
||||
]),
|
||||
)},
|
||||
${JSON.stringify(
|
||||
variable('shapes', 'list', [], 'struct', [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'y', kind: 'number' },
|
||||
{ key: 'cells', kind: 'list' },
|
||||
]),
|
||||
)},
|
||||
${JSON.stringify(variable('shapes', 'list', [{ x: 0, y: 0 }]))},
|
||||
);
|
||||
a.props.piece = { shape: 0, tint: 0, cells: [] };
|
||||
a.props.shapes = [];
|
||||
a.props.shapes = [{ x: 0, y: 0 }];
|
||||
a.graph.nodes.push(${JSON.stringify(node('vp-pack', 'PackStruct', 560, 400))});
|
||||
`);
|
||||
await sleep(300);
|
||||
@@ -278,14 +273,15 @@ export async function variableNodes(t) {
|
||||
await sleep(300);
|
||||
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
|
||||
|
||||
// 23h. A list of structs declares the shape of its items, which is the bundle a Pack builds for it.
|
||||
// 23h. A list declares nothing, so the shape comes off the struct items in it – the bundle a Pack
|
||||
// builds for such a list.
|
||||
await click(from.x, from.y);
|
||||
await sleep(200);
|
||||
await clickLast('shapes (item)');
|
||||
await sleep(300);
|
||||
const fromItem = await portsOf('vp-pack');
|
||||
check(
|
||||
'a list of structs offers its item shape too',
|
||||
'a list offers the shape of the struct items it holds',
|
||||
fromItem.map((p) => p.key).join(',') === 'x,y',
|
||||
JSON.stringify(fromItem),
|
||||
);
|
||||
|
||||
+10
-5
@@ -55,17 +55,22 @@ export const propertiesOf = (objectType, objectBehavior) => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A behavior variable declaration. `fields` is the shape of a `struct`; a `list` declares nothing,
|
||||
* its items each carry their own kind (a colour as `colorItem`).
|
||||
*/
|
||||
export const variable = (key, kind, value, items, fields) => ({
|
||||
export const variable = (key, kind, value, 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. */
|
||||
/** A struct field declaration. */
|
||||
export const field = (key, kind = 'number') => ({ key, kind });
|
||||
|
||||
/** A colour as a list item: inside a list, 0xRRGGBB would otherwise be a plain number. */
|
||||
export const colorItem = (color) => ({ $color: color });
|
||||
|
||||
/** A picture as a list item – a texture, one of its frames or an animation, all one id. */
|
||||
export const textureItem = (id) => ({ $texture: id });
|
||||
+53
-26
@@ -9,13 +9,26 @@
|
||||
*
|
||||
* Walking is one animation per direction, playing those three frames as `pingpong`
|
||||
* (left-stand-right-stand …) – the original walk cycle. Standing is not an animation at all but the
|
||||
* middle frame of the row, which is why the idle frames carry the ids the graph builds by name.
|
||||
* middle frame of the row.
|
||||
*
|
||||
* The graph picks a picture the way the editor does: two list variables hold the four walk
|
||||
* animations and the four standing frames, each item chosen with the texture picker, and `facing`
|
||||
* is the row index that indexes both. Nothing is built out of an id spelled by hand.
|
||||
*
|
||||
* Usage: node tools/seed-actor.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs';
|
||||
import {
|
||||
edge,
|
||||
node,
|
||||
port,
|
||||
propertiesOf,
|
||||
sceneObject,
|
||||
textureItem,
|
||||
variable,
|
||||
written,
|
||||
} from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor);
|
||||
@@ -38,14 +51,12 @@ const SPEED = 150;
|
||||
const DIRECTIONS = ['down', 'left', 'right', 'up'];
|
||||
|
||||
const WALK = (dir) => `walk-${dir}`;
|
||||
const IDLE = (dir) => `idle-${dir}`;
|
||||
|
||||
/**
|
||||
* The id of a grid cell. The standing frames of the demo character get named ids because the graph
|
||||
* builds the texture to wear by pasting `idle-` in front of the direction it faces.
|
||||
*/
|
||||
const frameId = (row, column) =>
|
||||
column === 1 && row < DIRECTIONS.length ? IDLE(DIRECTIONS[row]) : `fr-${row}-${column}`;
|
||||
/** The id of a grid cell. Plain: no part of the project builds one of these out of a name. */
|
||||
const frameId = (row, column) => `fr-${row}-${column}`;
|
||||
|
||||
/** The standing frame of a direction: the middle column of its row. */
|
||||
const idleFrame = (row) => frameId(row, 1);
|
||||
|
||||
/** Every cell of the 12×8 grid, in reading order and named like the slice dialog names them. */
|
||||
const frames = [];
|
||||
@@ -68,8 +79,16 @@ const walkAnimations = DIRECTIONS.map((dir, row) => ({
|
||||
frames: [frameId(row, 0), frameId(row, 1), frameId(row, 2)],
|
||||
}));
|
||||
|
||||
/** Sets `facing` to a constant – one per direction, so no edge has to cross the graph. */
|
||||
const face = (id, dir, x, y) => node(id, 'Variables', x, y, { facing: dir }, [written('facing', 'text')]);
|
||||
/** The two lists the graph indexes: one walk animation and one standing frame per direction. */
|
||||
const WALK_PICTURES = DIRECTIONS.map((dir) => textureItem(WALK(dir)));
|
||||
const IDLE_PICTURES = DIRECTIONS.map((_, row) => textureItem(idleFrame(row)));
|
||||
|
||||
/**
|
||||
* Sets `facing` to a constant – one per direction, so no edge has to cross the graph. It is the row
|
||||
* of the sheet, which is also the index into both picture lists.
|
||||
*/
|
||||
const face = (id, dir, x, y) =>
|
||||
node(id, 'Variables', x, y, { facing: DIRECTIONS.indexOf(dir) }, [written('facing', 'number')]);
|
||||
|
||||
const nodes = [
|
||||
// Which way the character looks: the key pressed last wins. Holding W and tapping D turns him
|
||||
@@ -118,14 +137,17 @@ const nodes = [
|
||||
face('c-up', 'up', 1920, 940),
|
||||
face('c-down', 'down', 1920, 1080),
|
||||
|
||||
// What the sprite shows. The texture is built by name: "walk-" or "idle-" plus the direction.
|
||||
// What the sprite shows. Both picture lists are indexed by the row the character faces, so there
|
||||
// is one node per list and no branch per direction.
|
||||
// Setting the same animation again lets it keep running, so writing it every frame is free.
|
||||
node('br-move', 'Branch', 1460, 1280),
|
||||
node('s-walk', 'String', 1460, 1420, { value: 'walk-' }),
|
||||
node('s-idle', 'String', 1460, 1660, { value: 'idle-' }),
|
||||
node('v-facing', 'Variables', 1460, 1540, {}, [port('facing', 'text')]),
|
||||
node('join-walk', 'Join', 1680, 1430),
|
||||
node('join-idle', 'Join', 1680, 1610),
|
||||
node('v-pics', 'Variables', 1400, 1480, {}, [
|
||||
port('walk', 'list'),
|
||||
port('idle', 'list'),
|
||||
port('facing', 'number'),
|
||||
]),
|
||||
node('pick-walk', 'ItemAt', 1680, 1430),
|
||||
node('pick-idle', 'ItemAt', 1680, 1640),
|
||||
node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'text')]),
|
||||
node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'text')]),
|
||||
];
|
||||
@@ -180,16 +202,16 @@ const edges = [
|
||||
|
||||
edge('e45', 'seq', 'c', 'br-move', 'exec'),
|
||||
edge('e46', 'axis', 'active', 'br-move', 'condition'),
|
||||
edge('e47', 's-walk', 'value', 'join-walk', 'a'),
|
||||
edge('e48', 'v-facing', 'facing', 'join-walk', 'b'),
|
||||
edge('e49', 's-idle', 'value', 'join-idle', 'a'),
|
||||
edge('e50', 'v-facing', 'facing', 'join-idle', 'b'),
|
||||
edge('e47', 'v-pics', 'walk', 'pick-walk', 'list'),
|
||||
edge('e48', 'v-pics', 'facing', 'pick-walk', 'index'),
|
||||
edge('e49', 'v-pics', 'idle', 'pick-idle', 'list'),
|
||||
edge('e50', 'v-pics', 'facing', 'pick-idle', 'index'),
|
||||
edge('e51', 'br-move', 'then', 'wear-walk', 'exec'),
|
||||
edge('e52', 'owner', 'object', 'wear-walk', 'object'),
|
||||
edge('e53', 'join-walk', 'text', 'wear-walk', 'texture'),
|
||||
edge('e53', 'pick-walk', 'item', 'wear-walk', 'texture'),
|
||||
edge('e54', 'br-move', 'else', 'wear-idle', 'exec'),
|
||||
edge('e55', 'owner', 'object', 'wear-idle', 'object'),
|
||||
edge('e56', 'join-idle', 'text', 'wear-idle', 'texture'),
|
||||
edge('e56', 'pick-idle', 'item', 'wear-idle', 'texture'),
|
||||
];
|
||||
|
||||
const sheet = await uploadTexture(baseUrl, SHEET_FILE);
|
||||
@@ -229,14 +251,19 @@ const project = {
|
||||
type: 'sprite',
|
||||
name: 'Actor',
|
||||
// White, or the tint would colour the frames. The texture is the standing frame it starts on.
|
||||
props: { width: CELL, height: CELL, color: 0xffffff, texture: IDLE('down') },
|
||||
props: { width: CELL, height: CELL, color: 0xffffff, texture: idleFrame(0) },
|
||||
},
|
||||
{
|
||||
id: 'player',
|
||||
type: 'behavior',
|
||||
name: 'Player',
|
||||
props: { speed: SPEED, facing: 'down' },
|
||||
variables: [variable('speed', 'number', SPEED), variable('facing', 'text', 'down')],
|
||||
props: { speed: SPEED, facing: 0, walk: WALK_PICTURES, idle: IDLE_PICTURES },
|
||||
variables: [
|
||||
variable('speed', 'number', SPEED),
|
||||
variable('facing', 'number', 0),
|
||||
variable('walk', 'list', WALK_PICTURES),
|
||||
variable('idle', 'list', IDLE_PICTURES),
|
||||
],
|
||||
graph: { nodes, edges },
|
||||
},
|
||||
],
|
||||
|
||||
+21
-10
@@ -7,7 +7,17 @@
|
||||
*
|
||||
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { edge, field, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs';
|
||||
import {
|
||||
colorItem,
|
||||
edge,
|
||||
field,
|
||||
node,
|
||||
port,
|
||||
propertiesOf,
|
||||
sceneObject,
|
||||
variable,
|
||||
written,
|
||||
} from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
|
||||
@@ -338,7 +348,7 @@ function fitsGraph() {
|
||||
}
|
||||
|
||||
const CLEAR_LINES_VARS = [
|
||||
variable('board', 'list', [], 'number'),
|
||||
variable('board', 'list', []),
|
||||
variable('cleared', 'number', 0),
|
||||
variable('row', 'number', 0),
|
||||
];
|
||||
@@ -469,7 +479,7 @@ function spawnPieceGraph() {
|
||||
}
|
||||
|
||||
const NEXT_MOVE_VARS = [
|
||||
variable('next', 'struct', NO_PIECE, undefined, PIECE_FIELDS),
|
||||
variable('next', 'struct', NO_PIECE, PIECE_FIELDS),
|
||||
variable('kind', 'text', ''),
|
||||
variable('outFall', 'number', 0),
|
||||
variable('outDx', 'number', 0),
|
||||
@@ -658,7 +668,7 @@ function drawPieceGraph() {
|
||||
}
|
||||
|
||||
/** Writes the four cells of the landed piece into the board, read back from where they stand. */
|
||||
const LOCK_PIECE_VARS = [variable('board', 'list', [], 'number')];
|
||||
const LOCK_PIECE_VARS = [variable('board', 'list', [])];
|
||||
|
||||
function lockPieceGraph() {
|
||||
const g = graphBuilder(LOCK_PIECE_VARS);
|
||||
@@ -878,13 +888,14 @@ function behavior(id, name, variables, graph) {
|
||||
}
|
||||
|
||||
const tetrisVariables = [
|
||||
variable('grid', 'list', EMPTY_GRID, 'number'),
|
||||
variable('shapes', 'list', shapeTable(), 'struct', [field('x'), field('y')]),
|
||||
variable('colors', 'list', SHAPE_COLORS, 'color'),
|
||||
variable('spawnX', 'list', spawnColumns(), 'number'),
|
||||
variable('grid', 'list', EMPTY_GRID),
|
||||
variable('shapes', 'list', shapeTable()),
|
||||
// Tagged, because a list says nothing about its items and a colour is a number like any other.
|
||||
variable('colors', 'list', SHAPE_COLORS.map(colorItem)),
|
||||
variable('spawnX', 'list', spawnColumns()),
|
||||
// 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('piece', 'struct', NO_PIECE, PIECE_FIELDS),
|
||||
variable('next', 'struct', NO_PIECE, PIECE_FIELDS),
|
||||
variable('kind', 'text', ''),
|
||||
variable('hasPiece', 'boolean', false),
|
||||
variable('fall', 'number', 0),
|
||||
|
||||
+26
-5
@@ -13,9 +13,15 @@ import { PROJECTS, cliArgs } from './lib/projects.mjs';
|
||||
const { projectId, baseUrl } = cliArgs('smoke-actor.mjs', PROJECTS.actor);
|
||||
|
||||
/** What the seeder writes. A mismatch means the loader dropped a node type or an edge. */
|
||||
const NODES = 46;
|
||||
const NODES = 44;
|
||||
const EDGES = 56;
|
||||
|
||||
/**
|
||||
* The standing frame of each direction: the middle column of its row in the sheet. The graph reaches
|
||||
* these through the `idle` list, indexed by `facing` – no id is spelled anywhere in it.
|
||||
*/
|
||||
const IDLE = { down: 'fr-0-1', left: 'fr-1-1', right: 'fr-2-1', up: 'fr-3-1' };
|
||||
|
||||
/** The 12×8 grid over the 576×384 sheet, and the corner the camera holds him at. */
|
||||
const CELLS = 96;
|
||||
const CELL = 48;
|
||||
@@ -63,10 +69,25 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
|
||||
);
|
||||
check(
|
||||
'each walk animation has the standing frame in the middle',
|
||||
anims.every((a) => a.mid === a.id.replace('walk-', 'idle-')),
|
||||
anims.every((a) => a.mid === IDLE[a.id.replace('walk-', '')]),
|
||||
JSON.stringify(anims.map((a) => a.mid)),
|
||||
);
|
||||
|
||||
// 3b. How the graph reaches a picture: two list variables of tagged texture items, indexed by the
|
||||
// row the character faces. Each item is what the texture picker writes, not a pasted id.
|
||||
const pictures = await json(
|
||||
`(() => { const b = __gm.state.project.assets.find((a) => a.id === 'player'); return { walk: b.props.walk, idle: b.props.idle, facing: b.props.facing }; })()`,
|
||||
);
|
||||
check(
|
||||
'the two picture lists hold four tagged textures each, indexed by facing',
|
||||
pictures.walk.length === 4 &&
|
||||
pictures.idle.length === 4 &&
|
||||
pictures.walk.every((item, row) => item.$texture === `walk-${['down', 'left', 'right', 'up'][row]}`) &&
|
||||
pictures.idle.every((item, row) => item.$texture === `fr-${row}-1`) &&
|
||||
pictures.facing === 0,
|
||||
JSON.stringify(pictures),
|
||||
);
|
||||
|
||||
await shot('01-editor');
|
||||
|
||||
// 4. Standing still.
|
||||
@@ -75,7 +96,7 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
|
||||
const start = await entity('oplayer');
|
||||
check(
|
||||
'the player starts at the origin showing the standing frame, facing down',
|
||||
Math.abs(start.x) < 1 && Math.abs(start.y) < 1 && start.props.texture === 'idle-down',
|
||||
Math.abs(start.x) < 1 && Math.abs(start.y) < 1 && start.props.texture === IDLE.down,
|
||||
JSON.stringify({ x: start.x, y: start.y, texture: start.props.texture }),
|
||||
);
|
||||
await shot('02-idle-down');
|
||||
@@ -95,7 +116,7 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
|
||||
);
|
||||
check(
|
||||
'letting go stops him on the standing frame of the way he faces',
|
||||
stopped.props.texture === 'idle-right' && Math.abs(stopped.x - walking.x) < 40,
|
||||
stopped.props.texture === IDLE.right && Math.abs(stopped.x - walking.x) < 40,
|
||||
JSON.stringify({ x: Math.round(stopped.x), texture: stopped.props.texture }),
|
||||
);
|
||||
|
||||
@@ -105,7 +126,7 @@ await runChecks({ projectId, baseUrl, port: 9341, shotPrefix: 'gm-actor' }, asyn
|
||||
const up = await entity('oplayer');
|
||||
check(
|
||||
'holding W walks up (−y) and faces up afterwards',
|
||||
up.y < -30 && up.props.texture === 'idle-up',
|
||||
up.y < -30 && up.props.texture === IDLE.up,
|
||||
JSON.stringify({ y: Math.round(up.y), texture: up.props.texture }),
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user