From cb8885359f626122dd8685b41dad09dd1cd68da7 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 16:14:01 +0200 Subject: [PATCH] Seed the saved demo scene (crate colliders) and make collider symbols grabbable only at their outline Co-Authored-By: Claude Fable 5.1 --- src/viewport/Viewport.ts | 23 +++++++++++++++++++++-- tools/e2e.mjs | 38 +++++++++++++++++++++++--------------- tools/seed-demo.mjs | 5 +++++ 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index 2bc96738..9854a933 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -5,7 +5,7 @@ import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/mod import { treeOrder, worldPosition } from '../core/model/hierarchy'; import { findAsset } from '../core/model/Project'; import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera'; -import { colliderShape, colliderSize, isTrigger } from '../core/properties/collider'; +import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { World } from '../runtime/World'; import { Theme, textStyle, Widget, type Size } from '../ui'; @@ -27,6 +27,22 @@ interface DrawItem { /** World-unit size of the behavior symbol (a diamond). */ const BEHAVIOR_SYMBOL = 28; +/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */ +const COLLIDER_GRAB_BAND = 6; + +/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */ +function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } { + if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band }; + const hw = shape.w / 2; + const hh = shape.h / 2; + return { + contains: (x, y) => { + const ax = Math.abs(x); + const ay = Math.abs(y); + return ax <= hw + band && ay <= hh + band && (ax >= hw - band || ay >= hh - band); + }, + }; +} /** * How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the @@ -301,12 +317,15 @@ export class Viewport extends Widget { node.circle(-6, -3, 2).fill(Theme.colors.accentText); node.circle(6, 3, 2).fill(Theme.colors.accentText); } else if (item.type === 'collider') { - // Outline plus a faint fill: the fill keeps the shape hit-testable and reads as "area". + // Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on + // top of their body, so only the outline band is hit-testable: clicks inside still reach + // the sprite underneath, the edge grabs the collider. const shape = colliderShape(item.props, { x: 0, y: 0 }); if (shape.kind === 'circle') node.circle(0, 0, shape.r); else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h); const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider; node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color }); + node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / view.scale); } } this.drawParentLinks(items, editing, view.scale); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 6d3f5967..f1652012 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -245,7 +245,7 @@ try { 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')) === 3); + check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 5); await clickText('▶ Play'); await sleep(300); info = await evaluate('JSON.stringify(__gm.viewportInfo())'); @@ -257,7 +257,7 @@ try { 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')) === 4); + check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 6); // 8. Context menu on the Crate asset → menu shows "Place in scene". await clickText('Crate', 'right'); @@ -268,7 +268,7 @@ try { await clickText('Place in scene'); await sleep(200); const count = await evaluate('__gm.state.activeScene.objects.length'); - check('placing from the menu adds an object', count === 5, `objects=${count}`); + check('placing from the menu adds an object', count === 7, `objects=${count}`); // 9. Create a new asset through the + menu. await clickText('+'); @@ -493,7 +493,9 @@ try { let o2 = JSON.parse(await objectJson('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 = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`)); - check('children are listed directly below their parent', order.indexOf('o2') === order.indexOf('o1') + 1 && order.indexOf('o4') === order.indexOf('o1') + 2, JSON.stringify(order)); + // 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', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['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. @@ -543,6 +545,9 @@ try { // 15. Colliders. 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 afterwards to keep exactly one collider per body for 15a/15b. + const objectsBeforeCollider = await evaluate('__gm.state.activeScene.objects.length'); const redRow3 = await locate('Red crate'); await click(redRow3.x, redRow3.y, 'right'); await sleep(150); @@ -555,13 +560,16 @@ try { 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 keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); + await sleep(200); + const colliderAssetGone = colAsset ? !(await evaluate(`__gm.state.project.assets.some(a => a.id === '${colAsset.id}')`)) : false; + check('undo removes the added collider object and its asset', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeCollider && colliderAssetGone); - // 15a. Give the blue crate the same collider, 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. + // 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with + // the crates overlapping (each carries its seeded collider): the log names the blue crate + // exactly once (enter, not stay), and colliders are not rendered in play mode. await evaluate(`(() => { const scene = __gm.state.activeScene; - scene.objects.push({ id: 'oc2', assetId: '${colAsset.id}', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o2' }); 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( @@ -581,10 +589,10 @@ try { await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); await sleep(200); - // 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (both 100 wide → - // 50 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 = −100. Making the blue collider a - // trigger lets it pass through instead (the collider is then drawn amber). + // 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). const holdRight = async (ms) => { await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); await sleep(ms); @@ -594,7 +602,7 @@ try { await evaluate(`(() => { 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 ? '{ isTrigger: true }' : '{}'}; + scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'}; __gm.state.select(null); __gm.state.preview(); })()`); @@ -607,9 +615,9 @@ try { return w.entities.find((e) => e.objectId === 'o1'); }; 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 - -100) < 0.01, `world x=${blocked?.x}`); + 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 > -100 + 20, `world x=${passed?.x}`); + check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`); await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`); await sleep(200); await shot('22-trigger-collider'); diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index d4f58044..5e8d73a3 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -51,10 +51,15 @@ const project = { { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' }, // The mover is parented to the red crate (its owner); its position is local to it. { id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' }, + // Both crates carry the shared "Crate collider" as a centred child; the blue crate's + // instance is widened to match its 140-wide sprite override. + { id: 'oc1', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o1' }, + { id: 'oc2', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 140 }, parentId: 'o2' }, ], }, { 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 } }, + { id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } }, { id: 'mover', type: 'behavior',