Trigger colliders and Move and slide against solid colliders
- Collider property isTrigger (no blocking, still reports enter/stay/exit) - Node 'Move and slide' with blocked x/y outputs, axis-wise resolution - axisSeparation/axisCorrection geometry for box/box, circle/circle, box/circle - Amber outline for trigger colliders in the viewport - Demo mover uses Move and slide; E2E step 15b, 7 new unit tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a59513b74
commit
6ecb20cf02
14 files changed
+283
-12
No files matched your search
@@ -96,13 +96,23 @@ other). They are editor symbols: green outlines in edit mode, invisible in play
|
||||
|
||||
Play runs a `CollisionSystem` (`src/runtime/systems/CollisionSystem.ts`) before the behaviors:
|
||||
every frame it tests all collider pairs (world positions applied; touching edges do not count)
|
||||
and sorts them into *enter*, *stay* and *exit*. **Detection only** – nothing is pushed apart
|
||||
yet. A behavior receives `On collision enter/stay/exit` for the colliders of its **owner**
|
||||
(its parent; itself when top-level): the outputs are `other` (the other body), `other collider`
|
||||
and sorts them into *enter*, *stay* and *exit*. Detection never pushes anything apart. A
|
||||
behavior receives `On collision enter/stay/exit` for the colliders of its **owner** (its
|
||||
parent; itself when top-level): the outputs are `other` (the other body), `other collider`
|
||||
and `collider` (the own one). `Is touching(a, b)` answers the same question on demand – an
|
||||
object stands for its collider children, a collider for itself. The geometry lives in
|
||||
`src/core/properties/collider.ts` so editor and runtime agree on it.
|
||||
|
||||
**Blocking** is opt-in per movement: the `Move and slide` node (same inputs as `Move`) moves
|
||||
an object one axis at a time and, after each axis, pushes its *solid* colliders back to just
|
||||
touch any solid collider they ran into – so the other axis still moves and the object slides
|
||||
along walls. Its `blocked x` / `blocked y` outputs tell which axes hit. A collider with
|
||||
**Trigger (no blocking)** enabled (`isTrigger`, drawn amber instead of green) still produces
|
||||
collision events but never blocks. Pairs that already overlapped before the step are left
|
||||
alone, so an object that starts inside a wall can leave it and stacked objects never jolt apart.
|
||||
Colliders of the moving object and of anything parented under it travel with it and cannot
|
||||
block it. Plain `Move` / `Set position` ignore colliders entirely.
|
||||
|
||||
## Stack
|
||||
|
||||
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
|
||||
|
||||
@@ -56,6 +56,11 @@ export interface NodeContext {
|
||||
* stands for itself; any other entity stands for its direct collider children.
|
||||
*/
|
||||
isTouching(a: EntityHandle, b: EntityHandle): boolean;
|
||||
/**
|
||||
* Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid
|
||||
* colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit.
|
||||
*/
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
|
||||
log(message: string): void;
|
||||
destroy(target: EntityHandle): void;
|
||||
stop(): void;
|
||||
|
||||
@@ -307,6 +307,25 @@ registerNode({
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'MoveAndSlide',
|
||||
label: 'Move and slide',
|
||||
category: 'action',
|
||||
description: 'Like Move, but solid colliders of the object stop at other solid colliders and slide along them. Outputs which axes were blocked.',
|
||||
inputs: [exec(), obj('object'), num('dx'), num('dy'), { ...num('scale'), default: 1 }, { ...num('dt'), default: 1 }],
|
||||
outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')],
|
||||
params: [],
|
||||
run: (ctx, i) => {
|
||||
const target = asEntity(i.object);
|
||||
let blockedX = false;
|
||||
let blockedY = false;
|
||||
if (target) {
|
||||
const factor = asNumber(i.scale) * asNumber(i.dt);
|
||||
({ blockedX, blockedY } = ctx.moveAndSlide(target, asNumber(i.dx) * factor, asNumber(i.dy) * factor));
|
||||
}
|
||||
return { next: 'exec', outputs: { blockedX, blockedY } };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetPosition',
|
||||
label: 'Set position',
|
||||
|
||||
@@ -44,6 +44,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
|
||||
// Triggers report collisions but never block "Move and slide"; solid colliders do both.
|
||||
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
|
||||
return { kind: 'box', x: centre.x, y: centre.y, w: Math.max(0, Number(props.width ?? d.width)), h: Math.max(0, Number(props.height ?? d.height)) };
|
||||
}
|
||||
|
||||
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
|
||||
export function isTrigger(props: PropertyBag): boolean {
|
||||
return Boolean(props.isTrigger ?? defaultsFor('collider').isTrigger);
|
||||
}
|
||||
|
||||
/** Footprint of a collider shape (used for culling and the selection gizmo). */
|
||||
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
||||
const shape = colliderShape(props, { x: 0, y: 0 });
|
||||
@@ -38,3 +43,45 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
|
||||
const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2));
|
||||
return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r;
|
||||
}
|
||||
|
||||
export type Axis = 'x' | 'y';
|
||||
|
||||
/**
|
||||
* The centre distance along `axis` at which the two shapes exactly touch, given their
|
||||
* current offset on the other axis. Only meaningful when the shapes overlap on that other
|
||||
* axis (otherwise they cannot collide by moving along `axis`); returns 0 when they do not.
|
||||
*/
|
||||
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: Axis): number {
|
||||
const other: Axis = axis === 'x' ? 'y' : 'x';
|
||||
const offset = Math.abs(a[other] - b[other]);
|
||||
if (a.kind === 'box' && b.kind === 'box') {
|
||||
return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0;
|
||||
}
|
||||
if (a.kind === 'circle' && b.kind === 'circle') {
|
||||
const r = a.r + b.r;
|
||||
return offset < r ? Math.sqrt(r * r - offset * offset) : 0;
|
||||
}
|
||||
const box = a.kind === 'box' ? a : (b as Extract<ColliderShape, { kind: 'box' }>);
|
||||
const circle = a.kind === 'circle' ? a : (b as Extract<ColliderShape, { kind: 'circle' }>);
|
||||
const halfOther = extent(box, other) / 2;
|
||||
const halfAxis = extent(box, axis) / 2;
|
||||
// Circle centre within the box's span on the other axis: the flat side is hit.
|
||||
if (offset <= halfOther) return halfAxis + circle.r;
|
||||
// Otherwise the corner is the closest point: a circle-vs-point solution.
|
||||
const corner = offset - halfOther;
|
||||
return corner < circle.r ? halfAxis + Math.sqrt(circle.r * circle.r - corner * corner) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `mover` has to be pushed back against its travel direction `dir` (±1) along
|
||||
* `axis` so it just touches `obstacle` (0 when they do not overlap). Only valid when
|
||||
* `mover` approached from the far side, i.e. it did not overlap before its move.
|
||||
*/
|
||||
export function axisCorrection(mover: ColliderShape, obstacle: ColliderShape, axis: Axis, dir: 1 | -1): number {
|
||||
if (!shapesOverlap(mover, obstacle)) return 0;
|
||||
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
|
||||
}
|
||||
|
||||
function extent(box: Extract<ColliderShape, { kind: 'box' }>, axis: Axis): number {
|
||||
return axis === 'x' ? box.w : box.h;
|
||||
}
|
||||
@@ -138,6 +138,14 @@ export class BehaviorSystem implements System {
|
||||
const eb = world.entities.find((e) => e === b || e.id === b.id);
|
||||
return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb));
|
||||
},
|
||||
moveAndSlide: (target, dx, dy) => {
|
||||
const found = world.entities.find((e) => e === target || e.id === target.id);
|
||||
if (!found) return { blockedX: false, blockedY: false };
|
||||
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
|
||||
found.x += dx;
|
||||
found.y += dy;
|
||||
return { blockedX: false, blockedY: false };
|
||||
},
|
||||
log: (message) => {
|
||||
world.log.push(message);
|
||||
console.log(`[behavior ${entity.name}]`, message);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { colliderShape, shapesOverlap } from '../../core/properties/collider';
|
||||
import { type Axis, axisCorrection, colliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import type { System } from './System';
|
||||
@@ -61,6 +61,43 @@ export class CollisionSystem implements System {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves `entity` by (dx, dy) in its local units, one axis at a time (x, then y): after
|
||||
* each axis step its solid colliders are pushed back to just touch whatever solid collider
|
||||
* they ran into, so the remaining axis still moves – the object slides along walls.
|
||||
* Pairs that already overlapped before the step are ignored (an object that starts inside
|
||||
* a wall may leave it, and stacked objects never get jolted apart). Triggers, the
|
||||
* entity's own colliders and colliders that travel with it (descendants) never block.
|
||||
*/
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
|
||||
const obstacles = own.length === 0 ? [] : world.entities.filter((c) => c.type === 'collider' && !isTrigger(c.props) && !this.travelsWith(c, entity));
|
||||
return { blockedX: this.slideAxis(world, entity, own, obstacles, 'x', dx), blockedY: this.slideAxis(world, entity, own, obstacles, 'y', dy) };
|
||||
}
|
||||
|
||||
private slideAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
|
||||
if (delta === 0) return false;
|
||||
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
||||
// Which pairs are free to collide: only those that were apart before this step.
|
||||
const candidates: [Entity, Entity][] = [];
|
||||
for (const a of own) for (const b of obstacles) if (!this.overlapping(world, a, b)) candidates.push([a, b]);
|
||||
entity[axis] += delta;
|
||||
let correction = 0;
|
||||
for (const [a, b] of candidates) {
|
||||
const t = axisCorrection(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b)), axis, dir);
|
||||
if (t > correction) correction = t;
|
||||
}
|
||||
if (correction === 0) return false;
|
||||
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
|
||||
private travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
|
||||
|
||||
+2
-1
@@ -32,8 +32,9 @@ export const Theme = {
|
||||
gizmo: 0x4c8dff,
|
||||
/** Behavior symbols and owner links. */
|
||||
behavior: 0xb066ff,
|
||||
/** Collider outlines. */
|
||||
/** Collider outlines: solid (blocking) and trigger (events only). */
|
||||
collider: 0x4fd18b,
|
||||
trigger: 0xf2c84b,
|
||||
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
|
||||
graphBackground: 0x15171b,
|
||||
node: 0x262a32,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/mod
|
||||
import { treeOrder, worldPosition } from '../core/model/hierarchy';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||
import { colliderShape, colliderSize } from '../core/properties/collider';
|
||||
import { colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { World } from '../runtime/World';
|
||||
import { Theme, textStyle, Widget, type Size } from '../ui';
|
||||
@@ -305,7 +305,8 @@ export class Viewport extends Widget {
|
||||
const shape = colliderShape(item.props, { x: 0, y: 0 });
|
||||
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
|
||||
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
|
||||
node.fill({ color: Theme.colors.collider, alpha: 0.12 }).stroke({ width: 2 / view.scale, color: Theme.colors.collider });
|
||||
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
|
||||
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color });
|
||||
}
|
||||
}
|
||||
this.drawParentLinks(items, editing, view.scale);
|
||||
|
||||
+103
-1
@@ -6,7 +6,7 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
|
||||
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 { axisCorrection, axisSeparation, 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';
|
||||
@@ -37,6 +37,23 @@ describe('collider geometry', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */
|
||||
@@ -132,6 +149,91 @@ describe('CollisionSystem', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('move and slide', () => {
|
||||
function runtime() {
|
||||
const s = setup();
|
||||
const world = World.instantiate(s.project, s.sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
|
||||
}
|
||||
|
||||
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 { project, stack, sceneId, a, b, colB } = setup();
|
||||
// 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 = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
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('pairs that already overlap are not resolved (no jolt), leaving is always possible', () => {
|
||||
const { world, collisions, crateA, crateB } = runtime();
|
||||
crateB.x = 60; // start overlapped
|
||||
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: false, blockedY: false });
|
||||
expect(crateA.x).toBe(5);
|
||||
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
|
||||
expect(crateA.x).toBe(-95);
|
||||
});
|
||||
|
||||
it('the Move and slide node moves the owner and outputs the blocked axes', () => {
|
||||
const { project, stack, sceneId, behavior, a } = setup();
|
||||
// On update → Move and slide(Owner, dx = 1000, dt) → Log(blocked x)
|
||||
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
|
||||
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
|
||||
const move = new AddNodeCommand(behavior.id, 'MoveAndSlide', 0, 0, { dx: 1000 });
|
||||
const log = new AddNodeCommand(behavior.id, 'Log', 0, 0);
|
||||
for (const c of [update, owner, move, log]) 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(update, 'exec', move, 'exec');
|
||||
connect(update, 'dt', move, 'dt');
|
||||
connect(owner, 'object', move, 'object');
|
||||
connect(move, 'exec', log, 'exec');
|
||||
connect(move, 'blockedX', log, 'message');
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
|
||||
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 { project, stack, sceneId, behavior, b } = setup();
|
||||
|
||||
@@ -26,6 +26,11 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
|
||||
findByName: () => null,
|
||||
isKeyDown: () => false,
|
||||
isTouching: () => false,
|
||||
moveAndSlide: (target: EntityHandle, dx: number, dy: number) => {
|
||||
target.x += dx;
|
||||
target.y += dy;
|
||||
return { blockedX: false, blockedY: 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, isTouching: () => 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, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
|
||||
new GraphInterpreter(graph).fire('OnStart', ctx);
|
||||
expect(logs).toEqual(['105', '5', '100']);
|
||||
});
|
||||
|
||||
@@ -581,6 +581,39 @@ try {
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(200);
|
||||
|
||||
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (both 100 wide →
|
||||
// 50 of free travel) and hold ArrowRight: the Mover's "Move and slide" parks the red
|
||||
// crate flush against the blue one at world x = −100. Making the blue collider a
|
||||
// trigger lets it pass through instead (the collider is then drawn amber).
|
||||
const holdRight = async (ms) => {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await sleep(ms);
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
};
|
||||
const playAgainstBlue = async (trigger) => {
|
||||
await evaluate(`(() => {
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
|
||||
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ isTrigger: true }' : '{}'};
|
||||
__gm.state.select(null);
|
||||
__gm.state.preview();
|
||||
})()`);
|
||||
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
|
||||
await sleep(300);
|
||||
await holdRight(700);
|
||||
const w = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
|
||||
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
|
||||
await sleep(200);
|
||||
return w.entities.find((e) => e.objectId === 'o1');
|
||||
};
|
||||
const blocked = await playAgainstBlue(false);
|
||||
check('Move and slide stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -100) < 0.01, `world x=${blocked?.x}`);
|
||||
const passed = await playAgainstBlue(true);
|
||||
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -100 + 20, `world x=${passed?.x}`);
|
||||
await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`);
|
||||
await sleep(200);
|
||||
await shot('22-trigger-collider');
|
||||
|
||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
|
||||
+4
-3
@@ -12,8 +12,9 @@ if (!projectId) {
|
||||
|
||||
/**
|
||||
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
|
||||
* axis, scaled by the `speed` variable per second (On update's dt feeds Move's dt input).
|
||||
* Diagonals are not faster than straight moves because Axis input normalizes the vector.
|
||||
* axis, scaled by the `speed` variable per second (On update's dt feeds the dt input).
|
||||
* Diagonals are not faster than straight moves because Axis input normalizes the vector, and
|
||||
* "Move and slide" stops the owner's solid colliders at other solid colliders.
|
||||
*/
|
||||
const moverGraph = {
|
||||
nodes: [
|
||||
@@ -21,7 +22,7 @@ const moverGraph = {
|
||||
{ id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} },
|
||||
{ id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} },
|
||||
{ id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } },
|
||||
{ id: 'n-move', type: 'Move', x: 340, y: 0, params: {} },
|
||||
{ id: 'n-move', type: 'MoveAndSlide', x: 340, y: 0, params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', from: { node: 'n-update', port: 'exec' }, to: { node: 'n-move', port: 'exec' } },
|
||||
|
||||
Reference in new issue
Block a user