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:
1 parent
b0860ca07f
commit
cb8885359f
3 files changed
+49
-17
No files matched your search
@@ -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
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user