From 0a59513b7442d3e2b4e0187290a935b4e9587a91 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 15:25:04 +0200 Subject: [PATCH] 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 --- README.md | 33 ++++- src/core/commands/assetCommands.ts | 2 + src/core/commands/colliderCommands.ts | 44 +++++++ src/core/graph/NodeRegistry.ts | 5 + src/core/graph/nodes.ts | 20 +++ src/core/model/Asset.ts | 10 +- src/core/model/AssetTypes.ts | 4 +- src/core/properties/PropertySchema.ts | 6 + src/core/properties/collider.ts | 40 ++++++ src/editor/App.ts | 6 +- src/editor/panels/AssetPanel.ts | 2 +- src/editor/panels/HierarchyPanel.ts | 10 ++ src/editor/panels/Inspector.ts | 15 +++ src/runtime/systems/BehaviorSystem.ts | 35 ++++- src/runtime/systems/CollisionSystem.ts | 75 +++++++++++ src/ui/Theme.ts | 2 + src/viewport/Viewport.ts | 15 ++- tests/core/collider.test.ts | 174 +++++++++++++++++++++++++ tests/core/graph.test.ts | 1 + tests/core/hierarchy.test.ts | 2 +- tools/e2e.mjs | 40 ++++++ 21 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 src/core/commands/colliderCommands.ts create mode 100644 src/core/properties/collider.ts create mode 100644 src/runtime/systems/CollisionSystem.ts create mode 100644 tests/core/collider.test.ts diff --git a/README.md b/README.md index aa716c17..a81880b4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Live: · Widget playground: o.id !== this.objectId); + project.assets = project.assets.filter((a) => a.id !== this.assetId); + } +} diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 8617b4b8..d6168052 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -51,6 +51,11 @@ export interface NodeContext { dt: number; findByName(name: string): EntityHandle | null; isKeyDown(key: string): boolean; + /** + * Whether any collider of `a` overlaps any collider of `b` right now. A collider entity + * stands for itself; any other entity stands for its direct collider children. + */ + isTouching(a: EntityHandle, b: EntityHandle): 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 c60dcfb2..2050f3a3 100644 --- a/src/core/graph/nodes.ts +++ b/src/core/graph/nodes.ts @@ -32,6 +32,12 @@ registerNode({ outputs: [exec()], params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }], }); +// Collision events reach the behavior whose owner is the collider's body (the collider's +// parent, or the collider itself when it is top-level). `other` is the other body. +const collisionOutputs = (): PortDef[] => [exec(), obj('other'), obj('otherCollider', 'other collider'), obj('collider')]; +registerNode({ type: 'OnCollisionEnter', label: 'On collision enter', category: 'event', description: 'Runs once when a collider of the owner starts overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); +registerNode({ type: 'OnCollisionStay', label: 'On collision stay', category: 'event', description: 'Runs every frame while a collider of the owner overlaps another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); +registerNode({ type: 'OnCollisionExit', label: 'On collision exit', category: 'event', description: 'Runs once when a collider of the owner stops overlapping another collider.', inputs: [], outputs: collisionOutputs(), params: [] }); // --- flow --------------------------------------------------------------------------------- @@ -266,6 +272,20 @@ registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both t registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) }); registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) }); registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) }); +registerNode({ + type: 'IsTouching', + label: 'Is touching', + category: 'data', + description: 'Whether the colliders of two objects overlap right now (an object stands for its collider children; a collider for itself).', + inputs: [obj('a'), obj('b')], + outputs: [bool('result')], + params: [], + run: (ctx, i) => { + const a = asEntity(i.a); + const b = asEntity(i.b); + return { outputs: { result: Boolean(a && b && ctx.isTouching(a, b)) } }; + }, +}); // --- actions -------------------------------------------------------------------------------- diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 8321810c..b6759fa6 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -37,7 +37,15 @@ export interface BehaviorAsset extends AssetBase { graph: Graph; } -export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset; +/** + * A collider is a shape (box or circle) used for collision detection. Placed as a child of + * the object it belongs to (its "body"); its position is the offset from that body. + */ +export interface ColliderAsset extends AssetBase { + type: 'collider'; +} + +export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset; export function isSceneAsset(asset: Asset): asset is SceneAsset { return asset.type === 'scene'; diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 17ec10ba..2de8c666 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -1,5 +1,5 @@ /** All asset types. Scenes are assets too, but cannot be placed in scenes. */ -export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior'] as const; +export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider'] as const; export type AssetType = (typeof ASSET_TYPES)[number]; @@ -19,5 +19,5 @@ export function isAssetType(value: unknown): value is AssetType { * They exist in the scene for their position, not for their looks. */ export function isSymbolType(type: AssetType): boolean { - return type === 'camera' || type === 'behavior'; + return type === 'camera' || type === 'behavior' || type === 'collider'; } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 8ed14e78..42fd9238 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -39,6 +39,12 @@ export const ASSET_SCHEMAS: Record = { ], // Behaviors declare their own properties (variables) per asset – see `schemaForAsset`. behavior: [], + collider: [ + { key: 'shape', label: 'Shape', kind: 'enum', default: 'box', options: [{ value: 'box', label: 'Box' }, { value: 'circle', label: 'Circle' }] }, + { 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 }, + ], }; /** Properties every scene object has regardless of its asset type. */ diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts new file mode 100644 index 00000000..e4e05ed2 --- /dev/null +++ b/src/core/properties/collider.ts @@ -0,0 +1,40 @@ +import type { PropertyBag, Vec2 } from '../model/Properties'; +import { defaultsFor } from './PropertySchema'; + +/** + * Collider geometry in world units. Pure functions shared by the editor (drawing, gizmos) + * and the runtime (CollisionSystem), so both agree on what "overlaps" means. + */ +export type ColliderShape = + | { kind: 'box'; x: number; y: number; w: number; h: number } + | { kind: 'circle'; x: number; y: number; r: number }; + +/** The shape described by collider properties, centred on `centre` (a world position). */ +export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape { + const d = defaultsFor('collider'); + if (props.shape === 'circle') return { kind: 'circle', x: centre.x, y: centre.y, r: Math.max(0, Number(props.radius ?? d.radius)) }; + 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)) }; +} + +/** 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 }); + return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h }; +} + +/** Whether two shapes overlap. Shapes that merely touch along an edge do not overlap. */ +export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean { + if (a.kind === 'box' && b.kind === 'box') { + return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h; + } + if (a.kind === 'circle' && b.kind === 'circle') { + const r = a.r + b.r; + return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r; + } + const box = a.kind === 'box' ? a : (b as Extract); + const circle = a.kind === 'circle' ? a : (b as Extract); + // Closest point of the box to the circle centre, then a plain distance check. + const cx = Math.max(box.x - box.w / 2, Math.min(circle.x, box.x + box.w / 2)); + 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; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 6dd3bdac..4e7f1635 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -6,6 +6,7 @@ import { listScenes } from '../core/model/Project'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore'; import { BehaviorSystem } from '../runtime/systems/BehaviorSystem'; +import { CollisionSystem } from '../runtime/systems/CollisionSystem'; import { Input } from '../runtime/systems/Input'; import { World } from '../runtime/World'; import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui'; @@ -262,7 +263,10 @@ class Editor { ui().focus.blur(); this.world = World.instantiate(this.state.project, scene.id); this.input.attach(); - this.behaviors = new BehaviorSystem(this.state.project, this.input); + // Collisions are detected first so behaviors see this frame's enter/stay/exit events. + const collisions = new CollisionSystem(); + this.behaviors = new BehaviorSystem(this.state.project, this.input, collisions); + this.world.addSystem(collisions); this.world.addSystem(this.behaviors); this.viewport.setWorld(this.world); this.state.setMode('play'); diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 0a56b880..a107f55e 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -7,7 +7,7 @@ import { findAsset, findUsages } from '../../core/model/Project'; import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui'; import type { EditorState } from '../state'; -const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior' }; +const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider' }; /** Lists all assets of the project; creates, places and deletes them. */ export class AssetPanel extends Panel { diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 44547b82..2cde2263 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,3 +1,4 @@ +import { AddColliderCommand } from '../../core/commands/colliderCommands'; import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { treeOrder } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; @@ -33,8 +34,17 @@ export class HierarchyPanel extends Panel { const scene = state.activeScene; if (!scene) return; const object = scene.objects.find((o) => o.id === id); + const type = object && findAsset(state.project, object.assetId)?.type; new Menu([ { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, + { + label: 'Add collider', + disabled: !type || type === 'collider', + onSelect: () => { + const command = new AddColliderCommand(scene.id, id); + if (state.execute(command)) state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId }); + }, + }, { label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) }, { label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) }, ]).open(x, y); diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index b8005d84..ae6b711d 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -1,4 +1,5 @@ import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { AddColliderCommand } from '../../core/commands/colliderCommands'; import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands'; import { ClearOverrideCommand, @@ -275,6 +276,20 @@ export class Inspector extends Panel { this.fields.set(def.key, row); this.body.add(row.row); } + + // Sprites (and other bodies) get their collider with one click: a child sized like the object. + if (asset.type !== 'collider') { + this.body.add(new Label('')); + this.body.add( + new Button('+ Add collider', { + variant: 'ghost', + onClick: () => { + const command = new AddColliderCommand(sceneId, objectId); + if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId, objectId: command.objectId }); + }, + }), + ); + } } /** diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 20004ca9..8306a5de 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -2,6 +2,7 @@ import { type EntityHandle, GraphInterpreter, type NodeContext, type TraceListen import { findAsset, type Project } from '../../core/model/Project'; import type { Entity } from '../Entity'; import type { World } from '../World'; +import { bodyOf, type CollisionPair, type CollisionSystem } from './CollisionSystem'; import type { Input } from './Input'; import type { System } from './System'; @@ -28,7 +29,12 @@ export class BehaviorSystem implements System { private instances: Instance[] = []; private world: World | null = null; - constructor(private readonly project: Project, private readonly input: Input) {} + constructor( + private readonly project: Project, + private readonly input: Input, + /** Source of this frame's collision events; null = no collision detection. */ + private readonly collisions: CollisionSystem | null = null, + ) {} start(world: World): void { this.world = world; @@ -63,11 +69,33 @@ export class BehaviorSystem implements System { i.fire('OnTimer', ctx, {}, (n) => n.id === node.id); } } + this.fireCollisions(instance, i, ctx); i.fire('OnUpdate', ctx, { dt }); }, dt); } } + /** + * Collision events for this behavior: every pair in which one collider belongs to the + * behavior's body (its owner, or itself when it has none) fires once, with `other` being + * the body on the far side. + */ + private fireCollisions(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void { + if (!this.collisions) return; + const body = instance.entity.parent ?? instance.entity; + const fire = (type: string, pairs: CollisionPair[]) => { + for (const { a, b } of pairs) { + const own = bodyOf(a) === body ? a : bodyOf(b) === body ? b : null; + if (!own) continue; + const other = own === a ? b : a; + interpreter.fire(type, ctx, { other: bodyOf(other), otherCollider: other, collider: own }); + } + }; + fire('OnCollisionEnter', this.collisions.enters); + fire('OnCollisionStay', this.collisions.stays); + fire('OnCollisionExit', this.collisions.exits); + } + stop(): void { this.instances = []; this.world = null; @@ -105,6 +133,11 @@ export class BehaviorSystem implements System { dt, findByName: (name) => world.findByName(name) ?? null, isKeyDown: (key) => this.input.isDown(key), + isTouching: (a, b) => { + const ea = world.entities.find((e) => e === a || e.id === a.id); + const eb = world.entities.find((e) => e === b || e.id === b.id); + return Boolean(this.collisions && ea && eb && this.collisions.touching(world, ea, eb)); + }, 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 new file mode 100644 index 00000000..9a423d65 --- /dev/null +++ b/src/runtime/systems/CollisionSystem.ts @@ -0,0 +1,75 @@ +import { colliderShape, shapesOverlap } from '../../core/properties/collider'; +import type { Entity } from '../Entity'; +import type { World } from '../World'; +import type { System } from './System'; + +/** Two collider entities that overlap (or overlapped) – always the same pair order per key. */ +export interface CollisionPair { + a: Entity; + b: Entity; +} + +/** + * Detects overlaps between collider entities once per tick and sorts them into enter / + * stay / exit lists for the frame. Detection only – nothing is pushed apart. Colliders + * of the same body (same parent) never collide with each other. Runs before the + * BehaviorSystem so graphs see this frame's events. + */ +export class CollisionSystem implements System { + enters: CollisionPair[] = []; + stays: CollisionPair[] = []; + exits: CollisionPair[] = []; + private previous = new Map(); + + update(world: World): void { + const colliders = world.entities.filter((e) => e.type === 'collider'); + const current = new Map(); + for (let i = 0; i < colliders.length; i++) { + for (let j = i + 1; j < colliders.length; j++) { + const a = colliders[i]!; + const b = colliders[j]!; + if (bodyOf(a) === bodyOf(b)) continue; + if (this.overlapping(world, a, b)) current.set(`${a.id}|${b.id}`, { a, b }); + } + } + this.enters = []; + this.stays = []; + this.exits = []; + for (const [key, pair] of current) (this.previous.has(key) ? this.stays : this.enters).push(pair); + for (const [key, pair] of this.previous) if (!current.has(key)) this.exits.push(pair); + this.previous = current; + } + + stop(): void { + this.previous = new Map(); + this.enters = []; + this.stays = []; + this.exits = []; + } + + /** Live test of two collider entities (world positions applied). */ + overlapping(world: World, a: Entity, b: Entity): boolean { + return shapesOverlap(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b))); + } + + /** Whether any collider of `a` overlaps any collider of `b` (see `collidersOf`). */ + touching(world: World, a: Entity, b: Entity): boolean { + for (const ca of collidersOf(world, a)) { + for (const cb of collidersOf(world, b)) { + if (ca !== cb && this.overlapping(world, ca, cb)) return true; + } + } + return false; + } +} + +/** The object a collider belongs to: its parent, or the collider itself when top-level. */ +export function bodyOf(collider: Entity): Entity { + return collider.parent ?? collider; +} + +/** A collider stands for itself; any other entity stands for its direct collider children. */ +export function collidersOf(world: World, entity: Entity): Entity[] { + if (entity.type === 'collider') return [entity]; + return world.childrenOf(entity).filter((e) => e.type === 'collider'); +} diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 161cdc46..8eb827d0 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -32,6 +32,8 @@ export const Theme = { gizmo: 0x4c8dff, /** Behavior symbols and owner links. */ behavior: 0xb066ff, + /** Collider outlines. */ + collider: 0x4fd18b, /** 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 f169818d..38b529bc 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -5,6 +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 { resolveSceneObjectProps } from '../core/properties/resolve'; import type { World } from '../runtime/World'; import { Theme, textStyle, Widget, type Size } from '../ui'; @@ -238,6 +239,7 @@ export class Viewport extends Widget { private itemSize(item: DrawItem): { w: number; h: number } { if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) }; if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; + if (item.type === 'collider') return colliderSize(item.props); return cameraFrame(item.props); } @@ -298,6 +300,12 @@ export class Viewport extends Widget { node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText }); node.circle(-6, -3, 2).fill(Theme.colors.accentText); node.circle(6, 3, 2).fill(Theme.colors.accentText); + } else if (item.type === 'collider') { + // Outline plus a faint fill: the fill keeps the shape hit-testable and reads as "area". + 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 }); } } this.drawParentLinks(items, editing, view.scale); @@ -335,14 +343,15 @@ export class Viewport extends Widget { /** * Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only). - * Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered. + * Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered; + * colliders sit on their body anyway, so they get no line either. */ private drawParentLinks(items: DrawItem[], editing: boolean, scale: number): void { this.links.clear(); if (!editing) return; const byId = new Map(items.map((i) => [i.id, i])); for (const item of items) { - if (!this.isSymbol(item) || !item.parentId) continue; + if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue; const parent = byId.get(item.parentId); if (!parent) continue; const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted; @@ -368,7 +377,7 @@ export class Viewport extends Widget { label.visible = true; label.text = item.name; label.scale.set(1 / scale); - if (item.type === 'camera') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale); + if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale); else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale); } diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts new file mode 100644 index 00000000..bf12847e --- /dev/null +++ b/tests/core/collider.test.ts @@ -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']); + }); +}); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index baba9f58..ebe86707 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -25,6 +25,7 @@ function context(overrides: Partial = {}): NodeContext & { logs: st stopped: false, findByName: () => null, isKeyDown: () => false, + isTouching: () => 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 41d9a041..0de4f5a0 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, 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']); }); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 3d1a9498..dd34fe9b 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -541,6 +541,46 @@ try { o2 = JSON.parse(await objectJson('o2')); check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2)); + // 15. Colliders. Right-click the red crate → "Add collider": a collider asset sized like the + // crate (100×60) is placed as its child at the centre, selected and drawn in the overlay. + const redRow3 = await locate('Red crate'); + await click(redRow3.x, redRow3.y, 'right'); + await sleep(150); + await clickText('Add collider'); + await sleep(200); + s = JSON.parse(await stateJson()); + const colObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null; + const colAsset = colObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${colObj.assetId}'))`)) : null; + check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset })); + check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay'); + check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`))); + await shot('21-collider'); + + // 15a. Give the blue crate the same collider, wire "On collision enter → Log(other)" into the + // Mover (owner = red crate) and play with the crates overlapping: the log names the blue + // crate exactly once (enter, not stay), and colliders are not rendered in play mode. + await evaluate(`(() => { + const scene = __gm.state.activeScene; + scene.objects.push({ id: 'oc2', assetId: '${colAsset.id}', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o2' }); + const mover = __gm.state.project.assets.find(a => a.id === 'mover'); + mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} }); + mover.graph.edges.push( + { id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } }, + { id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } }); + scene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap + __gm.state.select(null); + __gm.state.preview(); + })()`); + await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 }); + await sleep(500); + const collisionWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())')); + check('On collision enter fired once with the other body', collisionWorld && collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld?.log)); + const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`); + info = await evaluate('JSON.stringify(__gm.viewportInfo())'); + check('colliders are not rendered in play mode', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`); + await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); + await sleep(200); + 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) {