Make texture and colour full kinds and drop the load-time migrations
- `Texture` and `Color` can be picked as a variable kind, a struct field kind and a declared port kind; a new `Texture` node hands a picture id into the graph. Declared, a texture is its bare id – the `$texture` tag stays a list-only thing. - The `String` node is labelled "Text" like its kind; the type id stays. - Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`, `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts). - All server projects are reseeded, so `legacyNodes.ts`, `migrateDeclaredLists` and the `ownerId` fallback go, with their tests. - Note `reseed-because-of-list` moved to done/; new note `refactor-types`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
11c900c998
commit
8a7185948d
39 files changed
+378
-765
No files matched your search
@@ -108,9 +108,8 @@ shown on the node and editable in the inspector.
|
||||
of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read
|
||||
first, so one node swaps two variables. `count = count + 1` is one `Variables` node feeding its
|
||||
own output through an `Add` back into its input. The ports are picked from the behavior's
|
||||
variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along. Files
|
||||
from before this node still load: their `Get/Set variable(s)` and `Variable` nodes become
|
||||
`Variables` nodes that read or write the same variables. `Properties` is the same node for the
|
||||
variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along.
|
||||
`Properties` is the same node for the
|
||||
object it is handed (fixed input `object`). Its **Type** setting, above the list, decides what
|
||||
`+ Property` offers: *Sprite*, *Text*, *Collider*, *Camera* and *Container* offer their fields,
|
||||
*Behavior* the variables of the behavior picked below it (renaming such a variable carries the
|
||||
@@ -119,12 +118,9 @@ shown on the node and editable in the inspector.
|
||||
starting white, shown on the node as `#ffffff`, and a camera's `width` is its whole-pixel view
|
||||
width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its
|
||||
type as a setting row (*Type: Sprite*, plus *Behavior: Game* under that type). Textures and
|
||||
scenes are no type here: they are not objects in a running game. Older files load their `Get/Set property(ies)`, `Set enabled` and `Set text` as
|
||||
`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 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
|
||||
scenes are no type here: they are not objects in a running game. A variable is a
|
||||
**number, a yes/no, a text, a colour, a texture, a list or a struct**; a list declares **nothing** about its items –
|
||||
each one is a number, a text, a yes/no, a colour, a texture 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
|
||||
@@ -149,11 +145,13 @@ shown on the node and editable in the inspector.
|
||||
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
|
||||
the slot the kind it was. A **texture is a kind wherever a kind is picked** – a variable, a struct
|
||||
field, a list item, an Any port of `Properties`, a field of `Pack` – and the `Texture` node hands
|
||||
one into the graph, as `Number` and `Text` do a number and a text. Declared (a variable, a struct
|
||||
field of one, a port) it needs no tag: the declaration says what it is, and the value is the id –
|
||||
the way a colour field is a number. A declared port of kind colour or texture carries the field
|
||||
that edits it and travels as a number or a text, since the graph has neither; a port declared as
|
||||
the number a colour travels as still names the property (`isPortOf`). 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
|
||||
@@ -738,7 +736,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 631 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 633 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
|
||||
|
||||
+13
-2
@@ -36,11 +36,22 @@ export interface DeclaredPort {
|
||||
write?: true;
|
||||
}
|
||||
|
||||
export const DECLARED_PORT_KINDS = ['number', 'boolean', 'text', 'object', 'list', 'struct'] as const;
|
||||
/** A color travels as a number and a texture as text; the kind says which field edits the value. */
|
||||
export const DECLARED_PORT_KINDS = [
|
||||
'number',
|
||||
'boolean',
|
||||
'text',
|
||||
'color',
|
||||
'texture',
|
||||
'object',
|
||||
'list',
|
||||
'struct',
|
||||
] as const;
|
||||
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
||||
|
||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||
return kind === 'text' ? 'string' : kind;
|
||||
if (kind === 'text' || kind === 'texture') return 'string';
|
||||
return kind === 'color' ? 'number' : kind;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
||||
@@ -75,8 +75,14 @@ export function isTerminalNode(node: GraphNode): boolean {
|
||||
return isTerminalType(node.type);
|
||||
}
|
||||
|
||||
/** A color or texture port carries the field that edits it; the graph itself sees a number or a text. */
|
||||
export function portDefOf(port: DeclaredPort): PortDef {
|
||||
return { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||
if (port.kind === 'color')
|
||||
return { ...def, field: { key: port.key, label: port.key, kind: 'color', default: 0 } };
|
||||
if (port.kind === 'texture')
|
||||
return { ...def, field: { key: port.key, label: port.key, kind: 'texture', default: '' } };
|
||||
return def;
|
||||
}
|
||||
|
||||
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asListItem, asNumber } from '../../values';
|
||||
import { any, list, num, withDefault } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
/** Same order of magnitude as the interpreter's step limit – a typo in `count` must not eat memory. */
|
||||
const MAX_ITEMS = 100_000;
|
||||
@@ -10,7 +10,7 @@ export const Fill: NodeDef = {
|
||||
label: 'Fill',
|
||||
category: 'data',
|
||||
description: 'A new list of the same value repeated – how an empty board is made.',
|
||||
inputs: [any('value'), withDefault(num('count'), 1)],
|
||||
inputs: [any('value'), int('count', 1)],
|
||||
outputs: [list('result')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
export const ItemAt: NodeDef = {
|
||||
type: 'ItemAt',
|
||||
label: 'Item at',
|
||||
category: 'data',
|
||||
description: 'The item at an index, counting from 0. Out of range gives 0.',
|
||||
inputs: [list('list'), num('index')],
|
||||
inputs: [list('list'), int('index')],
|
||||
outputs: [any('item')],
|
||||
// A color item reads as its number: the graph has no color of its own (see `portKindOf`).
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { itemLike } from '../../../properties/listItems';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem, asNumber } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
export const SetItem: NodeDef = {
|
||||
type: 'SetItem',
|
||||
label: 'Set item',
|
||||
category: 'data',
|
||||
description: 'The same list with one item replaced. The list that came in is left alone.',
|
||||
inputs: [list('list'), num('index'), any('value')],
|
||||
inputs: [list('list'), int('index'), any('value')],
|
||||
outputs: [list('result')],
|
||||
run: (_ctx, inputs) => {
|
||||
const items = asList(inputs.list);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { str } from '../shared';
|
||||
|
||||
export const StringLiteral: NodeDef = {
|
||||
type: 'String',
|
||||
label: 'String',
|
||||
label: 'Text',
|
||||
category: 'data',
|
||||
description: 'A constant text.',
|
||||
inputs: [],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { list, num, withDefault } from '../shared';
|
||||
import { int, list } from '../shared';
|
||||
|
||||
export const SubList: NodeDef = {
|
||||
type: 'SubList',
|
||||
label: 'Sub list',
|
||||
category: 'data',
|
||||
description: 'A part of a list: "count" items starting at "start".',
|
||||
inputs: [list('list'), num('start'), withDefault(num('count'), 1)],
|
||||
inputs: [list('list'), int('start'), int('count', 1)],
|
||||
outputs: [list('result')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { num, str, withDefault } from '../shared';
|
||||
import { int, str } from '../shared';
|
||||
|
||||
export const Substring: NodeDef = {
|
||||
type: 'Substring',
|
||||
@@ -9,7 +9,7 @@ export const Substring: NodeDef = {
|
||||
description:
|
||||
'A part of a text: "count" characters starting at "start" (the first is 0). ' +
|
||||
'A negative count reaches to the end.',
|
||||
inputs: [str('text'), num('start'), withDefault(num('count'), -1)],
|
||||
inputs: [str('text'), int('start'), int('count', -1)],
|
||||
outputs: [str('text')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { str } from '../shared';
|
||||
|
||||
/** The picture is its id on the edge: a whole texture, one frame of a sheet or an animation. */
|
||||
export const TextureLiteral: NodeDef = {
|
||||
type: 'Texture',
|
||||
label: 'Texture',
|
||||
category: 'data',
|
||||
description: 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
|
||||
inputs: [],
|
||||
outputs: [str('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'texture', default: '' }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { any, num, str } from '../shared';
|
||||
import { any, int, str } from '../shared';
|
||||
|
||||
export const ToText: NodeDef = {
|
||||
type: 'ToText',
|
||||
label: 'To text',
|
||||
category: 'data',
|
||||
description: 'Turns a number into text with a fixed number of decimals.',
|
||||
inputs: [any('value'), num('decimals')],
|
||||
inputs: [any('value'), int('decimals')],
|
||||
outputs: [str('text')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { exec, int, num } from '../shared';
|
||||
|
||||
export const Repeat: NodeDef = {
|
||||
type: 'Repeat',
|
||||
@@ -9,7 +9,7 @@ export const Repeat: NodeDef = {
|
||||
description:
|
||||
'Runs "body" a number of times, counting "index" up from 0, and continues at "done" afterwards. ' +
|
||||
'Every round reads its data inputs anew, so an expression built on "index" changes with it.',
|
||||
inputs: [exec(), withDefault(num('times'), 1)],
|
||||
inputs: [exec(), int('times', 1)],
|
||||
outputs: [exec('body', 'body'), num('index'), exec('done', 'done')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Sqrt } from './data/Sqrt';
|
||||
import { StringLiteral } from './data/String';
|
||||
import { Substring } from './data/Substring';
|
||||
import { Subtract } from './data/Subtract';
|
||||
import { TextureLiteral } from './data/Texture';
|
||||
import { Time } from './data/Time';
|
||||
import { ToNumber } from './data/ToNumber';
|
||||
import { ToText } from './data/ToText';
|
||||
@@ -121,6 +122,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
NumberLiteral,
|
||||
BooleanLiteral,
|
||||
StringLiteral,
|
||||
TextureLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
|
||||
@@ -14,6 +14,15 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: '
|
||||
|
||||
export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value });
|
||||
|
||||
/** A whole-number input (an index, a count): its field shows no decimals, the node floors it anyway. */
|
||||
export const int = (key: string, value = 0): PortDef => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'number',
|
||||
default: value,
|
||||
field: { key, label: key, kind: 'number', default: value, step: 1, decimals: 0 },
|
||||
});
|
||||
|
||||
/** Outputs of the three collision events: the other body, its collider and the own collider. */
|
||||
export const collisionOutputs = (): PortDef[] => [
|
||||
exec(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ASSET_TYPE_LABELS } from '../model/AssetTypes';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { type PropertyDef, type PropertySchema, schemaFor } from '../properties/PropertySchema';
|
||||
import type { DeclaredPort } from './Graph';
|
||||
import { type DeclaredPort, declaredPortType } from './Graph';
|
||||
import { portDefOf } from './nested';
|
||||
import { type PortDef, withInlineDefault } from './NodeRegistry';
|
||||
import { portKindOf } from './variablePorts';
|
||||
@@ -55,10 +55,15 @@ export function targetProperties(target: PropertyTarget): PropertySchema {
|
||||
return target === 'any' ? [] : schemaFor(target);
|
||||
}
|
||||
|
||||
/** Carries the def its type has under that key (default, field); under Any or at another kind, none. */
|
||||
/** A port names the property when it travels as that property does: a `color` port and a `number` port both do. */
|
||||
export function isPortOf(port: DeclaredPort, def: PropertyDef): boolean {
|
||||
return declaredPortType(portKindOf(def)) === declaredPortType(port.kind);
|
||||
}
|
||||
|
||||
/** Carries the def its type has under that key (default, field); under Any or at another type, none. */
|
||||
export function propertyPort(port: DeclaredPort, params: PropertyBag): PortDef {
|
||||
const plain = withInlineDefault(portDefOf(port));
|
||||
const field = targetProperties(propertyTargetOf(params)).find((def) => def.key === port.key);
|
||||
if (!field || portKindOf(field) !== port.kind) return plain;
|
||||
if (!field || !isPortOf(port, field)) return plain;
|
||||
return { ...plain, field, default: field.default };
|
||||
}
|
||||
@@ -2,19 +2,13 @@ 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';
|
||||
|
||||
/** The port a variable or property travels on. A color is a number; an enum or a texture is text. */
|
||||
/** The port a variable or property travels on. An enum, a key or an asset is text. */
|
||||
export function portKindOf(def: PropertyDef): DeclaredPortKind {
|
||||
if (def.kind === 'color') return 'number';
|
||||
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
|
||||
? (def.kind as DeclaredPortKind)
|
||||
: 'text';
|
||||
}
|
||||
|
||||
/** A color is a number in the value model, and the graph has no port kind of its own for it. */
|
||||
export function fieldPortKind(field: StructFieldDef): DeclaredPortKind {
|
||||
return field.kind === 'color' ? 'number' : field.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -33,5 +27,5 @@ export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | und
|
||||
*/
|
||||
export function missingFieldPorts(node: GraphNode, fields: readonly StructFieldDef[]): DeclaredPort[] {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
return fields.filter((f) => !taken.has(f.key)).map((f) => ({ key: f.key, kind: fieldPortKind(f) }));
|
||||
return fields.filter((f) => !taken.has(f.key)).map((f) => ({ key: f.key, kind: f.kind }));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/** 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 – except a tagged colour. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem;
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged colour or texture. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem | TextureItem;
|
||||
|
||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||
export type StructValue = { readonly [field: string]: FieldValue };
|
||||
|
||||
@@ -61,11 +61,11 @@ export type PropertyDef =
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color', 'texture'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** 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 const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
/** What a field of a struct property holds – a primitive plus `list`. */
|
||||
@@ -89,7 +89,7 @@ export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const;
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'color', 'texture', 'list', 'struct'] as const;
|
||||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||
|
||||
/**
|
||||
@@ -243,7 +243,10 @@ export function variableDef(
|
||||
case 'boolean':
|
||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||
case 'text':
|
||||
case 'texture':
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'color':
|
||||
return { key, label: key, kind, default: colorOf(defaultValue ?? 0, 0) };
|
||||
case 'list':
|
||||
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
|
||||
case 'struct': {
|
||||
@@ -259,6 +262,7 @@ const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
text: '',
|
||||
boolean: false,
|
||||
color: 0,
|
||||
texture: '',
|
||||
};
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* 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.
|
||||
* and so does a list inside a struct field.
|
||||
*/
|
||||
import {
|
||||
booleanOf,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
type TextureItem,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
} from '../model/Properties';
|
||||
@@ -29,8 +29,8 @@ 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;
|
||||
/** What a flat position holds: no struct except a tagged color or texture, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | ColorItem | TextureItem;
|
||||
|
||||
/** 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 {
|
||||
@@ -42,10 +42,10 @@ export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
/** Flat positions hold no struct and no texture, so either of them reads as its text. */
|
||||
/** Flat positions hold no struct, so one reads as its text. */
|
||||
export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
|
||||
const kind = itemKindOf(value);
|
||||
return kind === 'struct' || kind === 'texture' ? 'text' : kind;
|
||||
return kind === 'struct' ? 'text' : kind;
|
||||
}
|
||||
|
||||
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
|
||||
@@ -77,9 +77,9 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu
|
||||
|
||||
/** `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);
|
||||
if (kind === 'color') return colorItem(colorOf(itemValue(value) ?? 0, 0));
|
||||
if (kind === 'texture') return textureItem(textOf(itemValue(value)));
|
||||
return 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. */
|
||||
@@ -93,12 +93,12 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A flat position holds a primitive or a tagged color or texture. 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 (isTextureItem(value)) return textureItem(textureItemValue(value));
|
||||
if (isList(value) || isStruct(value)) return undefined;
|
||||
return coerceField(value, fieldKindOf(value));
|
||||
}
|
||||
@@ -126,9 +126,9 @@ export function structItemFields(item: PropertyValue | undefined): { key: string
|
||||
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`. */
|
||||
/** A primitive as one of the flat 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 === 'text' || kind === 'texture') 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);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { PROPERTIES, VARIABLES } from '../graph/nested';
|
||||
import { OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM, targetProperties } from '../graph/propertyPorts';
|
||||
import { portKindOf } from '../graph/variablePorts';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { COMMON_PROPS, type PropertyDef, type PropertySchema } from '../properties/PropertySchema';
|
||||
|
||||
/** The nodes `Variables` replaced, and whether they wrote. */
|
||||
const SINGLE: Record<string, boolean> = { GetVariable: false, SetVariable: true, Variable: true };
|
||||
const PLURAL: Record<string, boolean> = { GetVariables: false, SetVariables: true };
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
const record = (value: unknown): Raw =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as Raw) : {};
|
||||
|
||||
/**
|
||||
* Rewrites the variable nodes of an older file into `Variables`, on the raw graph before it is
|
||||
* normalized. A single node's `value` port is renamed to the variable, which is the key there; one
|
||||
* without a variable has no pins left and loses its edges, flow included.
|
||||
*/
|
||||
export function upgradeVariableNodes(raw: unknown, variables: readonly PropertyDef[]): unknown {
|
||||
const graph = record(raw);
|
||||
if (!Array.isArray(graph.nodes)) return raw;
|
||||
const renamed = new Map<string, string>();
|
||||
const nodes = (graph.nodes as unknown[]).map((item) => {
|
||||
const node = record(item);
|
||||
const type = String(node.type);
|
||||
if (type in PLURAL) {
|
||||
const write = PLURAL[type];
|
||||
const ports = Array.isArray(node.ports)
|
||||
? (node.ports as unknown[]).map((p) => (write ? { ...record(p), write } : p))
|
||||
: [];
|
||||
return { ...node, type: VARIABLES, ports };
|
||||
}
|
||||
if (!(type in SINGLE)) return item;
|
||||
const write = SINGLE[type];
|
||||
const { variable, value, ...params } = record(node.params);
|
||||
const key = typeof variable === 'string' ? variable : '';
|
||||
renamed.set(String(node.id), key);
|
||||
if (!key) return { ...node, type: VARIABLES, params, ports: [] };
|
||||
const declared = variables.find((v) => v.key === key);
|
||||
const kind = declared ? portKindOf(declared) : 'text';
|
||||
if (write && value !== undefined) params[key] = value;
|
||||
return { ...node, type: VARIABLES, params, ports: [write ? { key, kind, write } : { key, kind }] };
|
||||
});
|
||||
const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => {
|
||||
const edge = record(item);
|
||||
const end = (side: unknown) => {
|
||||
const at = record(side);
|
||||
const key = renamed.get(String(at.node));
|
||||
if (key === '') return null;
|
||||
return key !== undefined && at.port === 'value' ? { ...at, port: key } : at;
|
||||
};
|
||||
const from = end(edge.from);
|
||||
const to = end(edge.to);
|
||||
return from && to ? [{ ...edge, from, to }] : [];
|
||||
});
|
||||
return { ...graph, nodes, edges };
|
||||
}
|
||||
|
||||
const SINGLE_PROPERTY: Record<string, boolean> = { GetProperty: false, SetProperty: true };
|
||||
const PLURAL_PROPERTY: Record<string, boolean> = { GetProperties: false, SetProperties: true };
|
||||
const FIXED_TARGETS = ['sprite', 'text', 'collider', 'camera', 'container'] as const;
|
||||
|
||||
interface Pinned {
|
||||
params: PropertyBag;
|
||||
defs: PropertySchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type an old node's ports fit: the first object type holding every key, else the one behavior
|
||||
* declaring them all as variables, else Any.
|
||||
*/
|
||||
export function pinPropertyTarget(
|
||||
keys: readonly string[],
|
||||
behaviors: ReadonlyMap<string, readonly PropertyDef[]>,
|
||||
): Pinned {
|
||||
const holds = (defs: PropertySchema) => keys.every((key) => defs.some((d) => d.key === key));
|
||||
if (keys.length === 0) return { params: {}, defs: [] };
|
||||
// Every object has these, so they say nothing about which type the node was meant for.
|
||||
if (holds(COMMON_PROPS)) return { params: {}, defs: COMMON_PROPS };
|
||||
for (const target of FIXED_TARGETS) {
|
||||
const defs = targetProperties(target);
|
||||
if (holds(defs)) return { params: { [OBJECT_TYPE_PARAM]: target }, defs };
|
||||
}
|
||||
const owners = [...behaviors].filter(([, defs]) => holds(defs));
|
||||
const [owner] = owners;
|
||||
if (owners.length !== 1 || !owner) return { params: {}, defs: [] };
|
||||
return { params: { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: owner[0] }, defs: owner[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes that were a `Set property` of one key, with what their unconnected input wrote. It has to
|
||||
* be spelled out: the new port would write the type's default instead (a text object's is "Text").
|
||||
*/
|
||||
const SETTERS: Record<string, { key: string; unset: boolean | string }> = {
|
||||
SetEnabled: { key: 'enabled', unset: true },
|
||||
SetText: { key: 'text', unset: '' },
|
||||
};
|
||||
|
||||
function fromSetter(node: Raw): Raw {
|
||||
const setter = SETTERS[String(node.type)];
|
||||
if (!setter) return node;
|
||||
const { [setter.key]: value = setter.unset } = record(node.params);
|
||||
return { ...node, type: 'SetProperty', params: { property: setter.key, value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites Get/Set property(ies), Set enabled and Set text of an older file into `Properties`, on
|
||||
* the raw graph before it is normalized. A single node's `value` port is renamed to the property; one
|
||||
* without a property keeps only its object input and loses its other edges.
|
||||
*/
|
||||
export function upgradePropertyNodes(
|
||||
raw: unknown,
|
||||
behaviors: ReadonlyMap<string, readonly PropertyDef[]>,
|
||||
): unknown {
|
||||
const graph = record(raw);
|
||||
if (!Array.isArray(graph.nodes)) return raw;
|
||||
const renamed = new Map<string, string>();
|
||||
const nodes = (graph.nodes as unknown[]).map((item) => {
|
||||
const node = fromSetter(record(item));
|
||||
const type = String(node.type);
|
||||
if (type in PLURAL_PROPERTY) {
|
||||
const write = PLURAL_PROPERTY[type];
|
||||
const ports = (Array.isArray(node.ports) ? (node.ports as unknown[]) : []).map(record);
|
||||
const pinned = pinPropertyTarget(
|
||||
ports.map((p) => String(p.key)),
|
||||
behaviors,
|
||||
);
|
||||
return {
|
||||
...node,
|
||||
type: PROPERTIES,
|
||||
params: { ...record(node.params), ...pinned.params },
|
||||
ports: ports.map((p) => (write ? { ...p, write } : p)),
|
||||
};
|
||||
}
|
||||
if (!(type in SINGLE_PROPERTY)) return node;
|
||||
const write = SINGLE_PROPERTY[type];
|
||||
const { property, value, ...params } = record(node.params);
|
||||
const key = typeof property === 'string' ? property : '';
|
||||
renamed.set(String(node.id), key);
|
||||
if (!key) return { ...node, type: PROPERTIES, params, ports: [] };
|
||||
const pinned = pinPropertyTarget([key], behaviors);
|
||||
const def = pinned.defs.find((d) => d.key === key);
|
||||
const kind = def ? portKindOf(def) : 'text';
|
||||
if (write && value !== undefined) params[key] = value;
|
||||
return {
|
||||
...node,
|
||||
type: PROPERTIES,
|
||||
params: { ...params, ...pinned.params },
|
||||
ports: [write ? { key, kind, write } : { key, kind }],
|
||||
};
|
||||
});
|
||||
const edges = (Array.isArray(graph.edges) ? graph.edges : []).flatMap((item) => {
|
||||
const edge = record(item);
|
||||
const end = (side: unknown) => {
|
||||
const at = record(side);
|
||||
const key = renamed.get(String(at.node));
|
||||
if (key === undefined || at.port === 'object') return at;
|
||||
if (key === '') return null;
|
||||
return at.port === 'value' ? { ...at, port: key } : at;
|
||||
};
|
||||
const from = end(edge.from);
|
||||
const to = end(edge.to);
|
||||
return from && to ? [{ ...edge, from, to }] : [];
|
||||
});
|
||||
return { ...graph, nodes, edges };
|
||||
}
|
||||
+11
-70
@@ -13,9 +13,8 @@ import {
|
||||
} from '../graph';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit, overrideSites } from '../model/instance';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
import {
|
||||
type ColorItem,
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
type FieldValue,
|
||||
@@ -23,18 +22,15 @@ import {
|
||||
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 type { FlatItem } from '../properties/listItems';
|
||||
import {
|
||||
coerceValue,
|
||||
COMMON_PROPS,
|
||||
@@ -42,12 +38,9 @@ import {
|
||||
type FieldKind,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaFor,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
VARIABLE_KINDS,
|
||||
variableDef,
|
||||
@@ -65,13 +58,10 @@ 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 declared = new Map<string, DeclaredItems>();
|
||||
const vars = normalizeVariables(a.variables, declared);
|
||||
if (declared.size > 0) legacyLists.set(a.id, declared);
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||||
} else {
|
||||
@@ -99,9 +89,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
name,
|
||||
props,
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(
|
||||
upgradePropertyNodes(upgradeVariableNodes(a.graph, variables.get(id) ?? []), variables),
|
||||
),
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
@@ -133,43 +121,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
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). */
|
||||
export function serializeProject(project: Project): unknown {
|
||||
return JSON.parse(JSON.stringify(project));
|
||||
@@ -191,8 +145,7 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
|
||||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
|
||||
overrides: normalizeBag(o.overrides, schema, true),
|
||||
// `ownerId` is the pre-hierarchy name of the same link.
|
||||
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||
parentId: typeof o.parentId === 'string' ? o.parentId : '',
|
||||
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
|
||||
});
|
||||
}
|
||||
@@ -233,8 +186,7 @@ function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
|
||||
return edits;
|
||||
}
|
||||
|
||||
/** `legacy` collects what a list variable still declares about its items – see `migrateDeclaredLists`. */
|
||||
function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>): PropertyDef[] {
|
||||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const defs: PropertyDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
@@ -247,8 +199,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
|
||||
if (!isValidVariableName(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
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);
|
||||
@@ -256,10 +206,6 @@ function normalizeVariables(raw: unknown, legacy?: Map<string, DeclaredItems>):
|
||||
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[] = [];
|
||||
@@ -410,21 +356,16 @@ 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 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 => {
|
||||
/** A primitive, or the objects a flat position may hold: a tagged color or texture. */
|
||||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||||
if (isPrimitive(value)) return value;
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
return isTextureItem(value) ? textureItemValue(value) : undefined;
|
||||
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
|
||||
};
|
||||
|
||||
/** A list item: a tagged texture, a flat one, or a struct of flat ones. */
|
||||
/** A list item: 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));
|
||||
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 =>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import {
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -204,11 +203,6 @@ 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':
|
||||
@@ -219,6 +213,8 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
|
||||
return { key, label, kind: 'boolean', default: false };
|
||||
case 'color':
|
||||
return { key, label, kind: 'color', default: 0 };
|
||||
case 'texture':
|
||||
return { key, label, kind: 'texture', default: '' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type GraphNode,
|
||||
isPortOf,
|
||||
isTerminalNode,
|
||||
missingFieldPorts,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
@@ -155,7 +156,7 @@ function buildPickedPortSection(
|
||||
continue;
|
||||
}
|
||||
const def = choices.find((d) => d.key === port.key);
|
||||
const known = def !== undefined && portKindOf(def) === port.kind;
|
||||
const known = def !== undefined && isPortOf(port, def);
|
||||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
@@ -163,7 +164,7 @@ function buildPickedPortSection(
|
||||
);
|
||||
new Menu(items, { searchable }).openBelow(choose);
|
||||
};
|
||||
portRow(host, assetId, node, port, choose, toggle);
|
||||
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
|
||||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
createField,
|
||||
type FieldContext,
|
||||
NAME_WIDTH,
|
||||
itemDef,
|
||||
primitiveDef,
|
||||
READ_ONLY,
|
||||
removeButton,
|
||||
@@ -225,7 +224,7 @@ export class ListEditor extends VStack {
|
||||
else {
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
itemDef(String(index), kind),
|
||||
primitiveDef(String(index), kind),
|
||||
itemValue(item) ?? '',
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
|
||||
@@ -156,14 +156,11 @@ describe('scene hierarchy', () => {
|
||||
expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe('');
|
||||
});
|
||||
|
||||
it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => {
|
||||
it('the loader drops dangling parents and breaks cycles', () => {
|
||||
const { project, stack, sceneId, a, b, c } = threeBoxes();
|
||||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||||
const raw = serializeProject(project) as { assets: { objects?: Record<string, unknown>[] }[] };
|
||||
const objects = raw.assets[0]!.objects!;
|
||||
// Legacy documents stored the link as `ownerId`.
|
||||
delete objects[1]!.parentId;
|
||||
objects[1]!.ownerId = a;
|
||||
// A cycle a → c → a and a dangling reference are both cleared.
|
||||
objects[0]!.parentId = c;
|
||||
objects[2]!.parentId = a;
|
||||
@@ -172,7 +169,6 @@ describe('scene hierarchy', () => {
|
||||
expect(parentOf(b)).toBe(a);
|
||||
expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives
|
||||
objects[1]!.parentId = 'ghost';
|
||||
delete objects[1]!.ownerId;
|
||||
expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GraphInterpreter, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import type { PropertyDef } from '../../src/core/properties/PropertySchema';
|
||||
import { pinPropertyTarget } from '../../src/core/store/legacyNodes';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { fakeContext, fakeEntity, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
type RawNode = { id: string; type: string; params?: Record<string, unknown>; ports?: unknown[] };
|
||||
type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
|
||||
|
||||
/** A saved project whose behavior "B" holds `nodes` and `edges` exactly as an older editor wrote them. */
|
||||
function loadOld(nodes: RawNode[], edges: RawEdge[]) {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', ['seen']);
|
||||
const game = makeBehavior(fixture, 'Game', ['coins']);
|
||||
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
|
||||
raw.assets.find((a) => a.id === behavior.id)!.graph = {
|
||||
nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
|
||||
edges: edges.map(([from, fromPort, to, toPort], i) => ({
|
||||
id: `e${i}`,
|
||||
from: { node: from, port: fromPort },
|
||||
to: { node: to, port: toPort },
|
||||
})),
|
||||
};
|
||||
const project = deserializeProject(raw);
|
||||
return { project, game, behavior: findAsset(project, behavior.id) as BehaviorAsset };
|
||||
}
|
||||
|
||||
const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
|
||||
const edges = (behavior: BehaviorAsset) =>
|
||||
behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
|
||||
|
||||
describe('property nodes of an older file', () => {
|
||||
it('load as Properties nodes that compute what the old graph computed', () => {
|
||||
const { project, behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'self', type: 'Self' },
|
||||
{ id: 'paint', type: 'SetProperty', params: { property: 'color', value: 0xff0000 } },
|
||||
{
|
||||
id: 'size',
|
||||
type: 'SetProperties',
|
||||
params: { width: 40 },
|
||||
ports: [{ key: 'width', kind: 'number' }],
|
||||
},
|
||||
{ id: 'get', type: 'GetProperty', params: { property: 'width' } },
|
||||
{ id: 'store', type: 'Variables', ports: [{ key: 'seen', kind: 'number', write: true }] },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'paint', 'exec'],
|
||||
['self', 'object', 'paint', 'object'],
|
||||
['paint', 'exec', 'size', 'exec'],
|
||||
['self', 'object', 'size', 'object'],
|
||||
['size', 'exec', 'store', 'exec'],
|
||||
['self', 'object', 'get', 'object'],
|
||||
['get', 'value', 'store', 'seen'],
|
||||
],
|
||||
);
|
||||
expect(['paint', 'size', 'get'].map((id) => node(behavior, id).type)).toEqual([
|
||||
'Properties',
|
||||
'Properties',
|
||||
'Properties',
|
||||
]);
|
||||
expect(edges(behavior)).toContain('get.width>store.seen');
|
||||
const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
|
||||
const ctx = fakeContext({ self, variables: { seen: 0 } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
|
||||
expect({ ...self.props, seen: ctx.variables.seen }).toEqual({ width: 40, color: 0xff0000, seen: 40 });
|
||||
});
|
||||
|
||||
it('keep whether they wrote and their typed-in value, under the kind of the property', () => {
|
||||
const { behavior, game } = loadOld(
|
||||
[
|
||||
{ id: 'paint', type: 'SetProperty', params: { property: 'color', value: 7 } },
|
||||
{ id: 'coins', type: 'SetProperty', params: { property: 'coins', value: 0 } },
|
||||
{ id: 'speed', type: 'GetProperty', params: { property: 'speed' } },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(node(behavior, 'paint')).toMatchObject({
|
||||
params: { [OBJECT_TYPE_PARAM]: 'sprite', color: 7 },
|
||||
ports: [{ key: 'color', kind: 'number', write: true }],
|
||||
});
|
||||
expect(node(behavior, 'coins')).toMatchObject({
|
||||
params: { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id, coins: 0 },
|
||||
ports: [{ key: 'coins', kind: 'number', write: true }],
|
||||
});
|
||||
// Nothing declares `speed`, so nothing says its kind.
|
||||
expect(node(behavior, 'speed').ports).toEqual([{ key: 'speed', kind: 'text' }]);
|
||||
expect(node(behavior, 'speed').params).toEqual({});
|
||||
});
|
||||
|
||||
it('turn Set enabled into Properties writing enabled, on unless told otherwise', () => {
|
||||
const { project, behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'self', type: 'Self' },
|
||||
{ id: 'screen', type: 'ObjectByName', params: { name: 'screen' } },
|
||||
{ id: 'no', type: 'Boolean', params: { value: false } },
|
||||
{ id: 'hide', type: 'SetEnabled' },
|
||||
{ id: 'show', type: 'SetEnabled', params: { enabled: false } },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'hide', 'exec'],
|
||||
['self', 'object', 'hide', 'object'],
|
||||
['no', 'value', 'hide', 'enabled'],
|
||||
['hide', 'exec', 'show', 'exec'],
|
||||
['screen', 'object', 'show', 'object'],
|
||||
],
|
||||
);
|
||||
expect(node(behavior, 'show')).toMatchObject({
|
||||
type: 'Properties',
|
||||
params: { enabled: false },
|
||||
ports: [{ key: 'enabled', kind: 'boolean', write: true }],
|
||||
});
|
||||
expect(node(behavior, 'hide').params).toEqual({ enabled: true });
|
||||
expect(edges(behavior)).toEqual([
|
||||
'start.exec>hide.exec',
|
||||
'self.object>hide.object',
|
||||
'no.value>hide.enabled',
|
||||
'hide.exec>show.exec',
|
||||
'screen.object>show.object',
|
||||
]);
|
||||
const self = { ...fakeEntity('self'), props: { enabled: true } };
|
||||
const screen = { ...fakeEntity('screen'), props: { enabled: true } };
|
||||
const ctx = fakeContext({ self, findByName: (name) => (name === 'screen' ? screen : null) });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
|
||||
expect([self.props.enabled, screen.props.enabled]).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('turn Set text into Text properties writing text, empty unless told otherwise', () => {
|
||||
const { project, behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'a', type: 'ObjectByName', params: { name: 'a' } },
|
||||
{ id: 'b', type: 'ObjectByName', params: { name: 'b' } },
|
||||
{ id: 'c', type: 'ObjectByName', params: { name: 'c' } },
|
||||
{ id: 'num', type: 'Number', params: { value: 12 } },
|
||||
{ id: 'blank', type: 'SetText' },
|
||||
{ id: 'typed', type: 'SetText', params: { text: 'Go' } },
|
||||
{ id: 'fed', type: 'SetText' },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'blank', 'exec'],
|
||||
['a', 'object', 'blank', 'object'],
|
||||
['blank', 'exec', 'typed', 'exec'],
|
||||
['b', 'object', 'typed', 'object'],
|
||||
['typed', 'exec', 'fed', 'exec'],
|
||||
['c', 'object', 'fed', 'object'],
|
||||
['num', 'value', 'fed', 'text'],
|
||||
],
|
||||
);
|
||||
expect(node(behavior, 'typed')).toMatchObject({
|
||||
type: 'Properties',
|
||||
params: { [OBJECT_TYPE_PARAM]: 'text', text: 'Go' },
|
||||
ports: [{ key: 'text', kind: 'text', write: true }],
|
||||
});
|
||||
expect(node(behavior, 'blank').params).toEqual({ [OBJECT_TYPE_PARAM]: 'text', text: '' });
|
||||
expect(edges(behavior)).toContain('num.value>fed.text');
|
||||
const texts = Object.fromEntries(
|
||||
['a', 'b', 'c'].map((name) => [name, { ...fakeEntity(name), props: { text: 'old' } }]),
|
||||
);
|
||||
const ctx = fakeContext({ findByName: (name) => texts[name] ?? null });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
|
||||
expect(['a', 'b', 'c'].map((name) => texts[name]!.props.text)).toEqual(['', 'Go', '12']);
|
||||
});
|
||||
|
||||
it('keep only the object input of one that named no property', () => {
|
||||
const { behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'self', type: 'Self' },
|
||||
{ id: 'num', type: 'Number' },
|
||||
{ id: 'blank', type: 'SetProperty', params: { value: 5 } },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'blank', 'exec'],
|
||||
['self', 'object', 'blank', 'object'],
|
||||
['num', 'value', 'blank', 'value'],
|
||||
],
|
||||
);
|
||||
expect(node(behavior, 'blank')).toMatchObject({ type: 'Properties', params: {} });
|
||||
expect(node(behavior, 'blank').ports).toBeUndefined();
|
||||
expect(edges(behavior)).toEqual(['self.object>blank.object']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the type an old node is pinned to', () => {
|
||||
const variable = (key: string): PropertyDef => ({ key, label: key, kind: 'number', default: 0 });
|
||||
const behaviors = new Map([
|
||||
['game', [variable('coins'), variable('lives')]],
|
||||
['hud', [variable('lives')]],
|
||||
]);
|
||||
const pinned = (keys: string[]) => pinPropertyTarget(keys, behaviors).params;
|
||||
|
||||
it('is the first object type holding every key', () => {
|
||||
expect(pinned(['color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
|
||||
expect(pinned(['color', 'align'])).toEqual({ [OBJECT_TYPE_PARAM]: 'text' });
|
||||
expect(pinned(['width', 'zoom'])).toEqual({ [OBJECT_TYPE_PARAM]: 'camera' });
|
||||
});
|
||||
|
||||
it('else the one behavior declaring them all', () => {
|
||||
expect(pinned(['coins', 'lives'])).toEqual({
|
||||
[OBJECT_TYPE_PARAM]: 'behavior',
|
||||
[OBJECT_BEHAVIOR_PARAM]: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('else Any: every object has it, nothing declares it, two behaviors do, or nothing to go by', () => {
|
||||
expect(pinPropertyTarget(['enabled'], behaviors)).toMatchObject({
|
||||
params: {},
|
||||
defs: [{ kind: 'boolean' }],
|
||||
});
|
||||
expect(pinned(['enabled', 'color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
|
||||
expect(pinned(['speed'])).toEqual({});
|
||||
expect(pinned(['lives'])).toEqual({});
|
||||
expect(pinned([])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { GraphInterpreter } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { fakeContext, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
type RawNode = { id: string; type: string; params?: Record<string, unknown>; ports?: unknown[] };
|
||||
type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
|
||||
|
||||
/** A saved project whose behavior holds `nodes` and `edges` exactly as an older editor wrote them. */
|
||||
function loadOld(nodes: RawNode[], edges: RawEdge[], variables = ['px', 'py', 'sum']) {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B', variables);
|
||||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'flag', 'boolean'));
|
||||
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
|
||||
raw.assets.find((a) => a.id === behavior.id)!.graph = {
|
||||
nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
|
||||
edges: edges.map(([from, fromPort, to, toPort], i) => ({
|
||||
id: `e${i}`,
|
||||
from: { node: from, port: fromPort },
|
||||
to: { node: to, port: toPort },
|
||||
})),
|
||||
};
|
||||
const project = deserializeProject(raw);
|
||||
return { project, behavior: findAsset(project, behavior.id) as BehaviorAsset };
|
||||
}
|
||||
|
||||
const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
|
||||
const edges = (behavior: BehaviorAsset) =>
|
||||
behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
|
||||
|
||||
describe('variable nodes of an older file', () => {
|
||||
it('load as Variables nodes that compute what the old graph computed', () => {
|
||||
const { project, behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'set', type: 'SetVariables', params: { px: 7 }, ports: [{ key: 'px', kind: 'number' }] },
|
||||
{ id: 'y', type: 'SetVariable', params: { variable: 'py', value: 9 } },
|
||||
{ id: 'get', type: 'GetVariable', params: { variable: 'px' } },
|
||||
{ id: 'both', type: 'GetVariables', ports: [{ key: 'py', kind: 'number' }] },
|
||||
{ id: 'add', type: 'Add' },
|
||||
{ id: 'sum', type: 'Variable', params: { variable: 'sum' } },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'set', 'exec'],
|
||||
['set', 'exec', 'y', 'exec'],
|
||||
['y', 'exec', 'sum', 'exec'],
|
||||
['get', 'value', 'add', 'a'],
|
||||
['both', 'py', 'add', 'b'],
|
||||
['add', 'result', 'sum', 'value'],
|
||||
],
|
||||
);
|
||||
expect(behavior.graph.nodes.map((n) => n.type)).toEqual([
|
||||
'OnStart',
|
||||
'Variables',
|
||||
'Variables',
|
||||
'Variables',
|
||||
'Variables',
|
||||
'Add',
|
||||
'Variables',
|
||||
]);
|
||||
expect(edges(behavior)).toEqual([
|
||||
'start.exec>set.exec',
|
||||
'set.exec>y.exec',
|
||||
'y.exec>sum.exec',
|
||||
'get.px>add.a',
|
||||
'both.py>add.b',
|
||||
'add.result>sum.sum',
|
||||
]);
|
||||
const ctx = fakeContext({ variables: { px: 0, py: 0, sum: 0 } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables).toEqual({ px: 7, py: 9, sum: 16 });
|
||||
});
|
||||
|
||||
it('keep whether they wrote, their typed-in value, and the kind of the variable', () => {
|
||||
const { behavior } = loadOld(
|
||||
[
|
||||
{ id: 'get', type: 'GetVariable', params: { variable: 'flag' } },
|
||||
{ id: 'set', type: 'SetVariable', params: { variable: 'px', value: 3 } },
|
||||
{ id: 'plural', type: 'SetVariables', params: { py: 4 }, ports: [{ key: 'py', kind: 'number' }] },
|
||||
{ id: 'gone', type: 'Variable', params: { variable: 'nope' } },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(node(behavior, 'get').ports).toEqual([{ key: 'flag', kind: 'boolean' }]);
|
||||
expect(node(behavior, 'set')).toMatchObject({
|
||||
params: { px: 3 },
|
||||
ports: [{ key: 'px', kind: 'number', write: true }],
|
||||
});
|
||||
expect(node(behavior, 'plural')).toMatchObject({
|
||||
params: { py: 4 },
|
||||
ports: [{ key: 'py', kind: 'number', write: true }],
|
||||
});
|
||||
// A variable the behavior does not declare keeps its port, as text; the inspector marks it.
|
||||
expect(node(behavior, 'gone').ports).toEqual([{ key: 'nope', kind: 'text', write: true }]);
|
||||
expect(node(behavior, 'get').params).toEqual({});
|
||||
});
|
||||
|
||||
it('give a struct variable a struct port, so an Expand can still take it apart', () => {
|
||||
const { behavior } = loadOld([{ id: 'get', type: 'GetVariable', params: { variable: 'piece' } }], []);
|
||||
expect(node(behavior, 'get').ports).toEqual([{ key: 'piece', kind: 'struct' }]);
|
||||
});
|
||||
|
||||
it('drop every edge of a node that never had a variable picked, flow included', () => {
|
||||
const { behavior } = loadOld(
|
||||
[
|
||||
{ id: 'start', type: 'OnStart' },
|
||||
{ id: 'empty', type: 'SetVariable' },
|
||||
{ id: 'add', type: 'Add' },
|
||||
],
|
||||
[
|
||||
['start', 'exec', 'empty', 'exec'],
|
||||
['add', 'result', 'empty', 'value'],
|
||||
],
|
||||
);
|
||||
expect(node(behavior, 'empty').ports).toBeUndefined();
|
||||
expect(edges(behavior)).toEqual([]);
|
||||
});
|
||||
|
||||
it('are left alone once they are Variables nodes', () => {
|
||||
const { project, behavior } = loadOld(
|
||||
[{ id: 'get', type: 'GetVariable', params: { variable: 'px' } }],
|
||||
[],
|
||||
);
|
||||
const again = findAsset(deserializeProject(serializeProject(project)), behavior.id) as BehaviorAsset;
|
||||
expect(again.graph).toEqual(behavior.graph);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||||
* value – and a color or a texture says so, or it would be a plain number or text.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
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 { findAsset } 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) =>
|
||||
objectHolders(project)
|
||||
.flatMap((holder) => holder.objects)
|
||||
.find((o) => o.id === id);
|
||||
|
||||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||||
function withList(value: unknown = [1, 2]) {
|
||||
const f = makeSpriteScene();
|
||||
@@ -71,13 +64,13 @@ describe('the kind of one list item', () => {
|
||||
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', () => {
|
||||
/** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */
|
||||
it('stays a texture inside a struct item', () => {
|
||||
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(back).toEqual([{ pic: textureItem('frame-26'), n: 1 }]);
|
||||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||||
'pic:text',
|
||||
'pic:texture',
|
||||
'n:number',
|
||||
]);
|
||||
});
|
||||
@@ -131,62 +124,9 @@ describe('a list in a node parameter, where nothing is declared', () => {
|
||||
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)]);
|
||||
it('and one inside a struct item as well', () => {
|
||||
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([
|
||||
{ pic: textureItem('frame-26'), n: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -231,6 +231,10 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
|
||||
{ name: 'unset is empty', outputs: { value: '' } },
|
||||
],
|
||||
Texture: [
|
||||
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
|
||||
{ name: 'unset is no picture', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands';
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
type DeclaredPortKind,
|
||||
fieldPortKind,
|
||||
missingFieldPorts,
|
||||
resolveDef,
|
||||
structShapeOf,
|
||||
} from '../../src/core/graph';
|
||||
import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||||
@@ -65,11 +59,19 @@ describe('the shape a variable declares', () => {
|
||||
expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined();
|
||||
});
|
||||
|
||||
/** A color is stored as a number and the graph has no port kind for it – every other kind is itself. */
|
||||
it('reads a color field as a number port and a list field as a list port', () => {
|
||||
expect(PIECE.map(fieldPortKind)).toEqual<DeclaredPortKind[]>([
|
||||
/** A field's kind is its port kind; a color port then travels as a number, since the graph has none. */
|
||||
it('gives each field a port of its own kind, a color travelling as a number', () => {
|
||||
const ports = missingFieldPorts({ id: 'n', type: 'PackStruct', x: 0, y: 0, params: {} }, PIECE);
|
||||
expect(ports.map((p) => `${p.key}:${p.kind}`)).toEqual([
|
||||
'shape:number',
|
||||
'name:text',
|
||||
'held:boolean',
|
||||
'tint:color',
|
||||
'cells:list',
|
||||
]);
|
||||
expect(ports.map((p) => declaredPortType(p.kind))).toEqual([
|
||||
'number',
|
||||
'text',
|
||||
'string',
|
||||
'boolean',
|
||||
'number',
|
||||
'list',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* A color and a texture are kinds of their own wherever a kind is picked – of a variable, of a struct
|
||||
* field, of a declared port – while on a data edge either is still the number or the text it wraps.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
declaredPortType,
|
||||
inlineInputDef,
|
||||
isPortOf,
|
||||
OBJECT_TYPE_PARAM,
|
||||
portDefOf,
|
||||
portKindOf,
|
||||
propertyPort,
|
||||
withInlineDefault,
|
||||
} from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { textureItem } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { type PropertyDef, structOf, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const COLOR: PropertyDef = { key: 'color', label: 'Color', kind: 'color', default: 0 };
|
||||
const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' };
|
||||
|
||||
describe('a variable of kind color or texture', () => {
|
||||
it('declares its default as the number or the id, empty when nothing is given', () => {
|
||||
expect(variableDef('pic', 'texture', 'fr-0-1')).toEqual({
|
||||
key: 'pic',
|
||||
label: 'pic',
|
||||
kind: 'texture',
|
||||
default: 'fr-0-1',
|
||||
});
|
||||
expect(variableDef('tint', 'color', '#ff0000').default).toBe(0xff0000);
|
||||
expect(variableDef('pic', 'texture').default).toBe('');
|
||||
expect(variableDef('tint', 'color').default).toBe(0);
|
||||
});
|
||||
|
||||
it('survives a save and load with its kind', () => {
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'pic', 'texture'));
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'tint', 'color'));
|
||||
findAsset(f.project, behavior.id)!.props.pic = 'fr-0-1';
|
||||
const back = findAsset(roundTrip(f.project), behavior.id);
|
||||
expect(back?.type === 'behavior' && back.variables.map((v) => `${v.key}:${v.kind}`)).toEqual([
|
||||
'pic:texture',
|
||||
'tint:color',
|
||||
]);
|
||||
expect(back?.props).toMatchObject({ pic: 'fr-0-1', tint: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a struct field of kind texture', () => {
|
||||
/** Declared, so the value needs no tag: the field says what it is, as a color field does. */
|
||||
it('holds the plain id, and starts empty', () => {
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: textureItem('fr-0-1') })).toEqual({
|
||||
pic: 'fr-0-1',
|
||||
});
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' });
|
||||
});
|
||||
|
||||
it('is added to a struct variable like any other field', () => {
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'look', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'look', { key: 'pic', kind: 'texture' }));
|
||||
findAsset(f.project, behavior.id)!.props.look = { pic: 'fr-0-1' };
|
||||
expect(findAsset(roundTrip(f.project), behavior.id)?.props.look).toEqual({ pic: 'fr-0-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a declared port of kind color or texture', () => {
|
||||
it('is the kind of the property it names, travelling as a number or a text', () => {
|
||||
expect(portKindOf(COLOR)).toBe('color');
|
||||
expect(portKindOf({ key: 'pic', label: 'pic', kind: 'texture', default: '' })).toBe('texture');
|
||||
expect(portKindOf({ key: 'align', label: 'align', kind: 'enum', default: 'left', options: [] })).toBe(
|
||||
'text',
|
||||
);
|
||||
expect(declaredPortType('color')).toBe('number');
|
||||
expect(declaredPortType('texture')).toBe('string');
|
||||
});
|
||||
|
||||
it('carries the field that edits it, so a typed-in value gets the picker', () => {
|
||||
const pic = withInlineDefault(portDefOf({ key: 'pic', kind: 'texture' }));
|
||||
expect(pic).toMatchObject({ type: 'string', default: '' });
|
||||
expect(inlineInputDef(pic).kind).toBe('texture');
|
||||
const tint = withInlineDefault(portDefOf({ key: 'tint', kind: 'color' }));
|
||||
expect(tint).toMatchObject({ type: 'number', default: 0 });
|
||||
expect(inlineInputDef(tint).kind).toBe('color');
|
||||
expect(portDefOf({ key: 'n', kind: 'number' }).field).toBeUndefined();
|
||||
});
|
||||
|
||||
/** A file from before the kind existed declares a sprite's `color` port as a number; it still is one. */
|
||||
it('names a property when it travels the way the property does', () => {
|
||||
expect(isPortOf({ key: 'color', kind: 'color' }, COLOR)).toBe(true);
|
||||
expect(isPortOf({ key: 'color', kind: 'number' }, COLOR)).toBe(true);
|
||||
expect(isPortOf({ key: 'color', kind: 'text' }, COLOR)).toBe(false);
|
||||
expect(propertyPort({ key: 'color', kind: 'color' }, SPRITE).field?.kind).toBe('color');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the literal nodes', () => {
|
||||
/** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */
|
||||
it('are labelled by the kind they hand out', () => {
|
||||
const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label;
|
||||
expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([
|
||||
'Number',
|
||||
'Boolean',
|
||||
'Text',
|
||||
'Texture',
|
||||
'List',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the whole-number inputs', () => {
|
||||
const input = (type: string, key: string) =>
|
||||
BUILT_IN_NODES.find((d) => d.type === type)!.inputs.find((p) => p.key === key)!;
|
||||
|
||||
it('show no decimals, since the node floors what arrives', () => {
|
||||
const whole = [
|
||||
['ItemAt', 'index'],
|
||||
['SetItem', 'index'],
|
||||
['SubList', 'start'],
|
||||
['SubList', 'count'],
|
||||
['Fill', 'count'],
|
||||
['Repeat', 'times'],
|
||||
['Substring', 'start'],
|
||||
['Substring', 'count'],
|
||||
['ToText', 'decimals'],
|
||||
] as const;
|
||||
for (const [type, key] of whole) {
|
||||
const def = inlineInputDef(input(type, key));
|
||||
expect(def.kind === 'number' && def.decimals, `${type}.${key}`).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keep their default on the port and in the field alike', () => {
|
||||
for (const [type, key, value] of [
|
||||
['SubList', 'count', 1],
|
||||
['Fill', 'count', 1],
|
||||
['Repeat', 'times', 1],
|
||||
['Substring', 'count', -1],
|
||||
['ItemAt', 'index', 0],
|
||||
] as const) {
|
||||
const port = input(type, key);
|
||||
expect([port.default, inlineInputDef(port).default], `${type}.${key}`).toEqual([value, value]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -257,11 +257,10 @@ describe('a list a struct field holds', () => {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* the next write, which reads as the menu entry doing nothing at all.
|
||||
*/
|
||||
it('offers the flat kinds only, where the whole list has all six', () => {
|
||||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color']);
|
||||
it('offers the flat kinds only, where the whole list has struct as well', () => {
|
||||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']);
|
||||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||||
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
|
||||
});
|
||||
|
||||
@@ -6,3 +6,13 @@
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
Umgesetzt. Entscheidungen dabei:
|
||||
- Nur das Label des `String`-Nodes heisst "Text", Typ-Id und Porttyp bleiben.
|
||||
- `Texture` (und `Color`) sind Variablen-Arten, Struct-Feld-Arten und Port-Arten; deklariert ist eine
|
||||
Textur die blanke Id, der `$texture`-Tag gilt nur in Listen. Neuer Node `Texture` mit Picker.
|
||||
- Jump-Run und Tetris wurden mit den aktuellen Seedern ueberschrieben; nur die fuenf Projekte auf
|
||||
dem Server zaehlen. Danach sind `legacyNodes.ts`, `migrateDeclaredLists` und der
|
||||
`ownerId`-Fallback samt Tests geloescht.
|
||||
@@ -0,0 +1,14 @@
|
||||
## Eine Refactoring-Runde speziell für Typen:
|
||||
|
||||
Typen (ob jetzt "String" oder "Color" oder "Texture") werden vertreut eingesetzt:
|
||||
Geh alles durch, und refactore so, dass
|
||||
- wenn ein neuer Typ in Zukunft irgendwann hinzugefügt wird
|
||||
- oder ein Typ geändert wird
|
||||
- oder wieder wegkommt
|
||||
dass es nicht ewig viele Stellen beachtet werden müssen.
|
||||
|
||||
Reduziere dass auf ein Minimum, wir brauchen das so "modular" wie möglich.
|
||||
|
||||
Falls es schon so passt wie es ist, dann braucht man natürlich auch nichts machen, aber ich will nur auf nummer sicher gehen.
|
||||
|
||||
Falls dir bei der Arbeit noch andere Dinge auffallen die man besser/konstruktiver/schöner bauen kann, dann kannst du das natürlich gleich mitnehmen.
|
||||
@@ -262,9 +262,9 @@ export async function variableNodes(t) {
|
||||
await sleep(300);
|
||||
const seeded = await portsOf('vp-pack');
|
||||
check(
|
||||
'picking a struct variable declares every field at once, a color among them as a number',
|
||||
'picking a struct variable declares every field at once, each of its own kind',
|
||||
JSON.stringify(seeded) ===
|
||||
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"number"},{"key":"cells","kind":"list"}]',
|
||||
'[{"key":"shape","kind":"number"},{"key":"tint","kind":"color"},{"key":"cells","kind":"list"}]',
|
||||
JSON.stringify(seeded),
|
||||
);
|
||||
check('and the node grew those inputs', Boolean(await portAt('vp-pack', 'cells', false)));
|
||||
|
||||
@@ -14,7 +14,7 @@ const logicGraph = {
|
||||
node('pp-owner', 'Owner', 0, 240),
|
||||
node('pp-set', 'Properties', 260, 0, { ...propertiesOf('sprite'), width: 40, color: 0xff0000 }, [
|
||||
written('width', 'number'),
|
||||
written('color', 'number'),
|
||||
written('color', 'color'),
|
||||
]),
|
||||
node('pp-store', 'Variables', 560, 0, {}, [written('seen', 'number')]),
|
||||
node('pp-any', 'Properties', 260, 420),
|
||||
@@ -152,7 +152,7 @@ export async function propertyNodes(t) {
|
||||
const grown = await portsOf('pp-set');
|
||||
check(
|
||||
'picking a property adds it as an output',
|
||||
grown.join(',') === 'width:number:set,color:number:set,height:number',
|
||||
grown.join(',') === 'width:number:set,color:color:set,height:number',
|
||||
grown.join(','),
|
||||
);
|
||||
const toggle = await json(`__gm.inspectorField('Set', true)`);
|
||||
@@ -196,7 +196,7 @@ export async function propertyNodes(t) {
|
||||
const kinds = await offered();
|
||||
check(
|
||||
'Any offers the kinds to pick from, not property names',
|
||||
['Number', 'Text', 'Boolean', 'List', 'Struct'].every((k) => kinds.includes(k)) &&
|
||||
['Number', 'Text', 'Boolean', 'Color', 'Texture', 'List', 'Struct'].every((k) => kinds.includes(k)) &&
|
||||
!kinds.includes('width'),
|
||||
kinds.join(' | '),
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ const logicGraph = (walkId) => ({
|
||||
node('an-add', 'Add', 300, 200, { b: 1 }),
|
||||
node('an-owner', 'Owner', 300, 360),
|
||||
node('an-wear', 'Properties', 600, 0, { ...propertiesOf('sprite'), texture: 'an-run' }, [
|
||||
written('texture', 'text'),
|
||||
written('texture', 'texture'),
|
||||
]),
|
||||
],
|
||||
edges: [
|
||||
@@ -398,6 +398,43 @@ export async function animation(t) {
|
||||
JSON.stringify({ texture: worn?.props.texture, clock: worn?.animation, after }),
|
||||
);
|
||||
|
||||
// 25l'. A picture is a kind of its own: the Texture node hands one into the graph, and a
|
||||
// variable may be one. Both are picked, never typed.
|
||||
const pickPicture = async (label, name) => {
|
||||
const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`);
|
||||
await click(field.x, field.y);
|
||||
await sleep(200);
|
||||
await typeText(name);
|
||||
await sleep(150);
|
||||
await press('Enter');
|
||||
await sleep(300);
|
||||
};
|
||||
await mutate(`
|
||||
const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic');
|
||||
logic.graph.nodes.push(${JSON.stringify(node('an-pic', 'Texture', 600, 300))});
|
||||
logic.variables.push(${JSON.stringify(variable('pic', 'texture', ''))});
|
||||
logic.props.pic = '';
|
||||
__gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] });
|
||||
`);
|
||||
await sleep(400);
|
||||
await pickPicture('Value', walk.name);
|
||||
const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic');
|
||||
const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? [];
|
||||
check(
|
||||
'the Texture node picks its picture and shows it by name',
|
||||
picNode?.params.value === walk.id && picRows.some((r) => r.includes(walk.name)),
|
||||
JSON.stringify({ params: picNode?.params, rows: picRows }),
|
||||
);
|
||||
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })");
|
||||
await sleep(400);
|
||||
await pickPicture('pic', walk.name);
|
||||
check(
|
||||
'a texture variable is picked with the same picker',
|
||||
(await t.asset('an-logic')).props.pic === walk.id,
|
||||
JSON.stringify((await t.asset('an-logic')).props),
|
||||
);
|
||||
await shot('04b-texture-kind');
|
||||
|
||||
// 25m. Deleting the texture counts the animations that play its frames.
|
||||
const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`);
|
||||
await click(again.x, again.y, 'right');
|
||||
|
||||
@@ -148,8 +148,8 @@ const nodes = [
|
||||
]),
|
||||
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')]),
|
||||
node('wear-walk', 'Properties', 1920, 1280, propertiesOf('sprite'), [written('texture', 'texture')]),
|
||||
node('wear-idle', 'Properties', 1920, 1510, propertiesOf('sprite'), [written('texture', 'texture')]),
|
||||
];
|
||||
|
||||
const edges = [
|
||||
|
||||
@@ -440,7 +440,7 @@ function drawBoardGraph() {
|
||||
g.at('show', 'Properties', 6, 0, propertiesOf('sprite'), [written('enabled', 'boolean')]);
|
||||
g.at('br', 'Branch', 7, 0);
|
||||
g.at('color', 'ItemAt', 7, 4);
|
||||
g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'number')]);
|
||||
g.at('paint', 'Properties', 8, 0, propertiesOf('sprite'), [written('color', 'color')]);
|
||||
g.link('start', 'grid', 'cell', 'list');
|
||||
g.link('loop', 'index', 'cell', 'index');
|
||||
g.link('cell', 'item', 'filled', 'a');
|
||||
@@ -656,7 +656,7 @@ function drawPieceGraph() {
|
||||
at('place', 'SetPosition', 6, 0);
|
||||
at('paint', 'Properties', 7, 0, { ...propertiesOf('sprite'), enabled: true }, [
|
||||
written('enabled', 'boolean'),
|
||||
written('color', 'number'),
|
||||
written('color', 'color'),
|
||||
]);
|
||||
link('cell', 'exec', 'place', 'exec');
|
||||
for (const id of ['place', 'paint']) link(object, 'object', id, 'object');
|
||||
|
||||
Reference in new issue
Block a user