With a large sheet the texture tab stalled on every mouse move. A
profile over a pan with 1728 frames showed two causes. Half of the time
went into convexPieces, which split every outline into convex pieces
only to pick its colour. And every pointer move during a pan rebuilt
and re-tessellated the whole overlay geometry.
- TextureCanvas draws on two layers. `sheet` holds every frame except
the selected one and is rebuilt only when the zoom, the selection or
one of those frames changes. `overlay` holds the selected frame, its
handles and the rubber band of a new frame.
- Both layers are drawn without the pan. A pan only moves the
container and places the name labels again.
- SheetCanvas has a new hook `panned()`. By default it calls `redraw()`
as before, so Pixel Art and Vector Art are unchanged.
- The validity of an outline is remembered per outline and comes from
polygonProblem, with the same result. This works because the model
replaces an outline list on every change and never edits it in
place, the same as frameOutline.ts already relies on.
- The name plates stay below the outlines and the texts on top. Checked
on a screenshot: the corner handle next to a label sits on top.
- New debug hook `__gm.texture.drawInfo()` with the rebuild count, the
outline count and the screen positions of outlines and names.
Measured with a throwaway probe, 1728 frames, 120 moves, main thread
time per move, before and after in the same browser:
- Middle-click pan: 76.9 ms -> 4.7 ms (4.4 ms without frames)
- Dragging a frame: 51.7 ms -> 10.1 ms
- Wheel zoom: 52.2 ms -> 30.9 ms
- Selecting another frame: 67.6 ms -> 41.0 ms
Findings:
- Wheel zoom and selection still rebuild `sheet` once on purpose. That
stays a single hitch per notch or click on very large sheets.
Tests:
- ./check.sh: 148 files, 2214 tests, CHECK EXIT 0.
- e2e 1-16,37,39-43 on the final build: ALL PASSED, 641 checks.
- Three new checks in 42c-ii. A corner drag does not rebuild the other
frames. An undo on an unselected frame reaches the picture (1 -> 2
outlines). A pan moves outlines and names by exactly 40/25 px without
a rebuild.
- Counter-checks: a missing outline in the cache key and a pan that
does not move each fail at their check. The selected frame in the
key fails alone with 9 rebuilds during an 8-step drag.
- Check total 1333 -> 1336 (confirmed by CI at ceaa44e, task 259).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
586 lines
25 KiB
JavaScript
586 lines
25 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { 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), 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
|
||
// press on an edge puts a corner there, a double-click takes it away – 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 [a, b] = await corners();
|
||
await click((a.x + b.x) / 2, (a.y + b.y) / 2);
|
||
const inserted = await points();
|
||
check(
|
||
'a press on an edge puts a corner there',
|
||
inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1,
|
||
JSON.stringify(inserted),
|
||
);
|
||
await shot('42-polygon-corners');
|
||
const fourth = (await corners())[1];
|
||
await doubleClick(fourth.x, fourth.y);
|
||
check(
|
||
'a double-click on a corner takes it away again',
|
||
JSON.stringify(await points()) === JSON.stringify(TRIANGLE),
|
||
JSON.stringify(await points()),
|
||
);
|
||
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, and Delete 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();
|
||
await shot('42-texture-two-outlines');
|
||
await press('Delete');
|
||
const left = await outline();
|
||
check(
|
||
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
|
||
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 }),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
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) }),
|
||
);
|
||
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();
|
||
}
|