game-maker / suite (push) Successful in 9m5s
Four new rows on sprite, text, collider and container (block TRANSFORM): scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they get override/reset/apply, loading of old projects, write-back after play and the Properties node for free; camera and behavior have none. The hierarchy composes T*R*S per level (composeAlongChain replaces sumAlongChain), so children rotate and scale with their parent. Reparenting and grouping keep the look (carriedTransform). Drawing goes through placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags are computed in the parent's system. Colliders are oriented boxes: SAT for box/box, box-circle in the box frame; exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along the object's own axes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
269 lines
12 KiB
TypeScript
269 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
MoveSceneObjectCommand,
|
||
RemoveSceneObjectsCommand,
|
||
ReorderObjectCommand,
|
||
SetParentCommand,
|
||
} from '../../src/core/commands/sceneObjectCommands';
|
||
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
|
||
import {
|
||
ancestorIdsOf,
|
||
descendantsOf,
|
||
findObject,
|
||
type ObjectTree,
|
||
treeOrder,
|
||
visibleTreeOrder,
|
||
} from '../../src/core/model/hierarchy';
|
||
import { findScene } from '../../src/core/model/Project';
|
||
import { localPositionFor } from '../../src/core/properties/transform';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
fakeContext,
|
||
fakeEntity,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */
|
||
function threeBoxes() {
|
||
const f = makeSpriteScene();
|
||
const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 });
|
||
const b = place(f, f.spriteId, { x: 10, y: 0 });
|
||
const c = place(f, f.spriteId, { x: 0, y: 5 });
|
||
return { ...f, a, b, c };
|
||
}
|
||
|
||
describe('scene hierarchy', () => {
|
||
it('re-parenting keeps the world position and moves children along with the parent', () => {
|
||
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
// b stays at world (10,0): local becomes (-90,-100,-1).
|
||
expect(object(b).parentId).toBe(a);
|
||
expect(object(b).position).toEqual({ x: -90, y: -100, z: -1 });
|
||
expect(world(b)).toEqual({ x: 10, y: 0, z: 0 });
|
||
|
||
stack.execute(new SetParentCommand(sceneId, c, b));
|
||
expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
|
||
|
||
// Moving the root drags the whole subtree.
|
||
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 200, y: 100, z: 1 }));
|
||
expect(world(b)).toEqual({ x: 110, y: 0, z: 0 });
|
||
expect(world(c)).toEqual({ x: 100, y: 5, z: 0 });
|
||
|
||
// Undo restores the previous parent and local position exactly.
|
||
stack.undo();
|
||
stack.undo();
|
||
expect(object(c).parentId).toBe('');
|
||
expect(object(c).position).toEqual({ x: 0, y: 5, z: 0 });
|
||
stack.undo();
|
||
expect(object(b).parentId).toBe('');
|
||
expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 });
|
||
|
||
expect(localPositionFor(project, sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
|
||
});
|
||
|
||
it('refuses cycles and self-parenting', () => {
|
||
const { stack, sceneId, a, b, c } = threeBoxes();
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
stack.execute(new SetParentCommand(sceneId, c, b));
|
||
expect(() => stack.execute(new SetParentCommand(sceneId, a, a))).toThrow(/own parent/);
|
||
expect(() => stack.execute(new SetParentCommand(sceneId, a, c))).toThrow(/own children/);
|
||
expect(() => stack.execute(new SetParentCommand(sceneId, a, b))).toThrow(/own children/);
|
||
});
|
||
|
||
it('lists objects in tree order and descendants depth-first', () => {
|
||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||
// Storage order is a, b, c; parent c under a → c is shown right after a, before b.
|
||
stack.execute(new SetParentCommand(sceneId, c, a));
|
||
expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([
|
||
[a, 0],
|
||
[c, 1],
|
||
[b, 0],
|
||
]);
|
||
stack.execute(new SetParentCommand(sceneId, b, c));
|
||
expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]);
|
||
});
|
||
|
||
it('hides the subtrees of collapsed objects and marks the rows that can fold', () => {
|
||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||
// a > c > b: two levels, so folding a hides both descendants.
|
||
stack.execute(new SetParentCommand(sceneId, c, a));
|
||
stack.execute(new SetParentCommand(sceneId, b, c));
|
||
const rows = (expanded: string[]) =>
|
||
visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
|
||
|
||
// Nothing expanded (the default when a scene is opened) – only the top level is listed.
|
||
expect(rows([])).toEqual([[a, 0, true]]);
|
||
expect(rows([a])).toEqual([
|
||
[a, 0, true],
|
||
[c, 1, true],
|
||
]);
|
||
expect(rows([a, c])).toEqual([
|
||
[a, 0, true],
|
||
[c, 1, true],
|
||
[b, 2, false],
|
||
]);
|
||
// Expanding a nested object without its parent shows nothing extra: every ancestor must be open.
|
||
expect(rows([c])).toEqual([[a, 0, true]]);
|
||
});
|
||
|
||
it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => {
|
||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||
stack.execute(new SetParentCommand(sceneId, c, a));
|
||
stack.execute(new SetParentCommand(sceneId, b, c));
|
||
expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]);
|
||
expect(ancestorIdsOf(sceneOf(), a)).toEqual([]);
|
||
});
|
||
|
||
it('removing an object or deleting an asset takes the subtree along, undo restores it', () => {
|
||
const f = threeBoxes();
|
||
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object } = f;
|
||
const logic = place(f, makeBehavior(f, 'Logic').id, { x: 0, y: 0 }, a);
|
||
expect(object(logic).position).toEqual({ x: -100, y: -100, z: -1 });
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
|
||
stack.execute(new RemoveSceneObjectsCommand(sceneId, [a]));
|
||
expect(sceneOf().objects.map((o) => o.id)).toEqual([c]);
|
||
stack.undo();
|
||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]);
|
||
expect(object(b).parentId).toBe(a);
|
||
|
||
// Deleting the sprite asset removes a, b, c – and the behavior parented below a.
|
||
stack.execute(new DeleteAssetCommand(spriteId));
|
||
expect(sceneOf().objects).toHaveLength(0);
|
||
stack.undo();
|
||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]);
|
||
expect(project.assets.map((x) => x.id)).toContain(spriteId);
|
||
});
|
||
|
||
it('a behavior is parented like any object: validated, kept across a round trip, dropped when dangling', () => {
|
||
const f = makeSpriteScene();
|
||
const { project, stack, sceneId, object } = f;
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const logic = place(f, makeBehavior(f).id, { x: 0, y: 0 });
|
||
expect(() => stack.execute(new SetParentCommand(sceneId, logic, logic))).toThrow(/own parent/);
|
||
stack.execute(new SetParentCommand(sceneId, logic, box));
|
||
expect(object(logic).parentId).toBe(box);
|
||
expect(findObject(findScene(roundTrip(project), sceneId)!, logic)!.parentId).toBe(box);
|
||
|
||
// A dangling parent is dropped by the loader.
|
||
const raw = serializeProject(project) as { assets: { objects?: { id: string; parentId: string }[] }[] };
|
||
raw.assets[0]!.objects!.find((o) => o.id === logic)!.parentId = 'ghost';
|
||
expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe('');
|
||
});
|
||
|
||
it('the loader drops dangling parents and breaks cycles', () => {
|
||
const { project, stack, sceneId, a, b, c } = threeBoxes();
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
const raw = serializeProject(project) as { assets: { objects?: Record<string, unknown>[] }[] };
|
||
const objects = raw.assets[0]!.objects!;
|
||
// A cycle a → c → a and a dangling reference are both cleared.
|
||
objects[0]!.parentId = c;
|
||
objects[2]!.parentId = a;
|
||
const loaded = findScene(deserializeProject(raw), sceneId)!;
|
||
const parentOf = (id: string) => findObject(loaded, id)!.parentId;
|
||
expect(parentOf(b)).toBe(a);
|
||
expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives
|
||
objects[1]!.parentId = 'ghost';
|
||
expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('ReorderObjectCommand', () => {
|
||
/** Ids in tree order – the order the panel lists and the tie-break for equal `z`. */
|
||
const order = (scene: ObjectTree) => treeOrder(scene).map((e) => e.object.id);
|
||
|
||
it('moves an object in front of a sibling and undo puts it back', () => {
|
||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||
expect(order(sceneOf())).toEqual([a, b, c]);
|
||
stack.execute(new ReorderObjectCommand(sceneId, c, a));
|
||
expect(order(sceneOf())).toEqual([c, a, b]);
|
||
stack.undo();
|
||
expect(order(sceneOf())).toEqual([a, b, c]);
|
||
});
|
||
|
||
it('moves it behind the last sibling when no successor is named', () => {
|
||
const { stack, sceneId, a, b, c, sceneOf } = threeBoxes();
|
||
stack.execute(new ReorderObjectCommand(sceneId, a, ''));
|
||
expect(order(sceneOf())).toEqual([b, c, a]);
|
||
});
|
||
|
||
it('orders children among themselves, leaving the subtree where it is', () => {
|
||
const f = threeBoxes();
|
||
const { stack, sceneId, a, b, c } = f;
|
||
const x = place(f, f.spriteId, { x: 0, y: 0 }, a);
|
||
const y = place(f, f.spriteId, { x: 0, y: 0 }, a);
|
||
expect(order(f.sceneOf())).toEqual([a, x, y, b, c]);
|
||
stack.execute(new ReorderObjectCommand(sceneId, y, x));
|
||
expect(order(f.sceneOf())).toEqual([a, y, x, b, c]);
|
||
// The move is among siblings only: the parent keeps its own place in the list.
|
||
expect(f.object(y).parentId).toBe(a);
|
||
});
|
||
|
||
it('refuses a target that is not a sibling, so a reorder never re-parents by accident', () => {
|
||
const f = threeBoxes();
|
||
const child = place(f, f.spriteId, { x: 0, y: 0 }, f.a);
|
||
expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, child))).toThrow(
|
||
/among its own siblings/,
|
||
);
|
||
expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, f.b))).toThrow(
|
||
/in front of itself/,
|
||
);
|
||
expect(order(f.sceneOf())).toEqual([f.a, child, f.b, f.c]);
|
||
});
|
||
});
|
||
|
||
describe('runtime hierarchy', () => {
|
||
it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => {
|
||
const f = threeBoxes();
|
||
const { stack, sceneId, a, b, c } = f;
|
||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||
stack.execute(new SetParentCommand(sceneId, c, b));
|
||
const { world } = startWorld(f);
|
||
const entity = (id: string) => world.findByObjectId(id)!;
|
||
expect(entity(b).parent).toBe(entity(a));
|
||
expect(worldPositionOf(entity(c))).toEqual({ x: 0, y: 5, z: 0 });
|
||
entity(a).x += 50;
|
||
expect(worldPositionOf(entity(c))).toEqual({ x: 50, y: 5, z: 0 });
|
||
expect(worldPositionOf(entity(b))).toEqual({ x: 60, y: 0, z: 0 });
|
||
world.remove(entity(a));
|
||
expect(world.entities).toHaveLength(0);
|
||
});
|
||
|
||
it('Get position exposes local and world coordinates, Parent walks up', () => {
|
||
const parent = { ...fakeEntity('p', 100), y: 20 };
|
||
const child = { ...fakeEntity('c', 5), y: 5, parent };
|
||
// On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent)
|
||
const graph: Graph = {
|
||
nodes: [
|
||
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
|
||
{ id: 'self', type: 'Self', x: 0, y: 0, params: {} },
|
||
{ id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||
{ id: 'par', type: 'Parent', x: 0, y: 0, params: {} },
|
||
{ id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} },
|
||
{ id: 'log1', type: 'Log', x: 0, y: 0, params: {} },
|
||
{ id: 'log2', type: 'Log', x: 0, y: 0, params: {} },
|
||
{ id: 'log3', type: 'Log', x: 0, y: 0, params: {} },
|
||
],
|
||
edges: [
|
||
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } },
|
||
{ id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } },
|
||
{ id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } },
|
||
{ id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } },
|
||
{ id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } },
|
||
{ id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } },
|
||
{ id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } },
|
||
{ id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } },
|
||
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
|
||
],
|
||
};
|
||
const ctx = fakeContext({ self: child, owner: parent, variables: {} });
|
||
new GraphInterpreter(graph).fire('OnStart', ctx);
|
||
expect(ctx.logs).toEqual(['105', '5', '100']);
|
||
});
|
||
});
|