Seed the saved demo scene (crate colliders) and make collider symbols grabbable only at their outline

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:14:01 +02:00
1 parent b0860ca07f
commit cb8885359f
3 files changed
+49 -17

No files matched your search

+21 -2
View File
@@ -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);
+23 -15
View File
@@ -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');
+5
View File
@@ -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',