Files
feuerwerk/tests/core/collider-parts.test.ts
adminandClaude Opus 5.5 d39bf83ea9
game-maker / gate (push) Successful in 2m49s
game-maker / suite (push) Successful in 13m30s
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>
2026-10-01 15:03:05 +02:00

184 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
colliderFit,
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. */
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/,
);
});
});
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 });
});
});