Collider asset type, CollisionSystem and collision nodes
- Asset type 'collider' (box/circle) placed as child of a body object - 'Add collider' shortcut (hierarchy context menu + inspector) - CollisionSystem with enter/stay/exit pairs, sibling colliders excluded - Nodes: On collision enter/stay/exit, Is touching - Green outline in edit mode, hidden in play Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
26d5edc496
commit
0a59513b74
21 files changed
+525
-16
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||||
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject, findScene } from '../../src/core/model/Project';
|
||||
import { colliderShape, shapesOverlap } from '../../src/core/properties/collider';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */
|
||||
function setup() {
|
||||
const project = createEmptyProject('p', 'Colliders');
|
||||
const stack = new CommandStack(project);
|
||||
const scene = new CreateAssetCommand('scene', 'Level');
|
||||
const sprite = new CreateAssetCommand('sprite', 'Crate');
|
||||
const behavior = new CreateAssetCommand('behavior', 'Hit');
|
||||
for (const c of [scene, sprite, behavior]) stack.execute(c);
|
||||
sprite.asset.props.height = 60;
|
||||
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
|
||||
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
|
||||
stack.execute(a);
|
||||
stack.execute(b);
|
||||
const colA = new AddColliderCommand(scene.asset.id, a.objectId);
|
||||
const colB = new AddColliderCommand(scene.asset.id, b.objectId);
|
||||
stack.execute(colA);
|
||||
stack.execute(colB);
|
||||
const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }, a.objectId);
|
||||
stack.execute(place);
|
||||
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, behavior: behavior.asset as BehaviorAsset, a: a.objectId, b: b.objectId, colA, colB };
|
||||
}
|
||||
|
||||
describe('AddColliderCommand', () => {
|
||||
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
|
||||
const { project, stack, sceneId, a, colA } = setup();
|
||||
const asset = project.assets.find((x) => x.id === colA.assetId)!;
|
||||
expect(asset.type).toBe('collider');
|
||||
expect(asset.name).toBe('Crate collider');
|
||||
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
|
||||
const object = findScene(project, sceneId)!.objects.find((o) => o.id === colA.objectId)!;
|
||||
expect(object.parentId).toBe(a);
|
||||
expect(object.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(findScene(project, sceneId)!.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/);
|
||||
const loaded = deserializeProject(serializeProject(project));
|
||||
expect(loaded.assets.find((x) => x.id === 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 { project, sceneId, a, b } = setup();
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
const crateA = world.findByObjectId(a)!;
|
||||
const crateB = world.findByObjectId(b)!;
|
||||
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 { project, stack, sceneId, a, b, colA, colB } = setup();
|
||||
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 = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
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('collision events in behaviors', () => {
|
||||
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
|
||||
const { project, stack, sceneId, behavior, b } = setup();
|
||||
// Graph: On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
|
||||
const enter = new AddNodeCommand(behavior.id, 'OnCollisionEnter', 0, 0);
|
||||
const logEnter = new AddNodeCommand(behavior.id, 'Log', 0, 0);
|
||||
const exit = new AddNodeCommand(behavior.id, 'OnCollisionExit', 0, 0);
|
||||
const logExit = new AddNodeCommand(behavior.id, 'Log', 0, 0, { message: 'bye' });
|
||||
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
|
||||
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
|
||||
const byName = new AddNodeCommand(behavior.id, 'ObjectByName', 0, 0, { name: 'B' });
|
||||
const touching = new AddNodeCommand(behavior.id, 'IsTouching', 0, 0);
|
||||
const logTouch = new AddNodeCommand(behavior.id, 'Log', 0, 0);
|
||||
for (const c of [enter, logEnter, exit, logExit, update, owner, byName, touching, logTouch]) stack.execute(c);
|
||||
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
|
||||
stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
|
||||
connect(enter, 'exec', logEnter, 'exec');
|
||||
connect(enter, 'other', logEnter, 'message');
|
||||
connect(exit, 'exec', logExit, 'exec');
|
||||
connect(update, 'exec', logTouch, 'exec');
|
||||
connect(owner, 'object', touching, 'a');
|
||||
connect(byName, 'object', touching, 'b');
|
||||
connect(touching, 'result', logTouch, 'message');
|
||||
findScene(project, sceneId)!.objects.find((o) => o.id === b)!.name = 'B';
|
||||
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
|
||||
stopped: false,
|
||||
findByName: () => null,
|
||||
isKeyDown: () => false,
|
||||
isTouching: () => false,
|
||||
log: (m: string) => logs.push(m),
|
||||
destroy: () => {},
|
||||
stop: () => { ctx.stopped = true; },
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('runtime hierarchy', () => {
|
||||
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
|
||||
] };
|
||||
const logs: string[] = [];
|
||||
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
|
||||
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
|
||||
new GraphInterpreter(graph).fire('OnStart', ctx);
|
||||
expect(logs).toEqual(['105', '5', '100']);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user