"Add collider" and "Add rigidbody" could only create a fresh asset.
Reusing one meant creating a copy by hand, and a new box collider
never matched the sprite it was added to.
- "Add collider…" and "Add rigidbody…" open a second menu at the same
spot: a search field, "Create new …", a separator, then every asset
of that kind sorted by name. Enter takes the first match.
- An existing asset is placed as a shared child, not copied. An asset
the object already carries is greyed out.
- The rule for when the entries are available lives in one place in
the collider commands. An object without a resolvable asset is
greyed out again, as with the old canAddCollider.
- "Fit to sprite" in the menu of a placed collider sizes it to its
sprite and centres it (position 0,0, z kept) as one undo step.
- Fit writes overrides of that placement, not the asset, because a
collider can now sit on several sprites. A value equal to the asset
value clears the override instead.
- Fit removes the collider's own scale, swaps width and height near
90 degrees and gives a circle the radius that fits (min/2). It is
greyed out for polygon and frame outlines and off a sprite.
- Menu has no submenus, so a second menu opens instead, similar to
"Add Component" in Unity. That costs one click more than before.
Tests:
- ./check.sh: 152 files, 2234 tests, CHECK EXIT 0 (before 151/2228).
- 6 new unit tests in tests/core/collider-parts.test.ts.
- 3 new e2e checks in 08a: submenu with search and greying, an
existing collider via search and Enter, Fit on oc2.
- 09, 40 and 41 follow the new labels.
- e2e 1-16,39-41 on the final build: ALL PASSED, 596 checks.
- Counter-checks: no greying, existing asset ignored and fit without
centring each fail at their own check. Breaking the override rule
fails 2 unit tests.
- CI confirmed 1345 checks at 2cf7202 (task 265). New total 1348,
calculated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
5.2 KiB
TypeScript
111 lines
5.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddColliderCommand,
|
||
AddRigidbodyCommand,
|
||
colliderFit,
|
||
FitColliderCommand,
|
||
} from '../../src/core/commands/colliderCommands';
|
||
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||
|
||
/** A 100×60 crate placed twice, the first with a collider of its own. */
|
||
function setup() {
|
||
const f = makeSpriteScene('Crate');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
const collider = new AddColliderCommand(f.sceneId, a);
|
||
f.stack.execute(collider);
|
||
return { ...f, a, b, collider };
|
||
}
|
||
|
||
describe('adding an existing collider or rigidbody (#222)', () => {
|
||
it('places the existing collider below the object without a new asset; undo keeps the asset', () => {
|
||
const f = setup();
|
||
const count = f.project.assets.length;
|
||
const reuse = new AddColliderCommand(f.sceneId, f.b, f.collider.assetId);
|
||
f.stack.execute(reuse);
|
||
expect(f.project.assets).toHaveLength(count);
|
||
expect(reuse.assetId).toBe(f.collider.assetId);
|
||
expect(f.object(reuse.objectId)).toMatchObject({ assetId: f.collider.assetId, parentId: f.b });
|
||
f.stack.undo();
|
||
expect(f.sceneOf().objects.some((o) => o.id === reuse.objectId)).toBe(false);
|
||
expect(f.project.assets.some((x) => x.id === f.collider.assetId)).toBe(true);
|
||
});
|
||
|
||
it('refuses an asset of the wrong kind and keeps the rules of a new part', () => {
|
||
const f = setup();
|
||
const behavior = makeBehavior(f, 'Hit');
|
||
expect(() => f.stack.execute(new AddColliderCommand(f.sceneId, f.b, behavior.id))).toThrow(
|
||
/not a collider/,
|
||
);
|
||
expect(() =>
|
||
f.stack.execute(new AddColliderCommand(f.sceneId, f.collider.objectId, f.collider.assetId)),
|
||
).toThrow(/cannot have a collider/);
|
||
|
||
const body = new AddRigidbodyCommand(f.sceneId, f.a);
|
||
f.stack.execute(body);
|
||
const shared = new AddRigidbodyCommand(f.sceneId, f.b, body.assetId);
|
||
f.stack.execute(shared);
|
||
expect(f.object(shared.objectId)).toMatchObject({ assetId: body.assetId, parentId: f.b });
|
||
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, f.b, body.assetId))).toThrow(
|
||
/has a rigidbody already/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('fitting a collider to its sprite (#222)', () => {
|
||
it('covers the sprite as it is now and centres the collider; undo puts everything back', () => {
|
||
const f = setup();
|
||
const { collider } = f;
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 240));
|
||
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, collider.objectId, { x: 30, y: -10, z: 2 }));
|
||
f.stack.execute(new FitColliderCommand(f.sceneId, collider.objectId));
|
||
expect(f.object(collider.objectId).overrides).toEqual({ width: 240 });
|
||
expect(f.object(collider.objectId).position).toEqual({ x: 0, y: 0, z: 2 });
|
||
f.stack.undo();
|
||
expect(f.object(collider.objectId).overrides).toEqual({});
|
||
expect(f.object(collider.objectId).position).toEqual({ x: 30, y: -10, z: 2 });
|
||
});
|
||
|
||
it('writes this placement only, so a collider shared by two sprites keeps its asset size', () => {
|
||
const f = setup();
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.b, 'width', 50));
|
||
const shared = new AddColliderCommand(f.sceneId, f.b, f.collider.assetId);
|
||
f.stack.execute(shared);
|
||
f.stack.execute(new FitColliderCommand(f.sceneId, shared.objectId));
|
||
expect(f.object(shared.objectId).overrides).toEqual({ width: 50 });
|
||
expect(f.object(f.collider.objectId).overrides).toEqual({});
|
||
});
|
||
|
||
it('divides out the collider’s scale, swaps for a quarter turn and fits a circle inside', () => {
|
||
const f = setup();
|
||
const id = f.collider.objectId;
|
||
const fit = () => colliderFit(f.project, f.sceneOf(), f.object(id));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'scale', 2));
|
||
expect(fit()).toEqual({ width: 50, height: 30 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'rotation', 90));
|
||
expect(fit()).toEqual({ width: 30, height: 50 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'shape', 'circle'));
|
||
expect(fit()).toEqual({ radius: 15 });
|
||
});
|
||
|
||
it('says why a collider cannot be fitted', () => {
|
||
const f = setup();
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.collider.objectId, 'shape', 'polygon'));
|
||
expect(colliderFit(f.project, f.sceneOf(), f.object(f.collider.objectId))).toMatch(/polygon/);
|
||
expect(colliderFit(f.project, f.sceneOf(), f.object(f.a))).toMatch(/Only a collider/);
|
||
|
||
const text = new CreateAssetCommand('text', 'Label');
|
||
f.stack.execute(text);
|
||
const label = place(f, text.asset.id, { x: 0, y: 200 });
|
||
const onText = new AddColliderCommand(f.sceneId, label);
|
||
f.stack.execute(onText);
|
||
expect(colliderFit(f.project, f.sceneOf(), f.object(onText.objectId))).toMatch(/not sit on a sprite/);
|
||
expect(() => f.stack.execute(new FitColliderCommand(f.sceneId, onText.objectId))).toThrow(
|
||
/not sit on a sprite/,
|
||
);
|
||
});
|
||
});
|