From 6ecb20cf02847dcb7ddb89912e84b0d74782eb76 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 15:38:14 +0200 Subject: [PATCH] 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 --- README.md | 16 +++- src/core/graph/NodeRegistry.ts | 5 ++ src/core/graph/nodes.ts | 19 +++++ src/core/properties/PropertySchema.ts | 2 + src/core/properties/collider.ts | 47 +++++++++++ src/runtime/systems/BehaviorSystem.ts | 8 ++ src/runtime/systems/CollisionSystem.ts | 39 +++++++++- src/ui/Theme.ts | 3 +- src/viewport/Viewport.ts | 5 +- tests/core/collider.test.ts | 104 ++++++++++++++++++++++++- tests/core/graph.test.ts | 5 ++ tests/core/hierarchy.test.ts | 2 +- tools/e2e.mjs | 33 ++++++++ tools/seed-demo.mjs | 7 +- 14 files changed, 283 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a81880b4..d5518f39 100644 --- a/README.md +++ b/README.md @@ -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/`. diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index d6168052..fa5d6b04 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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; diff --git a/src/core/graph/nodes.ts b/src/core/graph/nodes.ts index 2050f3a3..bc6e70e1 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -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', diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 42fd9238..9ccd3df2 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -44,6 +44,8 @@ export const ASSET_SCHEMAS: Record = { { 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 }, ], }; diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index e4e05ed2..6cbcd5bd 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -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); + const circle = a.kind === 'circle' ? a : (b as Extract); + 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, axis: Axis): number { + return axis === 'x' ? box.w : box.h; +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 8306a5de..b74ad812 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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); diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 9a423d65..916c822e 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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. */ diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8eb827d0..777caa48 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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, diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index 38b529bc..2bc96738 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -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); diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index bf12847e..870133b7 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -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(); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index ebe86707..aedf3e12 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -26,6 +26,11 @@ function context(overrides: Partial = {}): 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; }, diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 0de4f5a0..7db8aa9e 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -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']); }); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index dd34fe9b..bda62ddb 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -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) { diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index c333de8c..9b11531d 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -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' } },