Pick the frame's outline when adding a collider to a sprite (#218)
"Add collider" on a sprite always made a box of the sprite's size, even when the sprite's picture is a frame that has a collision outline. The outline then had to be picked by hand as "Frame collision". - newColliderProps in colliderCommands.ts is the rule as a pure function. A sprite whose picture is a frame with an outline gets a collider with shape "frame" and that frame. Anything else keeps the box of the sprite's size. - The frame is the one the sprite shows when the collider is added, also when only this placement overrides it. - Decided here, the issue says nothing about size: a frame collider is as big as its frame, so a sprite drawn bigger or smaller with the same proportions gets the collider's scale set to match. - Decided here as well: a sprite stretched out of the frame's proportions keeps the box, because an outline cannot be stretched in one direction. Tests: - Gate: 198 files, 2914 tests (2911 + 3), exit 0 - e2e 8,42: 46 checks passed (42: +1 for 42e-ii) - Three new unit tests in tests/core/collider-parts.test.ts: the frame with undo, the scale with an overridden frame, and the cases that keep the box - 42e-ii goes through the context menu: "Frame collision" with scale 2 on the sprite wearing the wedge frame, a box on a sprite without a picture - Five unit counter-probes fail, 1 to 2 tests each. They ran on stein2, not in the container. - The browser counter-probe fails 42e-ii with shape "box" - The e2e sum of 1617 is calculated (1616 + 1). Neither the full suite nor the smokes ran for this state. - Vector art has no test of its own, it takes the same branch as a whole texture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
defc34bd09
commit
d39bf83ea9
4 files changed
+150
-7
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1616 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1617 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -2,9 +2,11 @@ import { newId } from '../ids';
|
||||
import { findObject } from '../model/hierarchy';
|
||||
import { isLiveInstance, type ObjectRef, resolvedHolder } from '../model/instance';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { frameOutline } from '../model/frameOutline';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAsset, type ObjectHolder, type Project } from '../model/Project';
|
||||
import { createSceneObject, objectName, type SceneObject } from '../model/SceneObject';
|
||||
import { matchesAspect } from '../properties/aspect';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { transformOfObject } from '../properties/transform';
|
||||
import { removeById } from '../util/list';
|
||||
@@ -74,7 +76,23 @@ export function colliderRefusal(project: Project, holder: ObjectHolder, target:
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A new collider asset is sized like the object (sprites: width × height; anything else: schema defaults). */
|
||||
/**
|
||||
* What a new collider on `target` starts with. A sprite gets a box of its width × height – or, when
|
||||
* its picture is a frame with a collision outline, that outline (#218): a frame collider is the
|
||||
* frame's size, so it is scaled up to the sprite, and a sprite stretched out of the frame's
|
||||
* proportions keeps the box, since an outline cannot be stretched. Anything else: schema defaults.
|
||||
*/
|
||||
export function newColliderProps(project: Project, targetAsset: Asset, target: SceneObject): PropertyBag {
|
||||
if (targetAsset.type !== 'sprite') return {};
|
||||
const size = spriteSize(project, targetAsset, target);
|
||||
const frame = textOf(resolveSceneObjectProps(project, targetAsset, target).texture ?? '');
|
||||
const outline = frameOutline(project, frame);
|
||||
if (!outline || outline.pieces.length === 0 || !matchesAspect(outline, size)) return size;
|
||||
const scale = size.width / outline.w;
|
||||
return { shape: 'frame', frame, width: outline.w, height: outline.h, ...(scale === 1 ? {} : { scale }) };
|
||||
}
|
||||
|
||||
/** A new collider asset starts with `newColliderProps`. */
|
||||
export class AddColliderCommand extends AddPartCommand {
|
||||
readonly label = 'Add collider';
|
||||
protected readonly partType = 'collider';
|
||||
@@ -85,7 +103,7 @@ export class AddColliderCommand extends AddPartCommand {
|
||||
|
||||
protected build(project: Project, target: SceneObject, targetAsset: Asset, owner: string): Asset {
|
||||
const asset = buildAsset('collider', nextAssetName(project, `${owner} collider`), this.assetId);
|
||||
if (targetAsset.type === 'sprite') Object.assign(asset.props, spriteSize(project, targetAsset, target));
|
||||
Object.assign(asset.props, newColliderProps(project, targetAsset, target));
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
|
||||
import {
|
||||
AddColliderCommand,
|
||||
AddRigidbodyCommand,
|
||||
@@ -7,6 +8,9 @@ import {
|
||||
FitColliderCommand,
|
||||
} from '../../src/core/commands/colliderCommands';
|
||||
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { colliderFrame } from '../../src/core/properties/collider';
|
||||
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
/** A 100×60 crate placed twice, the first with a collider of its own. */
|
||||
@@ -108,3 +112,72 @@ describe('fitting a collider to its sprite (#222)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a new collider on a sprite whose frame has an outline (#218)', () => {
|
||||
const WEDGE = [
|
||||
{ x: 0, y: 20 },
|
||||
{ x: 40, y: 0 },
|
||||
{ x: 40, y: 20 },
|
||||
];
|
||||
|
||||
/** A sprite wearing a 40 × 20 frame at that size, and `added`: the props of a collider added to it. */
|
||||
function wearing() {
|
||||
const f = makeSpriteScene('Wedge');
|
||||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||||
f.stack.execute(texture);
|
||||
Object.assign(findAsset(f.project, texture.asset.id) as TextureAsset, {
|
||||
file: `${'a'.repeat(64)}.png`,
|
||||
width: 64,
|
||||
height: 64,
|
||||
});
|
||||
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 40, h: 20 });
|
||||
f.stack.execute(frame);
|
||||
const set = (key: string, value: number | string) =>
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, key, value));
|
||||
set('texture', frame.frameId);
|
||||
set('width', 40);
|
||||
set('height', 20);
|
||||
const id = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const added = () => {
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
return findAsset(f.project, command.assetId)!.props;
|
||||
};
|
||||
const outline = () =>
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [WEDGE]));
|
||||
return { ...f, id, textureId: texture.asset.id, frameId: frame.frameId, set, added, outline };
|
||||
}
|
||||
|
||||
it('wears that frame, and one undo takes collider and asset away again', () => {
|
||||
const f = wearing();
|
||||
f.outline();
|
||||
const count = f.project.assets.length;
|
||||
const props = f.added();
|
||||
expect(props).toMatchObject({ shape: 'frame', frame: f.frameId, width: 40, height: 20, scale: 1 });
|
||||
expect(colliderFrame(f.project, props)?.pieces).toHaveLength(1);
|
||||
f.stack.undo();
|
||||
expect(f.project.assets).toHaveLength(count);
|
||||
});
|
||||
|
||||
it('is scaled to a sprite drawn bigger, and reads the frame this placement overrides', () => {
|
||||
const f = wearing();
|
||||
f.outline();
|
||||
f.set('texture', '');
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'texture', f.frameId));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'width', 100));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.id, 'height', 50));
|
||||
expect(f.added()).toMatchObject({ shape: 'frame', frame: f.frameId, width: 40, height: 20, scale: 2.5 });
|
||||
});
|
||||
|
||||
it('stays the box of the sprite without an outline, with the whole texture or stretched', () => {
|
||||
const f = wearing();
|
||||
const box = { shape: 'box', width: 40, height: 20, scale: 1 };
|
||||
expect(f.added()).toMatchObject(box);
|
||||
f.outline();
|
||||
f.set('texture', f.textureId);
|
||||
expect(f.added()).toMatchObject(box);
|
||||
f.set('texture', f.frameId);
|
||||
f.set('height', 60);
|
||||
expect(f.added()).toMatchObject({ ...box, height: 60 });
|
||||
});
|
||||
});
|
||||
+55
-3
@@ -1,13 +1,14 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with
|
||||
* (#141). What only a running editor can show: the corners of a polygon collider are edited on
|
||||
* its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab
|
||||
* draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as
|
||||
* does a collider that wears the frame's outline (#193), and "Generate collision…" reads that
|
||||
* outline off the picture (#161).
|
||||
* does a collider that wears the frame's outline (#193) – which "Add collider…" picks by itself on a
|
||||
* sprite wearing such a frame (#218) –, and "Generate collision…" reads that outline off the picture
|
||||
* (#161).
|
||||
*/
|
||||
|
||||
/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */
|
||||
@@ -502,6 +503,57 @@ export async function shapes(t) {
|
||||
frameClimb.length > 100 && peak < F - 42 + 1 && peak > F - 42 - 3 && frameClimb.at(-1).x > 45,
|
||||
JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }),
|
||||
);
|
||||
|
||||
// 42e-ii. "Add collider…" on a sprite wearing that frame (#218): the new collider wears the frame's
|
||||
// outline at once, scaled to the sprite (64 wide, the frame 32); a sprite without a picture
|
||||
// gets the box. Undo takes each collider and its asset away again.
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
${MAKE_OBJECT}
|
||||
${put}
|
||||
put('sh-worn', 'crate', '', 150, ${F - 120}, 'Worn 42', { texture: 'sh-frame', width: 64, height: 64 });
|
||||
put('sh-plain', 'crate', '', -150, ${F - 120}, 'Plain 42', { width: 64, height: 64 });
|
||||
__gm.state.select(null)`);
|
||||
await evaluate(`__gm.lookAt(0, ${F - 60})`);
|
||||
const addedTo = async (id) => {
|
||||
await evaluate('__gm.hierarchyExpandAll()');
|
||||
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
|
||||
await click(row.x, row.y, 'right');
|
||||
await clickText('Add collider…');
|
||||
await clickText('Create new collider');
|
||||
await settle();
|
||||
const s = await t.state();
|
||||
const placed = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||||
const props = placed ? (await t.asset(placed.assetId))?.props : null;
|
||||
const rect = placed ? await json(`__gm.objectRect(${JSON.stringify(placed.id)})`) : null;
|
||||
return {
|
||||
parentId: placed?.parentId,
|
||||
shape: props?.shape,
|
||||
frame: props?.frame,
|
||||
scale: props?.scale,
|
||||
rect,
|
||||
};
|
||||
};
|
||||
const assetCount = () => json('__gm.state.project.assets.length');
|
||||
const assetsBefore = await assetCount();
|
||||
const worn = await addedTo('sh-worn');
|
||||
const wornRect = await json(`__gm.objectRect('sh-worn')`);
|
||||
await shot('42-add-frame-collider');
|
||||
await undo();
|
||||
const plain = await addedTo('sh-plain');
|
||||
await undo();
|
||||
check(
|
||||
'Add collider on a sprite wearing a frame with an outline takes "Frame collision" of that frame, sprite-sized',
|
||||
worn.parentId === 'sh-worn' &&
|
||||
worn.shape === 'frame' &&
|
||||
worn.frame === 'sh-frame' &&
|
||||
worn.scale === 2 &&
|
||||
Math.abs(worn.rect?.w - wornRect?.w) < 1 &&
|
||||
plain.parentId === 'sh-plain' &&
|
||||
plain.shape === 'box' &&
|
||||
(await assetCount()) === assetsBefore,
|
||||
JSON.stringify({ worn, wornRect, plain }),
|
||||
);
|
||||
await clearShapes();
|
||||
|
||||
// 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band
|
||||
|
||||
Reference in new issue
Block a user