game-maker / suite (push) Successful in 9m49s
Behavior and Container draw an icon because isSymbolType() is true for them, but that predicate does not mean "draws an icon" — it means "is editor decoration". Four places read it that way: collectWorldItems, GameViewport. collectFrame and syncDrawOrder all drop symbols out of the running world and the game picture, and placementOf() gives them an upright unscaled box. Listing 'particles' there would have handed the source an icon and taken away its particles, in the scene view as well as in the game. So the two questions are two functions now. hasSceneIcon() says a type draws an icon; isSymbolType() keeps meaning decoration. The icon itself is a SceneViewport decoration, which is editor-only for free because GameViewport never syncs decorations. A ring (r 10, #c26a1f) with four sparks, upright and unscaled like the other symbols, its middle left open — unlike Behavior and Container a source draws something of its own, and the field is densest exactly there. The name sits beside it and a dashed line runs to the parent, same colour. Found while writing the tests: the source's grab box scaled with its transform while the icon did not, so the two came apart. placementOf() returns only the translation for 'particles', making the box the upright 24 x 24 that covers its icon. The source's own transform still feeds every particle untouched. 824 checks (821 + 3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
9.7 KiB
TypeScript
204 lines
9.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
|
import {
|
|
MoveSceneObjectCommand,
|
|
SetOverrideCommand,
|
|
SetParentCommand,
|
|
} from '../../src/core/commands/sceneObjectCommands';
|
|
import { worldPositionOf, worldTransformOf } from '../../src/core/graph';
|
|
import type { ContainerAsset } from '../../src/core/model/Asset';
|
|
import { hasSceneIcon, isSymbolType } from '../../src/core/model/AssetTypes';
|
|
import {
|
|
apply,
|
|
decompose,
|
|
invert,
|
|
multiply,
|
|
scaleOf,
|
|
transformMatrix,
|
|
translation,
|
|
} from '../../src/core/model/matrix';
|
|
import { findAsset } from '../../src/core/model/Project';
|
|
import { footprintContains, footprintCorners, footprintExtent } from '../../src/core/properties/bounds';
|
|
import {
|
|
hasTransformProps,
|
|
isCommonProp,
|
|
isValidVariableName,
|
|
schemaFor,
|
|
} from '../../src/core/properties/PropertySchema';
|
|
import { carriedTransform, localPositionFor, worldTransform } from '../../src/core/properties/transform';
|
|
import { fakeHandle, makeSpriteScene, place, startWorld } from '../helpers';
|
|
|
|
const close = (p: { x: number; y: number }, x: number, y: number) => {
|
|
expect(p.x).toBeCloseTo(x, 9);
|
|
expect(p.y).toBeCloseTo(y, 9);
|
|
};
|
|
|
|
describe('matrix', () => {
|
|
it('turns clockwise on screen, scales about the origin, mirrors before turning', () => {
|
|
close(apply(transformMatrix(0, 0, 90, 1, false, false), { x: 1, y: 0 }), 0, 1);
|
|
close(apply(transformMatrix(10, 20, 0, 2, false, false), { x: 1, y: 1 }), 12, 22);
|
|
close(apply(transformMatrix(0, 0, 0, 1, true, false), { x: 1, y: 2 }), -1, 2);
|
|
close(apply(transformMatrix(0, 0, 90, 1, true, false), { x: 1, y: 0 }), 0, -1);
|
|
});
|
|
|
|
it('a product undoes through its inverse, and a similarity decomposes back into its rows', () => {
|
|
const m = multiply(
|
|
transformMatrix(5, 6, 30, 2, false, false),
|
|
transformMatrix(1, 2, 15, 0.5, true, false),
|
|
);
|
|
close(apply(invert(m), apply(m, { x: 3, y: 4 })), 3, 4);
|
|
expect(scaleOf(m)).toBeCloseTo(1);
|
|
expect(decompose(m)).toMatchObject({ mirrorX: true });
|
|
expect(decompose(m).rotation).toBeCloseTo(45);
|
|
const plain = decompose(transformMatrix(0, 0, 30, 2, false, false));
|
|
expect(plain.rotation).toBeCloseTo(30, 9);
|
|
expect(plain.scale).toBeCloseTo(2, 9);
|
|
expect(plain.mirrorX).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('transform chain', () => {
|
|
it('a child sits where its turned, sized parent puts it, in the model and in the world', () => {
|
|
const f = makeSpriteScene();
|
|
const parent = place(f, f.spriteId, { x: 100, y: 100 });
|
|
// `place` takes a world position; the local one is set outright.
|
|
const child = place(f, f.spriteId, { x: 0, y: 0 }, parent);
|
|
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, child, { x: 50, y: 0, z: 0 }));
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'scale', 2));
|
|
// Local (50, 0) → scaled (100, 0) → turned a quarter clockwise (0, 100) → at (100, 200).
|
|
close(f.world(child), 100, 200);
|
|
const chain = worldTransform(f.project, f.sceneOf(), f.object(child));
|
|
expect(scaleOf(chain.m)).toBeCloseTo(2);
|
|
expect(decompose(chain.m).rotation).toBeCloseTo(90);
|
|
|
|
const { world } = startWorld(f);
|
|
close(worldPositionOf(world.findByObjectId(child)!), 100, 200);
|
|
// Behaviors write the rows like any property: the next read of the chain sees it.
|
|
world.findByObjectId(parent)!.props.rotation = 0;
|
|
close(worldPositionOf(world.findByObjectId(child)!), 200, 100);
|
|
});
|
|
|
|
it('the rows are reserved names shared by drawn types, absent from cameras and behaviors', () => {
|
|
expect(schemaFor('sprite').map((d) => d.key)).toEqual(
|
|
expect.arrayContaining(['scale', 'rotation', 'mirrorX', 'mirrorY']),
|
|
);
|
|
expect(hasTransformProps('camera')).toBe(false);
|
|
expect(schemaFor('camera').some((d) => d.key === 'rotation')).toBe(false);
|
|
expect(isCommonProp('rotation')).toBe(true);
|
|
expect(isValidVariableName('scale')).toBe(false);
|
|
// A camera under a turned parent is still placed by the chain; its own props carry no rows.
|
|
const parent = fakeHandle({ rotation: 90 });
|
|
const camera = { ...fakeHandle({}, parent), x: 10, y: 0 };
|
|
close(worldPositionOf(camera), 0, 10);
|
|
expect(scaleOf(worldTransformOf(camera).m)).toBeCloseTo(1);
|
|
});
|
|
|
|
it('the local position for a world point goes back through the parent transform', () => {
|
|
const f = makeSpriteScene();
|
|
const parent = place(f, f.spriteId, { x: 100, y: 100 });
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
|
const local = localPositionFor(f.project, f.sceneOf(), parent, { x: 100, y: 150, z: 0 });
|
|
close(local, 50, 0);
|
|
const child = place(f, f.spriteId, { x: 100, y: 150 }, parent);
|
|
close(f.world(child), 100, 150);
|
|
});
|
|
});
|
|
|
|
describe('carried transform', () => {
|
|
it('re-parenting keeps the look: rows absorb the parent chain, nothing is written between upright parents', () => {
|
|
const f = makeSpriteScene();
|
|
const turned = place(f, f.spriteId, { x: 0, y: 0 });
|
|
const upright = place(f, f.spriteId, { x: 300, y: 0 });
|
|
const box = place(f, f.spriteId, { x: 100, y: 0 });
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'rotation', 90));
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'scale', 2));
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'mirrorY', true));
|
|
|
|
f.stack.execute(new SetParentCommand(f.sceneId, box, upright));
|
|
expect(f.object(box).overrides).toEqual({ mirrorY: true });
|
|
close(f.world(box), 100, 0);
|
|
|
|
f.stack.execute(new SetParentCommand(f.sceneId, box, turned));
|
|
close(f.world(box), 100, 0);
|
|
const world = worldTransform(f.project, f.sceneOf(), f.object(box)).m;
|
|
expect(decompose(world)).toMatchObject({ mirrorX: true });
|
|
expect(scaleOf(world)).toBeCloseTo(1);
|
|
expect(f.object(box).overrides.rotation).toBeCloseTo(-90);
|
|
expect(f.object(box).overrides.scale).toBeCloseTo(0.5);
|
|
expect(f.object(box).overrides.mirrorY).toBe(true);
|
|
|
|
f.stack.undo();
|
|
expect(f.object(box).overrides).toEqual({ mirrorY: true });
|
|
expect(f.object(box).parentId).toBe(upright);
|
|
});
|
|
|
|
it('a mirror in the chain is absorbed as a mirror, whichever way it was written', () => {
|
|
const own = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
|
|
const before = transformMatrix(0, 0, 0, 1, true, false);
|
|
const carried = carriedTransform(own, before, translation(0, 0));
|
|
expect(carried).toMatchObject({ mirrorX: true, mirrorY: false });
|
|
expect(carried.scale).toBeCloseTo(1);
|
|
expect(carried.rotation).toBeCloseTo(0);
|
|
});
|
|
|
|
it('grouping under a turned parent keeps every root where it was, rows included', () => {
|
|
const f = makeSpriteScene();
|
|
const parent = place(f, f.spriteId, { x: 0, y: 0 });
|
|
const a = place(f, f.spriteId, { x: 100, y: 0 }, parent);
|
|
const b = place(f, f.spriteId, { x: 100, y: 50 }, parent);
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
|
|
const worldsBefore = [f.world(a), f.world(b)];
|
|
const create = new CreateContainerCommand(f.sceneId, [a, b]);
|
|
f.stack.execute(create);
|
|
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
|
|
// Same parent for both: positions relative to the container in the parent's frame, no rows.
|
|
expect(asset.objects.map((o) => o.overrides)).toEqual([{}, {}]);
|
|
const contents = f.viewOf().objects.filter((o) => o.parentId === create.objectId);
|
|
expect(contents).toHaveLength(2);
|
|
const worldsAfter = contents.map((o) => f.world(o.id));
|
|
for (const before of worldsBefore)
|
|
expect(
|
|
worldsAfter.some((w) => Math.abs(w.x - before.x) < 1e-9 && Math.abs(w.y - before.y) < 1e-9),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('footprint under a transform', () => {
|
|
it('corners, extent and hit test follow the turn, symbols stay upright', () => {
|
|
const m = transformMatrix(100, 100, 90, 2, false, false);
|
|
const props = { width: 50, height: 10 };
|
|
const extent = footprintExtent('sprite', props, m);
|
|
expect(extent.left).toBeCloseTo(90);
|
|
expect(extent.right).toBeCloseTo(110);
|
|
expect(extent.top).toBeCloseTo(50);
|
|
expect(extent.bottom).toBeCloseTo(150);
|
|
expect(footprintContains('sprite', props, m, { x: 100, y: 145 })).toBe(true);
|
|
expect(footprintContains('sprite', props, m, { x: 145, y: 100 })).toBe(false);
|
|
// Two world units of padding around a box drawn at scale 2 is one local unit on every side.
|
|
const padded = footprintCorners('sprite', props, m, 2);
|
|
expect(padded[0]!.y).toBeCloseTo(48);
|
|
// A behavior's diamond keeps its size and stays upright: the frame does not scale by 2.
|
|
const symbol = footprintExtent('behavior', {}, m);
|
|
expect(symbol.right - symbol.left).toBe(28);
|
|
// Since a source wears an icon of its own (#89), the square that can be grabbed covers it
|
|
// instead of growing with the source's scale.
|
|
const source = footprintExtent('particles', {}, m);
|
|
expect(source.right - source.left).toBe(24);
|
|
expect(footprintContains('particles', {}, m, { x: 111, y: 100 })).toBe(true);
|
|
expect(footprintContains('particles', {}, m, { x: 121, y: 100 })).toBe(false);
|
|
});
|
|
|
|
it('a source wears an icon without becoming a symbol', () => {
|
|
expect(hasSceneIcon('particles')).toBe(true);
|
|
// The decisive one: `isSymbolType` does not mean "has an icon", it means "editor decoration".
|
|
// Were a source one, `collectWorldItems` and the Game tab would both drop it and the effect
|
|
// would never be seen outside the Scene tab.
|
|
expect(isSymbolType('particles')).toBe(false);
|
|
for (const type of ['camera', 'behavior', 'collider', 'container'] as const)
|
|
expect(hasSceneIcon(type)).toBe(true);
|
|
expect(hasSceneIcon('sprite')).toBe(false);
|
|
expect(hasSceneIcon('text')).toBe(false);
|
|
});
|
|
});
|