Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
14 KiB
TypeScript
291 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 used to jump clean over it: after the move nothing
|
||
// overlapped any more, so nothing pushed back and the mover was through the wall.
|
||
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']);
|
||
});
|
||
});
|