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:
rootandClaude Fable 5.1 committed 2026-09-12 13:35:02 +02:00
1 parent e147dd6d21
commit 11c900c998
34 files changed
+1218 -640

No files matched your search

+179 -36
View File
@@ -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
View File
@@ -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',
+7 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }),
);