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:
rootandClaude Fable 5.1 committed 2026-09-07 15:25:04 +02:00
1 parent 26d5edc496
commit 0a59513b74
21 files changed
+525 -16

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'sprite' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
return { ...base, type: 'collider' };
}
}
+44
View File
@@ -0,0 +1,44 @@
import { findScene, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { newId } from '../ids';
import { buildAsset, requireAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './projectCommands';
import { requireObject } from './sceneObjectCommands';
/**
* The one-click way to give an object a collider: creates a box collider asset sized like
* the object (sprites: width × height; anything else: schema defaults) and places it as a
* child at the object's centre. Undo removes both the placed collider and the asset.
*/
export class AddColliderCommand implements Command {
readonly label = 'Add collider';
readonly assetId = newId();
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly targetId: string) {}
execute(project: Project): void {
const scene = findScene(project, this.sceneId);
if (!scene) throw new CommandError(`Scene ${this.sceneId} not found`);
const target = requireObject(project, this.sceneId, this.targetId);
const targetAsset = requireAsset(project, target.assetId);
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
const asset = buildAsset('collider', nextAssetName(project, `${target.name || targetAsset.name} collider`), this.assetId);
if (targetAsset.type === 'sprite') {
const props = resolveSceneObjectProps(targetAsset, target);
asset.props.width = Number(props.width);
asset.props.height = Number(props.height);
}
project.assets.push(asset);
scene.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
}
undo(project: Project): void {
const scene = findScene(project, this.sceneId);
if (scene) scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
project.assets = project.assets.filter((a) => a.id !== this.assetId);
}
}
+5
View File
@@ -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;
+20
View File
@@ -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 --------------------------------------------------------------------------------
+9 -1
View File
@@ -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';
+2 -2
View File
@@ -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';
}
+6
View File
@@ -39,6 +39,12 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// 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. */
+40
View File
@@ -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<ColliderShape, { kind: 'box' }>);
const circle = a.kind === 'circle' ? a : (b as Extract<ColliderShape, { kind: 'circle' }>);
// 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;
}
+5 -1
View File
@@ -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');
+1 -1
View File
@@ -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<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior' };
const TYPE_LABELS: Record<AssetType, string> = { 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 {
+10
View File
@@ -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);
+15
View File
@@ -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 });
},
}),
);
}
}
/**
+34 -1
View File
@@ -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);
+75
View File
@@ -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<string, CollisionPair>();
update(world: World): void {
const colliders = world.entities.filter((e) => e.type === 'collider');
const current = new Map<string, CollisionPair>();
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');
}
+2
View File
@@ -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,
+12 -3
View File
@@ -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);
}