Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
game-maker / gate (push) Successful in 1m46s
game-maker / suite (push) Successful in 6m17s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 18:11:43 +02:00
1 parent d02bcca266
commit 78899b1ace
34 files changed
+2291 -676

No files matched your search

+5 -4
View File
@@ -12,9 +12,9 @@ way in.
## In one screen
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, container,
texture, font, sound, animation, state machine, shader, particles, variables, tile, tilemap) with
properties and no position.
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, collider, rigidbody,
container, texture, font, sound, animation, state machine, shader, particles, variables, tile,
tilemap) with properties and no position.
- A **scene object** places an asset: position, sparse **overrides** of its properties, an optional
parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑.
- A **container** is a reusable group edited once and placed many times; its content is resolved
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1045 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1052 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
@@ -57,6 +57,7 @@ beside it – so locally take the gate plus the step your change touches and lea
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement |
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step |
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
| [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to |
| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` |
+2
View File
@@ -35,6 +35,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'variables', variables: [] };
case 'collider':
return { ...base, type: 'collider' };
case 'rigidbody':
return { ...base, type: 'rigidbody' };
case 'container':
return { ...base, type: 'container', objects: [] };
case 'animation':
+49 -2
View File
@@ -1,7 +1,7 @@
import { newId } from '../ids';
import { isLiveInstance } from '../model/instance';
import type { Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { findAsset, type ObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
@@ -51,3 +51,50 @@ export class AddColliderCommand implements Command {
project.assets = removeById(project.assets, this.assetId);
}
}
/** Why the object cannot take a rigidbody, or null when it can – the one rule command and menu share. */
export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
const type = findAsset(project, target.assetId)?.type;
if (type === 'collider' || type === 'rigidbody' || type === 'behavior')
return `A ${type} cannot have a rigidbody`;
if (type === 'tilemap') return 'A tilemap is static ground and takes no rigidbody';
if (isLiveInstance(project, holder, target))
return 'A placed container shows its template; add the rigidbody inside it';
const taken = holder.objects.some(
(o) => o.parentId === target.id && findAsset(project, o.assetId)?.type === 'rigidbody',
);
return taken ? 'The object has a rigidbody already' : null;
}
/** Creates a rigidbody asset with the schema defaults and places it as a child at the object's centre. */
export class AddRigidbodyCommand implements Command {
readonly label = 'Add rigidbody';
readonly assetId = newId();
readonly objectId = newId();
constructor(
private readonly holderId: string,
private readonly targetId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const target = requireStructuralObject(project, holder, this.targetId);
const targetAsset = requireAsset(project, target.assetId);
const refusal = rigidbodyRefusal(project, holder, target);
if (refusal) throw new CommandError(refusal);
const asset = buildAsset(
'rigidbody',
nextAssetName(project, `${target.name || targetAsset.name} rigidbody`),
this.assetId,
);
project.assets.push(asset);
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
holder.objects = removeById(holder.objects, this.objectId);
project.assets = removeById(project.assets, this.assetId);
}
}
@@ -1,12 +1,15 @@
import type { NodeDef } from '../../NodeRegistry';
import { collisionOutputs } from '../shared';
import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionEnter: NodeDef = {
type: 'OnCollisionEnter',
label: 'On collision enter',
category: 'event',
description: 'Runs once when a collider of the owner starts overlapping another collider.',
description:
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
'normal x/y point from the other collider towards the own one and impulse says how hard they met ' +
'(both 0 for a trigger).',
inputs: [],
outputs: collisionOutputs(),
outputs: [...collisionOutputs(), ...contactOutputs()],
params: [],
};
+10
View File
@@ -31,6 +31,16 @@ export const collisionOutputs = (): PortDef[] => [
obj('collider'),
];
/**
* What only the start of a contact knows: which way the other side pushes (a unit vector pointing
* from it towards the own collider) and how hard they met. The physics engine works both out (#146).
*/
export const contactOutputs = (): PortDef[] => [
num('normalX', 'normal x'),
num('normalY', 'normal y'),
num('impulse'),
];
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** No `assetTypes`: any placeable asset is a valid answer here. */
+1
View File
@@ -19,6 +19,7 @@ export const PROPERTY_TARGETS = [
'sprite',
'text',
'collider',
'rigidbody',
'camera',
'container',
'behavior',
+9
View File
@@ -51,6 +51,14 @@ export interface ColliderAsset extends AssetBase {
type: 'collider';
}
/**
* Placed as a child of the object it moves – at most one per object. Its props say how the physics
* engine treats that object: falling, resting or pushed along by the graph (see `properties/rigidbody.ts`).
*/
export interface RigidbodyAsset extends AssetBase {
type: 'rigidbody';
}
/**
* `objects` is a template, not content: a placed container shows it without copying it, so an
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
@@ -236,6 +244,7 @@ export type Asset =
| BehaviorAsset
| VariablesAsset
| ColliderAsset
| RigidbodyAsset
| ContainerAsset
| TextureAsset
| AnimationAsset
+9 -1
View File
@@ -6,6 +6,7 @@ export const ASSET_TYPES = [
'behavior',
'variables',
'collider',
'rigidbody',
'container',
'texture',
'animation',
@@ -28,6 +29,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
behavior: 'Behavior',
variables: 'Variables',
collider: 'Collider',
rigidbody: 'Rigidbody',
container: 'Container',
texture: 'Texture',
animation: 'Animation',
@@ -75,7 +77,13 @@ export function isAssetType(value: unknown): value is AssetType {
/** Types drawn as an icon in the overlay layer instead of as scene content. */
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
return (
type === 'camera' ||
type === 'behavior' ||
type === 'collider' ||
type === 'rigidbody' ||
type === 'container'
);
}
/**
+30 -1
View File
@@ -181,7 +181,12 @@ export function isValidVariableName(name: string): boolean {
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
scene: [
{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 },
// Pulls on every dynamic rigidbody, in world units per second squared: 980 is Earth at 100 px = 1 m.
{ key: 'gravityX', label: 'Gravity X', kind: 'number', default: 0, step: 10 },
{ key: 'gravityY', label: 'Gravity Y', kind: 'number', default: 980, step: 10 },
],
camera: [
// A view is a pixel raster, so half a pixel of it means nothing.
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
@@ -350,6 +355,30 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
// The material, read by the physics engine where a rigidbody takes part (#146).
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 },
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
],
rigidbody: [
{
key: 'bodyType',
label: 'Body type',
kind: 'enum',
default: 'dynamic',
options: [
{ value: 'dynamic', label: 'Dynamic' },
{ value: 'static', label: 'Static' },
{ value: 'kinematic', label: 'Kinematic' },
],
},
// 0 leaves the mass to the colliders' density and size; anything else is the mass itself.
{ key: 'mass', label: 'Mass (0 = auto)', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'gravityScale', label: 'Gravity scale', kind: 'number', default: 1, step: 0.1 },
{ key: 'linearDamping', label: 'Linear damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'angularDamping', label: 'Angular damping', kind: 'number', default: 0, min: 0, step: 0.1 },
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
container: [],
+3
View File
@@ -26,6 +26,7 @@ const TEXT_DEFAULTS = defaultsFor('text');
export const BEHAVIOR_SYMBOL = 28;
export const CONTAINER_SYMBOL = 24;
export const PARTICLES_SYMBOL = 24;
export const RIGIDBODY_SYMBOL = 24;
/** Rough width of one character relative to the font size, for a plain sans. */
const CHAR_WIDTH = 0.55;
@@ -61,6 +62,8 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
case 'rigidbody':
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
// framed is the source, which is a point with a handle around it.
case 'particles':
+61
View File
@@ -0,0 +1,61 @@
/** What the physics engine reads off a rigidbody, a collider's material and the scene (#146). */
import type { PropertyBag, Vec2 } from '../model/Properties';
import { defaultsFor } from './PropertySchema';
const BODY = defaultsFor('rigidbody');
const COLLIDER = defaultsFor('collider');
const SCENE = defaultsFor('scene');
/**
* `dynamic` falls and is pushed, `static` never moves on its own, `kinematic` goes where the graph
* puts it and pushes what is in the way.
*/
export type BodyType = 'dynamic' | 'static' | 'kinematic';
export interface RigidbodySettings {
type: BodyType;
/** 0: the colliders' density decides. */
mass: number;
gravityScale: number;
linearDamping: number;
angularDamping: number;
fixedRotation: boolean;
ccd: boolean;
}
export interface ColliderMaterial {
friction: number;
bounciness: number;
density: number;
}
function num(value: unknown, fallback: unknown): number {
const n = Number(value ?? fallback);
return Number.isFinite(n) ? n : Number(fallback);
}
export function rigidbodySettings(props: PropertyBag): RigidbodySettings {
const type = props.bodyType;
return {
type: type === 'static' || type === 'kinematic' ? type : 'dynamic',
mass: Math.max(0, num(props.mass, BODY.mass)),
gravityScale: num(props.gravityScale, BODY.gravityScale),
linearDamping: Math.max(0, num(props.linearDamping, BODY.linearDamping)),
angularDamping: Math.max(0, num(props.angularDamping, BODY.angularDamping)),
fixedRotation: Boolean(props.fixedRotation ?? BODY.fixedRotation),
ccd: Boolean(props.ccd ?? BODY.ccd),
};
}
export function colliderMaterial(props: PropertyBag): ColliderMaterial {
return {
friction: Math.max(0, num(props.friction, COLLIDER.friction)),
bounciness: Math.min(1, Math.max(0, num(props.bounciness, COLLIDER.bounciness))),
density: Math.max(0, num(props.density, COLLIDER.density)),
};
}
/** World units per second squared. */
export function sceneGravity(props: PropertyBag): Vec2 {
return { x: num(props.gravityX, SCENE.gravityX), y: num(props.gravityY, SCENE.gravityY) };
}
+4 -1
View File
@@ -120,7 +120,10 @@ export class EditorRun {
// Baking a container's content into the scene would be an edit nobody asked for, so it is off
// here: the content runs, but resolved from its template (see `WorldOptions.bakeContainers`).
const fresh = World.instantiate(project, scene.id, { bakeContainers: false });
this.behaviors = addGameSystems(fresh, project, this.input, (e) => runsInEditor(e.props), soundLibrary);
this.behaviors = addGameSystems(fresh, project, this.input, {
shouldRun: (e) => runsInEditor(e.props),
audio: soundLibrary,
});
this.world = fresh;
this.hadFocus = this.hooks.focused();
this.input.attach(this.hooks.focused);
+31 -2
View File
@@ -13,6 +13,7 @@ import {
import type { AnimationPlayer } from '../core/model/animation';
import type { PropertyBag } from '../core/model/Properties';
import { gameClock } from '../core/time';
import { loadPhysics, readyPhysics } from '../runtime/physics/engine';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
@@ -55,6 +56,8 @@ export class PlaySession {
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
private waiting = false;
constructor(
private readonly state: EditorState,
@@ -111,6 +114,13 @@ export class PlaySession {
play(): void {
const scene = this.state.sceneToPlay();
if (!scene) return;
// The editor loads the engine right after opening, so it is almost always there already; a game
// without it would collide differently, so Play waits for it rather than running without (#146).
const physics = readyPhysics();
if (!physics) {
this.waitForPhysics();
return;
}
ui().focus.blur();
// A run starts at second zero, whatever the editor's previews have been counting up to.
gameClock.reset();
@@ -118,7 +128,7 @@ export class PlaySession {
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary);
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, physics });
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(this.playNotice(), 'quiet');
@@ -136,15 +146,34 @@ export class PlaySession {
const runtime = this.state.runtimeProject;
if (!old || !runtime) return;
const carried = this.behaviors?.shared ?? null;
const physics = readyPhysics();
old.destroy();
const world = World.instantiate(runtime, sceneId);
this.world = world;
this.behaviors = addGameSystems(world, runtime, this.input, undefined, soundLibrary, carried);
this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, carried, physics });
this.host.gameView.setWorld(world);
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
}
private waitForPhysics(): void {
if (this.waiting) return;
this.waiting = true;
this.state.setNotice('Loading the physics engine…', 'quiet');
loadPhysics().then(
() => {
this.waiting = false;
if (this.state.mode === 'edit') this.play();
},
(error: unknown) => {
this.waiting = false;
const reason = error instanceof Error ? error.message : String(error);
console.error('[physics] Play needs the physics engine, which could not be loaded:', error);
this.state.setNotice(`Play needs the physics engine, which could not be loaded: ${reason}`, 'error');
},
);
}
private playNotice(): string {
if (this.state.paused) return 'Paused · Ctrl+Enter resumes · Esc stops';
return this.world?.activeCamera()
+14 -1
View File
@@ -4,7 +4,7 @@
*/
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands';
import { AddColliderCommand, AddRigidbodyCommand, rigidbodyRefusal } from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
@@ -47,6 +47,19 @@ export function addCollider(state: EditorState, holderId: string, objectId: stri
return true;
}
/** The test `AddRigidbodyCommand` makes; template content has no structure of its own to change. */
export function canAddRigidbody(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null;
}
/** Gives the object a rigidbody child; selects the rigidbody. */
export function addRigidbody(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddRigidbodyCommand(holderId, objectId);
if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
+13 -1
View File
@@ -9,7 +9,14 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { deviationMarks } from '../../core/properties/deviation';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import {
addCollider,
addRigidbody,
canAddCollider,
canAddRigidbody,
createContainer,
removeObjects,
} from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
@@ -168,6 +175,11 @@ export class HierarchyPanel extends Panel {
disabled: !object || !source || many || !canAddCollider(state.project, source, object),
onSelect: () => addCollider(state, holder.id, id),
},
{
label: 'Add rigidbody',
disabled: !object || !source || many || !canAddRigidbody(state.project, source, object),
onSelect: () => addRigidbody(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
+1
View File
@@ -112,6 +112,7 @@ const OFF_GIZMO_ALPHA = 0.45;
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
behavior: Theme.colors.behavior,
particles: Theme.colors.particles,
rigidbody: Theme.colors.collider,
};
export class SceneViewport extends FreeViewport {
+560
View File
@@ -0,0 +1,560 @@
import type {
Collider,
ColliderDesc,
EventQueue,
RigidBody,
World as RapierWorld,
} from '@dimforge/rapier2d-compat';
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { parentChain } from '../../core/model/walk';
import { type ColliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
import { hasTransformProps } from '../../core/properties/PropertySchema';
import {
type BodyType,
colliderMaterial,
type RigidbodySettings,
rigidbodySettings,
sceneGravity,
} from '../../core/properties/rigidbody';
import type { Entity } from '../Entity';
import type { World } from '../World';
import {
bodyOf,
type CollisionPair,
type CollisionSource,
partsOf,
shapeOf,
Solids,
} from '../systems/solids';
import type { System } from '../systems/System';
import { type Rapier, toMetres, toPixels } from './engine';
/** The engine always steps this long, whatever the frame took. */
export const PHYSICS_STEP = 1 / 60;
/** After a hitch the world catches up this many steps at most, and forgets the rest. */
const MAX_STEPS_PER_TICK = 8;
/** A pose that moved less than this (world units, radians) was not moved by anyone. */
const POSE_EPSILON = 1e-6;
interface Pose {
x: number;
y: number;
angle: number;
}
/**
* One body in the engine. `rigid` is an object with a rigidbody child, carrying every collider below
* it; `free` is a collider of an object without one, a body of its own that goes where the object
* is put; `tilemap` is a placed map with a box per merged rectangle of its solid cells.
*/
interface Body {
kind: 'rigid' | 'free' | 'tilemap';
body: RigidBody;
colliders: Collider[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
settingsKey: string;
/** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */
turns: boolean;
/** The mass it carries of its own because no collider gives it one (kg, 0 = none). */
ownMass: number;
/** Where the pose was last put, by us or by the object moving. */
placed: Pose;
/** A dynamic body's pose before and after the last step, for drawing in between. */
previous: Pose;
current: Pose;
}
/** An engine collider: the entity it stands for and its world shape at the last sync. */
interface Part {
entity: Entity;
shape: ColliderShape;
owner: Body;
}
/**
* The played game's physics on Rapier (#146): rigidbodies fall, bounce and rest, and the collision
* events come from the engine's contact pairs. Ticked first, like the collision system it stands in
* for, so the behaviors see this frame's events.
*
* What has no rigidbody keeps behaving as it did before the engine: a collider is a body of its own
* that goes wherever the object is put, and whether two such colliders touch is decided by the
* editor's own overlap test – the engine only proposes the pairs. Touching without overlapping is
* no collision, as it never was. Only where a dynamic body is involved does the engine's contact say it.
*/
export class PhysicsSystem implements System, CollisionSource {
enters: CollisionPair[] = [];
stays: CollisionPair[] = [];
exits: CollisionPair[] = [];
private engine: RapierWorld | null = null;
private queue: EventQueue | null = null;
private readonly bodies = new Map<Entity, Body>();
private readonly parts = new Map<number, Part>();
/** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */
private readonly candidates = new Map<string, [number, number]>();
private current = new Map<string, CollisionPair>();
private readonly solids = new Solids();
private backlog = 0;
constructor(private readonly rapier: Rapier) {}
start(world: World): void {
const engine = new this.rapier.World(this.gravity(world));
engine.timestep = PHYSICS_STEP;
this.engine = engine;
this.queue = new this.rapier.EventQueue(true);
}
stop(): void {
this.engine?.free();
this.queue?.free();
this.engine = null;
this.queue = null;
this.bodies.clear();
this.parts.clear();
this.candidates.clear();
this.current = new Map();
this.enters = [];
this.stays = [];
this.exits = [];
this.backlog = 0;
}
update(world: World, dt: number): void {
const engine = this.engine;
if (!engine) return;
this.solids.reset();
this.enters = [];
this.exits = [];
this.sync(world);
engine.gravity = this.gravity(world);
this.backlog += dt;
let steps = 0;
const entered = new Set<string>();
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) {
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
engine.step(this.queue!);
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
this.collect(world, entered);
this.backlog -= PHYSICS_STEP;
steps++;
}
this.backlog = steps === MAX_STEPS_PER_TICK ? 0 : Math.max(0, this.backlog);
this.writeBack(this.backlog / PHYSICS_STEP);
this.stays = [...this.current].filter(([key]) => !entered.has(key)).map(([, pair]) => pair);
}
/** Whether any collider of `a` is in contact with any collider of `b` as of the last step. */
touching(world: World, a: Entity, b: Entity): boolean {
for (const ca of partsOf(world, a))
for (const cb of partsOf(world, b))
if (this.current.has(`${ca.id}|${cb.id}`) || this.current.has(`${cb.id}|${ca.id}`)) return true;
return false;
}
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
return this.solids.moveAndSlide(world, entity, dx, dy);
}
/** A dynamic body's velocity in world units per second; null for an object the engine does not move. */
velocityOf(owner: Entity): Vec2 | null {
const body = this.bodies.get(owner);
if (!body || body.kind !== 'rigid' || !body.body.isDynamic()) return null;
const v = body.body.linvel();
return { x: toPixels(v.x), y: toPixels(v.y) };
}
/** Sets a dynamic body's velocity (world units per second); false when the object has none. */
setVelocity(owner: Entity, velocity: Vec2): boolean {
const body = this.bodies.get(owner);
if (!body || body.kind !== 'rigid' || !body.body.isDynamic()) return false;
body.body.setLinvel({ x: toMetres(velocity.x), y: toMetres(velocity.y) }, true);
return true;
}
private gravity(world: World): Vec2 {
const g = sceneGravity(world.sceneProps);
return { x: toMetres(g.x), y: toMetres(g.y) };
}
/** Makes the engine's bodies match the entities: new ones in, gone ones out, moved ones moved. */
private sync(world: World): void {
const wanted = this.wantedBodies(world);
for (const [key, body] of this.bodies) {
const want = wanted.get(key);
if (!want || want.kind !== body.kind) this.remove(key, body);
}
for (const [key, want] of wanted) {
let body = this.bodies.get(key);
if (!body) {
body = this.create(want, key);
this.bodies.set(key, body);
}
this.place(body, want, key);
this.build(body, want, key, world);
}
}
/**
* Every body the entities ask for, keyed by the entity that stands for it. A collider belongs to
* the nearest object above it with a rigidbody switched on; without one it is a body of its own.
*/
private wantedBodies(world: World): Map<Entity, Wanted> {
const owners = new Map<Entity, Entity>();
for (const e of world.entities)
if (
e.type === 'rigidbody' &&
e.parent &&
e.parent.type !== 'tilemap' &&
!owners.has(e.parent) &&
isHandleEnabled(e)
)
owners.set(e.parent, e);
const wanted = new Map<Entity, Wanted>();
for (const [owner, rigidbody] of owners)
wanted.set(owner, { kind: 'rigid', settings: rigidbodySettings(rigidbody.props), colliders: [] });
for (const e of world.entities) {
if (e.type === 'tilemap') wanted.set(e, { kind: 'tilemap', settings: null, colliders: [] });
if (e.type !== 'collider') continue;
const owner = [...parentChain(e, (x) => x.parent)].slice(1).find((x) => owners.has(x));
if (owner) wanted.get(owner)!.colliders.push(e);
else wanted.set(e, { kind: 'free', settings: null, colliders: [e] });
}
return wanted;
}
private create(want: Wanted, key: Entity): Body {
const R = this.rapier;
const pose = this.poseWanted(want, key);
const desc =
want.kind === 'tilemap'
? R.RigidBodyDesc.fixed()
: want.kind === 'free'
? R.RigidBodyDesc.kinematicPositionBased()
: want.settings!.type === 'dynamic'
? R.RigidBodyDesc.dynamic()
: want.settings!.type === 'static'
? R.RigidBodyDesc.fixed()
: R.RigidBodyDesc.kinematicPositionBased();
desc.setTranslation(toMetres(pose.x), toMetres(pose.y)).setRotation(pose.angle);
return {
kind: want.kind,
body: this.engine!.createRigidBody(desc),
colliders: [],
shapeKey: '',
settingsKey: '',
turns: false,
ownMass: 0,
placed: pose,
previous: pose,
current: pose,
};
}
private remove(key: Entity, body: Body): void {
for (const collider of body.colliders) this.parts.delete(collider.handle);
this.engine!.removeRigidBody(body.body);
this.bodies.delete(key);
}
/** Where the entities say the body is: the object's origin, a free collider's shape, a map at zero. */
private poseWanted(want: Wanted, key: Entity): Pose {
if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 };
if (want.kind === 'free') {
const shape = shapeOf(key);
return { x: shape.x, y: shape.y, angle: shape.kind === 'box' ? shape.angle : 0 };
}
return matrixPose(worldMatrixOf(key));
}
/**
* Puts the body where its object is. A dynamic body is only moved when something else moved the
* object since we last wrote it – that is a teleport, and the body keeps its velocity (#146, 3).
*/
private place(body: Body, want: Wanted, key: Entity): void {
const settings = want.settings;
const settingsKey = settings ? JSON.stringify(settings) + hasTransformProps(key.type) : '';
if (settings && settingsKey !== body.settingsKey) {
body.settingsKey = settingsKey;
this.applySettings(body, settings, key);
}
const on = isHandleEnabled(key);
if (body.body.isEnabled() !== on) body.body.setEnabled(on);
const pose = this.poseWanted(want, key);
if (samePose(pose, body.placed)) return;
body.placed = pose;
const t = { x: toMetres(pose.x), y: toMetres(pose.y) };
if (body.body.isKinematic() && want.kind === 'rigid') {
body.body.setNextKinematicTranslation(t);
body.body.setNextKinematicRotation(pose.angle);
return;
}
body.body.setTranslation(t, true);
body.body.setRotation(pose.angle, true);
body.previous = pose;
body.current = pose;
}
private applySettings(target: Body, settings: RigidbodySettings, owner: Entity): void {
const body = target.body;
body.setBodyType(this.bodyType(settings.type), true);
body.setGravityScale(settings.gravityScale, true);
body.setLinearDamping(settings.linearDamping);
body.setAngularDamping(settings.angularDamping);
// An object without a rotation of its own could not show the body turning, so it does not turn.
target.turns = !settings.fixedRotation && hasTransformProps(owner.type);
body.lockRotations(!target.turns, true);
body.enableCcd(settings.ccd);
}
private bodyType(type: BodyType) {
const T = this.rapier.RigidBodyType;
return type === 'dynamic' ? T.Dynamic : type === 'static' ? T.Fixed : T.KinematicPositionBased;
}
/** The body's colliders, built again whenever what they were built from changed. */
private build(body: Body, want: Wanted, key: Entity, world: World): void {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map(({ entity, shape }) => ({ entity, shape, at: relativeTo(origin, shape) }));
const shapeKey =
JSON.stringify(
local.map(({ entity, shape, at }) => [
entity.id,
shape.kind === 'box' ? [round(shape.w), round(shape.h)] : round(shape.r),
round(at.x),
round(at.y),
round(at.angle),
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
]),
) + JSON.stringify(want.settings?.mass ?? 0);
if (shapeKey !== body.shapeKey) {
body.shapeKey = shapeKey;
for (const collider of body.colliders) {
this.parts.delete(collider.handle);
this.engine!.removeCollider(collider, true);
}
body.colliders = [];
const mass = want.settings?.mass ?? 0;
const area = local.reduce((sum, { entity, shape }) => sum + (isSolid(entity) ? areaOf(shape) : 0), 0);
for (const { entity, shape, at } of local) {
if (areaOf(shape) <= 0) continue;
const desc = this.colliderDesc(
entity,
shape,
at,
mass > 0 && area > 0 ? (mass * areaOf(shape)) / area : 0,
);
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
this.parts.set(collider.handle, { entity, shape, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them.
const built = local.filter(({ shape }) => areaOf(shape) > 0);
body.colliders.forEach((collider, i) => {
const { entity, shape } = built[i]!;
this.parts.set(collider.handle, { entity, shape, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
// A resting body sleeps, and losing what held it up does not wake it by itself.
body.body.wakeUp();
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = built.some(({ entity }) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
body.body.setAdditionalMass(own, true);
}
}
private shapesWanted(want: Wanted, key: Entity, world: World): { entity: Entity; shape: ColliderShape }[] {
if (want.kind === 'tilemap')
return this.solids.shapesOf(world, key).map((shape) => ({ entity: key, shape }));
return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) }));
}
private colliderDesc(entity: Entity, shape: ColliderShape, at: Pose, mass: number): ColliderDesc {
const R = this.rapier;
const desc =
shape.kind === 'circle'
? R.ColliderDesc.ball(toMetres(shape.r))
: R.ColliderDesc.cuboid(toMetres(shape.w / 2), toMetres(shape.h / 2));
desc.setTranslation(toMetres(at.x), toMetres(at.y)).setRotation(at.angle);
// A tile has no material of its own; it gets what a new collider starts with.
const material = colliderMaterial(entity.type === 'collider' ? entity.props : {});
desc.setFriction(material.friction).setRestitution(material.bounciness);
// The livelier side wins, as in Box2D: a ball that should bounce does so on plain ground too.
desc.setRestitutionCombineRule(R.CoefficientCombineRule.Max);
if (!isSolid(entity)) desc.setSensor(true).setDensity(0);
else if (mass > 0) desc.setMass(mass);
else desc.setDensity(material.density);
desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS);
desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
return desc;
}
/**
* The pairs in contact after a step, compared with those before it. The engine proposes the
* pairs; where no dynamic body takes part, the overlap test decides as it always did.
*/
private collect(world: World, entered: Set<string>): void {
this.queue!.drainCollisionEvents((h1, h2, started) => {
const key = h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
if (started) this.candidates.set(key, [h1, h2]);
else this.candidates.delete(key);
});
const order = new Map(world.entities.map((e, i) => [e, i]));
const now = new Map<string, CollisionPair>();
const first = new Map<string, [Collider, Collider]>();
for (const [key, [h1, h2]] of this.candidates) {
let p = this.parts.get(h1);
let q = this.parts.get(h2);
if (!p || !q) {
this.candidates.delete(key);
continue;
}
const a0 = p.entity;
const b0 = q.entity;
if (!order.has(a0) || !order.has(b0) || !isHandleEnabled(a0) || !isHandleEnabled(b0)) continue;
if (bodyOf(a0) === bodyOf(b0) || (a0.type === 'tilemap' && b0.type === 'tilemap')) continue;
const engineSays = p.owner.body.isDynamic() || q.owner.body.isDynamic();
if (!engineSays && !shapesOverlap(p.shape, q.shape)) continue;
let c1 = h1;
let c2 = h2;
if (order.get(a0)! > order.get(b0)!) {
[p, q] = [q, p];
[c1, c2] = [c2, c1];
}
const pairKey = `${p.entity.id}|${q.entity.id}`;
if (now.has(pairKey)) continue;
now.set(pairKey, { a: p.entity, b: q.entity });
first.set(pairKey, [this.engine!.getCollider(c1), this.engine!.getCollider(c2)]);
}
for (const [key, pair] of now) {
if (this.current.has(key)) {
now.set(key, this.current.get(key)!);
continue;
}
const [c1, c2] = first.get(key)!;
this.enters.push(Object.assign(pair, this.contactOf(c1, c2)));
entered.add(key);
}
for (const [key, pair] of this.current) if (!now.has(key)) this.exits.push(pair);
this.current = now;
}
/** How the pair met: the normal from the second collider towards the first, and the impulse. */
private contactOf(c1: Collider, c2: Collider): { normal: Vec2; impulse: number } {
let normal = { x: 0, y: 0 };
let impulse = 0;
this.engine!.contactPair(c1, c2, (manifold, flipped) => {
if (manifold.numContacts() === 0) return;
const n = manifold.normal();
// The manifold's normal runs from its first collider to its second; ours runs back to `a`.
const sign = flipped ? 1 : -1;
if (normal.x === 0 && normal.y === 0) normal = { x: sign * n.x, y: sign * n.y };
for (let i = 0; i < manifold.numContacts(); i++) impulse += manifold.contactImpulse(i);
});
return { normal, impulse: toPixels(impulse) };
}
/**
* Hands the dynamic bodies' poses to their objects, a parent before its children, drawn `alpha`
* of the way from the step before to the last one so the picture moves evenly between steps.
*/
private writeBack(alpha: number): void {
const dynamic = [...this.bodies].filter(([, body]) => body.kind === 'rigid' && body.body.isDynamic());
dynamic.sort(([a], [b]) => depthOf(a) - depthOf(b));
for (const [owner, body] of dynamic) {
const pose = blend(body.previous, body.current, alpha);
const parent = owner.parent ? worldMatrixOf(owner.parent) : IDENTITY;
const local = apply(invert(parent), pose);
owner.x = local.x;
owner.y = local.y;
if (body.turns) {
const turned = angleBetween(matrixPose(worldMatrixOf(owner)).angle, pose.angle);
const sign = determinant(parent) < 0 ? -1 : 1;
owner.props.rotation = Number(owner.props.rotation ?? 0) + (sign * turned * 180) / Math.PI;
}
body.placed = matrixPose(worldMatrixOf(owner));
}
}
}
interface Wanted {
kind: Body['kind'];
settings: RigidbodySettings | null;
colliders: Entity[];
}
function poseOf(body: RigidBody): Pose {
const t = body.translation();
return { x: toPixels(t.x), y: toPixels(t.y), angle: body.rotation() };
}
function matrixPose(m: Mat): Pose {
return { x: m.tx, y: m.ty, angle: Math.atan2(m.b, m.a) };
}
function samePose(a: Pose, b: Pose): boolean {
return (
Math.abs(a.x - b.x) < POSE_EPSILON &&
Math.abs(a.y - b.y) < POSE_EPSILON &&
Math.abs(angleBetween(a.angle, b.angle)) < POSE_EPSILON
);
}
/** The turn from `from` to `to`, the short way round. */
function angleBetween(from: number, to: number): number {
const d = (to - from) % (2 * Math.PI);
return d > Math.PI ? d - 2 * Math.PI : d < -Math.PI ? d + 2 * Math.PI : d;
}
function blend(a: Pose, b: Pose, t: number): Pose {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
angle: a.angle + angleBetween(a.angle, b.angle) * t,
};
}
/** A world shape as seen from a body at `origin`: offset and turn in the body's own frame. */
function relativeTo(origin: Pose, shape: ColliderShape): Pose {
const dx = shape.x - origin.x;
const dy = shape.y - origin.y;
const cos = Math.cos(origin.angle);
const sin = Math.sin(origin.angle);
return {
x: dx * cos + dy * sin,
y: -dx * sin + dy * cos,
angle: (shape.kind === 'box' ? shape.angle : 0) - origin.angle,
};
}
function areaOf(shape: ColliderShape): number {
return shape.kind === 'circle' ? Math.PI * shape.r * shape.r : shape.w * shape.h;
}
/** A tilemap's cells always block; a collider unless it is a trigger. */
function isSolid(entity: Entity): boolean {
return entity.type !== 'collider' || !isTrigger(entity.props);
}
function depthOf(entity: Entity): number {
return [...parentChain(entity, (e) => e.parent)].length;
}
function round(n: number): number {
return Math.round(n * 1e4) / 1e4;
}
+7
View File
@@ -15,6 +15,7 @@ export type PhysicsStatus =
let status: PhysicsStatus = { state: 'idle' };
let loading: Promise<Rapier> | null = null;
let loaded: Rapier | null = null;
/**
* Rapier is its own chunk with the WebAssembly inside. Every caller shares the one load, and a
@@ -27,6 +28,7 @@ export function loadPhysics(): Promise<Rapier> {
loading = import('@dimforge/rapier2d-compat').then(async ({ default: rapier }) => {
await rapier.init();
status = { state: 'ready', ms: Math.round(performance.now() - start), version: rapier.version() };
loaded = rapier;
return rapier;
});
loading.catch((error: unknown) => {
@@ -38,3 +40,8 @@ export function loadPhysics(): Promise<Rapier> {
export function physicsStatus(): PhysicsStatus {
return status;
}
/** The engine once it is loaded, for code that cannot wait: null while loading, failed or never asked. */
export function readyPhysics(): Rapier | null {
return loaded;
}
+32 -12
View File
@@ -26,7 +26,10 @@ import { loopsOf, volumeOf } from '../../core/properties/sounds';
import { type AudioSink, SILENT_AUDIO } from './Audio';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
import type { Rapier } from '../physics/engine';
import { PhysicsSystem } from '../physics/PhysicsSystem';
import { CollisionSystem } from './CollisionSystem';
import { bodyOf, type CollisionPair, type CollisionSource } from './solids';
import { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import type { System } from './System';
@@ -78,17 +81,26 @@ interface Instance {
*/
const ERROR_PREFIX = 'Error in behavior';
export interface GameSystemOptions {
shouldRun?: (entity: Entity) => boolean;
audio?: AudioSink;
/** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */
carried?: Readonly<SharedState> | null;
/**
* The loaded engine: a played game simulates its rigidbodies on it (#146). Without one the
* collisions are detected only, which is what the editor run still does.
*/
physics?: Rapier | null;
}
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
export function addGameSystems(
world: World,
project: Project,
input: Input,
shouldRun?: (entity: Entity) => boolean,
audio: AudioSink = SILENT_AUDIO,
/** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */
carried: Readonly<SharedState> | null = null,
{ shouldRun, audio = SILENT_AUDIO, carried = null, physics = null }: GameSystemOptions = {},
): BehaviorSystem {
const collisions = new CollisionSystem();
const collisions = physics ? new PhysicsSystem(physics) : new CollisionSystem();
const animations = new AnimationSystem();
const behaviors = new BehaviorSystem(project, input, { collisions, animations, audio }, shouldRun);
if (carried) behaviors.carryOver(carried);
@@ -100,7 +112,7 @@ export function addGameSystems(
/** The systems whose per-frame results become events; a missing one means no such events. */
export interface EventSources {
collisions?: CollisionSystem | null;
collisions?: (CollisionSource & System) | null;
animations?: AnimationSystem | null;
/** Where `Play sound` ends up. Absent is a run that plays silently, which is what a test wants. */
audio?: AudioSink | null;
@@ -121,7 +133,7 @@ export class BehaviorSystem implements System {
private pending: StateChange[] = [];
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
private declared: Record<string, PropertyBag> = {};
private readonly collisions: CollisionSystem | null;
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly audio: AudioSink;
@@ -423,15 +435,23 @@ export class BehaviorSystem implements System {
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 fire = (type: string, pairs: CollisionPair[], contact = false) => {
for (const { a, b, normal, impulse } 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 });
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
if (contact) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === a ? 1 : -1;
outputs.normalX = sign * (normal?.x ?? 0) || 0;
outputs.normalY = sign * (normal?.y ?? 0) || 0;
outputs.impulse = impulse ?? 0;
}
interpreter.fire(type, ctx, outputs);
}
};
fire('OnCollisionEnter', this.collisions.enters);
fire('OnCollisionEnter', this.collisions.enters, true);
fire('OnCollisionStay', this.collisions.stays);
fire('OnCollisionExit', this.collisions.exits);
}
+12 -208
View File
@@ -1,58 +1,28 @@
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, IDENTITY } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import {
type ColliderShape,
colliderShape,
exitDistance,
isTrigger,
type PlaneAxis,
shapeExtent,
shapesOverlap,
} from '../../core/properties/collider';
import { isHandleEnabled } from '../../core/graph';
import { shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import { bodyOf, type CollisionPair, type CollisionSource, isCollisionBody, partsOf, Solids } from './solids';
import type { System } from './System';
/** Overlaps up to this depth (world units) count as contact, not as "already inside". */
const SKIN = 0.05;
/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */
const MAX_STEPS = 256;
/**
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
*/
export interface CollisionPair {
a: Entity;
b: Entity;
}
/**
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
* collide. Runs before the BehaviorSystem so graphs see this frame's events.
* collide. Runs before the BehaviorSystem so graphs see this frame's events. This is the editor
* run's collision; a played game has the physics engine instead (`PhysicsSystem`, #146).
*
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
* are ground, not things that walk into each other.
*/
export class CollisionSystem implements System {
export class CollisionSystem implements System, CollisionSource {
enters: CollisionPair[] = [];
stays: CollisionPair[] = [];
exits: CollisionPair[] = [];
private previous = new Map<string, CollisionPair>();
/**
* The blocking rectangles of each tilemap, in cells. Worked out once per tick – painting, or a
* behavior changing the map, is picked up by the next one – and turned into world shapes only
* when asked, since the map may move in between.
*/
private rects = new Map<Entity, CellRect[]>();
private readonly solids = new Solids();
update(world: World): void {
this.rects = new Map();
this.solids.reset();
// Switched-off bodies (or ones below something switched off) take no part at all.
const bodies = world.entities.filter((e) => isCollisionBody(e) && isHandleEnabled(e));
const current = new Map<string, CollisionPair>();
@@ -75,7 +45,7 @@ export class CollisionSystem implements System {
stop(): void {
this.previous = new Map();
this.rects = new Map();
this.solids.reset();
this.enters = [];
this.stays = [];
this.exits = [];
@@ -83,8 +53,8 @@ export class CollisionSystem implements System {
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
private overlaps(world: World, a: Entity, b: Entity): boolean {
const others = this.shapesOf(world, b);
return this.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
const others = this.solids.shapesOf(world, b);
return this.solids.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
}
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
@@ -97,178 +67,12 @@ export class CollisionSystem implements System {
return false;
}
/**
* The shapes a body occupies in the world: one for a collider, one box per merged rectangle
* for a tilemap. A tilemap placed before the first tick is worked out on the spot.
*/
shapesOf(world: World, body: Entity): ColliderShape[] {
if (body.type !== 'tilemap') return [shapeOf(body)];
let rects = this.rects.get(body);
if (!rects) {
const asset = findAssetOf(world.project, body.assetId, 'tilemap');
rects = asset ? solidRects(world.project, body.props, asset) : [];
this.rects.set(body, rects);
}
const m = worldMatrixOf(body);
return rects.map((rect) => cellRectShape(body.props, rect, m));
}
/**
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
* The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it.
*/
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
const obstacles =
own.length === 0
? []
: world.entities
.filter(
(c) =>
isCollisionBody(c) &&
!(c.type === 'collider' && isTrigger(c.props)) &&
isHandleEnabled(c) &&
!travelsWith(c, entity),
)
.flatMap((c) => this.shapesOf(world, c));
return {
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
};
}
private slideAxis(
entity: Entity,
own: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
const travel = axisTravel(entity, axis);
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
);
for (let i = 0; i < steps; i++)
if (this.stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
private stepAxis(
entity: Entity,
own: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
travel: AxisTravel,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y };
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
// previous correction land, and taking those for "already inside" would open the wall.
const before = own.map((a) => {
const mover = shapeOf(a);
return obstacles.map((b) => {
const p = penetration(mover, b, forward);
return p > SKIN ? p : 0;
});
});
entity[axis] += delta;
let correction = 0;
own.forEach((a, i) => {
const mover = shapeOf(a);
obstacles.forEach((b, j) => {
const t = penetration(mover, b, forward) - before[i]![j]!;
if (t > correction) correction = t;
});
});
if (correction <= 0) return false;
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
return true;
return this.solids.moveAndSlide(world, entity, dx, dy);
}
}
/** Where one unit along an entity's local axis goes in the world, and how far. */
interface AxisTravel {
unit: Vec2;
length: number;
}
function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel {
const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY;
const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 });
const length = Math.hypot(v.x, v.y);
return length > 0
? { unit: { x: v.x / length, y: v.y / length }, length }
: { unit: { x: 1, y: 0 }, length: 1 };
}
function shapeOf(collider: Entity) {
return colliderShape(collider.props, worldMatrixOf(collider));
}
/**
* How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
* so nothing blocks – that keeps an object that ended up inside something able to leave.
*/
function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec2): number {
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
function travelsWith(collider: Entity, entity: Entity): boolean {
for (const e of parentChain(collider, (c) => c.parent)) if (e === entity) return true;
return false;
}
/**
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Entity[], obstacles: ColliderShape[], along: Vec2): number {
let min = Infinity;
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const shape of obstacles) min = Math.min(min, shapeExtent(shape, along));
return Math.max(1, min / 2);
}
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';
}
/**
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
*/
export function bodyOf(collider: Entity): Entity {
if (collider.type === 'tilemap') return collider;
return collider.parent ?? collider;
}
/** What a body touches with: a tilemap its cells, anything else its colliders (see `collidersOf`). */
function partsOf(world: World, entity: Entity): Entity[] {
return entity.type === 'tilemap' ? [entity] : collidersOf(world, entity);
}
/**
* A collider stands for itself; any other entity stands for its direct collider children – a
* tilemap as well when it is the one being moved: its cells only ever take part as an obstacle.
*/
function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
}
+245
View File
@@ -0,0 +1,245 @@
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, IDENTITY } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import {
type ColliderShape,
colliderShape,
exitDistance,
isTrigger,
type PlaneAxis,
shapeExtent,
} from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
/** Overlaps up to this depth (world units) count as contact, not as "already inside". */
const SKIN = 0.05;
/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */
const MAX_STEPS = 256;
/**
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
* `normal` points from `b` towards `a` and `impulse` is how hard they met; both are only known
* where the physics engine saw the contact begin (#146), and zero elsewhere.
*/
export interface CollisionPair {
a: Entity;
b: Entity;
normal?: Vec2;
impulse?: number;
}
/** What the behaviors read of collisions, whichever system works them out. */
export interface CollisionSource {
readonly enters: CollisionPair[];
readonly stays: CollisionPair[];
readonly exits: CollisionPair[];
touching(world: World, a: Entity, b: Entity): boolean;
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean };
}
/**
* The world as `Move` with "Collide with solids" sees it: the shapes of every collider and every
* placed tilemap, and the move that stops at them and slides along.
*/
export class Solids {
/**
* The blocking rectangles of each tilemap, in cells. Worked out once per tick – painting, or a
* behavior changing the map, is picked up by the next one – and turned into world shapes only
* when asked, since the map may move in between.
*/
private rects = new Map<Entity, CellRect[]>();
/** Forgets the rectangles, so the next tick works them out afresh. */
reset(): void {
this.rects = new Map();
}
rectsOf(world: World, tilemap: Entity): CellRect[] {
let rects = this.rects.get(tilemap);
if (!rects) {
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
rects = asset ? solidRects(world.project, tilemap.props, asset) : [];
this.rects.set(tilemap, rects);
}
return rects;
}
/**
* The shapes a body occupies in the world: one for a collider, one box per merged rectangle
* for a tilemap. A tilemap placed before the first tick is worked out on the spot.
*/
shapesOf(world: World, body: Entity): ColliderShape[] {
if (body.type !== 'tilemap') return [shapeOf(body)];
const m = worldMatrixOf(body);
return this.rectsOf(world, body).map((rect) => cellRectShape(body.props, rect, m));
}
/**
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
* The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it.
*/
moveAndSlide(
world: World,
entity: Entity,
dx: number,
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
const obstacles =
own.length === 0
? []
: world.entities
.filter(
(c) =>
isCollisionBody(c) &&
!(c.type === 'collider' && isTrigger(c.props)) &&
isHandleEnabled(c) &&
!travelsWith(c, entity),
)
.flatMap((c) => this.shapesOf(world, c));
return {
blockedX: slideAxis(entity, own, obstacles, 'x', dx),
blockedY: slideAxis(entity, own, obstacles, 'y', dy),
};
}
}
function slideAxis(
entity: Entity,
own: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
): boolean {
if (delta === 0 || own.length === 0) return false;
const travel = axisTravel(entity, axis);
const steps = Math.min(
MAX_STEPS,
Math.max(1, Math.ceil((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
);
for (let i = 0; i < steps; i++)
if (stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
function stepAxis(
entity: Entity,
own: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
travel: AxisTravel,
): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y };
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
// previous correction land, and taking those for "already inside" would open the wall.
const before = own.map((a) => {
const mover = shapeOf(a);
return obstacles.map((b) => {
const p = penetration(mover, b, forward);
return p > SKIN ? p : 0;
});
});
entity[axis] += delta;
let correction = 0;
own.forEach((a, i) => {
const mover = shapeOf(a);
obstacles.forEach((b, j) => {
const t = penetration(mover, b, forward) - before[i]![j]!;
if (t > correction) correction = t;
});
});
if (correction <= 0) return false;
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
return true;
}
/** Where one unit along an entity's local axis goes in the world, and how far. */
interface AxisTravel {
unit: Vec2;
length: number;
}
function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel {
const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY;
const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 });
const length = Math.hypot(v.x, v.y);
return length > 0
? { unit: { x: v.x / length, y: v.y / length }, length }
: { unit: { x: 1, y: 0 }, length: 1 };
}
export function shapeOf(collider: Entity): ColliderShape {
return colliderShape(collider.props, worldMatrixOf(collider));
}
/**
* How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
* so nothing blocks – that keeps an object that ended up inside something able to leave.
*/
function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec2): number {
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
function travelsWith(collider: Entity, entity: Entity): boolean {
for (const e of parentChain(collider, (c) => c.parent)) if (e === entity) return true;
return false;
}
/**
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Entity[], obstacles: ColliderShape[], along: Vec2): number {
let min = Infinity;
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const shape of obstacles) min = Math.min(min, shapeExtent(shape, along));
return Math.max(1, min / 2);
}
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
export function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';
}
/**
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
*/
export function bodyOf(collider: Entity): Entity {
if (collider.type === 'tilemap') return collider;
return collider.parent ?? collider;
}
/** What a body touches with: a tilemap its cells, anything else its colliders (see `collidersOf`). */
export function partsOf(world: World, entity: Entity): Entity[] {
return entity.type === 'tilemap' ? [entity] : collidersOf(world, entity);
}
/**
* A collider stands for itself; any other entity stands for its direct collider children – a
* tilemap as well when it is the one being moved: its cells only ever take part as an obstacle.
*/
function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
}
+10
View File
@@ -6,6 +6,7 @@ import {
CONTAINER_SYMBOL,
PARTICLES_SYMBOL,
placementOf,
RIGIDBODY_SYMBOL,
textAnchor,
} from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
@@ -160,6 +161,15 @@ export function drawItem(
node.rect(bx, by, 6, 6).fill(Theme.colors.container);
return;
}
// A weight: a ring with an arrow pointing the way gravity pulls.
case 'rigidbody': {
const r = RIGIDBODY_SYMBOL / 2;
const color = Theme.colors.collider;
node.circle(0, 0, r).fill({ color, alpha: 0.25 }).stroke({ width: 2, color });
node.moveTo(0, -6).lineTo(0, 4).stroke({ width: 2, color });
node.moveTo(-5, 1).lineTo(0, 7).lineTo(5, 1).closePath().fill(color);
return;
}
case 'text':
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
+1 -1
View File
@@ -249,7 +249,7 @@ describe('the reach of a row (#109)', () => {
it("keys the scene's own properties on the scene itself", () => {
const f = makeSpriteScene();
const walk = addAnimation(f, f.sceneId);
expect(animatableDefs('scene').map((d) => d.key)).toEqual(['backgroundColor']);
expect(animatableDefs('scene').map((d) => d.key)).toEqual(['backgroundColor', 'gravityX', 'gravityY']);
expect(() => f.stack.execute(new AddAnimationTrackCommand(walk.id, '', 'x'))).toThrow(CommandError);
addRow(f, walk, 'backgroundColor', [
[0, 0x000000],
+46 -31
View File
@@ -3,11 +3,20 @@
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
* difference between a collision that starts and one that continues.
*/
import { describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
import { loadPhysics } from '../../src/runtime/physics/engine';
import {
COLLISION_SYSTEMS,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
startWorld,
type SceneFixture,
} from '../helpers';
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
@@ -266,39 +275,45 @@ describe('On timer', () => {
});
});
describe('On collision stay', () => {
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, a));
f.stack.execute(new AddColliderCommand(f.sceneId, b));
place(f, behavior.id, { x: 0, y: 0 }, a);
const fixture = { ...f, behavior };
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
logOn(fixture, 'OnCollisionStay', {}, 'stay');
logOn(fixture, 'OnCollisionExit', {}, 'exit');
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
const crateB = world.findByObjectId(b)!;
describe('On collision stay', () => {
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
const f = makeSpriteScene('Crate');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
const behavior = makeBehavior(f, 'Hit');
const a = place(f, f.spriteId, { x: 0, y: 0 });
const b = place(f, f.spriteId, { x: 300, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, a));
f.stack.execute(new AddColliderCommand(f.sceneId, b));
place(f, behavior.id, { x: 0, y: 0 }, a);
const fixture = { ...f, behavior };
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
logOn(fixture, 'OnCollisionStay', {}, 'stay');
logOn(fixture, 'OnCollisionExit', {}, 'exit');
frame(0.1);
expect(world.log).toEqual([]);
const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true });
const crateB = world.findByObjectId(b)!;
crateB.x = 50; // now overlapping
frame(0.1);
expect(world.log).toEqual(['enter']);
frame(0.1);
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay']);
frame(0.1);
expect(world.log).toEqual([]);
crateB.x = 500;
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
frame(0.1);
expect(world.log).toHaveLength(4);
crateB.x = 50; // now overlapping
frame(0.1);
expect(world.log).toEqual(['enter']);
frame(0.1);
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay']);
crateB.x = 500;
frame(0.1);
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
frame(0.1);
expect(world.log).toHaveLength(4);
});
});
});
+184 -167
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
@@ -10,7 +10,16 @@ import {
shapeContains,
shapesOverlap,
} from '../../src/core/properties/collider';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
import { loadPhysics } from '../../src/runtime/physics/engine';
import {
COLLISION_SYSTEMS,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
startWorld,
} from '../helpers';
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
kind: 'box' as const,
@@ -116,9 +125,9 @@ function setup() {
}
/** The crates in a running world with collision detection. */
function runtime() {
function runtime(physics: boolean) {
const s = setup();
const { world, collisions } = startWorld(s, { collisions: true });
const { world, collisions } = startWorld(s, { physics, collisions: true });
return { ...s, world, collisions, crateA: world.findByObjectId(s.a)!, crateB: world.findByObjectId(s.b)! };
}
@@ -155,181 +164,189 @@ describe('AddColliderCommand', () => {
});
});
describe('CollisionSystem', () => {
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
const { world, collisions, crateA, crateB } = runtime();
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);
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
it('respects overrides and circles; two colliders of the same body never collide', () => {
const s = setup();
const { stack, sceneId, a, b, colA, colB } = s;
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, collisions } = startWorld(s, { collisions: true });
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('enter, stay and exit', () => {
it('reports enter, stay and exit as colliders move (parents carry their colliders)', () => {
const { world, collisions, crateA, crateB } = runtime(physics);
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 s = setup();
const { stack, sceneId, a, b, colA, colB } = s;
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, collisions } = startWorld(s, { physics, collisions: true });
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('move and slide', () => {
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
describe('move and slide', () => {
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
// Crates are 100 wide, 300 apart: 200 of travel before they touch (centres 100 apart).
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200); // touching edges do not overlap, so the wall keeps holding
expect(collisions.moveAndSlide(world, crateA, -30, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(170);
});
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime(physics);
// Crates are 100 wide, 300 apart: 200 of travel before they touch (centres 100 apart).
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200); // touching edges do not overlap, so the wall keeps holding
expect(collisions.moveAndSlide(world, crateA, -30, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(170);
});
it('slides along the wall: a diagonal move keeps its y component', () => {
const { world, collisions, crateA } = runtime();
expect(collisions.moveAndSlide(world, crateA, 250, 40)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(crateA.y).toBe(40);
});
it('slides along the wall: a diagonal move keeps its y component', () => {
const { world, collisions, crateA } = runtime(physics);
expect(collisions.moveAndSlide(world, crateA, 250, 40)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(crateA.y).toBe(40);
});
it('triggers report but never block; own and descendant colliders are ignored', () => {
const s = setup();
const { stack, sceneId, a, b, colB } = s;
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true));
const { world, collisions } = startWorld(s, { collisions: true });
const crateA = world.findByObjectId(a)!;
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(250);
world.tick(0.1);
expect(collisions.enters).toHaveLength(1);
// A collider parented under the mover travels with it and cannot block it.
const rider = world.entities.find((e) => e.type === 'collider' && e.parent === world.findByObjectId(b))!;
rider.props.isTrigger = false;
rider.parent = crateA;
rider.x = 150;
expect(collisions.moveAndSlide(world, crateA, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(260);
});
it('triggers report but never block; own and descendant colliders are ignored', () => {
const s = setup();
const { stack, sceneId, a, b, colB } = s;
// Make B's collider a trigger → A passes through, but the CollisionSystem still sees the overlap.
stack.execute(new SetOverrideCommand(sceneId, colB.objectId, 'isTrigger', true));
const { world, collisions } = startWorld(s, { physics, collisions: true });
const crateA = world.findByObjectId(a)!;
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(250);
world.tick(0.1);
expect(collisions.enters).toHaveLength(1);
// A collider parented under the mover travels with it and cannot block it.
const rider = world.entities.find(
(e) => e.type === 'collider' && e.parent === world.findByObjectId(b),
)!;
rider.props.isTrigger = false;
rider.parent = crateA;
rider.x = 150;
expect(collisions.moveAndSlide(world, crateA, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(260);
});
it('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 60; // start overlapped by 40
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either
expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-20); // moving away is always free
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-120);
});
it('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => {
const { world, collisions, crateA, crateB } = runtime(physics);
crateB.x = 60; // start overlapped by 40
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either
expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-20); // moving away is always free
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-120);
});
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 0;
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
for (let i = 0; i < 500; i++) {
// Uneven steps, so contact is never hit at a round number.
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
if (i > 60) expect(blockedY).toBe(true);
}
expect(crateA.y).toBeGreaterThan(240 - SKIN);
// A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
crateA.y = 0;
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
expect(crateA.y).toBeCloseTo(240, 6);
});
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
const { world, collisions, crateA, crateB } = runtime(physics);
crateB.x = 0;
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
for (let i = 0; i < 500; i++) {
// Uneven steps, so contact is never hit at a round number.
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
if (i > 60) expect(blockedY).toBe(true);
}
expect(crateA.y).toBeGreaterThan(240 - SKIN);
// A step larger than the obstacle must not jump over it: past the wall nothing overlaps to push back.
crateA.y = 0;
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
expect(crateA.y).toBeCloseTo(240, 6);
});
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
const s = setup();
const { behavior, a } = s;
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 1000, collide: true });
const log = edit.addNode('Log');
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
[move, 'exec', log, 'exec'],
[move, 'blockedX', log, 'message'],
]);
const { world } = startWorld(s, { collisions: true, behaviors: true });
const crateA = world.findByObjectId(a)!;
world.tick(0.1); // 100 units: free
expect(crateA.x).toBe(100);
world.tick(0.2); // 200 more would reach 300; the wall stops it at 200
expect(crateA.x).toBe(200);
expect(world.log).toEqual(['false', 'true']);
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
const s = setup();
const { behavior, a } = s;
// On update → Move(Owner, dx = 1000, dt, collide) → Log(blocked x)
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 1000, collide: true });
const log = edit.addNode('Log');
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
[move, 'exec', log, 'exec'],
[move, 'blockedX', log, 'message'],
]);
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
const crateA = world.findByObjectId(a)!;
world.tick(0.1); // 100 units: free
expect(crateA.x).toBe(100);
world.tick(0.2); // 200 more would reach 300; the wall stops it at 200
expect(crateA.x).toBe(200);
expect(world.log).toEqual(['false', 'true']);
});
});
});
describe('collision events in behaviors', () => {
it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => {
const s = setup();
const { behavior, b, object } = s;
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const exit = edit.addNode('OnCollisionExit');
const logExit = edit.addNode('Log', { message: 'bye' });
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'B' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[exit, 'exec', logExit, 'exec'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
object(b).name = 'B';
describe('collision events in behaviors', () => {
it("fires On collision enter/exit on the body's behavior with the other body; Is touching agrees", () => {
const s = setup();
const { behavior, b, object } = s;
// On collision enter → Log(other); On collision exit → Log("bye"); On update → Log(Is touching(Owner, Object by name "B"))
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const exit = edit.addNode('OnCollisionExit');
const logExit = edit.addNode('Log', { message: 'bye' });
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'B' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[exit, 'exec', logExit, 'exec'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
object(b).name = 'B';
const { world } = startWorld(s, { collisions: true, behaviors: true });
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']);
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
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']);
});
});
});
+1 -1
View File
@@ -115,7 +115,7 @@ describe('Load scene', () => {
behaviors.liveVariables(vars.id)!.score = 7;
const second = World.instantiate(f.project, f.otherId);
const carried = addGameSystems(second, f.project, input, undefined, undefined, behaviors.shared);
const carried = addGameSystems(second, f.project, input, { carried: behaviors.shared });
expect(carried.liveVariables(vars.id)?.score).toBe(7);
// A fresh run, by contrast, starts at what the asset declares.
+486
View File
@@ -0,0 +1,486 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { TilemapAsset } from '../../src/core/model/Asset';
import type { PropertyValue } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import type { Entity } from '../../src/runtime/Entity';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { PHYSICS_STEP, type PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem';
import type { CollisionPair } from '../../src/runtime/systems/solids';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
interface Thing {
/** The object's size; its collider is made to match. */
w: number;
h?: number;
circle?: boolean;
/** Overrides on the collider (friction, bounciness, density, isTrigger). */
collider?: Record<string, PropertyValue>;
/** With a rigidbody, and these overrides on it (bodyType, fixedRotation, …). */
body?: Record<string, PropertyValue>;
/** Overrides on the object itself (rotation, scale). */
props?: Record<string, PropertyValue>;
parent?: string;
}
/** A scene of sprites with colliders, each maybe with a rigidbody; `add` returns the object's id. */
function physicsScene() {
const f = makeSpriteScene('Box');
const bodies = new Map<string, string>();
const add = (x: number, y: number, thing: Thing): string => {
const id = place(f, f.spriteId, { x, y }, thing.parent);
const set = (objectId: string, key: string, value: PropertyValue) =>
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, key, value));
set(id, 'width', thing.w);
set(id, 'height', thing.h ?? thing.w);
for (const [key, value] of Object.entries(thing.props ?? {})) set(id, key, value);
const collider = new AddColliderCommand(f.sceneId, id);
f.stack.execute(collider);
if (thing.circle) {
set(collider.objectId, 'shape', 'circle');
set(collider.objectId, 'radius', thing.w / 2);
}
for (const [key, value] of Object.entries(thing.collider ?? {})) set(collider.objectId, key, value);
if (thing.body) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(thing.body)) set(rigidbody.objectId, key, value);
bodies.set(id, rigidbody.objectId);
}
return id;
};
const gravity = (x: number, y: number) => {
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityX', x));
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityY', y));
};
return { ...f, add, gravity, rigidbodyOf: (id: string) => bodies.get(id)! };
}
/** The ground used throughout: 400 × 40 at y = 200, its top face at y = 180. */
const GROUND: Thing = { w: 400, h: 40 };
function run(f: SceneFixture) {
const started = startWorld(f, { physics: true, collisions: true, behaviors: true });
const physics = started.collisions as PhysicsSystem;
const seconds = (s: number, each?: () => void) => {
for (let i = 0; i < Math.round(s * 60); i++) {
started.frame(1 / 60);
each?.();
}
};
const entity = (id: string) => started.world.findByObjectId(id)!;
return { ...started, physics, seconds, entity };
}
describe('AddRigidbodyCommand', () => {
it('puts a rigidbody below the object, one at most, and undo takes it away', () => {
const f = physicsScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });
const command = new AddRigidbodyCommand(f.sceneId, box);
f.stack.execute(command);
const asset = findAsset(f.project, command.assetId)!;
expect(asset).toMatchObject({ type: 'rigidbody', name: 'Box rigidbody' });
expect(asset.props).toMatchObject({
bodyType: 'dynamic',
mass: 0,
gravityScale: 1,
fixedRotation: false,
});
expect(f.object(command.objectId).parentId).toBe(box);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, box))).toThrow(/has a rigidbody already/);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, command.objectId))).toThrow(
/rigidbody cannot have a rigidbody/,
);
const collider = new AddColliderCommand(f.sceneId, box);
f.stack.execute(collider);
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, collider.objectId))).toThrow(
/collider cannot have a rigidbody/,
);
f.stack.undo();
f.stack.undo();
expect(f.project.assets.some((a) => a.id === command.assetId)).toBe(false);
expect(f.sceneOf().objects.some((o) => o.id === command.objectId)).toBe(false);
});
it('keeps the material and the body settings through a round trip', () => {
const f = physicsScene();
const box = f.add(0, 0, {
w: 32,
collider: { friction: 0.2, bounciness: 0.7 },
body: { bodyType: 'kinematic' },
});
const loaded = roundTrip(f.project);
const scene = loaded.assets.find((a) => a.id === f.sceneId)!;
expect(scene.props).toMatchObject({ gravityX: 0, gravityY: 980 });
const objects = scene.type === 'scene' ? scene.objects : [];
const child = (type: string) =>
objects.find((o) => o.parentId === box && findAsset(loaded, o.assetId)?.type === type)!;
expect(child('collider').overrides).toMatchObject({ friction: 0.2, bounciness: 0.7 });
expect(child('rigidbody').overrides).toMatchObject({ bodyType: 'kinematic' });
});
});
describe('falling and resting', () => {
it('a dynamic box falls at 980 per second squared and comes to rest on a collider without a rigidbody', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics } = run(f);
seconds(0.5);
// ½·g·t² = 122.5 after 30 steps; the picture is drawn between the last two steps, and with
// frames exactly one step long that is the one before: ½·g·(29/60)² ≈ 114.5.
expect(entity(box).y).toBeGreaterThan(114.5 - 2);
expect(entity(box).y).toBeLessThan(122.5 + 2);
seconds(2.5);
expect(entity(box).y).toBeCloseTo(180 - 16, 0);
expect(Math.abs(physics.velocityOf(entity(box))!.y)).toBeLessThan(1);
seconds(2);
expect(entity(box).y).toBeCloseTo(180 - 16, 0);
expect(entity(box).x).toBeCloseTo(0, 3);
});
it('comes to rest on a painted tilemap', () => {
const f = physicsScene();
const solid = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(solid);
f.stack.execute(map);
f.stack.execute(new SetAssetPropertyCommand(solid.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
place(f, map.asset.id, { x: 0, y: 0 });
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
// The bottom row: cells of 48, the grid centred on the origin, so its top face is at 4·48 = 192.
const row = Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: solid.asset.id }));
f.stack.execute(new PaintTilesCommand(map.asset.id, layer, row));
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(3);
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
});
it('a static, a switched-off or a weightless rigidbody stays where it is, and so does all in a scene without gravity', () => {
const f = physicsScene();
const still = f.add(-150, 0, { w: 32, body: { bodyType: 'static' } });
const weightless = f.add(-50, 0, { w: 32, body: { gravityScale: 0 } });
const off = f.add(50, 0, { w: 32, body: {} });
f.stack.execute(new SetOverrideCommand(f.sceneId, f.rigidbodyOf(off), 'enabled', false));
const falls = f.add(150, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(1);
expect(entity(still).y).toBe(0);
expect(entity(weightless).y).toBe(0);
expect(entity(off).y).toBe(0);
expect(entity(falls).y).toBeGreaterThan(400);
const g = physicsScene();
g.gravity(0, 0);
const floating = g.add(0, 0, { w: 32, body: {} });
const second = run(g);
second.seconds(1);
expect(second.entity(floating).y).toBe(0);
});
it('gravity is the scene’s own and can pull sideways', () => {
const f = physicsScene();
f.gravity(490, 0);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(1);
expect(Math.abs(entity(box).x - 245)).toBeLessThan(10);
expect(entity(box).y).toBeCloseTo(0, 6);
});
});
describe('the material', () => {
/** How high a ball dropped from 184 above the ground climbs again after its first bounce. */
function rebound(bounciness: number): number {
const f = physicsScene();
f.add(0, 200, GROUND);
const ball = f.add(0, 0, { w: 32, circle: true, body: {}, collider: { bounciness } });
const { seconds, entity } = run(f);
let landed = false;
let highest = Infinity;
seconds(4, () => {
const y = entity(ball).y;
if (y > 180 - 16 - 1) landed = true;
else if (landed) highest = Math.min(highest, y);
});
return Number.isFinite(highest) ? 180 - 16 - highest : 0;
}
it('bounciness 0 stays down, 0.5 climbs back a quarter of the drop, 1 nearly all of it', () => {
const drop = 180 - 16;
expect(rebound(0)).toBeLessThan(1);
const half = rebound(0.5) / drop;
expect(half).toBeGreaterThan(0.2);
expect(half).toBeLessThan(0.3);
expect(rebound(1) / drop).toBeGreaterThan(0.9);
});
/** A box set flat on a slope turned 30°, left to itself for half a second: how far it went. */
function slide(friction: number): number {
const f = physicsScene();
f.add(0, 200, { ...GROUND, props: { rotation: 30 }, collider: { friction } });
// The slope's top face, lifted by half the box: along the face normal (sin 30°, −cos 30°).
const lift = 20 + 16 + 0.5;
const x = lift * Math.sin(Math.PI / 6);
const y = 200 - lift * Math.cos(Math.PI / 6);
const box = f.add(x, y, { w: 32, props: { rotation: 30 }, body: {}, collider: { friction } });
const { seconds, entity } = run(f);
seconds(0.5);
return Math.hypot(entity(box).x - x, entity(box).y - y);
}
it('slides down a slope without friction and holds on it with enough', () => {
// Frictionless the slope pulls with g·sin 30° = 490: about 61 along it in half a second.
expect(slide(0)).toBeGreaterThan(40);
// tan 30° ≈ 0.58, so a friction of 1 holds it.
expect(slide(1)).toBeLessThan(2);
});
it('a push between two masses keeps the momentum', () => {
const f = physicsScene();
f.gravity(0, 0);
const light = f.add(0, 0, { w: 32, body: {}, collider: { bounciness: 1 } });
const heavy = f.add(100, 0, { w: 32, body: {}, collider: { bounciness: 1, density: 3 } });
const { seconds, entity, physics } = run(f);
seconds(1 / 60);
physics.setVelocity(entity(light), { x: 300, y: 0 });
seconds(1);
const after = physics.velocityOf(entity(light))!.x + 3 * physics.velocityOf(entity(heavy))!.x;
expect(physics.velocityOf(entity(heavy))!.x).toBeGreaterThan(100);
expect(Math.abs(after - 300)).toBeLessThan(3);
});
it('a fixed mass counts instead of the density', () => {
const f = physicsScene();
f.gravity(0, 0);
const light = f.add(0, 0, { w: 32, body: {}, collider: { bounciness: 1 } });
// Density 50 would make it fifty times the light one; the mass says three times.
const heavy = f.add(100, 0, {
w: 32,
body: { mass: 3 * 0.32 * 0.32 },
collider: { bounciness: 1, density: 50 },
});
const { seconds, entity, physics } = run(f);
seconds(1 / 60);
physics.setVelocity(entity(light), { x: 300, y: 0 });
seconds(1);
const after = physics.velocityOf(entity(light))!.x + 3 * physics.velocityOf(entity(heavy))!.x;
expect(Math.abs(after - 300)).toBeLessThan(3);
});
});
describe('turning', () => {
/** A box dropped with its centre just past the edge of a narrow ledge. */
function tip(body: Record<string, PropertyValue>): number {
const f = physicsScene();
f.add(0, 200, { w: 100, h: 40 });
const box = f.add(60, 150, { w: 32, body });
const { seconds, entity } = run(f);
seconds(1);
return Number(entity(box).props.rotation ?? 0);
}
it('a box falling past an edge tips over; with fixed rotation it does not', () => {
expect(Math.abs(tip({}))).toBeGreaterThan(5);
expect(tip({ fixedRotation: true })).toBe(0);
});
});
describe('objects and bodies', () => {
it('simulates below a turned parent in world units and hands back a local position', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const parent = place(f, f.spriteId, { x: 100, y: -100 });
f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90));
const box = f.add(20, 0, { w: 32, body: {}, parent });
const { seconds, entity } = run(f);
seconds(3);
const world = worldPositionOf(entity(box));
expect(world.x).toBeCloseTo(20, 1);
expect(world.y).toBeCloseTo(164, 0);
// Turned a quarter clockwise, the parent's own x axis points down the world's y.
expect(entity(box).x).toBeCloseTo(264, 0);
expect(Math.abs(Number(entity(box).props.rotation ?? 0))).toBeLessThan(0.5);
});
it('putting a falling object somewhere else keeps its speed', () => {
const f = physicsScene();
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics } = run(f);
seconds(0.5);
const speed = physics.velocityOf(entity(box))!.y;
expect(speed).toBeGreaterThan(400);
entity(box).y = -500;
seconds(1 / 60);
expect(physics.velocityOf(entity(box))!.y).toBeGreaterThan(speed);
expect(entity(box).y).toBeLessThan(-490);
});
it('a scaled object rests on its grown shape', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity } = run(f);
seconds(2);
expect(entity(box).y).toBeCloseTo(164, 0);
entity(box).props.scale = 2;
seconds(2);
expect(entity(box).y).toBeCloseTo(180 - 32, 0);
});
it('a kinematic body driven sideways pushes a dynamic box along the ground', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 164, { w: 32, body: {} });
const paddle = f.add(-40, 160, { w: 16, h: 30, body: { bodyType: 'kinematic' } });
const { seconds, entity } = run(f);
seconds(0.5);
const start = entity(box).x;
seconds(1, () => {
entity(paddle).x += 2;
});
// The paddle travels 120; the box, 24 ahead of it, is shoved along for the rest of the way.
expect(entity(box).x - start).toBeGreaterThan(80);
// Flush would be 24. While shoving the two sink into each other by about a step's travel, and
// the box is drawn a step behind – measured 20 at 2 units a frame – but it stays in front.
expect(entity(box).x - entity(paddle).x).toBeGreaterThan(16);
});
it('a kinematic body goes where it is put and carries nothing of gravity', () => {
const f = physicsScene();
const lift = f.add(0, 0, { w: 64, h: 16, body: { bodyType: 'kinematic' } });
const { seconds, entity } = run(f);
seconds(0.5);
expect(entity(lift).y).toBe(0);
entity(lift).x = 40;
seconds(0.5);
expect(entity(lift).x).toBe(40);
});
});
describe('the engine’s clock', () => {
function fallAfter(frames: readonly number[]): number {
const f = physicsScene();
const box = f.add(0, 0, { w: 32, body: {} });
const { world, entity } = run(f);
for (const dt of frames) world.tick(dt);
return entity(box).y;
}
it('steps the same whatever the frames were, and never catches up more than a few steps', () => {
const even = fallAfter(Array.from({ length: 60 }, () => 1 / 60));
const uneven: number[] = [];
for (let left = 1; left > 1e-9;) {
const dt = Math.min(left, [0.004, 0.031, 0.012, 0.02, 0.009][uneven.length % 5]!);
uneven.push(dt);
left -= dt;
}
expect(Math.abs(fallAfter(uneven) - even)).toBeLessThan(0.5);
// A frame of a whole second is a hitch: the world moves a few steps on, not sixty.
expect(fallAfter([1])).toBeLessThan(fallAfter(Array.from({ length: 9 }, () => PHYSICS_STEP)));
});
});
describe('collision events from the engine', () => {
it('a body falling through a trigger enters and leaves it and is not stopped', () => {
const f = physicsScene();
f.add(0, 100, { w: 200, h: 20, collider: { isTrigger: true } });
const ball = f.add(0, 0, { w: 16, circle: true, body: {} });
const { seconds, entity, physics } = run(f);
const enters: CollisionPair[] = [];
const exits: CollisionPair[] = [];
seconds(1, () => {
enters.push(...physics.enters);
exits.push(...physics.exits);
});
expect(enters).toHaveLength(1);
expect(exits).toHaveLength(1);
expect(entity(ball).y).toBeGreaterThan(300);
});
it('On collision enter tells which way the other side pushes and how hard they met', () => {
const f = physicsScene();
const ground = f.add(0, 200, GROUND);
const ball = f.add(0, 0, { w: 32, circle: true, body: {} });
for (const [owner, name] of [
[ball, 'Ball'],
[ground, 'Ground'],
] as const) {
const behavior = makeBehavior(f, name);
place(f, behavior.id, { x: 0, y: 0 }, owner);
const edit = graphEditor(f, behavior);
const enter = edit.addNode('OnCollisionEnter');
const normal = edit.addNode('Log');
const impulse = edit.addNode('Log');
edit.wire([
[enter, 'exec', normal, 'exec'],
[enter, 'normalY', normal, 'message'],
[normal, 'exec', impulse, 'exec'],
[enter, 'impulse', impulse, 'message'],
]);
}
const { seconds, world } = run(f);
seconds(1);
// Each side once: its normal, then the impulse.
expect(world.log).toHaveLength(4);
const [ballNormal, i1, groundNormal, i2] = world.log.map(Number) as [number, number, number, number];
// The ball's behavior runs first. The ground pushes the ball up; the ball pushes the ground down.
expect(ballNormal).toBe(-1);
expect(groundNormal).toBe(1);
// The ball of 32 hits at about √(2·980·164) ≈ 567 per second, and its mass is π·0.16² ≈ 0.08.
expect(i1).toBeGreaterThan(20);
expect(i2).toBe(i1);
});
it('two objects without a rigidbody only collide while they overlap, not while they merely touch', () => {
const f = physicsScene();
const a = f.add(0, 0, { w: 100 });
const b = f.add(100, 0, { w: 100 });
const { seconds, entity, physics, world } = run(f);
seconds(1 / 60);
expect(physics.touching(world, entity(a), entity(b))).toBe(false);
entity(b).x = 99.5;
seconds(1 / 60);
expect(physics.enters).toHaveLength(1);
entity(b).x = 100;
seconds(1 / 60);
expect(physics.exits).toHaveLength(1);
});
it('a switched-off collider leaves the pair it was in', () => {
const f = physicsScene();
f.add(0, 200, GROUND);
const box = f.add(0, 0, { w: 32, body: {} });
const { seconds, entity, physics, world } = run(f);
seconds(2);
expect(physics.stays).toHaveLength(1);
const collider = world.entities.find((e: Entity) => e.type === 'collider' && e.parent === entity(box))!;
collider.props.enabled = false;
seconds(1 / 60);
expect(physics.exits).toHaveLength(1);
// And without a collider nothing holds the box up.
seconds(1);
expect(entity(box).y).toBeGreaterThan(300);
});
});
+1 -1
View File
@@ -141,7 +141,7 @@ describe('serialize', () => {
expect(project.activeSceneId).toBe('s1');
expect(project.assets.map((a) => a.id)).toEqual(['s1', 'sp1']);
const scene = project.assets[0]!;
expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0 });
expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0, gravityX: 0, gravityY: 980 });
const objects = scene.type === 'scene' ? scene.objects : [];
expect(objects).toHaveLength(1);
expect(objects[0]).toEqual({
+206 -185
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
@@ -25,7 +25,16 @@ import {
solidRects,
} from '../../src/core/model/tilemapCollision';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
import { loadPhysics } from '../../src/runtime/physics/engine';
import {
COLLISION_SYSTEMS,
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
startWorld,
} from '../helpers';
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
@@ -320,202 +329,214 @@ describe('which cells block', () => {
});
});
describe('a tilemap in the CollisionSystem', () => {
it('stops a move flush at a solid column and lets it leave again', () => {
const s = setup();
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
// Right edge at 20, the wall's face at 48: 28 of travel.
expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 9);
expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 9);
expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false });
expect(hero.x).toBeCloseTo(-2, 9);
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
it('a step larger than a cell does not jump over the wall', () => {
const s = setup();
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 6);
});
describe('a tilemap in the CollisionSystem', () => {
it('stops a move flush at a solid column and lets it leave again', () => {
const s = setup();
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
// Right edge at 20, the wall's face at 48: 28 of travel.
expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 9);
expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 9);
expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false });
expect(hero.x).toBeCloseTo(-2, 9);
});
it('slides along a wall made of several rectangles without catching on their seams', () => {
const s = setup();
// The column with a bump on its far side in row 5: three rectangles stacked along the face.
s.paint([...COLUMN_SIX, [7, 5]]);
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(3);
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
hero.y = -200;
collisions.moveAndSlide(world, hero, 97.3, 0);
for (let i = 0; i < 60; i++) {
// Pressed against the face while walking down past both seams.
const { blockedX, blockedY } = collisions.moveAndSlide(world, hero, 3.1, 6.7);
expect(blockedX).toBe(true);
expect(blockedY).toBe(false);
}
expect(hero.x).toBeCloseTo(28, 6);
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
});
it('a step larger than a cell does not jump over the wall', () => {
const s = setup();
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false });
expect(hero.x).toBeCloseTo(28, 6);
});
it('a drawn-in wall lets the hero closer and stays one rectangle; slides past a bump on it', () => {
const s = setup();
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5));
s.paint(COLUMN_SIX);
// Inset a quarter cell (12 px) on the open sides: the face moves from 48 to 60.
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(1);
let started = startWorld(s, { collisions: true });
let hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 97.3, 0).blockedX).toBe(true);
expect(hero.x).toBeCloseTo(40, 9);
// A bump on the far side cuts the column into several rectangles; the face stays one plane.
s.paint([[7, 5]]);
expect(solidRects(s.project, s.assetOf().props, s.assetOf()).length).toBeGreaterThan(1);
started = startWorld(s, { collisions: true });
hero = started.world.findByObjectId(s.hero)!;
hero.y = -200;
started.collisions.moveAndSlide(started.world, hero, 97.3, 0);
for (let i = 0; i < 60; i++) {
const { blockedX, blockedY } = started.collisions.moveAndSlide(started.world, hero, 3.1, 6.7);
expect(blockedX).toBe(true);
expect(blockedY).toBe(false);
}
expect(hero.x).toBeCloseTo(40, 6);
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
});
it('slides along a wall made of several rectangles without catching on their seams', () => {
const s = setup();
// The column with a bump on its far side in row 5: three rectangles stacked along the face.
s.paint([...COLUMN_SIX, [7, 5]]);
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(3);
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
hero.y = -200;
collisions.moveAndSlide(world, hero, 97.3, 0);
for (let i = 0; i < 60; i++) {
// Pressed against the face while walking down past both seams.
const { blockedX, blockedY } = collisions.moveAndSlide(world, hero, 3.1, 6.7);
expect(blockedX).toBe(true);
expect(blockedY).toBe(false);
}
expect(hero.x).toBeCloseTo(28, 6);
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
});
it('a grown wall keeps the hero further off', () => {
const s = setup();
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 1.5));
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
collisions.moveAndSlide(world, hero, 97.3, 0);
expect(hero.x).toBeCloseTo(16, 9); // face at 48 − 12 = 36
});
it('a drawn-in wall lets the hero closer and stays one rectangle; slides past a bump on it', () => {
const s = setup();
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5));
s.paint(COLUMN_SIX);
// Inset a quarter cell (12 px) on the open sides: the face moves from 48 to 60.
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(1);
let started = startWorld(s, { physics, collisions: true });
let hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 97.3, 0).blockedX).toBe(true);
expect(hero.x).toBeCloseTo(40, 9);
// A bump on the far side cuts the column into several rectangles; the face stays one plane.
s.paint([[7, 5]]);
expect(solidRects(s.project, s.assetOf().props, s.assetOf()).length).toBeGreaterThan(1);
started = startWorld(s, { physics, collisions: true });
hero = started.world.findByObjectId(s.hero)!;
hero.y = -200;
started.collisions.moveAndSlide(started.world, hero, 97.3, 0);
for (let i = 0; i < 60; i++) {
const { blockedX, blockedY } = started.collisions.moveAndSlide(started.world, hero, 3.1, 6.7);
expect(blockedX).toBe(true);
expect(blockedY).toBe(false);
}
expect(hero.x).toBeCloseTo(40, 6);
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
});
it('grass blocks nothing, a switched-off map blocks nothing', () => {
const s = setup();
s.paint(COLUMN_SIX, s.grassId);
let started = startWorld(s, { collisions: true });
let hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
s.paint(COLUMN_SIX);
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'enabled', false));
started = startWorld(s, { collisions: true });
hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
});
it('a grown wall keeps the hero further off', () => {
const s = setup();
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 1.5));
s.paint(COLUMN_SIX);
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
collisions.moveAndSlide(world, hero, 97.3, 0);
expect(hero.x).toBeCloseTo(16, 9); // face at 48 − 12 = 36
});
it('follows the placed map: moved, grown and turned', () => {
const s = setup();
s.paint([[6, 5]]); // one cell: x 48…96, y 0…48 at scale 1
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2));
// Doubled: the cell spans x 96…192, y 0…96. The hero at y = 40 is in its rows.
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
hero.y = 40;
collisions.moveAndSlide(world, hero, 300, 0);
expect(hero.x).toBeCloseTo(76, 6);
// The map moved on by 100 takes its cell along at once: the shapes follow its current place.
const map = world.findByObjectId(s.mapObject)!;
map.x = 100;
collisions.moveAndSlide(world, hero, 300, 0);
expect(hero.x).toBeCloseTo(176, 6);
// Turned a quarter clockwise about its centre, the cell lies at x −96…0, y 96…192: out of the way.
map.x = 0;
map.props.rotation = 90;
hero.x = 0;
expect(collisions.moveAndSlide(world, hero, 300, 0).blockedX).toBe(false);
});
it('grass blocks nothing, a switched-off map blocks nothing', () => {
const s = setup();
s.paint(COLUMN_SIX, s.grassId);
let started = startWorld(s, { physics, collisions: true });
let hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
s.paint(COLUMN_SIX);
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'enabled', false));
started = startWorld(s, { physics, collisions: true });
hero = started.world.findByObjectId(s.hero)!;
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
});
it('picks up cells painted while running on the next tick', () => {
const s = setup();
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
s.paint(COLUMN_SIX);
world.tick(0.1);
expect(collisions.moveAndSlide(world, hero, 100, 0).blockedX).toBe(true);
});
it('follows the placed map: moved, grown and turned', () => {
const s = setup();
s.paint([[6, 5]]); // one cell: x 48…96, y 0…48 at scale 1
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2));
// Doubled: the cell spans x 96…192, y 0…96. The hero at y = 40 is in its rows.
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
hero.y = 40;
collisions.moveAndSlide(world, hero, 300, 0);
expect(hero.x).toBeCloseTo(76, 6);
// The map moved on by 100 takes its cell along at once: the shapes follow its current place.
const map = world.findByObjectId(s.mapObject)!;
map.x = 100;
collisions.moveAndSlide(world, hero, 300, 0);
expect(hero.x).toBeCloseTo(176, 6);
// Turned a quarter clockwise about its centre, the cell lies at x −96…0, y 96…192: out of the way.
map.x = 0;
map.props.rotation = 90;
hero.x = 0;
expect(collisions.moveAndSlide(world, hero, 300, 0).blockedX).toBe(false);
});
it('reports enter, stay and exit with the tilemap as a body of its own; two maps never meet', () => {
const s = setup();
s.paint(COLUMN_SIX);
place(s, s.mapId, { x: 0, y: 0 }); // a second placement right on top of the first
const { world, collisions } = startWorld(s, { collisions: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
expect(collisions.enters).toHaveLength(0);
hero.x = 40;
world.tick(0.1);
expect(collisions.enters).toHaveLength(2); // one per placed map, none between the maps
expect(collisions.enters.every((p) => p.a.type === 'tilemap' || p.b.type === 'tilemap')).toBe(true);
world.tick(0.1);
expect(collisions.stays).toHaveLength(2);
const map = world.findByObjectId(s.mapObject)!;
expect(collisions.touching(world, hero, map)).toBe(true);
hero.x = 0;
world.tick(0.1);
expect(collisions.exits).toHaveLength(2);
expect(collisions.touching(world, hero, map)).toBe(false);
it('picks up cells painted while running on the next tick', () => {
const s = setup();
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
s.paint(COLUMN_SIX);
world.tick(0.1);
expect(collisions.moveAndSlide(world, hero, 100, 0).blockedX).toBe(true);
});
it('reports enter, stay and exit with the tilemap as a body of its own; two maps never meet', () => {
const s = setup();
s.paint(COLUMN_SIX);
place(s, s.mapId, { x: 0, y: 0 }); // a second placement right on top of the first
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
expect(collisions.enters).toHaveLength(0);
hero.x = 40;
world.tick(0.1);
expect(collisions.enters).toHaveLength(2); // one per placed map, none between the maps
expect(collisions.enters.every((p) => p.a.type === 'tilemap' || p.b.type === 'tilemap')).toBe(true);
world.tick(0.1);
expect(collisions.stays).toHaveLength(2);
const map = world.findByObjectId(s.mapObject)!;
expect(collisions.touching(world, hero, map)).toBe(true);
hero.x = 0;
world.tick(0.1);
expect(collisions.exits).toHaveLength(2);
expect(collisions.touching(world, hero, map)).toBe(false);
});
});
});
describe('tilemap collisions in behaviors', () => {
it('On collision enter names the tilemap as the other body; Is touching agrees', () => {
const s = setup();
s.paint(COLUMN_SIX);
const behavior = makeBehavior(s, 'Bump');
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'Ground' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
const { world } = startWorld(s, { collisions: true, behaviors: true });
world.tick(0.1);
expect(world.log).toEqual(['false']);
world.findByObjectId(s.hero)!.x = 40;
world.tick(0.1);
expect(world.log).toEqual(['false', 'Ground', 'true']);
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
it('the Move node with "Collide with solids" stops at the painted wall', () => {
const s = setup();
s.paint(COLUMN_SIX);
const behavior = makeBehavior(s, 'Walk');
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 200, collide: true });
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
]);
const { world } = startWorld(s, { collisions: true, behaviors: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
expect(hero.x).toBeCloseTo(20, 9);
world.tick(0.1);
expect(hero.x).toBeCloseTo(28, 9);
describe('tilemap collisions in behaviors', () => {
it('On collision enter names the tilemap as the other body; Is touching agrees', () => {
const s = setup();
s.paint(COLUMN_SIX);
const behavior = makeBehavior(s, 'Bump');
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
const edit = graphEditor(s, behavior);
const enter = edit.addNode('OnCollisionEnter');
const logEnter = edit.addNode('Log');
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const byName = edit.addNode('ObjectByName', { name: 'Ground' });
const touching = edit.addNode('IsTouching');
const logTouch = edit.addNode('Log');
edit.wire([
[enter, 'exec', logEnter, 'exec'],
[enter, 'other', logEnter, 'message'],
[update, 'exec', logTouch, 'exec'],
[owner, 'object', touching, 'a'],
[byName, 'object', touching, 'b'],
[touching, 'result', logTouch, 'message'],
]);
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
world.tick(0.1);
expect(world.log).toEqual(['false']);
world.findByObjectId(s.hero)!.x = 40;
world.tick(0.1);
expect(world.log).toEqual(['false', 'Ground', 'true']);
});
it('the Move node with "Collide with solids" stops at the painted wall', () => {
const s = setup();
s.paint(COLUMN_SIX);
const behavior = makeBehavior(s, 'Walk');
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
const edit = graphEditor(s, behavior);
const update = edit.addNode('OnUpdate');
const owner = edit.addNode('Owner');
const move = edit.addNode('Move', { dx: 200, collide: true });
edit.wire([
[update, 'exec', move, 'exec'],
[update, 'dt', move, 'dt'],
[owner, 'object', move, 'object'],
]);
const { world } = startWorld(s, { physics, collisions: true, behaviors: true });
const hero = world.findByObjectId(s.hero)!;
world.tick(0.1);
expect(hero.x).toBeCloseTo(20, 9);
world.tick(0.1);
expect(hero.x).toBeCloseTo(28, 9);
});
});
});
+57 -47
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph';
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
import { makeSpriteScene, place, startWorld } from '../helpers';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
interface Node {
id: string;
@@ -39,52 +40,61 @@ describe('parentChain', () => {
});
});
describe('cycle-safe walks in the runtime', () => {
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
const brand = { [ENTITY_HANDLE]: true } as const;
const a: EntityHandle = {
...brand,
id: 'a',
assetId: 'x',
name: 'a',
x: 1,
y: 10,
z: 0,
props: {},
parent: null,
};
const b: EntityHandle = {
...brand,
id: 'b',
assetId: 'x',
name: 'b',
x: 2,
y: 20,
z: 0,
props: {},
parent: a,
};
a.parent = b;
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
// Guards `travelsWith`: a parent walk without a seen-set hangs here.
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
const moverCollider = new AddColliderCommand(f.sceneId, moverId);
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
f.stack.execute(moverCollider);
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { collisions: true });
const mover = world.findByObjectId(moverId)!;
const wall = world.findByObjectId(wallId)!;
const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!;
// Close the loop: the wall's collider is its child and the wall is parented to that collider.
wall.parent = wallColliderEntity;
describe('cycle-safe walks in the runtime', () => {
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
const brand = { [ENTITY_HANDLE]: true } as const;
const a: EntityHandle = {
...brand,
id: 'a',
assetId: 'x',
name: 'a',
x: 1,
y: 10,
z: 0,
props: {},
parent: null,
};
const b: EntityHandle = {
...brand,
id: 'b',
assetId: 'x',
name: 'b',
x: 2,
y: 20,
z: 0,
props: {},
parent: a,
};
a.parent = b;
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
});
expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(mover.x).toBe(10);
}, 2000);
// Guards `travelsWith`: a parent walk without a seen-set hangs here.
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
const moverCollider = new AddColliderCommand(f.sceneId, moverId);
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
f.stack.execute(moverCollider);
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { physics, collisions: true });
const mover = world.findByObjectId(moverId)!;
const wall = world.findByObjectId(wallId)!;
const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!;
// Close the loop: the wall's collider is its child and the wall is parented to that collider.
wall.parent = wallColliderEntity;
expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(mover.x).toBe(10);
// And a whole tick, which walks every collider up to the body it belongs to.
world.tick(1 / 60);
expect(collisions.enters).toEqual([]);
}, 2000);
});
});
+28 -3
View File
@@ -31,7 +31,10 @@ import type { SceneObject } from '../src/core/model/SceneObject';
import { deserializeProject, serializeProject } from '../src/core/store/serialize';
import type { AudioSink } from '../src/runtime/systems/Audio';
import { BehaviorSystem } from '../src/runtime/systems/BehaviorSystem';
import { readyPhysics } from '../src/runtime/physics/engine';
import { PhysicsSystem } from '../src/runtime/physics/PhysicsSystem';
import { CollisionSystem } from '../src/runtime/systems/CollisionSystem';
import type { CollisionSource } from '../src/runtime/systems/solids';
import { AnimationSystem } from '../src/runtime/systems/AnimationSystem';
import { Input } from '../src/runtime/systems/Input';
import { World } from '../src/runtime/World';
@@ -269,6 +272,11 @@ export function runNode(
export interface WorldOptions {
collisions?: boolean;
/**
* With `collisions`: the played game's physics engine rather than the editor run's detection
* (#146). The engine has to be loaded first – `await loadPhysics()` in a `beforeAll`.
*/
physics?: boolean;
animations?: boolean;
behaviors?: boolean;
/** Where `Play sound` ends up; without one the run plays to nobody (see `SILENT_AUDIO`). */
@@ -277,7 +285,7 @@ export interface WorldOptions {
export interface StartedWorld {
world: World;
collisions: CollisionSystem | null;
collisions: CollisionSource | null;
behaviors: BehaviorSystem | null;
/** Feed keys with `input.press('a')`; `frame()` clears the edges the way the editor does. */
input: Input;
@@ -289,11 +297,15 @@ export interface StartedWorld {
export function startWorld(
fixture: SceneFixture,
options: WorldOptions & { collisions: true },
): StartedWorld & { collisions: CollisionSystem };
): StartedWorld & { collisions: CollisionSource };
export function startWorld(fixture: SceneFixture, options?: WorldOptions): StartedWorld;
export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): StartedWorld {
const world = World.instantiate(fixture.project, fixture.sceneId);
const collisions = options.collisions ? new CollisionSystem() : null;
const collisions = options.collisions
? options.physics
? new PhysicsSystem(readyPhysics() ?? fail('load the physics engine before starting a world with it'))
: new CollisionSystem()
: null;
const input = new Input();
const animations = options.animations ? new AnimationSystem() : null;
if (collisions) world.addSystem(collisions);
@@ -314,6 +326,19 @@ export function startWorld(fixture: SceneFixture, options: WorldOptions = {}): S
};
}
/**
* Both ways a run detects collisions: the editor run's own and the played game's engine (#146).
* Everything without a rigidbody must come out the same on either.
*/
export const COLLISION_SYSTEMS = [
{ system: 'CollisionSystem', physics: false },
{ system: 'PhysicsSystem', physics: true },
] as const;
function fail(message: string): never {
throw new Error(message);
}
/** The project as it comes back after being saved and loaded again. */
export function roundTrip(project: Project): Project {
return deserializeProject(serializeProject(project));
+2 -2
View File
@@ -74,7 +74,7 @@ export async function assets(t) {
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
// what can be placed in a scene, a line, then what only lives in the library – and it is
// tall enough to show all seventeen types without scrolling.
// tall enough to show all eighteen types without scrolling.
await clickText('+');
const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null);
@@ -87,7 +87,7 @@ export async function assets(t) {
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === 17 &&
placeable.length + library.length === 18 &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),
+164 -2
View File
@@ -1,9 +1,32 @@
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
* whatever is drawn there instead.
*/
async function inView(t, panel, point) {
for (let i = 0; i < 15; i++) {
const rect = await t.json(panel);
const at = await t.json(point);
if (!rect) return null;
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
await t.settle();
}
return null;
}
/**
* The physics engine (Rapier) loads in the editor, in the background after the first project is
* open, from a chunk of its own – the editor bundle does not carry it.
* open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its
* rigidbodies on it (#146).
*/
export async function physics(t) {
const { check, json, waitUntil, consoleLog } = t;
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
t;
const { has, object, asset, objects, state, settle, sleep, play, stop, shot } = t;
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
const status = await json('__gm.physics()');
@@ -25,4 +48,143 @@ export async function physics(t) {
!consoleLog.some((line) => /\[physics\]|rapier/i.test(line)),
consoleLog.filter((line) => /\[physics\]|rapier/i.test(line)).join(' | '),
);
// 41b. A crate and a ball above a ground on a lane of their own, each with a collider and no
// rigidbody yet. The rigidbodies come from the hierarchy menu, the ball's bounciness is typed
// into its collider's field, and the played scene is sampled frame by frame.
const Y = LANES.physics;
const REST = Y + 200 - 20 - 20; // the ground's top face minus half of a 40-high body
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
const collider = (id, props) =>
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
collider('phys-col-ground', { width: 600, height: 40 });
collider('phys-col-crate', {});
collider('phys-col-ball', { shape: 'circle' });
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};
put('phys-ground', 'crate', '', 0, ${Y + 200}, 'Physics ground', { width: 600, height: 40 });
put('phys-crate', 'crate', '', -100, ${Y}, 'Physics crate', { width: 40, height: 40 });
put('phys-ball', 'crate', '', 100, ${Y}, 'Physics ball', { width: 40, height: 40 });
put('phys-ground-col', 'phys-col-ground', 'phys-ground', 0, 0);
put('phys-crate-col', 'phys-col-crate', 'phys-crate', 0, 0);
put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0);
__gm.state.select(null)`);
const selectObject = (id) => select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] });
const rigidbodyFromMenu = async (id) => {
await selectObject(id);
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
const before = (await objects()).length;
await click(row.x, row.y, 'right');
await clickText('Add rigidbody');
await press('Escape');
const s = await state();
const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
return { added: (await objects()).length - before, selected };
};
const crate = await rigidbodyFromMenu('phys-crate');
const crateBody = crate.selected;
check(
'Add rigidbody puts a dynamic rigidbody below the object and shows its settings',
crate.added === 1 &&
crateBody?.parentId === 'phys-crate' &&
(await asset(crateBody.assetId))?.type === 'rigidbody' &&
(await has('Body type')) &&
(await has('Dynamic')),
JSON.stringify(crate),
);
check(
'the rigidbody is drawn as an icon in the overlay',
crateBody && (await evaluate(`__gm.layerOf('${crateBody.id}')`)) === 'overlay',
);
const again = await rigidbodyFromMenu('phys-crate');
check('the menu offers no second rigidbody', again.added === 0, JSON.stringify(again));
const ballBody = (await rigidbodyFromMenu('phys-ball')).selected;
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
await settle();
await shot('41-rigidbody');
await selectObject('phys-ball-col');
const field = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Bounciness')");
if (field) {
await click(field.x, field.y);
await press('a', { ctrl: true });
await typeText('0.8');
await press('Enter');
}
await settle();
check(
"the collider's Bounciness field takes 0.8",
(await object('phys-ball-col'))?.overrides?.bounciness === 0.8,
JSON.stringify({ field, overrides: (await object('phys-ball-col'))?.overrides }),
);
await select(null);
await play();
await evaluate(`(() => {
const out = (window.__physicsSamples = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
out.push({ t: performance.now() - start, crate: y('phys-crate'), ball: y('phys-ball') });
if (performance.now() - start < 3000) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(3300);
const samples = await json('window.__physicsSamples');
await stop();
const last = samples.slice(-20).map((s) => s.crate);
check(
'the crate falls onto the ground and stays there',
samples.length > 100 && samples[0].crate < REST - 50 && last.every((y) => Math.abs(y - REST) < 1),
JSON.stringify({ frames: samples.length, first: samples[0], last: last.slice(-3), rest: REST }),
);
// The heights the ball climbs back to: the highest point between two landings.
const peaks = [];
let landings = 0;
let down = false;
let top = Infinity;
for (const { ball } of samples) {
if (ball > REST - 1) {
if (!down && landings++ > 0 && Number.isFinite(top)) peaks.push(REST - top);
down = true;
top = Infinity;
} else {
down = false;
if (landings > 0) top = Math.min(top, ball);
}
}
const drop = REST - Y;
check(
'the ball bounces back up and every bounce is lower than the one before',
peaks.length >= 2 &&
peaks[0] / drop > 0.4 &&
peaks[0] / drop < 0.8 &&
peaks.every((h, i) => i === 0 || h < peaks[i - 1]),
JSON.stringify({ drop, peaks: peaks.map((h) => Math.round(h * 10) / 10), frames: samples.length }),
);
check(
'playing left the scene as it was',
(await object('phys-crate'))?.position.y === Y && (await object('phys-ball'))?.position.y === Y,
);
const bodies = [crateBody?.id, ballBody?.id].filter(Boolean);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const gone = new Set(${JSON.stringify(bodies)});
const assets = new Set(scene.objects.filter((o) => gone.has(o.id)).map((o) => o.assetId));
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && !gone.has(o.id));
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-col-') && !assets.has(a.id));
__gm.state.select(null)`);
}
+1
View File
@@ -21,6 +21,7 @@ export const LANES = {
particles: -2300,
sound: -2700,
tilemap: -3100,
physics: -3500,
};
/**