Files
adminandClaude Opus 5.5 0f85476b93
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s
Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.

- A double-click on a line, edge or segment puts a point there, in the
  Curve tab on free space too. The point is picked and stays in place.
  It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
  on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
  point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
  point. In the Curve and Vector art tab the four point types stand
  below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
  is greyed out then.
- The pick ends with a click beside the points, with another object,
  shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
  editor/pointMenu.ts builds the menu for all four.

Decided here:

- A single click on a line no longer adds anything. In Vector art it
  drags the shape, on a polygon it drops the pick, and in the Texture
  tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
  and that corner is picked. Delete takes the last corner first. To
  remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
  not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
  focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
  an empty shape stayed in the list.

Findings:

- Fixed: the new hint line in the Texture tab was cut off at the edge.
  The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
  corner that sits near the centre. That was so before.

Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
  For that final state the gate and e2e 42,47,53 (105 checks) ran
  again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
  and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
  47 and 53. The existing checks for adding and removing a point use
  the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
  Without the Delete wiring the key removes the collider. Without the
  right-click hook the menu is missing, and that run stops in module
  42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
  the Vector art tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:13 +02:00

723 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with
* (#141). What only a running editor can show: the corners of a polygon collider are edited on
* its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab
* draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as
* does a collider that wears the frame's outline (#193) – which "Add collider…" picks by itself on a
* sprite wearing such a frame (#218) –, and "Generate collision…" reads that outline off the picture
* (#161).
*/
/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */
const PASTE_PICTURE = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#3c9a4a'; g.fillRect(0, 0, 32, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'wedge.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/**
* A 64 × 32 sheet, pasted like a file, painted pixel by pixel so no edge is smoothed: on the left a
* wedge (pixels with x + y ≥ 32) and a band of alpha 0.3 above it, on the right a 16 × 16 square.
*/
const PASTE_SHEET = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#c04040';
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) if (x + y >= 32) g.fillRect(x, y, 1, 1);
g.fillRect(40, 8, 16, 16);
g.fillStyle = 'rgba(64, 64, 192, 0.3)';
g.fillRect(0, 0, 20, 4);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'generate.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */
const climbGraph = {
nodes: [
node('sc-update', 'OnUpdate', 0, 0),
node('sc-owner', 'Owner', 0, 160),
node('sc-move', 'Move', 300, 0, { dx: 1, dy: 0.3, scale: 120, collide: true }),
],
edges: [
edge('sc-e1', 'sc-update', 'exec', 'sc-move', 'exec'),
edge('sc-e2', 'sc-update', 'dt', 'sc-move', 'dt'),
edge('sc-e3', 'sc-owner', 'object', 'sc-move', 'object'),
],
};
const TRIANGLE = [
{ x: 0, y: -80 },
{ x: 80, y: 80 },
{ x: -80, y: 80 },
];
const TAN30 = Math.tan(Math.PI / 6);
/** A ramp falling right at 30°; its top face runs through the object's origin. */
const RAMP = [
{ x: -200, y: -200 * TAN30 },
{ x: 200, y: 200 * TAN30 },
{ x: 200, y: 150 },
{ x: -200, y: 150 },
];
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
export async function shapes(t) {
const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t;
const { object, settle, sleep, play, stop, shot, showTab, press } = t;
const Y = LANES.shapes;
const put = `
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};`;
// 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a
// double-click on an edge puts a corner there, Delete or the corner's menu takes the picked
// one away (#288) – one undo step each.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-poly', type: 'collider', name: 'sh-col-poly',
props: { shape: 'polygon', points: ${JSON.stringify(TRIANGLE)}, isTrigger: false } });
put('sh-poly', 'crate', '', 0, ${Y}, 'Polygon crate', { width: 160, height: 160 });
put('sh-poly-col', 'sh-col-poly', 'sh-poly', 0, 0, 'Polygon collider');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${Y})`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'sh-poly-col', objectIds: ['sh-poly-col'] });
await settle();
const corners = async () => (await json('__gm.polygonCorners()'))?.corners ?? [];
const points = async () => (await object('sh-poly-col'))?.overrides?.points;
const start = await corners();
check(
'a selected polygon collider shows a handle on each of its corners',
start.length === 3 && start[0].y < start[1].y && start[1].x > start[2].x,
JSON.stringify(start),
);
const zoom = distance(start[0], start[1]) / distance(TRIANGLE[0], TRIANGLE[1]);
await drag(start[1].x, start[1].y, start[1].x + 40, start[1].y);
const dragged = await points();
check(
"dragging a corner moves it in the collider's own units, and one undo puts it back",
dragged?.length === 3 &&
Math.abs(dragged[1].x - (80 + 40 / zoom)) <= 1 &&
dragged[1].y === 80 &&
(await undo(), (await points()) === undefined),
JSON.stringify({ dragged, zoom }),
);
const pickedCorner = async () => (await json('__gm.polygonCorners()'))?.picked ?? null;
const [a, b] = await corners();
const onEdge = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
await click(onEdge.x, onEdge.y);
const clicked = await points();
// Past the double-click window: the click above must not count as the first of the pair.
await sleep(500);
await doubleClick(onEdge.x, onEdge.y);
const inserted = await points();
check(
'a click on an edge adds nothing, a double-click puts a corner there and picks it',
clicked === undefined &&
inserted?.length === 4 &&
Math.abs(inserted[1].x - 40) <= 1 &&
Math.abs(inserted[1].y) <= 1 &&
(await pickedCorner()) === 1,
JSON.stringify({ clicked, inserted, picked: await pickedCorner() }),
);
await shot('42-polygon-corners');
const fourth = (await corners())[1];
await sleep(500);
await doubleClick(fourth.x, fourth.y);
const kept = await points();
await press('Delete');
const deleted = await points();
check(
'a double-click on a corner keeps it; Delete takes the picked corner away and leaves the collider',
kept?.length === 4 &&
JSON.stringify(deleted) === JSON.stringify(TRIANGLE) &&
Boolean(await object('sh-poly-col')) &&
(await pickedCorner()) === -1 &&
(await undo(), (await points())?.length === 4),
JSON.stringify({ kept, deleted, picked: await pickedCorner() }),
);
const again = (await corners())[1];
await click(again.x, again.y, 'right');
const cornerMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const byMenu = await points();
const last = (await corners())[1];
await click(last.x, last.y, 'right');
const lastThree = await json('__gm.menuEnabled()');
await press('Escape');
check(
"a right-click on a corner offers Delete point, which takes it away – greyed out at the polygon's last three",
JSON.stringify(cornerMenu) === JSON.stringify(['Delete point']) &&
JSON.stringify(byMenu) === JSON.stringify(TRIANGLE) &&
JSON.stringify(lastThree) === JSON.stringify([false]) &&
Boolean(await object('sh-poly-col')),
JSON.stringify({ cornerMenu, byMenu, lastThree }),
);
const clearShapes = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sh-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('sh-'));
__gm.state.select(null)`);
await clearShapes();
// Every step clears its objects, so each has the lane to itself.
const RAMP_Y = Y + 100;
// 42b. The engine with the new shapes: a capsule with a rigidbody (no turning) falls onto a
// polygon ramp and slides down it, on the surface and never into it.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-ramp', type: 'collider', name: 'sh-col-ramp',
props: { shape: 'polygon', points: ${JSON.stringify(RAMP)}, isTrigger: false } });
assets.push({ id: 'sh-col-capsule', type: 'collider', name: 'sh-col-capsule',
props: { shape: 'capsule', width: 20, height: 40, friction: 0, isTrigger: false } });
assets.push({ id: 'sh-rb', type: 'rigidbody', name: 'sh-rb',
props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
put('sh-ramp', 'crate', '', 0, ${RAMP_Y}, 'Ramp', { width: 20, height: 20 });
put('sh-ramp-col', 'sh-col-ramp', 'sh-ramp', 0, 0);
put('sh-capsule', 'crate', '', -150, ${RAMP_Y - 200}, 'Capsule', { width: 20, height: 40 });
put('sh-capsule-col', 'sh-col-capsule', 'sh-capsule', 0, 0);
put('sh-capsule-rb', 'sh-rb', 'sh-capsule', 0, 0);
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${RAMP_Y - 100})`);
await settle();
await shot('42-capsule-ramp');
await play();
await evaluate(`(() => {
const out = (window.__shapeSamples = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-capsule');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 2500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(2800);
const samples = await json('window.__shapeSamples');
await stop();
// The lower round end's centre is 10 above the capsule's centre; its distance to the ramp's face
// (through (0, RAMP_Y), normal (sin 30°, −cos 30°)) is what keeps it on the surface.
const n = { x: 0.5, y: -Math.cos(Math.PI / 6) };
const gap = (s) => (s.x - 0) * n.x + (s.y + 10 - RAMP_Y) * n.y - 10;
// Only while it is over the ramp, which ends at x = 200: past it the capsule falls free.
const landed = samples.filter((s) => s.x < 190 && gap(s) < 1);
check(
'a capsule falls onto a polygon ramp and slides down it on the surface',
samples.length > 60 &&
landed.length > 30 &&
Math.max(...landed.map((s) => s.x)) > -150 + 60 &&
// The landing sinks it in a little (measured 0.9 at ~6 a frame); the engine pushes it back out.
landed.every((s) => gap(s) > -2) &&
landed.slice(30).every((s) => Math.abs(gap(s)) < 0.1),
JSON.stringify({
frames: samples.length,
landed: landed.length,
furthest: Math.max(...landed.map((s) => s.x)),
deepest: Math.min(...landed.map(gap)),
settled: Math.max(...landed.slice(30).map((s) => Math.abs(gap(s)))),
}),
);
await clearShapes();
// 42c. The Texture tab draws a frame's collision outline (#141): with "Collision" on, three
// clicks in the selected frame are three corners; a corner drags; undo takes the drag back.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_PICTURE);
await sleep(2500);
const texture = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the picture is imported at its own size',
texture?.w === 32 && texture?.h === 32,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)});
texture.frames.push({ id: 'sh-frame', name: 'Wedge', x: 0, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' });
await showTab('texture');
await settle();
await clickText('Collision');
const outline = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)})` +
`.frames.find((f) => f.id === 'sh-frame').collision ?? null`,
);
for (const [x, y] of [
[0, 32],
[32, 0],
[32, 32],
]) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y);
}
const drawn = await outline();
check(
'with Collision on, clicks in the selected frame draw its outline corner by corner',
(await json('__gm.texture.collisionMode()')) === true &&
JSON.stringify(drawn) ===
JSON.stringify([
[
{ x: 0, y: 32 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
],
]),
JSON.stringify(drawn),
);
const from = await json('__gm.texture.pointAt(32, 0)');
const to = await json('__gm.texture.pointAt(16, 0)');
await drag(from.x, from.y, to.x, to.y);
const moved = await outline();
await shot('42-texture-outline');
await undo();
check(
'a corner of the outline drags, and one undo puts it back',
moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }),
);
// A frame holds several outlines: Ctrl+click starts a second one. The corner put down last is the
// picked one; a click on an edge drops the pick, and Delete then takes the active outline away
// whole – not the frame, which Delete removes outside the mode.
for (const [i, [x, y]] of [
[2, 2],
[10, 2],
[2, 10],
].entries()) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {});
}
const two = await outline();
const pickedLast = await json('__gm.texture.pickedCorner()');
await shot('42-texture-two-outlines');
const secondEdge = await json('__gm.texture.pointAt(6, 2)');
await click(secondEdge.x, secondEdge.y);
const pickedNone = await json('__gm.texture.pickedCorner()');
await press('Delete');
const left = await outline();
check(
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
JSON.stringify(pickedLast) === JSON.stringify({ outline: 1, index: 2 }) &&
pickedNone === null &&
two?.length === 2 &&
JSON.stringify(two[1]) ===
JSON.stringify([
{ x: 2, y: 2 },
{ x: 10, y: 2 },
{ x: 2, y: 10 },
]) &&
left?.length === 1 &&
left[0].length === 3 &&
(await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`,
)) === true,
JSON.stringify({ two, left, pickedLast, pickedNone }),
);
// #288 on the outline that is left: a double-click on an edge puts a corner there and picks it,
// Delete takes the picked corner (not the outline), and a corner's menu does the same.
const slope = await json('__gm.texture.pointAt(16, 16)');
await sleep(500);
await doubleClick(slope.x, slope.y);
const four = await outline();
const pickedNew = await json('__gm.texture.pickedCorner()');
await press('Delete');
const three = await outline();
const apex = await json('__gm.texture.pointAt(32, 0)');
await click(apex.x, apex.y, 'right');
const outlineMenu = await json('__gm.menuRows()');
await clickText('Delete point');
const pair = await outline();
// All three steps back: the checks after this one count on the undo stack as it was.
await undo();
await undo();
await undo();
check(
'a double-click on an edge adds a picked corner, Delete and the menu of a corner take one away',
four?.[0]?.length === 4 &&
four[0][1].x === 16 &&
four[0][1].y === 16 &&
JSON.stringify(pickedNew) === JSON.stringify({ outline: 0, index: 1 }) &&
three?.length === 1 &&
three[0].length === 3 &&
JSON.stringify(outlineMenu) === JSON.stringify(['Delete point']) &&
pair?.[0]?.length === 2 &&
(await outline())?.[0]?.length === 3,
JSON.stringify({ four, pickedNew, three, outlineMenu, pair }),
);
// 42c-ii. A sheet full of outlines stays smooth (#220): dragging a corner redraws only the
// edited frame, a pan moves the drawn outlines instead of drawing them anew, and a
// change to an unselected frame still reaches the picture.
const drawInfo = () => json('__gm.texture.drawInfo()');
const beforeDrag = await drawInfo();
const corner = await json('__gm.texture.pointAt(32, 0)');
const aside = await json('__gm.texture.pointAt(24, 0)');
await drag(corner.x, corner.y, aside.x, aside.y, 8);
const afterDrag = await drawInfo();
await undo();
check(
'dragging a corner does not redraw the frames around it',
afterDrag.sheetBuilds === beforeDrag.sheetBuilds,
JSON.stringify({ before: beforeDrag.sheetBuilds, after: afterDrag.sheetBuilds }),
);
await select({ kind: 'asset', assetId: texture?.id });
await settle();
const unselected = await drawInfo();
await undo();
await settle();
const restored = await drawInfo();
await t.redo();
await settle();
check(
'undo on an unselected frame redraws its outlines',
unselected.sheetOutlines === 1 && restored.sheetOutlines === 2 && (await drawInfo()).sheetOutlines === 1,
JSON.stringify({ unselected: unselected.sheetOutlines, restored: restored.sheetOutlines }),
);
// Every frame's outlines show, but only the selected frame's are tinted, so the tint does not lie
// over the whole sheet.
check(
"only the selected frame's collision outlines are tinted",
beforeDrag.filled.overlay === 1 &&
beforeDrag.filled.sheet === 0 &&
unselected.sheetOutlines === 1 &&
unselected.filled.sheet === 0 &&
unselected.filled.overlay === 0,
JSON.stringify({ selected: beforeDrag.filled, unselected: unselected.filled }),
);
const area = await json('__gm.texture.rect()');
const beforePan = await drawInfo();
const [px, py] = [Math.round(area.x + area.w / 2), Math.round(area.y + area.h / 2)];
await drag(px, py, px + 40, py + 25, { steps: 8, button: 'middle' });
const afterPan = await drawInfo();
const origin = await json('__gm.texture.pointAt(0, 0)');
const label = afterPan.labels.find((l) => l.text === 'Wedge');
check(
'a pan moves the drawn outlines and names along without drawing them anew',
afterPan.sheetBuilds === beforePan.sheetBuilds &&
Math.abs(afterPan.origin.x - beforePan.origin.x - 40) < 0.01 &&
Math.abs(afterPan.origin.y - beforePan.origin.y - 25) < 0.01 &&
Math.abs(afterPan.origin.x - origin.x) < 0.01 &&
Math.abs(afterPan.origin.y - origin.y) < 0.01 &&
Boolean(label) &&
Math.abs(label.x - origin.x - 5) < 0.01 &&
Math.abs(label.y - origin.y - 5) < 0.01,
JSON.stringify({ beforePan, afterPan, origin }),
);
await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' });
await settle();
await clickText('Collision');
await showTab('scene');
await settle();
// 42d. A plain tile of that frame blocks with the outline: a wedge on a floor of plain solid
// tiles. The scene view outlines it, and a walker whose Move collides climbs over it.
const MAP_Y = Y; // cell (x, y) spans x·32 − 192 … and y·32 − 64 + MAP_Y …; the floor's top is MAP_Y + 32
const floor = Object.fromEntries(Array.from({ length: 12 }, (_, x) => [`${x},3`, 'sh-wall']));
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
const behavior = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} });
assets.push(behavior);
assets.push({ id: 'sh-wedge', type: 'tile', name: 'Wedge', props: { kind: 'none', solid: true }, pictures: ['sh-frame'] });
assets.push({ id: 'sh-wall', type: 'tile', name: 'Floor', props: { kind: 'none', solid: true }, pictures: [] });
assets.push({ id: 'sh-map', type: 'tilemap', name: 'Wedge map', props: { columns: 12, rows: 4, tileSize: 32 },
layers: [{ id: 'sh-layer', name: 'Layer 1', cells: { ...${JSON.stringify(floor)}, '6,2': 'sh-wedge' } }] });
assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker',
props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } });
put('sh-map-obj', 'sh-map', '', 0, ${MAP_Y}, 'Wedge map');
put('sh-walker', 'crate', '', -100, ${MAP_Y + 22}, 'Walker', { width: 20, height: 20 });
put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0);
put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${MAP_Y})`);
await settle();
const solids = await json(`__gm.tilemap.solids('sh-map-obj')`);
check(
'the scene view outlines the wedge cell with its outline, the floor as one rectangle',
solids?.visible === true &&
JSON.stringify(solids.rects) === JSON.stringify([{ x: 0, y: 3, w: 12, h: 1 }]) &&
JSON.stringify(solids.outlines) ===
JSON.stringify([
[
{ x: 6, y: 3 },
{ x: 7, y: 2 },
{ x: 7, y: 3 },
],
]),
JSON.stringify(solids),
);
// Drawn along the outer edges only (#173): the wedge's foot on the floor is no stroke.
const cornersOf = (line) => (line?.points ?? []).map((p) => `${p.x},${p.y}`).sort();
check(
'the scene view draws the wedge and the floor as one closed outline, without the edge they share',
solids?.boundary?.length === 1 &&
solids.boundary[0].closed === true &&
JSON.stringify(cornersOf(solids.boundary[0])) ===
JSON.stringify(['0,3', '0,4', '12,3', '12,4', '6,3', '7,2', '7,3'].sort()),
JSON.stringify(solids?.boundary),
);
await shot('42-wedge-map');
await play();
await evaluate(`(() => {
const out = (window.__climb = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-walker');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 4500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(4800);
const climb = await json('window.__climb');
await stop();
const highest = Math.min(...climb.map((s) => s.y));
check(
"a walker whose Move collides climbs the wedge tile's slope and goes on past it",
climb.length > 100 && highest < MAP_Y - 10 + 1 && highest > MAP_Y - 10 - 3 && climb.at(-1).x > 45,
JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }),
);
await clearShapes();
// 42e. A collider wears the same frame's outline (#193), linked: shape "Frame collision", the frame
// picked like a texture. It is the frame's size in the scene, and a walker climbs its slope –
// a box of that size would have stopped it at its side.
const F = Y; // the floor's top face; the wedge stands on it, its slope rising to x = 16
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
const texture = assets.find((a) => a.id === ${JSON.stringify(texture?.id)});
texture.frames.find((f) => f.id === 'sh-frame').collision = [[{ x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }]];
${MAKE_OBJECT}
${put}
const behavior = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} });
assets.push(behavior);
assets.push({ id: 'sh-col-wedge', type: 'collider', name: 'sh-col-wedge',
props: { shape: 'frame', frame: 'sh-frame', isTrigger: false } });
assets.push({ id: 'sh-col-floor', type: 'collider', name: 'sh-col-floor',
props: { shape: 'box', width: 400, height: 32, isTrigger: false } });
assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker',
props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } });
put('sh-floor', 'crate', '', 0, ${F + 16}, 'Floor', { width: 20, height: 20 });
put('sh-floor-col', 'sh-col-floor', 'sh-floor', 0, 0);
put('sh-wedge', 'crate', '', 0, ${F - 16}, 'Wedge', { width: 20, height: 20 });
put('sh-wedge-col', 'sh-col-wedge', 'sh-wedge', 0, 0);
put('sh-walker', 'crate', '', -100, ${F - 10}, 'Walker', { width: 20, height: 20 });
put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0);
put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${F})`);
await settle();
const wedgeRect = await json(`__gm.objectRect('sh-wedge-col')`);
const floorRect = await json(`__gm.objectRect('sh-floor-col')`);
check(
'a collider wearing a frame outline is the size of the frame in the scene, not its own width',
wedgeRect &&
floorRect &&
Math.abs(wedgeRect.w - wedgeRect.h) < 1 &&
Math.abs(floorRect.w / wedgeRect.w - 400 / 32) < 0.2,
JSON.stringify({ wedgeRect, floorRect }),
);
await shot('42-frame-collider');
await play();
await evaluate(`(() => {
const out = (window.__frameClimb = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-walker');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 3000) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(3300);
const frameClimb = await json('window.__frameClimb');
await stop();
// Over the wedge's top corner (16, F − 32) the walker's centre stands 10 higher.
const peak = Math.min(...frameClimb.map((s) => s.y));
check(
"a walker whose Move collides climbs the slope drawn in the collider's frame and goes on past it",
frameClimb.length > 100 && peak < F - 42 + 1 && peak > F - 42 - 3 && frameClimb.at(-1).x > 45,
JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }),
);
// 42e-ii. "Add collider…" on a sprite wearing that frame (#218): the new collider wears the frame's
// outline at once, scaled to the sprite (64 wide, the frame 32); a sprite without a picture
// gets the box. Undo takes each collider and its asset away again.
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
${put}
put('sh-worn', 'crate', '', 150, ${F - 120}, 'Worn 42', { texture: 'sh-frame', width: 64, height: 64 });
put('sh-plain', 'crate', '', -150, ${F - 120}, 'Plain 42', { width: 64, height: 64 });
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${F - 60})`);
const addedTo = async (id) => {
await evaluate('__gm.hierarchyExpandAll()');
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
await click(row.x, row.y, 'right');
await clickText('Add collider…');
await clickText('Create new collider');
await settle();
const s = await t.state();
const placed = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const props = placed ? (await t.asset(placed.assetId))?.props : null;
const rect = placed ? await json(`__gm.objectRect(${JSON.stringify(placed.id)})`) : null;
return {
parentId: placed?.parentId,
shape: props?.shape,
frame: props?.frame,
scale: props?.scale,
rect,
};
};
const assetCount = () => json('__gm.state.project.assets.length');
const assetsBefore = await assetCount();
const worn = await addedTo('sh-worn');
const wornRect = await json(`__gm.objectRect('sh-worn')`);
await shot('42-add-frame-collider');
await undo();
const plain = await addedTo('sh-plain');
await undo();
check(
'Add collider on a sprite wearing a frame with an outline takes "Frame collision" of that frame, sprite-sized',
worn.parentId === 'sh-worn' &&
worn.shape === 'frame' &&
worn.frame === 'sh-frame' &&
worn.scale === 2 &&
Math.abs(worn.rect?.w - wornRect?.w) < 1 &&
plain.parentId === 'sh-plain' &&
plain.shape === 'box' &&
(await assetCount()) === assetsBefore,
JSON.stringify({ worn, wornRect, plain }),
);
await clearShapes();
// 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band
// (alpha 30 %) in one frame, a square in the other. From 50 % the band is not solid, from
// 20 % it is an outline of its own; "All frames" writes both frames in one undo step.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_SHEET);
await sleep(2500);
const sheet = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
await mutate(`
const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)});
sheet.frames.push({ id: 'gc-left', name: 'Left', x: 0, y: 0, w: 32, h: 32 },
{ id: 'gc-right', name: 'Right', x: 32, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: sheet?.id, frameId: 'gc-left' });
await showTab('texture');
await settle();
await clickText('Generate collision…');
await settle();
const dialog = () => json('__gm.texture.generateDialog()');
const opened = await dialog();
check(
'Generate collision… opens on the selected frame and names what it would write from 50 % alpha',
sheet?.w === 64 &&
opened?.enabled === true &&
opened.all === false &&
/^Frame "Left": 1 outline, [34] corners$/.test(opened.summary),
JSON.stringify({ sheet, opened }),
);
const retype = async (at, text) => {
await click(at.x, at.y);
await press('a', { ctrl: true });
await t.typeText(text);
await press('Enter');
await settle();
};
await retype(opened.at.threshold, '20');
const faint = await dialog();
await shot('42-generate-dialog');
await retype(opened.at.threshold, '50');
check(
'from 20 % alpha the faint band is solid too and becomes an outline of its own',
/^Frame "Left": 2 outlines, \d+ corners$/.test(faint?.summary ?? '') &&
/^Frame "Left": 1 outline/.test((await dialog())?.summary ?? ''),
JSON.stringify(faint),
);
await click(opened.at.all.x, opened.at.all.y);
await settle();
const both = await dialog();
await clickText('Generate');
await settle();
const outlines = () =>
json(
`Object.fromEntries(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)})` +
`.frames.map((f) => [f.id, f.collision ?? null]))`,
);
const written = await outlines();
const cornerSet = (ring) => (ring ?? []).map((p) => `${p.x},${p.y}`).sort();
const ringArea = (ring) =>
Math.abs(
ring.reduce(
(sum, p, i) => sum + p.x * ring[(i + 1) % ring.length].y - ring[(i + 1) % ring.length].x * p.y,
0,
),
) / 2;
check(
'with All frames, Generate outlines the wedge and the square of both frames',
both?.all === true &&
/^2 frames: 2 outlines/.test(both.summary) &&
written['gc-left']?.length === 1 &&
written['gc-left'][0].length <= 4 &&
Math.abs(ringArea(written['gc-left'][0]) - 512) < 26 &&
JSON.stringify(cornerSet(written['gc-right']?.[0])) ===
JSON.stringify(['24,24', '24,8', '8,24', '8,8']),
JSON.stringify({ both, written }),
);
await clickText('Collision');
await settle();
await shot('42-generated-outline');
await clickText('Collision');
await undo();
const undone = await outlines();
check(
'one undo takes the outlines of both frames back',
undone['gc-left'] === null && undone['gc-right'] === null,
JSON.stringify(undone),
);
await showTab('scene');
await settle();
}