Collider asset type, CollisionSystem and collision nodes
- Asset type 'collider' (box/circle) placed as child of a body object - 'Add collider' shortcut (hierarchy context menu + inspector) - CollisionSystem with enter/stay/exit pairs, sibling colliders excluded - Nodes: On collision enter/stay/exit, Is touching - Green outline in edit mode, hidden in play Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
26d5edc496
commit
0a59513b74
21 files changed
+525
-16
No files matched your search
@@ -10,9 +10,10 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`) and typed properties. Assets have **no position**. |
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`) and typed properties. Assets have **no position**. |
|
||||
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
|
||||
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
|
||||
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
|
||||
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
|
||||
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
|
||||
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||
@@ -42,10 +43,10 @@ shown on the node and editable in the inspector.
|
||||
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
|
||||
from any object.
|
||||
- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`,
|
||||
`On timer` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Get/Set
|
||||
`On timer`, `On collision enter/stay/exit` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Get/Set
|
||||
variable`, `Get/Set property`, `Get position` (local and world), literals, `Add/Subtract/Multiply/Divide`,
|
||||
`Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
|
||||
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down` ·
|
||||
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Is touching` ·
|
||||
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Destroy`,
|
||||
`Log`, `Stop game`. A node type is one `registerNode()` call; palette, ports, validation and
|
||||
inspector fields are generated from it. A port may declare a `default` for its inline value
|
||||
@@ -83,6 +84,25 @@ Unparent) to move it to the top level; the inspector offers the same as a **Pare
|
||||
play mode entities carry the same `parent` link: moving a parent entity moves its subtree,
|
||||
`Destroy` removes it. `Get position` exposes both the local and the world coordinates.
|
||||
|
||||
## Colliders and collisions
|
||||
|
||||
A **collider** is an asset (`shape` box/circle, `width`/`height` or `radius`) placed like any
|
||||
other object – typically as a child of the sprite it belongs to, so its position is the offset
|
||||
from that *body* and it moves with it. Right-click an object in **Scene objects** (or press
|
||||
**+ Add collider** in its inspector) to get one with one click: a box collider sized like the
|
||||
sprite, parented at its centre. Colliders can be reused across objects, overridden per instance
|
||||
and stacked (hitbox + hurtbox on one sprite; colliders of the same body never collide with each
|
||||
other). They are editor symbols: green outlines in edit mode, invisible in play mode.
|
||||
|
||||
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 `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.
|
||||
|
||||
## Stack
|
||||
|
||||
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
|
||||
@@ -99,7 +119,7 @@ play mode entities carry the same `parent` link: moving a parent entity moves it
|
||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
||||
src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested)
|
||||
src/runtime/ World, Entity, System interface, Input tracker, BehaviorSystem
|
||||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
src/viewport/ scene renderer: model in edit mode, world in play mode
|
||||
src/editor/ App composition, state, panels, inspector field factory
|
||||
@@ -154,5 +174,6 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi
|
||||
|
||||
## Not yet
|
||||
|
||||
Textures on sprites, physics/collisions, spawning from graphs, multi-select, auth, editing
|
||||
while playing, touch-optimised layout, graph comments/groups.
|
||||
Textures on sprites, solid collisions (blocking movement, "move and slide"), spawning from
|
||||
graphs, multi-select, reordering the hierarchy, auth, editing while playing, touch-optimised
|
||||
layout, graph comments/groups.
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||||
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject, findScene } from '../../src/core/model/Project';
|
||||
import { colliderShape, shapesOverlap } from '../../src/core/properties/collider';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
|
||||
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
|
||||
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
|
||||
|
||||
describe('collider geometry', () => {
|
||||
it('box vs box: overlapping yes, touching edges no', () => {
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), box(90, 0, 100, 100))).toBe(true);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), box(100, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), circle(60, 0, 20))).toBe(true);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), circle(80, 0, 20))).toBe(false);
|
||||
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
|
||||
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
|
||||
});
|
||||
|
||||
it('builds shapes from properties with defaults', () => {
|
||||
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
|
||||
});
|
||||
});
|
||||
|
||||
/** A scene with two crates (100×60) 300 apart, each with a collider child; a behavior on crate A. */
|
||||
function setup() {
|
||||
const project = createEmptyProject('p', 'Colliders');
|
||||
const stack = new CommandStack(project);
|
||||
const scene = new CreateAssetCommand('scene', 'Level');
|
||||
const sprite = new CreateAssetCommand('sprite', 'Crate');
|
||||
const behavior = new CreateAssetCommand('behavior', 'Hit');
|
||||
for (const c of [scene, sprite, behavior]) stack.execute(c);
|
||||
sprite.asset.props.height = 60;
|
||||
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
|
||||
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
|
||||
stack.execute(a);
|
||||
stack.execute(b);
|
||||
const colA = new AddColliderCommand(scene.asset.id, a.objectId);
|
||||
const colB = new AddColliderCommand(scene.asset.id, b.objectId);
|
||||
stack.execute(colA);
|
||||
stack.execute(colB);
|
||||
const place = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }, a.objectId);
|
||||
stack.execute(place);
|
||||
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, behavior: behavior.asset as BehaviorAsset, a: a.objectId, b: b.objectId, colA, colB };
|
||||
}
|
||||
|
||||
describe('AddColliderCommand', () => {
|
||||
it('creates a collider asset sized like the sprite, parented to it; undo removes both', () => {
|
||||
const { project, stack, sceneId, a, colA } = setup();
|
||||
const asset = project.assets.find((x) => x.id === colA.assetId)!;
|
||||
expect(asset.type).toBe('collider');
|
||||
expect(asset.name).toBe('Crate collider');
|
||||
expect(asset.props).toMatchObject({ shape: 'box', width: 100, height: 60 });
|
||||
const object = findScene(project, sceneId)!.objects.find((o) => o.id === colA.objectId)!;
|
||||
expect(object.parentId).toBe(a);
|
||||
expect(object.position).toEqual({ x: 0, y: 0, z: 0 });
|
||||
// Undo of the last three commands: behavior placement, collider B, collider A.
|
||||
stack.undo();
|
||||
stack.undo();
|
||||
stack.undo();
|
||||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(false);
|
||||
expect(findScene(project, sceneId)!.objects.some((o) => o.id === colA.objectId)).toBe(false);
|
||||
stack.redo();
|
||||
expect(project.assets.some((x) => x.id === colA.assetId)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a collider on a collider and survives a round trip', () => {
|
||||
const { project, stack, sceneId, colA } = setup();
|
||||
expect(() => stack.execute(new AddColliderCommand(sceneId, colA.objectId))).toThrow(/cannot have a collider/);
|
||||
const loaded = deserializeProject(serializeProject(project));
|
||||
expect(loaded.assets.find((x) => x.id === colA.assetId)?.props).toMatchObject({ shape: 'box', width: 100, height: 60, radius: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollisionSystem', () => {
|
||||
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
|
||||
const { project, sceneId, a, b } = setup();
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
const crateA = world.findByObjectId(a)!;
|
||||
const crateB = world.findByObjectId(b)!;
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(0);
|
||||
crateB.x = 90; // boxes of width 100 overlap once the centres are closer than 100
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(1);
|
||||
expect(collisions.stays).toHaveLength(0);
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(0);
|
||||
expect(collisions.stays).toHaveLength(1);
|
||||
expect(collisions.touching(world, crateA, crateB)).toBe(true);
|
||||
crateB.x = 300;
|
||||
world.tick(0.1);
|
||||
expect(collisions.exits).toHaveLength(1);
|
||||
expect(collisions.stays).toHaveLength(0);
|
||||
expect(collisions.touching(world, crateA, crateB)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects overrides and circles; two colliders of the same body never collide', () => {
|
||||
const { project, stack, sceneId, a, b, colA, colB } = setup();
|
||||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'shape', 'circle'));
|
||||
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'radius', 30));
|
||||
const second = new AddColliderCommand(sceneId, a);
|
||||
stack.execute(second); // a second collider on crate A, overlapping colA completely
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(0); // colA and the second collider share a body
|
||||
world.findByObjectId(b)!.x = 75; // circle r=30 at 75 vs box half-width 50 → overlap
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(2); // circle B against both colliders of A
|
||||
expect(collisions.enters.every((p) => p.a.objectId === colA.objectId || p.b.objectId === colA.objectId || p.a.objectId === second.objectId || p.b.objectId === second.objectId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collision events in behaviors', () => {
|
||||
it('fires On collision enter/exit on the body\'s behavior with the other body; Is touching agrees', () => {
|
||||
const { project, stack, sceneId, behavior, b } = setup();
|
||||
// Graph: On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
|
||||
const enter = new AddNodeCommand(behavior.id, 'OnCollisionEnter', 0, 0);
|
||||
const logEnter = new AddNodeCommand(behavior.id, 'Log', 0, 0);
|
||||
const exit = new AddNodeCommand(behavior.id, 'OnCollisionExit', 0, 0);
|
||||
const logExit = new AddNodeCommand(behavior.id, 'Log', 0, 0, { message: 'bye' });
|
||||
const update = new AddNodeCommand(behavior.id, 'OnUpdate', 0, 0);
|
||||
const owner = new AddNodeCommand(behavior.id, 'Owner', 0, 0);
|
||||
const byName = new AddNodeCommand(behavior.id, 'ObjectByName', 0, 0, { name: 'B' });
|
||||
const touching = new AddNodeCommand(behavior.id, 'IsTouching', 0, 0);
|
||||
const logTouch = new AddNodeCommand(behavior.id, 'Log', 0, 0);
|
||||
for (const c of [enter, logEnter, exit, logExit, update, owner, byName, touching, logTouch]) stack.execute(c);
|
||||
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
|
||||
stack.execute(new ConnectCommand(behavior.id, { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
|
||||
connect(enter, 'exec', logEnter, 'exec');
|
||||
connect(enter, 'other', logEnter, 'message');
|
||||
connect(exit, 'exec', logExit, 'exec');
|
||||
connect(update, 'exec', logTouch, 'exec');
|
||||
connect(owner, 'object', touching, 'a');
|
||||
connect(byName, 'object', touching, 'b');
|
||||
connect(touching, 'result', logTouch, 'message');
|
||||
findScene(project, sceneId)!.objects.find((o) => o.id === b)!.name = 'B';
|
||||
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const collisions = new CollisionSystem();
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(new BehaviorSystem(project, new Input(), collisions));
|
||||
const crateB = world.findByObjectId(b)!;
|
||||
world.tick(0.1);
|
||||
expect(world.log).toEqual(['false']);
|
||||
crateB.x = 50;
|
||||
world.tick(0.1);
|
||||
expect(world.log).toEqual(['false', 'B', 'true']);
|
||||
crateB.x = 500;
|
||||
world.tick(0.1);
|
||||
expect(world.log).toEqual(['false', 'B', 'true', 'bye', 'false']);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: st
|
||||
stopped: false,
|
||||
findByName: () => null,
|
||||
isKeyDown: () => false,
|
||||
isTouching: () => false,
|
||||
log: (m: string) => logs.push(m),
|
||||
destroy: () => {},
|
||||
stop: () => { ctx.stopped = true; },
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('runtime hierarchy', () => {
|
||||
{ id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } },
|
||||
] };
|
||||
const logs: string[] = [];
|
||||
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
|
||||
const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, log: (m) => logs.push(m), destroy: () => {}, stop: () => {} };
|
||||
new GraphInterpreter(graph).fire('OnStart', ctx);
|
||||
expect(logs).toEqual(['105', '5', '100']);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user