The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
9.7 KiB
JavaScript
231 lines
9.7 KiB
JavaScript
import { inView } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
|
||
* behavior, Move with "Collide with solids" – blocking, triggers, no tunnelling – and the
|
||
* collision layers that decide which colliders meet at all (#152).
|
||
*/
|
||
export async function colliders(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
doubleClick,
|
||
clickText,
|
||
clickToolbar,
|
||
locate,
|
||
press,
|
||
typeText,
|
||
holdKey,
|
||
undo,
|
||
redo,
|
||
mutate,
|
||
select,
|
||
state,
|
||
object,
|
||
objects,
|
||
asset,
|
||
world,
|
||
entity,
|
||
stop,
|
||
} = t;
|
||
|
||
// 8a. 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');
|
||
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,
|
||
);
|
||
|
||
// 8b. 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.sceneObjects().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();
|
||
|
||
// 8c. 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 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 shot('22-trigger-collider');
|
||
|
||
// 8d. Collision layers (#152). The project starts with the one layer every collider is on; a
|
||
// second one is added and named in the Project settings dialog, which is where the layers
|
||
// moved with the top bar's rework (#183) – menu button → "Project settings…".
|
||
await clickToolbar('menu');
|
||
check(
|
||
'the top bar menu offers the project settings',
|
||
(await json('__gm.menuRows()')).includes('Project settings…'),
|
||
JSON.stringify(await json('__gm.menuRows()')),
|
||
);
|
||
await clickText('Project settings…');
|
||
const opened = await json('__gm.collisionLayers()');
|
||
check(
|
||
'the dialog opens on the one layer a project starts with',
|
||
opened.project.length === 1 && opened.dialog?.length === 1 && opened.dialog[0] === 'Default',
|
||
JSON.stringify(opened),
|
||
);
|
||
await clickText('Add layer');
|
||
const added = await json('__gm.collisionLayers()');
|
||
check(
|
||
'Add layer appends a second one, named after its number until it is typed over',
|
||
added.dialog?.length === 2 && added.dialog[1] === 'Layer 1' && added.project.length === 1,
|
||
JSON.stringify(added),
|
||
);
|
||
const layerField = await locate('Layer 1');
|
||
await doubleClick(layerField.x, layerField.y);
|
||
await typeText('Shots');
|
||
await press('Enter');
|
||
await shot('22b-collision-layers');
|
||
await clickText('Apply');
|
||
const applied = await json('__gm.collisionLayers()');
|
||
check(
|
||
'Apply writes the names into the project and closes the dialog',
|
||
applied.dialog === null && JSON.stringify(applied.project) === JSON.stringify(['Default', 'Shots']),
|
||
JSON.stringify(applied),
|
||
);
|
||
await undo();
|
||
check(
|
||
'the layers are an ordinary undo step',
|
||
JSON.stringify((await json('__gm.collisionLayers()')).project) === JSON.stringify(['Default']),
|
||
JSON.stringify(await json('__gm.collisionLayers()')),
|
||
);
|
||
await redo();
|
||
|
||
// The collider inspector carries both rows, and "Collides with" ticks every layer by default.
|
||
// They are the last rows of a long schema, so the inspector is scrolled to them first.
|
||
// Clicked rather than selected through the hook: after the play runs above, a selection set
|
||
// from outside leaves the inspector empty – only the click rebuilds it.
|
||
const crateRow = await inView(t, '__gm.leftColumn().objects', "__gm.hierarchyRow('oc2')");
|
||
await click(crateRow.x, crateRow.y);
|
||
const layerRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layer')");
|
||
check('the collider inspector shows the Layer row', Boolean(layerRow), JSON.stringify(layerRow));
|
||
const collidesWith = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Collides with')");
|
||
await click(collidesWith.x, collidesWith.y);
|
||
await shot('22c-collides-with');
|
||
const ticks = await t.menuTicked();
|
||
const both = (a, b) => [
|
||
{ label: 'Default', ticked: a },
|
||
{ label: 'Shots', ticked: b },
|
||
];
|
||
check(
|
||
'"Collides with" lists every layer of the project, all ticked',
|
||
JSON.stringify(ticks) === JSON.stringify(both(true, true)),
|
||
JSON.stringify(ticks),
|
||
);
|
||
// The last "Default" on screen: the Layer row above says it too, the menu is drawn on top.
|
||
const defaultEntry = await t.locate('Default', true);
|
||
await click(defaultEntry.x, defaultEntry.y);
|
||
const unticked = await t.menuTicked();
|
||
check(
|
||
'picking one unticks it without closing the menu',
|
||
JSON.stringify(unticked) === JSON.stringify(both(false, true)),
|
||
JSON.stringify(unticked),
|
||
);
|
||
await press('Escape');
|
||
|
||
// Putting the blue crate's collider on "Shots" – which the red crate's does not meet – is what
|
||
// 8c blocked on: the very same run now walks straight through it.
|
||
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 = { width: 140, layer: 1, collidesWith: 2 };
|
||
__gm.state.select(null)`);
|
||
await press('Enter', { ctrl: true });
|
||
await holdKey('ArrowRight', 700);
|
||
const throughLayer = await entity('o1');
|
||
await stop();
|
||
check(
|
||
'a collider on a layer the mover does not meet stops nothing',
|
||
throughLayer && throughLayer.x > -120 + 20,
|
||
`world x=${throughLayer?.x}`,
|
||
);
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'oc2').overrides = { width: 140 }`);
|
||
}
|