Refactoring: one file per node, consistent comments, shared helpers, split large modules
- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
3234eb480f
commit
1eb28314cf
189 files changed
+6231
-5543
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { same } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level objects
|
||||
* are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open and shut
|
||||
* without touching the selection, and selecting a nested object unfolds the way down to it.
|
||||
*/
|
||||
export async function hierarchy(t) {
|
||||
const { check, sleep, evaluate, json, click, select, state, shot } = t;
|
||||
const rowIds = () => json('__gm.hierarchyOrder()');
|
||||
const rows = async () => JSON.stringify(await rowIds());
|
||||
const selectionBefore = (await state()).selection;
|
||||
|
||||
check('hierarchy starts collapsed – only top-level objects are listed', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null);
|
||||
const chevron = await json(`__gm.hierarchyChevron('o2')`);
|
||||
await click(chevron.x, chevron.y);
|
||||
check(
|
||||
'clicking the chevron unfolds the row, leaving the selection alone',
|
||||
same(await rowIds(), ['o1', 'o2', 'oc2', 'o3', 'o5']) && same((await state()).selection, selectionBefore),
|
||||
await rows(),
|
||||
);
|
||||
await shot('01a-hierarchy-unfolded');
|
||||
await click(chevron.x, chevron.y);
|
||||
check('clicking it again folds the subtree away', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' });
|
||||
await sleep(150);
|
||||
check('selecting a nested object unfolds its ancestors', same(await rowIds(), ['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), await rows());
|
||||
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
|
||||
await sleep(150);
|
||||
check('Collapse all folds everything back', same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), await rows());
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Selecting, renaming and scrubbing in the inspector, dragging in the viewport, autosave to
|
||||
* the server, and undo/redo of all of it.
|
||||
*/
|
||||
export async function inspector(t) {
|
||||
const { check, sleep, json, locate, shot, click, clickText, drag, press, typeText, undo, redo, state, object, api, sceneOf, projectId } = t;
|
||||
|
||||
// 1. Select the blue crate in the hierarchy.
|
||||
await clickText('Blue crate');
|
||||
let s = await state();
|
||||
check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
|
||||
await shot('02-object-selected');
|
||||
|
||||
// 2. Rename via inspector: the name field sits right of the "Name" label.
|
||||
const nameLabel = await locate('Name');
|
||||
await click(nameLabel.x + 160, nameLabel.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('Renamed crate');
|
||||
await shot('03-typing');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
check('typing + Enter renamed the object', (await object('o2')).name === 'Renamed crate', JSON.stringify((await object('o2')).name));
|
||||
await shot('04-renamed');
|
||||
|
||||
// 3. Scrub the X field (right of the "X" label) by 100 px.
|
||||
const xLabel = await locate('X');
|
||||
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
|
||||
const xNow = (await object('o2')).position.x;
|
||||
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
|
||||
await shot('05-scrubbed');
|
||||
|
||||
// 3b. Type into X, then click straight into the empty viewport: the typed value must be
|
||||
// committed (not reverted) even though the click deselects and rebuilds the inspector.
|
||||
const center = await json('__gm.viewportCenter()');
|
||||
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
|
||||
await typeText('300');
|
||||
await click(center.x, center.y + 250);
|
||||
const xTyped = (await object('o2')).position.x;
|
||||
s = await state();
|
||||
check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`);
|
||||
|
||||
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
|
||||
// on the origin, so compute its screen position from the viewport centre.
|
||||
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
|
||||
const o1Now = (await object('o1')).position;
|
||||
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
|
||||
await shot('06-dragged');
|
||||
|
||||
// 5. Autosave → server.
|
||||
await sleep(1800);
|
||||
const saved = await api(`projects/${projectId}`);
|
||||
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
|
||||
check('rename persisted', renamed.name === 'Renamed crate', renamed.name);
|
||||
check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position));
|
||||
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
|
||||
check('save status is saved', (await state()).save === 'saved');
|
||||
|
||||
// 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
|
||||
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
|
||||
for (let i = 0; i < 4; i++) await undo();
|
||||
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
|
||||
check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo));
|
||||
await redo();
|
||||
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
|
||||
await shot('07-undo-redo');
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
const FIXTURE_IDS = ['o1', 'o2', 'o3', 'o4', 'o5', 'oc1', 'oc2'];
|
||||
|
||||
/**
|
||||
* Play mode: the camera frame is rendered, the Mover behavior moves its owner on the arrow
|
||||
* keys, Esc stops without touching the model. Then editing while playing (moves, delete, undo
|
||||
* and Spawn reach the world at once; Stop discards all of it), culling, and a scene without a
|
||||
* camera.
|
||||
*/
|
||||
export async function play(t) {
|
||||
const { check, sleep, evaluate, json, shot, clickText, press, holdKey, undo, state, object, objects, world, entity, select, mutate, stop } = t;
|
||||
const gameInfo = () => json('__gm.gameInfo()');
|
||||
const play = t.play;
|
||||
|
||||
// 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
|
||||
// editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the
|
||||
// world (Axis input × speed × dt), Esc stops without touching the model.
|
||||
await clickText('Renamed crate');
|
||||
const xBeforePlay = (await object('o2')).position.x;
|
||||
await play();
|
||||
let s = await state();
|
||||
check('play mode entered', s.mode === 'play', JSON.stringify(s));
|
||||
let info = await gameInfo();
|
||||
check('play renders the camera frame with both crates', info.rendering && info.visibleItems === 2, JSON.stringify(info));
|
||||
await shot('08-playing');
|
||||
await holdKey('ArrowRight', 400);
|
||||
const movedCrate = await entity('o1');
|
||||
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
|
||||
await holdKey('ArrowLeft', 250);
|
||||
const afterLeft = await entity('o1');
|
||||
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
|
||||
await stop();
|
||||
s = await state();
|
||||
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
||||
await shot('09-stopped');
|
||||
const o2AfterPlay = (await object('o2')).position.x;
|
||||
const o1AfterPlay = (await object('o1')).position.x;
|
||||
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
|
||||
|
||||
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
|
||||
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
|
||||
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
|
||||
await play();
|
||||
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' });
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400`);
|
||||
let liveO2 = await entity('o2');
|
||||
check('an edit while playing moves the entity in the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
await shot('08b-editing-while-playing');
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
const liveIds = (await world()).entities.map((e) => e.objectId);
|
||||
check('Delete while playing removes the entity and its collider child from the world', !liveIds.includes('o2') && !liveIds.includes('oc2') && liveIds.length === 5, JSON.stringify(liveIds));
|
||||
await undo();
|
||||
liveO2 = await entity('o2');
|
||||
check('undo while playing brings the entity back into the world', liveO2 && liveO2.x === 400, JSON.stringify(liveO2));
|
||||
|
||||
// 7a-ii. Spawning: the "Spawner" behavior places a Crate at (0, 150) on Space. The new object
|
||||
// shows up in the runtime scene (hierarchy), in the world and in the Game view at once;
|
||||
// Stop discards it with the rest of the runtime copy.
|
||||
const visibleBeforeSpawn = (await gameInfo()).visibleItems;
|
||||
await press('Space');
|
||||
await sleep(200);
|
||||
const spawnedObjects = await objects();
|
||||
const spawned = spawnedObjects.find((o) => !FIXTURE_IDS.includes(o.id));
|
||||
check(
|
||||
'Spawn node adds a crate object to the runtime scene at (0, 150)',
|
||||
spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8,
|
||||
JSON.stringify(spawned),
|
||||
);
|
||||
const spawnWorld = await world();
|
||||
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
|
||||
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
|
||||
info = await gameInfo();
|
||||
check('the Game view renders the spawned crate', info.visibleItems === visibleBeforeSpawn + 1, `${JSON.stringify(info)} before=${visibleBeforeSpawn}`);
|
||||
await shot('08c-spawned');
|
||||
await stop();
|
||||
const o2AfterEdit = (await object('o2')).position.x;
|
||||
check('stop restores the saved model untouched by play-mode edits (spawned object gone)', o2AfterEdit === xBeforePlay && (await objects()).length === 7, `x=${o2AfterEdit}`);
|
||||
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
|
||||
|
||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`);
|
||||
await play();
|
||||
info = await gameInfo();
|
||||
check('objects outside the camera frame are culled', info.visibleItems === 1, JSON.stringify(info));
|
||||
await stop();
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
|
||||
|
||||
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' });
|
||||
await press('Delete');
|
||||
await sleep(200);
|
||||
check('camera object deleted', (await objects()).length === 6);
|
||||
await play();
|
||||
info = await gameInfo();
|
||||
s = await state();
|
||||
check('play without camera renders nothing', s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info));
|
||||
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
|
||||
await shot('10-play-no-camera');
|
||||
await stop();
|
||||
await undo();
|
||||
check('undo restored the camera object', (await objects()).length === 7);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** The asset panel: placing from the context menu, creating through "+", and resetting an asset value. */
|
||||
export async function assets(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, undo, select, objects, asset } = t;
|
||||
|
||||
// 8. Context menu on the Crate asset → menu shows "Place in scene".
|
||||
await clickText('Crate', 'right');
|
||||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Place in scene')`)));
|
||||
await shot('11-context-menu');
|
||||
await clickText('Place in scene');
|
||||
await sleep(100);
|
||||
const count = (await objects()).length;
|
||||
check('placing from the menu adds an object', count === 8, `objects=${count}`);
|
||||
|
||||
// 9. Create a new asset through the + menu.
|
||||
await clickText('+');
|
||||
await clickText('Sprite');
|
||||
await sleep(100);
|
||||
const names = await json('__gm.state.project.assets.map(a => a.name)');
|
||||
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
|
||||
await shot('12-new-asset');
|
||||
|
||||
// 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and color at
|
||||
// their defaults → exactly one reset button is visible. Clicking it puts height back to
|
||||
// 100 and hides the button; undo brings 60 back.
|
||||
await select({ kind: 'asset', assetId: 'crate' });
|
||||
await sleep(200);
|
||||
const resetButton = await evaluate(`__gm.locate('↺')`);
|
||||
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
|
||||
await click(resetButton.x, resetButton.y);
|
||||
await sleep(100);
|
||||
check('reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`);
|
||||
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
|
||||
await shot('15-asset-reset');
|
||||
await undo();
|
||||
check('undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`);
|
||||
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Draw order: symbols always above sprites, `z` above hierarchy order. */
|
||||
export async function drawing(t) {
|
||||
const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
|
||||
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
|
||||
|
||||
// 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
|
||||
// right on top of its icon → the icon must still be drawn above the sprite.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0];
|
||||
objs.unshift(cam);
|
||||
const crate = objs.find(o => o.id === 'o1');
|
||||
crate.position.x = 0; crate.position.y = 0;
|
||||
__gm.state.select(null)`);
|
||||
const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`);
|
||||
check('camera symbol is drawn in the overlay layer above sprites', JSON.stringify(layers) === '["overlay","objects","objects"]', JSON.stringify(layers));
|
||||
await shot('13-symbol-on-top');
|
||||
|
||||
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
|
||||
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
|
||||
await mutate(`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`);
|
||||
check('equal z keeps hierarchy order', JSON.stringify(await orderOfCrates()) === '["o1","o2"]', JSON.stringify(await orderOfCrates()));
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
|
||||
await sleep(200);
|
||||
const zLabel = await locate('Z');
|
||||
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
|
||||
await typeText('5');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
check('Z field sets position.z', (await object('o1')).position.z === 5, `z=${(await object('o1')).position.z}`);
|
||||
check('higher z is drawn on top', JSON.stringify(await orderOfCrates()) === '["o2","o1"]', JSON.stringify(await orderOfCrates()));
|
||||
await shot('14-z-order');
|
||||
await undo();
|
||||
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The behavior graph editor: binding to the selection, dragging nodes, the palette (row click
|
||||
* and Enter), connecting ports, deleting, the live highlight while playing, variables.
|
||||
*/
|
||||
export async function graph(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, typeText, press, holdKey, undo, state, select, stop, asset } = t;
|
||||
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
|
||||
const graphCounts = async () => {
|
||||
const g = (await asset('mover')).graph;
|
||||
return { nodes: g.nodes.length, edges: g.edges.length };
|
||||
};
|
||||
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
|
||||
|
||||
// 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
|
||||
await clickText('Mover');
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
|
||||
await shot('16-graph');
|
||||
|
||||
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
|
||||
const axisBefore = await nodeOf('n-axis');
|
||||
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
|
||||
let s = await state();
|
||||
const axisAfter = await nodeOf('n-axis');
|
||||
check(
|
||||
'dragging a node selects and moves it',
|
||||
s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && axisAfter.x === axisBefore.x + 60 && axisAfter.y === axisBefore.y + 40,
|
||||
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
|
||||
);
|
||||
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
|
||||
await undo();
|
||||
const axisUndone = await nodeOf('n-axis');
|
||||
check('undo moves the node back', axisUndone.x === axisBefore.x && axisUndone.y === axisBefore.y, `${axisUndone.x},${axisUndone.y}`);
|
||||
|
||||
// 13b. Regression: typing a query and then *clicking* a row must add exactly one node.
|
||||
// (The search field's blur-commit once picked the first match and the row click picked
|
||||
// it again → two nodes.)
|
||||
const gcenter = await json('__gm.graph.center()');
|
||||
const beforeClickPick = await graphCounts();
|
||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||
await sleep(100);
|
||||
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||
await typeText('stop');
|
||||
await sleep(150);
|
||||
const stopRow = await locate('Stop game'); // nothing else on screen shows this label
|
||||
await click(stopRow.x, stopRow.y);
|
||||
await sleep(100);
|
||||
const afterClickPick = await graphCounts();
|
||||
check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick }));
|
||||
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||
await undo(); // so the next step finds the same empty spot and the expected graph
|
||||
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
|
||||
|
||||
// 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
|
||||
const before13 = await graphCounts();
|
||||
await click(gcenter.x + 250, gcenter.y + 250, 'right');
|
||||
await sleep(100);
|
||||
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
|
||||
await typeText('log');
|
||||
await sleep(100);
|
||||
await press('Enter');
|
||||
await sleep(250);
|
||||
const after13 = await graphCounts();
|
||||
s = await state();
|
||||
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
|
||||
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
|
||||
await shot('17-node-added');
|
||||
|
||||
// 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
|
||||
// exec output must not connect to a data input: dragging onto "message" does nothing.
|
||||
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
|
||||
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
|
||||
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
|
||||
const connected = await graphCounts();
|
||||
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
|
||||
const dataPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false)`);
|
||||
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
|
||||
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
|
||||
await shot('18-connected');
|
||||
|
||||
// 13e. Delete removes the selected node together with its edge; undo brings both back.
|
||||
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
const deleted = await graphCounts();
|
||||
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
|
||||
await undo();
|
||||
const restored = await graphCounts();
|
||||
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
|
||||
|
||||
// 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
|
||||
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
|
||||
const fpsEdit = await evaluate('__gm.fps()');
|
||||
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
|
||||
// Single evaluate: the readout refreshes twice a second, so read value and label together.
|
||||
check('fps label is visible in the toolbar', (await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`)) === true);
|
||||
await t.play();
|
||||
check('play switches to the game tab', (await evaluate('__gm.tab()')) === 'game');
|
||||
await sleep(600);
|
||||
const fpsPlay = await evaluate('__gm.fps()');
|
||||
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(100);
|
||||
const holding = holdKey('ArrowRight', 300);
|
||||
await sleep(250);
|
||||
await shot('19-graph-live');
|
||||
await holding;
|
||||
await stop();
|
||||
|
||||
// 13g. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(150);
|
||||
await clickText('+ Variable ▾');
|
||||
await clickText('Number');
|
||||
await sleep(100);
|
||||
check('adding a variable declares it on the behavior', (await variableKeys()) === 'speed,var1', await variableKeys());
|
||||
await shot('20-variable-added');
|
||||
await undo();
|
||||
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
|
||||
|
||||
// 13h. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
|
||||
await select(null);
|
||||
await sleep(100);
|
||||
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
|
||||
await select({ kind: 'asset', assetId: 'cam' });
|
||||
await sleep(100);
|
||||
check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(100);
|
||||
check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { same } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* Hierarchy / parenting. Gives both crates unique names (later steps locate them by these),
|
||||
* then parents by drag, by the inspector menu and unparents from the context menu – the world
|
||||
* position is kept every time.
|
||||
*/
|
||||
export async function parenting(t) {
|
||||
const { check, sleep, json, shot, click, clickText, drag, locate, typeText, press, undo, mutate, select, object, entity, stop } = t;
|
||||
|
||||
// 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
|
||||
// position (local becomes the difference) and is listed right below its parent.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
|
||||
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
|
||||
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
|
||||
__gm.state.select(null)`);
|
||||
const redRow = await locate('Red crate');
|
||||
const blueRow = await locate('Blue crate');
|
||||
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
|
||||
await sleep(100);
|
||||
let o2 = await object('o2');
|
||||
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
|
||||
const order = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||||
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
|
||||
const o1At = order.indexOf('o1');
|
||||
check('children are listed directly below their parent', same(order.slice(o1At + 1, o1At + 5), ['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order));
|
||||
await shot('20-parented');
|
||||
|
||||
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
|
||||
await clickText('Red crate');
|
||||
const xField = await locate('X');
|
||||
await click(xField.x + 160, xField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('-50');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
o2 = await object('o2');
|
||||
const parent = await object('o2').then((o) => object(o.parentId));
|
||||
const worldOfO2 = { x: o2.position.x + parent.position.x, y: o2.position.y + parent.position.y };
|
||||
check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && same(worldOfO2, { x: 220, y: -80 }), `${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`);
|
||||
|
||||
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
|
||||
await select(null);
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(300);
|
||||
const e2 = await entity('o2');
|
||||
check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2));
|
||||
await stop();
|
||||
await undo(); // move
|
||||
await undo(); // parent
|
||||
o2 = await object('o2');
|
||||
check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
|
||||
|
||||
// 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu.
|
||||
await clickText('Blue crate');
|
||||
await clickText('None ▾');
|
||||
const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text
|
||||
await click(menuEntry.x, menuEntry.y);
|
||||
await sleep(100);
|
||||
o2 = await object('o2');
|
||||
check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2));
|
||||
const blueRow2 = await locate('Blue crate');
|
||||
await click(blueRow2.x, blueRow2.y, 'right');
|
||||
await clickText('Unparent (move to top level)');
|
||||
await sleep(100);
|
||||
o2 = await object('o2');
|
||||
check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
|
||||
* behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling.
|
||||
*/
|
||||
export async function colliders(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, locate, press, holdKey, undo, mutate, select, state, object, objects, asset, world, entity, stop } = t;
|
||||
|
||||
// 15. Right-click the red crate → "Add collider": a collider asset sized like the crate
|
||||
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The
|
||||
// seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is
|
||||
// undone again to keep exactly one collider per body for the checks below.
|
||||
const objectsBefore = (await objects()).length;
|
||||
const redRow = await locate('Red crate');
|
||||
await click(redRow.x, redRow.y, 'right');
|
||||
await clickText('Add collider');
|
||||
await sleep(100);
|
||||
const s = await state();
|
||||
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||||
const colAsset = colObj ? await asset(colObj.assetId) : null;
|
||||
check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset }));
|
||||
check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay');
|
||||
check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`)));
|
||||
await shot('21-collider');
|
||||
await undo();
|
||||
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
|
||||
check('undo removes the added collider object and its asset', (await objects()).length === objectsBefore && colliderAssetGone);
|
||||
|
||||
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
|
||||
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
|
||||
// and colliders are not rendered in play mode.
|
||||
await mutate(`
|
||||
const mover = __gm.state.project.assets.find(a => a.id === 'mover');
|
||||
mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} });
|
||||
mover.graph.edges.push(
|
||||
{ id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } },
|
||||
{ id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } });
|
||||
__gm.state.activeScene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap
|
||||
__gm.state.select(null)`);
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(500);
|
||||
const collisionWorld = await world();
|
||||
check('On collision enter fired once with the other body', collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld.log));
|
||||
const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`);
|
||||
const info = await json('__gm.gameInfo()');
|
||||
check('colliders are not rendered in the game view', info.visibleItems === spriteCount, `${JSON.stringify(info)} sprites=${spriteCount}`);
|
||||
await stop();
|
||||
|
||||
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
|
||||
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
|
||||
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
|
||||
// Making the blue collider a trigger lets it pass through instead (drawn amber). A frame
|
||||
// step far larger than the collider must not jump across it either (sub-steps).
|
||||
const playAgainstBlue = async (trigger, speed = 0) => {
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
|
||||
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
|
||||
${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
|
||||
__gm.state.select(null)`);
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(300);
|
||||
await holdKey('ArrowRight', 700);
|
||||
const red = await entity('o1');
|
||||
await stop();
|
||||
return red;
|
||||
};
|
||||
const blocked = await playAgainstBlue(false);
|
||||
check("Move with \"Collide with solids\" stops the red crate flush against the blue crate's solid collider", blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`);
|
||||
const passed = await playAgainstBlue(true);
|
||||
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`);
|
||||
const fast = await playAgainstBlue(false, 40000);
|
||||
check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`);
|
||||
await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed
|
||||
await select({ kind: 'object', objectId: 'oc2' });
|
||||
await sleep(200);
|
||||
await shot('22-trigger-collider');
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { same } from '../lib/harness.mjs';
|
||||
|
||||
/**
|
||||
* Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber
|
||||
* band, group move/delete, placing and spawning the container asset, and apply/revert of
|
||||
* instance values against the template and the asset.
|
||||
*/
|
||||
export async function containers(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, drag, locate, press, undo, mutate, select, state, object, objects, asset, world, play, stop } = t;
|
||||
const center = await json('__gm.viewportCenter()');
|
||||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||||
|
||||
// 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
|
||||
// both are selected, the inspector shows the multi view.
|
||||
await mutate(`
|
||||
const objs = __gm.state.activeScene.objects;
|
||||
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
|
||||
o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
|
||||
o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
|
||||
objs.find(o => o.id === 'oc2').overrides = { width: 140 };
|
||||
__gm.state.select(null)`);
|
||||
await clickText('Red crate');
|
||||
const blueRow = await locate('Blue crate');
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
let s = await state();
|
||||
check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1', 'o2']) && s.selection.objectId === 'o2', JSON.stringify(s.selection));
|
||||
check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED'));
|
||||
await shot('23-multi-selected');
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
s = await state();
|
||||
check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']), JSON.stringify(s.selection));
|
||||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||||
|
||||
// 16a. Create container: both crates (with their colliders and the Mover) become the content
|
||||
// of a new container asset; the scene gets one container object at the selection's
|
||||
// centre (-15,-40), the crates hang below it with recomputed local positions, world
|
||||
// positions are unchanged, and the container is selected.
|
||||
const assetsBefore = (await json('__gm.state.project.assets')).length;
|
||||
const objectsBefore = (await objects()).length;
|
||||
await clickText('Create container');
|
||||
await sleep(100);
|
||||
s = await state();
|
||||
const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||||
const containerAsset = containerObj ? await asset(containerObj.assetId) : null;
|
||||
check(
|
||||
'Create container adds one container asset and one placed container, selected',
|
||||
containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await json('__gm.state.project.assets')).length === assetsBefore + 1 && (await objects()).length === objectsBefore + 1,
|
||||
JSON.stringify({ containerObj, type: containerAsset?.type }),
|
||||
);
|
||||
check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }), JSON.stringify(containerObj?.position));
|
||||
let o1c = await object('o1');
|
||||
const o2c = await object('o2');
|
||||
check(
|
||||
'the crates are re-parented below the container with local positions relative to its centre',
|
||||
o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40,
|
||||
JSON.stringify({ o1: o1c.position, o2: o2c.position }),
|
||||
);
|
||||
check(
|
||||
'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
|
||||
containerAsset?.objects.length === 5 && containerAsset.objects.filter((o) => o.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((o) => o.id === o1c.templateId),
|
||||
JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
|
||||
);
|
||||
const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||||
const cAt = orderC.indexOf(containerObj?.id);
|
||||
check('hierarchy lists the container with its content indented below', cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
|
||||
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
|
||||
await shot('24-container-created');
|
||||
|
||||
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||||
const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`);
|
||||
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
|
||||
const containerMoved = await object(containerObj.id);
|
||||
o1c = await object('o1');
|
||||
check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position }));
|
||||
await undo();
|
||||
check('undo moves the container back', (await object(containerObj.id)).position.x === -15);
|
||||
|
||||
// 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
|
||||
// the objects whose position lies inside – the crate and its collider, not the Mover
|
||||
// (at y = 110), not the camera (at the origin).
|
||||
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
|
||||
s = await state();
|
||||
check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']), JSON.stringify(s.selection));
|
||||
await shot('25-marquee');
|
||||
|
||||
// 16d. Dragging one of several selected objects moves all of them as one undo step.
|
||||
const redScreen = await json(`__gm.objectScreen('o1')`);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||||
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
|
||||
o1c = await object('o1');
|
||||
const o2d = await object('o2');
|
||||
check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position }));
|
||||
await undo();
|
||||
o1c = await object('o1');
|
||||
check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135);
|
||||
|
||||
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
|
||||
const objectsBeforeDelete = (await objects()).length;
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||||
await press('Delete');
|
||||
await sleep(150);
|
||||
check('Delete removes the whole multi-selection with its subtrees', (await objects()).length === objectsBeforeDelete - 5);
|
||||
await undo();
|
||||
check('undo restores all of them', (await objects()).length === objectsBeforeDelete);
|
||||
|
||||
// 16f. Placing the container asset again from the asset panel creates a second instance:
|
||||
// the root plus 5 content objects with fresh ids, the crate copy at the same offset.
|
||||
await clickText(containerAsset.name, 'right');
|
||||
await clickText('Place in scene');
|
||||
await sleep(100);
|
||||
const afterPlace = await objects();
|
||||
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
|
||||
const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
|
||||
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
|
||||
await shot('26-container-placed');
|
||||
|
||||
// 16f-ii. Spawning the container at runtime instantiates its content the same way: point the
|
||||
// Spawner at the container asset, press Space while playing – a whole instance appears
|
||||
// in the runtime copy and in the world, and Stop throws it away with the rest.
|
||||
const spawnParam = (assetId) => mutate(`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`);
|
||||
await spawnParam(containerAsset.id);
|
||||
const beforeSpawn = await objects();
|
||||
await play();
|
||||
await press('Space');
|
||||
await sleep(250);
|
||||
const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id));
|
||||
const spawnedRoot = fresh.find((o) => o.parentId === '');
|
||||
check(
|
||||
'Spawn of a container asset creates the container object plus its content',
|
||||
fresh.length === 6 && spawnedRoot?.assetId === containerAsset.id && fresh.filter((o) => o.templateId).length === 5,
|
||||
JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))),
|
||||
);
|
||||
const spawnWorld = await world();
|
||||
check('the spawned instance lives in the world and its name reached the Log node', spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }));
|
||||
await stop();
|
||||
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
|
||||
await spawnParam('crate');
|
||||
|
||||
// 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
|
||||
// container; the second instance has an unmodified copy of it. Moving o1 on x shows
|
||||
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
|
||||
// both back; ↺ puts o1 back to the template value.
|
||||
const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId);
|
||||
await select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' });
|
||||
await sleep(200);
|
||||
check('inspector names the container the object is part of', await has(containerAsset.name));
|
||||
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`);
|
||||
const applyButton = await evaluate(`__gm.locate('↑')`);
|
||||
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
|
||||
await shot('27-apply-buttons');
|
||||
await click(applyButton.x, applyButton.y);
|
||||
await sleep(100);
|
||||
check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }));
|
||||
check('apply hides the buttons again', !(await has('↑')));
|
||||
await undo();
|
||||
check('undo restores template and copy', (await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135);
|
||||
const revertButton = await evaluate(`__gm.locate('↺')`);
|
||||
await click(revertButton.x, revertButton.y);
|
||||
await sleep(100);
|
||||
check('revert puts the instance back to the template position', (await object('o1')).position.x === -135);
|
||||
|
||||
// 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`);
|
||||
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
|
||||
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
|
||||
await click(applyMenu.x, applyMenu.y);
|
||||
check('the menu offers container and asset', (await has('Apply to container')) && (await has('Apply to asset')));
|
||||
await shot('28-apply-menu');
|
||||
await clickText('Apply to asset');
|
||||
await sleep(100);
|
||||
const crateAfterApply = (await asset('crate')).props;
|
||||
check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply));
|
||||
await undo();
|
||||
check('undo restores asset and override', (await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77);
|
||||
await click(applyMenu.x, applyMenu.y);
|
||||
await clickText('Apply to container');
|
||||
await sleep(100);
|
||||
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77);
|
||||
|
||||
// 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
|
||||
// the value really differs from are offered, so "Reset to container" is left out and the
|
||||
// menu collapses to a direct ↺. Deviating from both brings the menu back.
|
||||
await sleep(200);
|
||||
check('a reset that would change nothing is left out – a direct button instead of a menu', (await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')));
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`);
|
||||
const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
|
||||
check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
|
||||
await click(resetMenu.x, resetMenu.y);
|
||||
check('the reset menu offers container and asset', (await has('Reset to container')) && (await has('Reset to asset')));
|
||||
await shot('28b-reset-menu');
|
||||
await clickText('Reset to container');
|
||||
await sleep(100);
|
||||
check('Reset to container puts the override back on the template value', (await object('o1')).overrides.width === 77);
|
||||
const singleReset = await evaluate(`__gm.locate('↺')`);
|
||||
check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await has('↺ ▾')));
|
||||
await click(singleReset.x, singleReset.y);
|
||||
await sleep(100);
|
||||
check('↺ drops the override so the object follows the asset again', (await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77);
|
||||
// Dropping the override left the object on the asset value (100) while the template still
|
||||
// says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
|
||||
check('the remaining deviation from the template keeps a single button, not a menu', (await has('↺')) && !(await has('↺ ▾')));
|
||||
await undo();
|
||||
check('undo restores the override', (await object('o1')).overrides.width === 77);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Text assets rendered in the scene, and the enabled switch in the editor and in the running game. */
|
||||
export async function textAndEnabled(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, mutate, object, play, stop } = t;
|
||||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||||
|
||||
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
await clickText('+');
|
||||
await clickText('Text');
|
||||
await sleep(100);
|
||||
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
|
||||
check('+ menu creates a text asset', Boolean(textAssetId));
|
||||
await mutate(`
|
||||
const id = ${JSON.stringify(textAssetId)};
|
||||
__gm.state.project.assets.find(a => a.id === id).props.text = 'Hello world';
|
||||
// On the camera so the game view really renders it (outside the frame it would be culled).
|
||||
const cam = __gm.state.activeScene.objects.find(o => o.id === 'o3').position;
|
||||
__gm.state.activeScene.objects.push({ id: 'otext', assetId: id, name: '', position: { x: cam.x, y: cam.y, z: 5 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', templateId: '' });
|
||||
__gm.state.select({ kind: 'object', sceneId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
|
||||
await sleep(100);
|
||||
check('a placed text object draws its content in the scene', await has('Hello world'));
|
||||
await shot('29-text-object');
|
||||
|
||||
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
|
||||
// switched-off object disappears from the scene view too (it stays in the hierarchy).
|
||||
const enabledRow = await evaluate(`__gm.locate('Enabled')`);
|
||||
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
|
||||
const visibleBefore = (await json('__gm.viewportInfo()')).visibleItems;
|
||||
// The checkbox sits right of the 64 px label column; locate() gives the label's text centre.
|
||||
await click(enabledRow.x + 55, enabledRow.y);
|
||||
await sleep(100);
|
||||
const textObject = await object('otext');
|
||||
check('clicking it writes an override instead of changing the asset', textObject.overrides.enabled === false, JSON.stringify(textObject.overrides));
|
||||
check('a switched-off object is not drawn in the scene view', !(await json('__gm.drawOrder()')).includes('otext'));
|
||||
const visibleAfter = (await json('__gm.viewportInfo()')).visibleItems;
|
||||
check('the scene view renders one item less', visibleAfter === visibleBefore - 1, `${visibleBefore} → ${visibleAfter}`);
|
||||
check('it stays selectable in the hierarchy, so its switch stays reachable', await has('Enabled'));
|
||||
await shot('30-disabled');
|
||||
|
||||
// 17b. In the running game a switched-off object does not exist: it is not rendered, and
|
||||
// switching it back on brings it back.
|
||||
await play();
|
||||
await sleep(200);
|
||||
const offItems = (await json('__gm.gameInfo()')).visibleItems;
|
||||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'otext').overrides.enabled = true`);
|
||||
await sleep(200);
|
||||
const onItems = (await json('__gm.gameInfo()')).visibleItems;
|
||||
check('the game renders one item more once the object is switched on', onItems === offItems + 1, `${offItems} → ${onItems}`);
|
||||
await stop();
|
||||
check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The e2e steps in the order they run. Each step is `async (t) => {}` over the harness
|
||||
* toolbox; later steps rely on the scene state earlier ones leave behind (names given in
|
||||
* the parenting step, the container created in the container step, …).
|
||||
*/
|
||||
import { hierarchy } from './01-hierarchy.mjs';
|
||||
import { inspector } from './02-inspector.mjs';
|
||||
import { play } from './03-play.mjs';
|
||||
import { assets } from './04-assets.mjs';
|
||||
import { drawing } from './05-drawing.mjs';
|
||||
import { graph } from './06-graph.mjs';
|
||||
import { parenting } from './07-parenting.mjs';
|
||||
import { colliders } from './08-colliders.mjs';
|
||||
import { containers } from './09-containers.mjs';
|
||||
import { textAndEnabled } from './10-text-and-enabled.mjs';
|
||||
|
||||
export const STEPS = [hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, textAndEnabled];
|
||||
Reference in new issue
Block a user