Camera view frame, viewport clipping, overlay layer for editor symbols
- Camera assets get width/height properties; play mode renders only the camera frame (letterboxed, masked, culled) and nothing without a camera - Remove demo arrow-key/camera-follow systems from play mode - Clip world and gizmos to the viewport (selection frame no longer leaks over the left panel) - Blur focused widget on pointerdown outside it so inspector edits commit before selection changes - Separate objects/overlay layers so camera symbols always render on top - Add tools/seed-demo.mjs for reproducible E2E runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a46124454
commit
aaa8154adf
11 files changed
+307
-142
No files matched your search
+63
-9
@@ -159,9 +159,18 @@ try {
|
||||
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 evaluate('__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 evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||
s = JSON.parse(await stateJson());
|
||||
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 widget bounds.
|
||||
const center = await evaluate('__gm.viewportCenter()');
|
||||
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
|
||||
const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`);
|
||||
check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, o1Now);
|
||||
@@ -176,40 +185,69 @@ try {
|
||||
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
|
||||
check('save status is saved', (JSON.parse(await stateJson())).save === 'saved');
|
||||
|
||||
// 6. Undo three times (drag, scrub, rename) with Ctrl+Z, then redo once.
|
||||
// 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 < 3; i++) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||
await sleep(80);
|
||||
}
|
||||
const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`));
|
||||
check('undo reverted drag, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo));
|
||||
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 keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z
|
||||
const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`);
|
||||
check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo);
|
||||
await shot('07-undo-redo');
|
||||
|
||||
// 7. Play with the renamed crate selected; arrow keys move it; Esc stops.
|
||||
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
|
||||
// origin), arrow keys do nothing, Esc stops without touching the model.
|
||||
await clickText('Renamed crate');
|
||||
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
s = JSON.parse(await stateJson());
|
||||
check('play mode entered', s.mode === 'play', JSON.stringify(s));
|
||||
let info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
|
||||
await shot('08-playing');
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await sleep(700);
|
||||
await sleep(300);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await sleep(100);
|
||||
await shot('09-moved-in-play');
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(300);
|
||||
s = JSON.parse(await stateJson());
|
||||
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
|
||||
await shot('10-stopped');
|
||||
await shot('09-stopped');
|
||||
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
|
||||
check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`);
|
||||
|
||||
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
|
||||
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info);
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(200);
|
||||
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}; __gm.state.preview()`);
|
||||
|
||||
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
|
||||
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
|
||||
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
|
||||
await sleep(200);
|
||||
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 2);
|
||||
await clickText('▶ Play');
|
||||
await sleep(300);
|
||||
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
|
||||
s = JSON.parse(await stateJson());
|
||||
check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info);
|
||||
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
|
||||
await shot('10-play-no-camera');
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(300);
|
||||
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||
await sleep(200);
|
||||
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
|
||||
|
||||
// 8. Context menu on the Crate asset → menu shows "Place in scene".
|
||||
await clickText('Crate', 'right');
|
||||
await sleep(200);
|
||||
@@ -230,6 +268,22 @@ try {
|
||||
check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets);
|
||||
await shot('12-new-asset');
|
||||
|
||||
// 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 evaluate(`(() => {
|
||||
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);
|
||||
__gm.state.preview();
|
||||
})()`);
|
||||
await sleep(200);
|
||||
const layers = await evaluate(`JSON.stringify([__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')])`);
|
||||
check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers);
|
||||
await shot('13-symbol-on-top');
|
||||
|
||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Resets the demo project to the state tools/e2e.mjs expects (the e2e run mutates it).
|
||||
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs <projectId> [baseUrl]
|
||||
*/
|
||||
const projectId = process.argv[2];
|
||||
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
|
||||
if (!projectId) {
|
||||
console.error('usage: node tools/seed-demo.mjs <projectId> [baseUrl]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const project = {
|
||||
formatVersion: 1,
|
||||
id: projectId,
|
||||
name: 'Demo project',
|
||||
activeSceneId: 'scene1',
|
||||
assets: [
|
||||
{
|
||||
id: 'scene1',
|
||||
type: 'scene',
|
||||
name: 'Level 1',
|
||||
props: { backgroundColor: 0x22252c },
|
||||
objects: [
|
||||
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0 }, overrides: {} },
|
||||
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80 }, overrides: { width: 140, color: 0x4488ff } },
|
||||
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0 }, overrides: {} },
|
||||
],
|
||||
},
|
||||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
||||
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
|
||||
const current = await fetch(url);
|
||||
if (!current.ok) {
|
||||
console.error(`GET failed: ${current.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { revision } = await current.json();
|
||||
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error('PUT failed', res.status, body);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`seeded ${projectId} → revision ${body.revision}`);
|
||||
Reference in new issue
Block a user