A scene now stores only the container object; its content is resolved from the template on every read, with deviations kept as sparse instanceEdits on the container object. This drops the propagation bookkeeping entirely -- templateSync.ts is gone and the three structural gaps (add, remove, reparent) cannot diverge any more. Values stay editable on an instance, structure does not; Unpack writes the content into the scene, keeping ids. Play mode bakes containers into the throwaway copy before entities are built. No migration: formatVersion is 2 and the seeders write the new format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.4 KiB
JavaScript
134 lines
5.4 KiB
JavaScript
/**
|
||
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
|
||
* behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling.
|
||
*/
|
||
export async function colliders(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
locate,
|
||
press,
|
||
holdKey,
|
||
undo,
|
||
mutate,
|
||
select,
|
||
state,
|
||
object,
|
||
objects,
|
||
asset,
|
||
world,
|
||
entity,
|
||
stop,
|
||
} = t;
|
||
|
||
// 15. 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 to keep exactly one collider per body for the checks below.
|
||
const objectsBefore = (await objects()).length;
|
||
const redRow = await locate('Red crate');
|
||
await click(redRow.x, redRow.y, 'right');
|
||
await clickText('Add collider');
|
||
await sleep(100);
|
||
const s = await state();
|
||
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||
const colAsset = colObj ? await asset(colObj.assetId) : null;
|
||
check(
|
||
'Add collider creates a collider child sized like the sprite',
|
||
colObj?.parentId === 'o1' &&
|
||
colAsset?.type === 'collider' &&
|
||
colAsset.props.width === 100 &&
|
||
colAsset.props.height === 60,
|
||
JSON.stringify({ colObj, colAsset }),
|
||
);
|
||
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 undo();
|
||
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
|
||
check(
|
||
'undo removes the added collider object and its asset',
|
||
(await objects()).length === objectsBefore && colliderAssetGone,
|
||
);
|
||
|
||
// 15a. 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.
|
||
await mutate(`
|
||
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(
|
||
{ id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } },
|
||
{ id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } });
|
||
__gm.state.activeScene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap
|
||
__gm.state.select(null)`);
|
||
await press('Enter', { ctrl: true });
|
||
await sleep(500);
|
||
const collisionWorld = await world();
|
||
check(
|
||
'On collision enter fired once with the other body',
|
||
collisionWorld.log.filter((l) => l === 'Blue crate').length === 1,
|
||
JSON.stringify(collisionWorld.log),
|
||
);
|
||
const spriteCount = await evaluate(
|
||
`__gm.sceneObjects().filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`,
|
||
);
|
||
const info = await json('__gm.gameInfo()');
|
||
check(
|
||
'colliders are not rendered in the game view',
|
||
info.visibleItems === spriteCount,
|
||
`${JSON.stringify(info)} sprites=${spriteCount}`,
|
||
);
|
||
await stop();
|
||
|
||
// 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). A frame
|
||
// step far larger than the collider must not jump across it either (sub-steps).
|
||
const playAgainstBlue = async (trigger, speed = 0) => {
|
||
await mutate(`
|
||
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 ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
|
||
${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
|
||
__gm.state.select(null)`);
|
||
await press('Enter', { ctrl: true });
|
||
await sleep(300);
|
||
await holdKey('ArrowRight', 700);
|
||
const red = await entity('o1');
|
||
await stop();
|
||
return red;
|
||
};
|
||
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 - -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 > -120 + 20,
|
||
`world x=${passed?.x}`,
|
||
);
|
||
const fast = await playAgainstBlue(false, 40000);
|
||
check(
|
||
'a very fast mover cannot tunnel through a solid collider',
|
||
fast && Math.abs(fast.x - -120) < 0.5,
|
||
`world x=${fast?.x}`,
|
||
);
|
||
await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed
|
||
await select({ kind: 'object', objectId: 'oc2' });
|
||
await sleep(200);
|
||
await shot('22-trigger-collider');
|
||
}
|