diff --git a/README.md b/README.md index 72fc0b9f..5e75632c 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index c293b7c8..3254b1d5 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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': diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 624d36fc..307828ed 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -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); + } +} diff --git a/src/core/graph/nodes/events/OnCollisionEnter.ts b/src/core/graph/nodes/events/OnCollisionEnter.ts index 36bf4d26..d690cc03 100644 --- a/src/core/graph/nodes/events/OnCollisionEnter.ts +++ b/src/core/graph/nodes/events/OnCollisionEnter.ts @@ -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: [], }; diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 3b0cdf33..0e532275 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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. */ diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index 549c41b8..8068c85b 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -19,6 +19,7 @@ export const PROPERTY_TARGETS = [ 'sprite', 'text', 'collider', + 'rigidbody', 'camera', 'container', 'behavior', diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 7baa4baf..26a3bdb7 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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 diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 44612aea..008fe258 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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' + ); } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 35e6c85c..680e0c1a 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { - 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 = { { 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: [], diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 3d314e5f..b8c07185 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -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': diff --git a/src/core/properties/rigidbody.ts b/src/core/properties/rigidbody.ts new file mode 100644 index 00000000..aa6aacea --- /dev/null +++ b/src/core/properties/rigidbody.ts @@ -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) }; +} diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 75637d13..bdda2c87 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 16a8fda7..700aa31e 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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() diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 5ca3dd15..57a54011 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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)); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 6fa706bf..2551d9cd 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -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)), diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 06843dfb..5712ded7 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -112,6 +112,7 @@ const OFF_GIZMO_ALPHA = 0.45; const LINK_COLORS: Partial> = { behavior: Theme.colors.behavior, particles: Theme.colors.particles, + rigidbody: Theme.colors.collider, }; export class SceneViewport extends FreeViewport { diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts new file mode 100644 index 00000000..15f744ad --- /dev/null +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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(); + private readonly parts = new Map(); + /** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */ + private readonly candidates = new Map(); + private current = new Map(); + 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(); + 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 { + const owners = new Map(); + 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(); + 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): 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(); + const first = new Map(); + 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; +} diff --git a/src/runtime/physics/engine.ts b/src/runtime/physics/engine.ts index 49c1fd58..acc4707f 100644 --- a/src/runtime/physics/engine.ts +++ b/src/runtime/physics/engine.ts @@ -15,6 +15,7 @@ export type PhysicsStatus = let status: PhysicsStatus = { state: 'idle' }; let loading: Promise | 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 { 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 { 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; +} diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 06811593..0c63f742 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 | 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 | 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 = {}; - 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 = { 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); } diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 17107726..840f748e 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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(); - /** - * 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(); + 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(); @@ -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)); -} diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts new file mode 100644 index 00000000..cea1e1d1 --- /dev/null +++ b/src/runtime/systems/solids.ts @@ -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(); + + /** 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)); +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index c3c64804..69253ca1 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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, diff --git a/tests/core/animation-preview.test.ts b/tests/core/animation-preview.test.ts index aa9a43b8..127267da 100644 --- a/tests/core/animation-preview.test.ts +++ b/tests/core/animation-preview.test.ts @@ -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], diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index f55d3650..464794f9 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -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); + }); }); }); diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index 7b2c6530..c873c1f4 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -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']); + }); }); }); diff --git a/tests/core/load-scene.test.ts b/tests/core/load-scene.test.ts index 1feea1cf..df417813 100644 --- a/tests/core/load-scene.test.ts +++ b/tests/core/load-scene.test.ts @@ -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. diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts new file mode 100644 index 00000000..693dcfb4 --- /dev/null +++ b/tests/core/physics-system.test.ts @@ -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; + /** With a rigidbody, and these overrides on it (bodyType, fixedRotation, …). */ + body?: Record; + /** Overrides on the object itself (rotation, scale). */ + props?: Record; + 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(); + 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): 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); + }); +}); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 270de4d2..0ea1d606 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -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({ diff --git a/tests/core/tilemap-collision.test.ts b/tests/core/tilemap-collision.test.ts index f890e4df..bf23174a 100644 --- a/tests/core/tilemap-collision.test.ts +++ b/tests/core/tilemap-collision.test.ts @@ -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); + }); }); }); diff --git a/tests/core/walk.test.ts b/tests/core/walk.test.ts index 3e1b0d88..11ff98ef 100644 --- a/tests/core/walk.test.ts +++ b/tests/core/walk.test.ts @@ -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); + }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 294a6564..1b29e132 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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)); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 58fd338f..4e3fd07b 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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 }), diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index ec993de2..4a2190ab 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -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)`); } diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 846eda14..cf5952f1 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -21,6 +21,7 @@ export const LANES = { particles: -2300, sound: -2700, tilemap: -3100, + physics: -3500, }; /**