Files
feuerwerk/tests/core/hierarchy.test.ts
T
rootandClaude Fable 5.1 016e83c9b5
game-maker / suite (push) Successful in 9m5s
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
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>
2026-09-13 17:25:06 +02:00

269 lines
12 KiB
TypeScript
Raw 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 { 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']);
});
});