Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
14 KiB
TypeScript
290 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import {
|
||
axisCorrection,
|
||
axisSeparation,
|
||
colliderShape,
|
||
shapesOverlap,
|
||
} from '../../src/core/properties/collider';
|
||
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||
|
||
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
|
||
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
|
||
|
||
describe('collider geometry', () => {
|
||
it('box vs box: overlapping yes, touching edges no', () => {
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(90, 0, 100, 100))).toBe(true);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(100, 0, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
|
||
});
|
||
|
||
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
|
||
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
|
||
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), circle(60, 0, 20))).toBe(true);
|
||
expect(shapesOverlap(box(0, 0, 100, 100), circle(80, 0, 20))).toBe(false);
|
||
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
|
||
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
|
||
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
|
||
});
|
||
|
||
it('builds shapes from properties with defaults', () => {
|
||
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
|
||
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
|
||
});
|
||
|
||
it('axis separation: the centre distance at which shapes touch along one axis', () => {
|
||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
|
||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
|
||
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
|
||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
|
||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
|
||
});
|
||
|
||
it('axis correction: how far a mover must back off to just touch', () => {
|
||
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
|
||
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
|
||
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
|
||
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
|
||
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
|
||
});
|
||
});
|
||
|
||
/** Two crates (100×60) 300 apart, each with a collider child; a behavior "Hit" placed below crate a. */
|
||
function setup() {
|
||
const f = makeSpriteScene('Crate');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const behavior = makeBehavior(f, 'Hit');
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
const colA = new AddColliderCommand(f.sceneId, a);
|
||
const colB = new AddColliderCommand(f.sceneId, b);
|
||
f.stack.execute(colA);
|
||
f.stack.execute(colB);
|
||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||
return { ...f, behavior, a, b, colA, colB };
|
||
}
|
||
|
||
/** The crates in a running world with collision detection. */
|
||
function runtime() {
|
||
const s = setup();
|
||
const { world, collisions } = startWorld(s, { collisions: true });
|
||
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
|
||
}
|
||
|
||
describe('AddColliderCommand', () => {
|
||
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
|
||
const { project, stack, sceneOf, object, a, colA } = setup();
|
||
const asset = findAsset(project, colA.assetId)!;
|
||
expect(asset.type).toBe('collider');
|
||
expect(asset.name).toBe('Crate collider');
|
||
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
|
||
expect(object(colA.objectId).parentId).toBe(a);
|
||
expect(object(colA.objectId).position).toEqual({ x: 0, y: 0, z: 0 });
|
||
// Undo of the last three commands: behavior placement, collider B, collider A.
|
||
stack.undo();
|
||
stack.undo();
|
||
stack.undo();
|
||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false);
|
||
expect(sceneOf().objects.some((o) => o.id === colA.objectId)).toBe(false);
|
||
stack.redo();
|
||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true);
|
||
});
|
||
|
||
it('refuses a collider on a collider and survives a round trip', () => {
|
||
const { project, stack, sceneId, colA } = setup();
|
||
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(
|
||
/cannot have a collider/,
|
||
);
|
||
expect(findAsset(roundTrip(project), colA.assetId)?.props).toMatchObject({
|
||
shape: 'box',
|
||
width: 100,
|
||
height: 60,
|
||
radius: 50,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('CollisionSystem', () => {
|
||
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
|
||
const { world, collisions, crateA, crateB } = runtime();
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0);
|
||
crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(1);
|
||
expect(collisions.stays).toHaveLength(0);
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0);
|
||
expect(collisions.stays).toHaveLength(1);
|
||
expect(collisions.touching(world, crateA, crateB)).toBe(true);
|
||
crateB.x = 300;
|
||
world.tick(0.1);
|
||
expect(collisions.exits).toHaveLength(1);
|
||
expect(collisions.stays).toHaveLength(0);
|
||
expect(collisions.touching(world, crateA, crateB)).toBe(false);
|
||
});
|
||
|
||
it('respects overrides and circles; two colliders of the same body never collide', () => {
|
||
const s = setup();
|
||
const { stack, sceneId, a, b, colA, colB } = s;
|
||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle'));
|
||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30));
|
||
const second = new AddColliderCommand(sceneId, a);
|
||
stack.execute(second); // a second collider on crate A, overlapping colA completely
|
||
const { world, collisions } = startWorld(s, { collisions: true });
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body
|
||
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
|
||
expect(
|
||
collisions.enters.every(
|
||
(p) =>
|
||
p.a.objectId === colA.objectId ||
|
||
p.b.objectId === colA.objectId ||
|
||
p.a.objectId === second.objectId ||
|
||
p.b.objectId === second.objectId,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('move and slide', () => {
|
||
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
|
||
|
||
it('stops flush at a solid collider and reports the blocked axis', () => {
|
||
const { world, collisions, crateA } = runtime();
|
||
// Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart).
|
||
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
|
||
expect(crateA.x).toBe(200);
|
||
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
|
||
expect(crateA.x).toBe(200); // touching edges do not overlap, so the wall keeps holding
|
||
expect(collisions.moveAndSlide(world, crateA, -30, 0)).toEqual({ blockedX: false, blockedY: false });
|
||
expect(crateA.x).toBe(170);
|
||
});
|
||
|
||
it('slides along the wall: a diagonal move keeps its y component', () => {
|
||
const { world, collisions, crateA } = runtime();
|
||
expect(collisions.moveAndSlide(world, crateA, 250, 40)).toEqual({ blockedX: true, blockedY: false });
|
||
expect(crateA.x).toBe(200);
|
||
expect(crateA.y).toBe(40);
|
||
});
|
||
|
||
it('triggers report but never block; own and descendant colliders are ignored', () => {
|
||
const s = setup();
|
||
const { stack, sceneId, a, b, colB } = s;
|
||
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
|
||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true));
|
||
const { world, collisions } = startWorld(s, { collisions: true });
|
||
const crateA = world.findByObjectId(a)!;
|
||
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false });
|
||
expect(crateA.x).toBe(250);
|
||
world.tick(0.1);
|
||
expect(collisions.enters).toHaveLength(1);
|
||
// A collider parented under the mover travels with it and cannot block it.
|
||
const rider = world.entities.find((e) => e.type === 'collider' && e.parent === world.findByObjectId(b))!;
|
||
rider.props.isTrigger = false;
|
||
rider.parent = crateA;
|
||
rider.x = 150;
|
||
expect(collisions.moveAndSlide(world, crateA, 10, 0)).toEqual({ blockedX: false, blockedY: false });
|
||
expect(crateA.x).toBe(260);
|
||
});
|
||
|
||
it('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => {
|
||
const { world, collisions, crateA, crateB } = runtime();
|
||
crateB.x = 60; // start overlapped by 40
|
||
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false });
|
||
expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either
|
||
expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false });
|
||
expect(crateA.x).toBe(-20); // moving away is always free
|
||
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
|
||
expect(crateA.x).toBe(-120);
|
||
});
|
||
|
||
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
|
||
const { world, collisions, crateA, crateB } = runtime();
|
||
crateB.x = 0;
|
||
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
|
||
for (let i = 0; i < 500; i++) {
|
||
// Uneven steps, so contact is never hit at a round number.
|
||
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
|
||
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
|
||
if (i > 60) expect(blockedY).toBe(true);
|
||
}
|
||
expect(crateA.y).toBeGreaterThan(240 - SKIN);
|
||
// A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
|
||
crateA.y = 0;
|
||
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
|
||
expect(crateA.y).toBeCloseTo(240, 6);
|
||
});
|
||
|
||
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
|
||
const s = setup();
|
||
const { behavior, a } = s;
|
||
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
|
||
const edit = graphEditor(s, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const move = edit.addNode('Move', { dx: 1000, collide: true });
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[update, 'exec', move, 'exec'],
|
||
[update, 'dt', move, 'dt'],
|
||
[owner, 'object', move, 'object'],
|
||
[move, 'exec', log, 'exec'],
|
||
[move, 'blockedX', log, 'message'],
|
||
]);
|
||
const { world } = startWorld(s, { collisions: true, behaviors: true });
|
||
const crateA = world.findByObjectId(a)!;
|
||
world.tick(0.1); // 100 units: free
|
||
expect(crateA.x).toBe(100);
|
||
world.tick(0.2); // 200 more would reach 300; the wall stops it at 200
|
||
expect(crateA.x).toBe(200);
|
||
expect(world.log).toEqual(['false', 'true']);
|
||
});
|
||
});
|
||
|
||
describe('collision events in behaviors', () => {
|
||
it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => {
|
||
const s = setup();
|
||
const { behavior, b, object } = s;
|
||
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
|
||
const edit = graphEditor(s, behavior);
|
||
const enter = edit.addNode('OnCollisionEnter');
|
||
const logEnter = edit.addNode('Log');
|
||
const exit = edit.addNode('OnCollisionExit');
|
||
const logExit = edit.addNode('Log', { message: 'bye' });
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const byName = edit.addNode('ObjectByName', { name: 'B' });
|
||
const touching = edit.addNode('IsTouching');
|
||
const logTouch = edit.addNode('Log');
|
||
edit.wire([
|
||
[enter, 'exec', logEnter, 'exec'],
|
||
[enter, 'other', logEnter, 'message'],
|
||
[exit, 'exec', logExit, 'exec'],
|
||
[update, 'exec', logTouch, 'exec'],
|
||
[owner, 'object', touching, 'a'],
|
||
[byName, 'object', touching, 'b'],
|
||
[touching, 'result', logTouch, 'message'],
|
||
]);
|
||
object(b).name = 'B';
|
||
|
||
const { world } = startWorld(s, { collisions: true, behaviors: true });
|
||
const crateB = world.findByObjectId(b)!;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false']);
|
||
crateB.x = 50;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false', 'B', 'true']);
|
||
crateB.x = 500;
|
||
world.tick(0.1);
|
||
expect(world.log).toEqual(['false', 'B', 'true', 'bye', 'false']);
|
||
});
|
||
});
|