From 7dd3ae11310ff7b840b24858ba7b6bd53564c721 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 2 Oct 2026 21:43:12 +0200 Subject: [PATCH] Tie two objects with a hinge, a spring, a rope or for good (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bodies could not be connected: no door on a hinge, no lamp on a rope, no wheel on an axle. This is the first step of the issue, the four joints without a motor. The slider and the motors follow in a second step under the same issue. - Joint is a new asset type, placed as a child of the object it holds, like a collider or a rigidbody. "Add joint…" in the object menu makes one or places one the project has. An object takes as many as it needs. - Where the joint sits is the point it holds its object at. A hinge and a fixed joint have that one point only. A spring and a rope end at a connected anchor on the other object, counted in that object's own units, or in the scene's without one. - The rows follow the type: length, stiffness and damping for a spring, length for a rope, an angle limit for a hinge. Length 0 takes the distance the two ends were placed at. The limit counts from how the two were placed. - The connected object is an object reference in the schema of the type. Pasting, containers and removing treat it as they treat every reference since #258. - shownFor is a new schema option: a row is shown while an enum of the same bag holds one of the named values. - The object needs a rigidbody, its own or one further up. Without one the joint holds nothing and the console says so once. - A connected object without a rigidbody is a moving anchor: the joint has a kinematic body of its own that follows it. - A connected object destroyed while playing makes the joint let go with a hint. Removed in the editor the reference empties, so the joint holds on to the scene. The issue asked for "inactive" there. - "Add joint" ties two objects while the game runs, from a joint asset as the template. Destroy on the joint lets go, there is no Break joint node. Destroy with keep children takes the joints along with the object. - The scene view draws a line from each joint to what it holds on to and rings the held point. A selected spring or rope has a round handle on its far end, and dragging it is one undo step. - Joints hold in Play only. The editor run simulates nothing (#148), and the inspector of a placed joint says so. - Rapier counts a revolute joint's limits absolutely, not from the start, so the placed angle is added. Measured with a probe. - The + menu has 25 types now and no longer fits under its button on a canvas 800 high. It scrolls there, and e2e 04 scrolls to the last entry instead of expecting it in view. Tests: - Gate on melektaus-atx for this state: 218 files, 3390 tests (3336 + 54), exit 0. - e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all pass. Module 41 alone: 25 checks (21 + 4). - 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum, limit, fixed, the other end, the node, containers, paste), 6 for the line the scene view draws, 4 catalog cases of the node. The remaining 4 come from tests that make a case per asset type. - Four new e2e checks in module 41: Add joint from the menu, the rope to the hook with its line and handle, dragging the handle with one undo, the bob hanging in Play next to one that falls. - e2e 04, 40 and 46 count the new type in their lists. - 21 of 22 unit counter-probes fail their tests. The other one is equivalent: Rapier ignores removing a joint that left with its body, the guard stays. - Three browser counter-probes fail their checks (2, 1 and 2). - One screenshot looked at: icon, line, handle and the rope rows. - The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI on e066613). The full suite and the smokes did not run. - Not seen in the browser: spring, hinge with limit and fixed joint, the node Add joint, a connected object that moves, joints inside a container, the released game. Unit tests cover them but the last. Limits under a mirrored parent are not handled. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/assetCommands.ts | 2 + src/core/commands/colliderCommands.ts | 35 +- src/core/graph/NodeContext.ts | 10 + src/core/graph/nodes/action/AddJoint.ts | 49 ++ src/core/graph/nodes/action/Destroy.ts | 2 +- src/core/graph/nodes/index.ts | 2 + src/core/graph/propertyPorts.ts | 1 + src/core/model/Asset.ts | 9 + src/core/model/AssetTypes.ts | 3 + src/core/model/joint.ts | 34 ++ src/core/model/sceneVisibility.ts | 1 + src/core/properties/PropertySchema.ts | 112 +++- src/core/properties/bounds.ts | 3 + src/core/properties/joint.ts | 90 +++ src/editor/actions.ts | 15 + src/editor/debug/sceneHooks.ts | 2 + src/editor/inspector/objectSection.ts | 5 + src/editor/objectMenu.ts | 15 +- src/editor/panels/assetGroups.ts | 1 + src/editor/panels/assetIcons.ts | 2 + src/editor/panels/icons/joint.svg | 6 + src/editor/viewport/SceneViewport.ts | 49 +- src/editor/viewport/jointAnchor.ts | 138 +++++ src/editor/viewport/sizeGizmoEditor.ts | 2 +- src/runtime/World.ts | 6 +- src/runtime/physics/PhysicsSystem.ts | 27 +- src/runtime/physics/joints.ts | 251 +++++++++ src/runtime/systems/behaviorContext.ts | 13 +- src/viewport/jointLinks.ts | 49 ++ src/viewport/symbolIcons.ts | 5 +- src/viewport/symbols.ts | 1 + tests/core/joints.test.ts | 706 ++++++++++++++++++++++++ tests/core/node-catalog.test.ts | 39 ++ tests/editor/scene-visibility.test.ts | 1 + tests/helpers.ts | 1 + tests/ui/svg-icons.test.ts | 1 + tests/viewport/joint-links.test.ts | 89 +++ tools/e2e/04-assets.mjs | 18 +- tools/e2e/40-tilemap.mjs | 1 + tools/e2e/41-physics.mjs | 145 +++++ tools/e2e/46-topbar.mjs | 1 + 42 files changed, 1917 insertions(+), 27 deletions(-) create mode 100644 src/core/graph/nodes/action/AddJoint.ts create mode 100644 src/core/model/joint.ts create mode 100644 src/core/properties/joint.ts create mode 100644 src/editor/panels/icons/joint.svg create mode 100644 src/editor/viewport/jointAnchor.ts create mode 100644 src/runtime/physics/joints.ts create mode 100644 src/viewport/jointLinks.ts create mode 100644 tests/core/joints.test.ts create mode 100644 tests/viewport/joint-links.test.ts diff --git a/README.md b/README.md index fc7e0724..10d922be 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1687 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1691 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 84655060..31e7127b 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -61,6 +61,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'collider' }; case 'rigidbody': return { ...base, type: 'rigidbody' }; + case 'joint': + return { ...base, type: 'joint' }; case 'container': return { ...base, type: 'container', objects: [] }; // A drawing starts empty: the pen tool of #129 sets the first shape down, on its one layer. diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 5404524e..c96b1f22 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -16,10 +16,10 @@ import { nextAssetName } from './naming'; import { requireAsset, requireHolder, requireStructuralObject } from './require'; import { ObjectEditCommand } from './sceneObjectCommands'; -type PartType = 'collider' | 'rigidbody'; +type PartType = 'collider' | 'rigidbody' | 'joint'; /** - * A collider or a rigidbody placed as a child at the object's centre: a new asset, or – given + * A collider, a rigidbody or a joint placed as a child at the object's centre: a new asset, or – given * `existingAssetId` (#222) – one that is there already, shared with wherever else it is placed. */ abstract class AddPartCommand implements Command { @@ -111,7 +111,7 @@ export class AddColliderCommand extends AddPartCommand { /** 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') + if (type === 'collider' || type === 'rigidbody' || type === 'behavior' || type === 'joint') 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)) @@ -136,6 +136,35 @@ export class AddRigidbodyCommand extends AddPartCommand { } } +/** + * Why the object cannot take a joint, or null when it can. It may hold several, and needs no + * rigidbody yet: a joint on an object without one holds nothing and says so when played (#154). + */ +export function jointRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { + const type = findAsset(project, target.assetId)?.type; + if (!type) return 'The object has no asset'; + if (type === 'collider' || type === 'rigidbody' || type === 'behavior' || type === 'joint') + return `A ${type} cannot have a joint`; + if (type === 'tilemap') return 'A tilemap is static ground and takes no joint'; + if (isLiveInstance(project, holder, target)) + return 'A placed container shows its template; add the joint inside it'; + return null; +} + +/** A new joint asset has the schema defaults: a hinge at the object's centre, held to the scene. */ +export class AddJointCommand extends AddPartCommand { + readonly label = 'Add joint'; + protected readonly partType = 'joint'; + + protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null { + return jointRefusal(project, holder, target); + } + + protected build(project: Project, _target: SceneObject, _targetAsset: Asset, owner: string): Asset { + return buildAsset('joint', nextAssetName(project, `${owner} joint`), this.assetId); + } +} + function spriteSize(project: Project, asset: Asset, object: SceneObject): { width: number; height: number } { const props = resolveSceneObjectProps(project, asset, object); return { width: Number(props.width), height: Number(props.height) }; diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 0e37e0d4..a1b735cf 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -241,6 +241,16 @@ export interface NodeContext { position: { x: number; y: number; z: number }, parent: EntityHandle | null, ): EntityHandle | null; + /** + * Places the joint asset on `owner` at `at` of that object and connects it to `connected`, the + * scene when null (#154). Null when the asset is no joint or the object is gone. + */ + addJoint( + assetId: string, + owner: EntityHandle, + connected: EntityHandle | null, + at: { x: number; y: number }, + ): EntityHandle | null; /** * Removes `target` with everything below it. With `keepChildren` only the object goes: its * children move up to its parent where they stand, and are answered, nearest level only. diff --git a/src/core/graph/nodes/action/AddJoint.ts b/src/core/graph/nodes/action/AddJoint.ts new file mode 100644 index 00000000..2fc5a111 --- /dev/null +++ b/src/core/graph/nodes/action/AddJoint.ts @@ -0,0 +1,49 @@ +import type { PropertyDef } from '../../../properties/PropertySchema'; +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asEntity, asNumber, asString } from '../../values'; +import { exec, num, obj } from '../shared'; + +const jointParam: PropertyDef = { + key: 'jointAsset', + label: 'Joint', + kind: 'asset', + default: '', + assetTypes: ['joint'], +}; + +/** The joint asset a new joint takes its type and values from; unconnected, it shows the picker. */ +const jointPort = (): PortDef => ({ + key: 'jointAsset', + label: 'joint asset', + type: 'string', + field: jointParam, +}); + +export const AddJoint: NodeDef = { + type: 'AddJoint', + label: 'Add joint', + category: 'action', + description: + 'Ties an object to another while the game runs: places the picked Joint asset on "object" at ' + + '(x, y) of that object and connects it to "connected" – or, with nothing there, to the scene. The ' + + 'asset says what kind of joint it is and how long, stiff or limited. Outputs the new joint; Destroy ' + + 'on it lets go again. The object needs a rigidbody, and joints only hold while the game is played.', + inputs: [exec(), jointPort(), obj('object'), obj('connected'), num('x'), num('y')], + outputs: [exec(), obj('joint')], + params: [], + run: (ctx, inputs) => { + const assetId = asString(inputs.jointAsset); + const owner = asEntity(inputs.object); + const at = { x: asNumber(inputs.x), y: asNumber(inputs.y) }; + const joint = assetId && owner ? ctx.addJoint(assetId, owner, asEntity(inputs.connected), at) : null; + if (!joint) + ctx.log( + !assetId + ? 'Add joint: no joint selected' + : !owner + ? 'Add joint: no object to put it on' + : `Add joint: asset ${assetId} is no joint`, + ); + return { next: 'exec', outputs: { joint } }; + }, +}; diff --git a/src/core/graph/nodes/action/Destroy.ts b/src/core/graph/nodes/action/Destroy.ts index cf2472fd..5e15f9d0 100644 --- a/src/core/graph/nodes/action/Destroy.ts +++ b/src/core/graph/nodes/action/Destroy.ts @@ -9,7 +9,7 @@ export const Destroy: NodeDef = { description: 'Removes an object from the running game, with everything below it. With "keep children" only the ' + 'object goes: its children move up to its parent and stay where they are in the world, and come out ' + - 'as "children" – the nearest level only. Its behaviors and its rigidbody go with it either way.', + 'as "children" – the nearest level only. Its behaviors, its rigidbody and its joints go with it either way.', inputs: [exec(), obj('object'), withDefault(bool('keepChildren', 'keep children'), false)], outputs: [exec(), list('children')], params: [], diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index d03b5455..72a4482e 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -1,4 +1,5 @@ import { type NodeDef, registerNode } from '../NodeRegistry'; +import { AddJoint } from './action/AddJoint'; import { Destroy } from './action/Destroy'; import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes'; import { Emit } from './action/Emit'; @@ -275,6 +276,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ TileLiteral, Spawn, Destroy, + AddJoint, Emit, PlayAnimation, PauseAnimation, diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index 42287c71..2ccef72a 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -20,6 +20,7 @@ const PROPERTY_TARGETS = [ 'text', 'collider', 'rigidbody', + 'joint', 'camera', 'container', 'clip', diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 51e1de14..e3b1fef5 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -129,6 +129,14 @@ export interface RigidbodyAsset extends AssetBase { type: 'rigidbody'; } +/** + * Placed as a child of the object it holds – as many as that object needs. Where it sits is the + * point it holds the object at; its props say to what and how (see `properties/joint.ts`, #154). + */ +export interface JointAsset extends AssetBase { + type: 'joint'; +} + /** * `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 @@ -525,6 +533,7 @@ export type Asset = | StructAsset | ColliderAsset | RigidbodyAsset + | JointAsset | ContainerAsset | TextureAsset | VectorArtAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 8591355c..e21d049b 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -8,6 +8,7 @@ export const ASSET_TYPES = [ 'struct', 'collider', 'rigidbody', + 'joint', 'container', 'clip', 'texture', @@ -37,6 +38,7 @@ export const ASSET_TYPE_LABELS: Record = { struct: 'Struct', collider: 'Collider', rigidbody: 'Rigidbody', + joint: 'Joint', container: 'Container', clip: 'Clip area', texture: 'Texture', @@ -113,6 +115,7 @@ export function isSymbolType(type: AssetType): boolean { type === 'behavior' || type === 'collider' || type === 'rigidbody' || + type === 'joint' || type === 'container' || type === 'clip' ); diff --git a/src/core/model/joint.ts b/src/core/model/joint.ts new file mode 100644 index 00000000..de26114d --- /dev/null +++ b/src/core/model/joint.ts @@ -0,0 +1,34 @@ +/** + * A joint ties the object it is placed below to another one (#154): a hinge turns round one point, a + * spring pulls towards a length, a rope allows no more than one, and a fixed joint keeps the two as + * they were placed. These are the keys of its rows; what the engine reads off them is in + * `properties/joint.ts`. + */ +export const JOINT_TYPES = ['hinge', 'spring', 'rope', 'fixed'] as const; +export type JointType = (typeof JOINT_TYPES)[number]; + +export const JOINT_TYPE_LABELS: Record = { + hinge: 'Hinge', + spring: 'Spring', + rope: 'Rope', + fixed: 'Fixed', +}; + +export const JOINT_TYPE_KEY = 'jointType'; +export const JOINT_CONNECTED_KEY = 'connected'; +export const JOINT_ANCHOR_X_KEY = 'connectedAnchorX'; +export const JOINT_ANCHOR_Y_KEY = 'connectedAnchorY'; +export const JOINT_LENGTH_KEY = 'length'; +export const JOINT_STIFFNESS_KEY = 'stiffness'; +export const JOINT_DAMPING_KEY = 'damping'; +export const JOINT_LIMIT_KEY = 'limitAngle'; +export const JOINT_MIN_ANGLE_KEY = 'minAngle'; +export const JOINT_MAX_ANGLE_KEY = 'maxAngle'; +export const JOINT_COLLIDE_KEY = 'collideConnected'; + +/** The types with a point of their own on the other end; a hinge and a fixed joint have the one they sit on. */ +export const TWO_ANCHOR_TYPES: readonly JointType[] = ['spring', 'rope']; + +export function hasConnectedAnchor(type: JointType): boolean { + return TWO_ANCHOR_TYPES.includes(type); +} diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts index b2130507..4cba77ac 100644 --- a/src/core/model/sceneVisibility.ts +++ b/src/core/model/sceneVisibility.ts @@ -18,6 +18,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ 'clip', 'collider', 'container', + 'joint', 'particles', 'rigidbody', 'sprite', diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index a8286e4d..b495f845 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -40,6 +40,23 @@ import { TILE_TRIGGER_KEY, } from '../model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; +import { + JOINT_ANCHOR_X_KEY, + JOINT_ANCHOR_Y_KEY, + JOINT_COLLIDE_KEY, + JOINT_CONNECTED_KEY, + JOINT_DAMPING_KEY, + JOINT_LENGTH_KEY, + JOINT_LIMIT_KEY, + JOINT_MAX_ANGLE_KEY, + JOINT_MIN_ANGLE_KEY, + JOINT_STIFFNESS_KEY, + JOINT_TYPE_KEY, + JOINT_TYPE_LABELS, + JOINT_TYPES, + type JointType, + TWO_ANCHOR_TYPES, +} from '../model/joint'; import { PALETTE_KEY } from '../model/pixelart'; import { SLICE_SCALE_KEY } from '../model/texture'; import { @@ -92,7 +109,8 @@ type Extras = K extends 'number' * The definition of a flat kind: its default is a value of that kind, as the table says. Not an * object's: that default is no value of the kind but its tagged place (`ObjectDef`). * `shownWith` names a switch on the same bag; the inspector shows the row only while it is on - * (see `isRowShown`), everything else – graph, defaults, files – always has it. + * (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the + * same by a choice: the row is shown while the enum under `key` holds one of `values` (#154). */ export type FlatDef = { [P in Exclude]: { @@ -103,6 +121,7 @@ export type FlatDef = { /** What a new asset starts with when it differs from `default`, the value a missing key reads as. */ initial?: ValueOf

; shownWith?: string; + shownFor?: { key: string; values: readonly string[] }; } & Extras

; }[Exclude]; @@ -113,6 +132,12 @@ export function initialOf(def: PropertyDef): PropertyValue { /** Whether the inspector shows the row, given the values of the bag it is on. */ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { + if ( + 'shownFor' in def && + def.shownFor && + !def.shownFor.values.includes(textOf(values[def.shownFor.key] ?? '')) + ) + return false; return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true; } @@ -160,6 +185,11 @@ const COLLISION_LAYER_ROWS: readonly FlatDef[] = [ { key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS }, ]; +const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY, values } }); +const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES); +const ONLY_SPRING = jointRows('spring'); +const ONLY_HINGE = jointRows('hinge'); + /** The inspector is generated from these entries, so a new property is one line in a schema below. */ export type PropertyDef = | FlatDef @@ -632,6 +662,86 @@ const ASSET_SCHEMAS: Record = { { key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false }, { key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false }, ], + // What it ties its object to, and how (#154). Lengths are world units, angles degrees counted + // from how the two were placed, stiffness a force per 100 units as the engine weighs things. + joint: [ + { + key: JOINT_TYPE_KEY, + label: 'Type', + kind: 'enum', + default: 'hinge', + options: JOINT_TYPES.map((value) => ({ value, label: JOINT_TYPE_LABELS[value] })), + }, + // Empty is a point of the scene that never moves. + { key: JOINT_CONNECTED_KEY, label: 'Connected object', kind: 'object', default: NO_OBJECT }, + // Where a spring or a rope ends on the other object, in that object's own units – or, with no + // object, in the scene's. A hinge and a fixed joint have one point only: where the joint sits. + { + key: JOINT_ANCHOR_X_KEY, + label: 'Connected anchor X', + kind: 'number', + default: 0, + step: 1, + ...JOINT_ENDS, + }, + { + key: JOINT_ANCHOR_Y_KEY, + label: 'Connected anchor Y', + kind: 'number', + default: 0, + step: 1, + ...JOINT_ENDS, + }, + { + key: JOINT_LENGTH_KEY, + label: 'Length (0 = as placed)', + kind: 'number', + default: 0, + min: 0, + step: 1, + ...JOINT_ENDS, + }, + { + key: JOINT_STIFFNESS_KEY, + label: 'Stiffness', + kind: 'number', + default: 50, + min: 0, + step: 1, + ...ONLY_SPRING, + }, + { + key: JOINT_DAMPING_KEY, + label: 'Damping', + kind: 'number', + default: 1, + min: 0, + step: 0.1, + ...ONLY_SPRING, + }, + { key: JOINT_LIMIT_KEY, label: 'Limit angle', kind: 'boolean', default: false, ...ONLY_HINGE }, + { + key: JOINT_MIN_ANGLE_KEY, + label: 'Min angle', + kind: 'number', + default: -45, + max: 0, + step: 1, + shownWith: JOINT_LIMIT_KEY, + ...ONLY_HINGE, + }, + { + key: JOINT_MAX_ANGLE_KEY, + label: 'Max angle', + kind: 'number', + default: 45, + min: 0, + step: 1, + shownWith: JOINT_LIMIT_KEY, + ...ONLY_HINGE, + }, + { key: JOINT_COLLIDE_KEY, label: 'Collide with connected', kind: 'boolean', default: false }, + ], // A container is pure structure: its content is the template, not a property. container: [], // What its children are cut to (#260). Units, not pixels: it turns and grows with its parent. diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index aee04888..30903bfb 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -28,6 +28,7 @@ const BEHAVIOR_SYMBOL = 28; const CONTAINER_SYMBOL = 24; const PARTICLES_SYMBOL = 24; const RIGIDBODY_SYMBOL = 24; +const JOINT_SYMBOL = 24; /** Rough width of one character relative to the font size, for a plain sans. */ const CHAR_WIDTH = 0.55; @@ -73,6 +74,8 @@ export function footprintOf( return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; case 'rigidbody': return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL }; + case 'joint': + return { w: JOINT_SYMBOL, h: JOINT_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/joint.ts b/src/core/properties/joint.ts new file mode 100644 index 00000000..199d9376 --- /dev/null +++ b/src/core/properties/joint.ts @@ -0,0 +1,90 @@ +/** What the physics engine and the scene view read off a joint (#154). */ +import { + hasConnectedAnchor, + JOINT_ANCHOR_X_KEY, + JOINT_ANCHOR_Y_KEY, + JOINT_COLLIDE_KEY, + JOINT_DAMPING_KEY, + JOINT_LENGTH_KEY, + JOINT_LIMIT_KEY, + JOINT_MAX_ANGLE_KEY, + JOINT_MIN_ANGLE_KEY, + JOINT_STIFFNESS_KEY, + JOINT_TYPE_KEY, + JOINT_TYPES, + type JointType, +} from '../model/joint'; +import { apply, type Mat } from '../model/matrix'; +import type { PropertyBag, Vec2 } from '../model/Properties'; +import { defaultsFor } from './PropertySchema'; + +const JOINT = defaultsFor('joint'); + +export interface JointSettings { + type: JointType; + /** Where a spring or a rope ends, in the connected object's units – the scene's without one. */ + connectedAnchor: Vec2; + /** World units; 0 takes the distance the two ends were placed at. */ + length: number; + stiffness: number; + damping: number; + /** How far a hinge turns either way from how it was placed, in degrees; null for all the way round. */ + limits: { min: number; max: number } | null; + collideConnected: boolean; +} + +function num(value: unknown, fallback: unknown): number { + const n = Number(value ?? fallback); + return Number.isFinite(n) ? n : Number(fallback); +} + +export function jointTypeOf(props: PropertyBag): JointType { + const type = props[JOINT_TYPE_KEY]; + return (JOINT_TYPES as readonly unknown[]).includes(type) ? (type as JointType) : 'hinge'; +} + +export function jointSettings(props: PropertyBag): JointSettings { + const type = jointTypeOf(props); + const limited = type === 'hinge' && props[JOINT_LIMIT_KEY] === true; + return { + type, + connectedAnchor: { + x: num(props[JOINT_ANCHOR_X_KEY], JOINT[JOINT_ANCHOR_X_KEY]), + y: num(props[JOINT_ANCHOR_Y_KEY], JOINT[JOINT_ANCHOR_Y_KEY]), + }, + length: Math.max(0, num(props[JOINT_LENGTH_KEY], JOINT[JOINT_LENGTH_KEY])), + stiffness: Math.max(0, num(props[JOINT_STIFFNESS_KEY], JOINT[JOINT_STIFFNESS_KEY])), + damping: Math.max(0, num(props[JOINT_DAMPING_KEY], JOINT[JOINT_DAMPING_KEY])), + limits: limited + ? { + min: Math.min(0, num(props[JOINT_MIN_ANGLE_KEY], JOINT[JOINT_MIN_ANGLE_KEY])), + max: Math.max(0, num(props[JOINT_MAX_ANGLE_KEY], JOINT[JOINT_MAX_ANGLE_KEY])), + } + : null, + collideConnected: props[JOINT_COLLIDE_KEY] === true, + }; +} + +/** + * Where the joint's other end is in the world. A spring and a rope end at their connected anchor, + * on the connected object or – without one – in the scene. A hinge and a fixed joint have no second + * point: `at`, where the joint itself sits, is the one both objects are held at. + */ +export function jointFarEnd(settings: JointSettings, at: Vec2, connected: Mat | null): Vec2 { + if (!hasConnectedAnchor(settings.type)) return at; + return connected ? apply(connected, settings.connectedAnchor) : settings.connectedAnchor; +} + +/** The length a spring rests at or a rope allows: the one set, else the distance it was placed at. */ +export function jointLength(settings: JointSettings, placed: number): number { + return settings.length > 0 ? settings.length : placed; +} + +/** + * A hinge's limits as the engine counts them (radians, the held body's turn against the other's): + * the degrees either way, from the angle the two were placed at. + */ +export function hingeLimits(limits: { min: number; max: number }, placed: number): [number, number] { + const rad = Math.PI / 180; + return [placed + limits.min * rad, placed + limits.max * rad]; +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 24885bd5..f54f8d52 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -7,8 +7,10 @@ import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annota import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; import { AddColliderCommand, + AddJointCommand, AddRigidbodyCommand, colliderRefusal, + jointRefusal, rigidbodyRefusal, } from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; @@ -84,6 +86,19 @@ export function addRigidbody(state: EditorState, holderId: string, objectId: str return true; } +/** The test `AddJointCommand` makes; template content has no structure of its own to change. */ +export function canAddJoint(project: Project, holder: ObjectHolder, object: SceneObject): boolean { + return !isTemplateContent(holder, object.id) && jointRefusal(project, holder, object) === null; +} + +/** Gives the object a joint child (#154), new or the existing `assetId`; selects the joint. */ +export function addJoint(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean { + const command = new AddJointCommand(holderId, objectId, assetId); + 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/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index c4adf32b..99bbe0f5 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -60,6 +60,8 @@ export function sceneHooks({ state, editor }: HookContext) { gizmos: () => viewport.gizmoInfo(), /** The corner handles of the polygon collider being edited, in global pixels (#151). */ polygonCorners: () => viewport.cornerInfo(), + /** The lines of the joints in the Scene tab, in world units, and the anchor handle in global pixels (#154). */ + joints: () => viewport.jointInfo(), /** The size gizmo on the selected sprite or collider, in global pixels (#223). */ sizeGizmo: () => viewport.sizeGizmoInfo(), /** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 7a533a45..70c24f44 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -234,6 +234,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object buildOwnRows(host, holderId, objectId); markObjectRows(host, object, asset, target); + // The editor run does not simulate (#148), so nothing shows a joint at work there. + if (asset.type === 'joint') { + addNote(host, 'Holds while the game is played,', 'small'); + addNote(host, 'not in the editor run.', 'small'); + } if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); } diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index b6e4fd12..6be55170 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -12,8 +12,10 @@ import { Menu, type MenuItem } from '../ui'; import { activateAsset, addCollider, + addJoint, addRigidbody, canAddCollider, + canAddJoint, canAddRigidbody, createContainer, removeObjects, @@ -89,6 +91,11 @@ export function openObjectMenu( disabled: !object || many || !canAddRigidbody(state.project, source, object), onSelect: () => openPartMenu(state, 'rigidbody', holder, id, x, y), }, + { + label: 'Add joint…', + disabled: !object || many || !canAddJoint(state.project, source, object), + onSelect: () => openPartMenu(state, 'joint', holder, id, x, y), + }, ...(object && asset?.type === 'collider' ? [ { @@ -129,18 +136,18 @@ export function openObjectMenu( } /** - * Which collider or rigidbody to add (#222): a new one, or any the project has, found by typing. One - * the object wears already is greyed out – a second copy of it would only double up. + * Which collider, rigidbody or joint to add (#222): a new one, or any the project has, found by + * typing. One the object wears already is greyed out – a second copy of it would only double up. */ function openPartMenu( state: EditorState, - type: 'collider' | 'rigidbody', + type: 'collider' | 'rigidbody' | 'joint', holder: ObjectHolder, id: string, x: number, y: number, ): Menu { - const add = type === 'collider' ? addCollider : addRigidbody; + const add = { collider: addCollider, rigidbody: addRigidbody, joint: addJoint }[type]; const worn = new Set(holder.objects.filter((o) => o.parentId === id).map((o) => o.assetId)); const existing = state.project.assets .filter((a) => a.type === type) diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index f99f78f8..b23a5104 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -15,6 +15,7 @@ export const ASSET_GROUP_LABELS: Record = { struct: 'Structs', collider: 'Colliders', rigidbody: 'Rigidbodies', + joint: 'Joints', container: 'Containers', clip: 'Clip areas', texture: 'Textures', diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 86c8915b..94d484a4 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -14,6 +14,7 @@ import font from './icons/font.svg?raw'; import game from './icons/game.svg?raw'; import group from './icons/group.svg?raw'; import list from './icons/list.svg?raw'; +import joint from './icons/joint.svg?raw'; import palette from './icons/palette.svg?raw'; import particles from './icons/particles.svg?raw'; import pixelart from './icons/pixelart.svg?raw'; @@ -42,6 +43,7 @@ export const ASSET_ICON_SVG: Record = { container, curve, font, + joint, palette, particles, pixelart, diff --git a/src/editor/panels/icons/joint.svg b/src/editor/panels/icons/joint.svg new file mode 100644 index 00000000..74034cdb --- /dev/null +++ b/src/editor/panels/icons/joint.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 2b29048f..830518fb 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -49,12 +49,14 @@ import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; import { shows, type VisibilityPart } from '../../core/model/sceneVisibility'; import { dashedLine } from '../../viewport/symbols'; import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons'; +import { type JointLink, jointLinkOf } from '../../viewport/jointLinks'; import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows'; import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout'; import { cameraOfItems } from '../../viewport/activeCamera'; import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { FreeViewport } from './FreeViewport'; +import { JointAnchorEditor } from './jointAnchor'; import { PolygonCornerEditor } from './polygonCorners'; import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor'; import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost'; @@ -109,13 +111,14 @@ const LABEL_PAD_Y = 1; const LABEL_ROW_GAP = 1; const LABEL_PLATE_ALPHA = 0.7; /** Types whose icon is all there is of them: they are selected and outlined where it stands. */ -const ICON_ONLY = new Set(['behavior', 'container', 'rigidbody']); +const ICON_ONLY = new Set(['behavior', 'container', 'rigidbody', 'joint']); /** Owner links in the colour of the icon they leave; anything else in the muted default. */ const LINK_COLORS: Partial> = { behavior: Theme.colors.behavior, particles: Theme.colors.particles, rigidbody: Theme.colors.collider, + joint: Theme.colors.collider, }; export class SceneViewport extends FreeViewport { @@ -152,6 +155,7 @@ export class SceneViewport extends FreeViewport { }; private readonly painter = new TilemapPainter(this.host); private readonly corners = new PolygonCornerEditor(this.host); + private readonly anchors = new JointAnchorEditor(this.host); private readonly sizer = new SizeGizmoEditor(this.host); private editorWorld: World | null = null; private lastRays: CastRay[] = []; @@ -163,6 +167,8 @@ export class SceneViewport extends FreeViewport { private lastPickOutline: string | null = null; /** Ids that got an owner link this frame (test hook). */ private lastLinks: string[] = []; + /** What was drawn of each joint this frame, in world units (test hook). */ + private lastJointLinks: JointLink[] = []; /** Last place the pointer was seen, in global coordinates – where a paste lands. */ private pointerGlobal: Vec2 | null = null; @@ -316,7 +322,7 @@ export class SceneViewport extends FreeViewport { this.openEmptyMenu(e); return; } - if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return; + if (e.button === 0 && (this.corners.press(e) || this.anchors.press(e) || this.sizer.press(e))) return; if (e.button === 0 && e.target === this) this.pressEmpty(e); } @@ -345,6 +351,7 @@ export class SceneViewport extends FreeViewport { if ( this.panTo(e) || this.corners.move(e.global) || + this.anchors.move(e.global) || this.sizer.move(e.global, e.shiftKey) || this.painter.move(e.global) ) @@ -427,7 +434,8 @@ export class SceneViewport extends FreeViewport { return; } this.endPan(); - if (this.corners.release() || this.sizer.release() || this.painter.release()) return; + if (this.corners.release() || this.anchors.release() || this.sizer.release() || this.painter.release()) + return; const marquee = this.marquee; this.marquee = null; if (marquee) { @@ -504,7 +512,7 @@ export class SceneViewport extends FreeViewport { return; } if (e.button !== 0) return; - if (this.corners.press(e) || this.sizer.press(e)) return; + if (this.corners.press(e) || this.anchors.press(e) || this.sizer.press(e)) return; const holder = this.resolved(); if (!holder) return; const stack = this.stackUnder(e); @@ -575,6 +583,7 @@ export class SceneViewport extends FreeViewport { this.dragging || this.painter.active || this.corners.active || + this.anchors.active || this.sizer.active || this.marquee ) @@ -686,6 +695,7 @@ export class SceneViewport extends FreeViewport { protected override afterItems(items: DrawItem[], view: View): void { this.placeIcons(items, view.scale); this.drawParentLinks(items, view.scale); + this.drawJointLinks(items, view.scale); this.syncCursors(); } @@ -944,6 +954,36 @@ export class SceneViewport extends FreeViewport { } } + /** + * A line from every joint to what it holds on to (#154): the connected anchor of a spring or a + * rope, the connected object of a hinge or a fixed joint. The point the joint itself holds is + * ringed, since its icon may stand aside when it shares the spot with others. + */ + private drawJointLinks(items: DrawItem[], scale: number): void { + this.lastJointLinks = []; + const byId = new Map(items.map((i) => [i.id, i])); + const color = Theme.colors.collider; + for (const item of items) { + if (!this.spots.has(item.id)) continue; + const link = jointLinkOf(item, byId); + if (!link) continue; + this.lastJointLinks.push(link); + const { from, to } = link; + this.links.circle(from.x, from.y, 4 / scale).stroke({ width: 1.5 / scale, color }); + if (from.x === to.x && from.y === to.y) continue; + this.links + .moveTo(from.x, from.y) + .lineTo(to.x, to.y) + .stroke({ width: 1.5 / scale, color }); + this.links.circle(to.x, to.y, 3 / scale).fill(color); + } + } + + /** The joints' lines as drawn, in world units, and the handle on the selected one's far end (test hook). */ + jointInfo(): { links: JointLink[]; handle: { objectId: string; x: number; y: number } | null } { + return { links: this.lastJointLinks, handle: this.anchors.info() }; + } + /** * The name of an object whose type shows names; `placeLabels` puts it beside the icon once every * icon has its spot. One off screen is kept up to date but hidden: it still takes its row. @@ -1032,6 +1072,7 @@ export class SceneViewport extends FreeViewport { if (!picking) { this.sizer.draw(this.gizmos, view); this.corners.draw(this.gizmos, view); + this.anchors.draw(this.gizmos, view); } this.drawDropPreview(view); if (picking) return; diff --git a/src/editor/viewport/jointAnchor.ts b/src/editor/viewport/jointAnchor.ts new file mode 100644 index 00000000..869ae6f0 --- /dev/null +++ b/src/editor/viewport/jointAnchor.ts @@ -0,0 +1,138 @@ +import type { FederatedPointerEvent, Graphics } from 'pixi.js'; +import { oneStep } from '../../core/commands/CompoundCommand'; +import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands'; +import { objectRef } from '../../core/model/instance'; +import { JOINT_ANCHOR_X_KEY, JOINT_ANCHOR_Y_KEY } from '../../core/model/joint'; +import { apply as applyPoint, IDENTITY, invert, type Mat } from '../../core/model/matrix'; +import type { PropertyValue, Vec2 } from '../../core/model/Properties'; +import { Theme } from '../../ui'; +import { type JointLink, jointLinkOf } from '../../viewport/jointLinks'; +import { type View, worldToScreen } from '../../viewport/view'; +import { FramePreview } from '../framePreview'; +import { type SceneViewHost, selectedItem } from './sceneViewHost'; + +/** Screen pixels around the anchor that grab it. */ +const ANCHOR_GRIP = 8; +/** Diameter of the handle in screen pixels. */ +const ANCHOR_SIZE = 10; +/** Pixels of travel before a press turns into a drag. */ +const DRAG_THRESHOLD = 2; + +/** The connected anchor being dragged; `before` are the overrides put back on release. */ +interface AnchorDrag { + holderId: string; + objectId: string; + before: [PropertyValue | undefined, PropertyValue | undefined]; + /** From the world into the frame the anchor is counted in. */ + toLocal: Mat; + at: Vec2; + pointer: Vec2; + moved: boolean; +} + +/** + * The handle on the far end of the selected spring or rope (#154): dragging it moves the joint's + * connected anchor, as a preview while it goes and as one undo step on release. A hinge and a fixed + * joint have no far end of their own – they are moved by moving the joint. + */ +export class JointAnchorEditor { + private drag: AnchorDrag | null = null; + /** The handle drawn last, in global pixels (test hook). */ + private lastHandle: { objectId: string; x: number; y: number } | null = null; + private readonly perFrame: FramePreview; + + constructor(private readonly host: SceneViewHost) { + this.perFrame = new FramePreview(host.state, 'joint-anchor'); + } + + get active(): boolean { + return this.drag !== null; + } + + /** The selected joint's link, if it has an anchor to drag. */ + private edited(): JointLink | null { + const item = selectedItem(this.host); + if (item?.type !== 'joint') return null; + const link = jointLinkOf(item, new Map(this.host.items().map((i) => [i.id, i]))); + return link?.anchored ? link : null; + } + + /** A press on the handle starts dragging it; false when the press was elsewhere. */ + press(e: FederatedPointerEvent): boolean { + const link = this.edited(); + const holder = this.host.holder(); + const ref = link && holder && objectRef(this.host.state.project, holder, link.id); + if (!link || !holder || !ref) return false; + const at = this.host.worldAt(e.global); + if (Math.hypot(at.x - link.to.x, at.y - link.to.y) > ANCHOR_GRIP / this.host.scale()) return false; + e.stopPropagation(); + this.drag = { + holderId: holder.id, + objectId: link.id, + before: [ref.object.overrides[JOINT_ANCHOR_X_KEY], ref.object.overrides[JOINT_ANCHOR_Y_KEY]], + toLocal: invert(link.frame ?? IDENTITY), + at: applyPoint(invert(link.frame ?? IDENTITY), link.to), + pointer: { x: e.global.x, y: e.global.y }, + moved: false, + }; + return true; + } + + /** Follows the pointer with the anchor under way; false when there is none. */ + move(global: Vec2): boolean { + const drag = this.drag; + if (!drag) return false; + if (!drag.moved && Math.hypot(global.x - drag.pointer.x, global.y - drag.pointer.y) < DRAG_THRESHOLD) + return true; + drag.moved = true; + const at = applyPoint(drag.toLocal, this.host.worldAt(global)); + drag.at = { x: Math.round(at.x), y: Math.round(at.y) }; + const holder = this.host.holder(); + const ref = holder && objectRef(this.host.state.project, holder, drag.objectId); + if (!ref) return true; + ref.setOverride(JOINT_ANCHOR_X_KEY, drag.at.x); + ref.setOverride(JOINT_ANCHOR_Y_KEY, drag.at.y); + this.perFrame.request(); + return true; + } + + /** Puts the preview back and lets the commands do the edit, so one drag is one undo step. */ + release(): boolean { + const drag = this.drag; + if (!drag) return false; + this.drag = null; + this.perFrame.cancel(); + const state = this.host.state; + const holder = this.host.holder(); + const ref = holder && objectRef(state.project, holder, drag.objectId); + if (!ref) return true; + ref.setOverride(JOINT_ANCHOR_X_KEY, drag.before[0]); + ref.setOverride(JOINT_ANCHOR_Y_KEY, drag.before[1]); + if (drag.moved) + state.execute( + oneStep('Move joint anchor', [ + new SetOverrideCommand(drag.holderId, drag.objectId, JOINT_ANCHOR_X_KEY, drag.at.x), + new SetOverrideCommand(drag.holderId, drag.objectId, JOINT_ANCHOR_Y_KEY, drag.at.y), + ]), + ); + else state.preview(); + return true; + } + + /** Where the handle is drawn, in global pixels; null while there is none (test hook). */ + info(): { objectId: string; x: number; y: number } | null { + return this.lastHandle; + } + + draw(g: Graphics, view: View): void { + const link = this.edited(); + this.lastHandle = null; + if (!link) return; + const at = worldToScreen(view, link.to.x, link.to.y); + g.circle(at.x, at.y, ANCHOR_SIZE / 2) + .fill(this.drag ? Theme.colors.accent : Theme.colors.accentText) + .stroke({ width: 1, color: this.drag ? Theme.colors.accentText : Theme.colors.accent }); + const global = g.toGlobal(at); + this.lastHandle = { objectId: link.id, x: global.x, y: global.y }; + } +} diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index 1ca07c6c..be1486e4 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -55,7 +55,7 @@ const TURN_SNAP = 15; /** How strongly the frame round a container's content is drawn. */ const FRAME_ALPHA = 0.6; /** Content that is only an icon or a view, and so takes no room in a container's frame. */ -const SHAPELESS = new Set(['behavior', 'rigidbody', 'camera', 'container']); +const SHAPELESS = new Set(['behavior', 'rigidbody', 'joint', 'camera', 'container']); /** The keys a drag writes; the preview puts the values straight on the object, the release a command. */ type Writes = Partial>; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 6f871658..0ad6da38 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -615,8 +615,8 @@ export class World { /** * Removes only the entity: its children move up to its parent (top level when it had none) and - * stay where they are in the world. Its behaviors and its rigidbody are part of it rather than - * things it carries, so they go with it – moved up they would drive the parent instead. + * stay where they are in the world. Its behaviors, its rigidbody and its joints are part of it rather + * than things it carries, so they go with it – moved up they would drive the parent instead. * Answers the children that moved up. */ removeKeepingChildren(entity: Entity): Entity[] { @@ -624,7 +624,7 @@ export class World { const own = localMatrixOf(entity, transformOf(entity.props)); const kept: Entity[] = []; for (const child of this.childrenOf(entity)) { - if (child.type === 'behavior' || child.type === 'rigidbody') continue; + if (child.type === 'behavior' || child.type === 'rigidbody' || child.type === 'joint') continue; const at = apply(own, child); child.x = at.x; child.y = at.y; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index ef734af2..3a38fb76 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -63,6 +63,7 @@ import { samePose, } from './bodyGeometry'; import { type Rapier, toMetres, toPixels } from './engine'; +import { type JointHost, JointSet, type RigidOwner } from './joints'; import { passesOneWay, reachOf } from './oneWay'; /** The engine always steps this long, whatever the frame took. */ @@ -159,7 +160,15 @@ export class PhysicsSystem implements System, CollisionSource { /** How bodies moved in the world before (#165), until the body of each is made here. */ private resumed = new Map(); - constructor(private readonly rapier: Rapier) {} + private readonly joints: JointSet; + private readonly jointHost: JointHost = { + rigidAbove: (from) => this.rigidAbove(from), + hint: (world, target, text) => this.hint(world, target, text), + }; + + constructor(private readonly rapier: Rapier) { + this.joints = new JointSet(rapier); + } start(world: World): void { const engine = new this.rapier.World(this.gravity(world)); @@ -176,6 +185,7 @@ export class PhysicsSystem implements System, CollisionSource { } stop(): void { + this.joints.clear(); this.engine?.free(); this.queue?.free(); this.engine = null; @@ -421,6 +431,21 @@ export class PhysicsSystem implements System, CollisionSource { } // Only the body an object arrives with moves on: one it gets later starts at rest like any other. this.resumed.clear(); + this.joints.sync(world, this.engine!, this.jointHost); + } + + /** The nearest object from `from` upwards whose body the engine moves by a rigidbody of its own. */ + private rigidAbove(from: Entity | null): RigidOwner | null { + for (let entity = from; entity; entity = entity.parent) { + const body = this.bodies.get(entity); + if (body?.kind === 'rigid') return { entity, body: body.body }; + } + return null; + } + + /** Whether a joint holds its two ends right now (#154): it does not without a rigidbody to hold. */ + holdsJoint(joint: Entity): boolean { + return this.joints.holds(joint); } /** diff --git a/src/runtime/physics/joints.ts b/src/runtime/physics/joints.ts new file mode 100644 index 00000000..27a868e2 --- /dev/null +++ b/src/runtime/physics/joints.ts @@ -0,0 +1,251 @@ +import type { + ImpulseJoint, + JointData, + RevoluteImpulseJoint, + RigidBody, + World as RapierWorld, +} from '@dimforge/rapier2d-compat'; +import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; +import { isEntity } from '../../core/graph/values'; +import { hasConnectedAnchor, JOINT_CONNECTED_KEY } from '../../core/model/joint'; +import { referencedId } from '../../core/model/objectRefs'; +import type { PropertyValue, Vec2 } from '../../core/model/Properties'; +import { hingeLimits, jointFarEnd, jointLength, jointSettings } from '../../core/properties/joint'; +import type { Entity } from '../Entity'; +import type { World } from '../World'; +import { angleBetween, matrixPose, type Pose, round, samePose } from './bodyGeometry'; +import { type Rapier, toMetres } from './engine'; + +/** The body the engine moves for an object, and that object. */ +export interface RigidOwner { + entity: Entity; + body: RigidBody; +} + +/** What the joints need of the system that owns the bodies. */ +export interface JointHost { + /** The nearest object from `from` upwards that has a rigidbody switched on; null when none has. */ + rigidAbove(from: Entity | null): RigidOwner | null; + hint(world: World, target: Entity, text: string): void; +} + +/** One joint entity as the engine has it. `joint` is null while it holds nothing. */ +interface Held { + joint: ImpulseJoint | null; + /** What the engine joint was built from; it is built again when that changes. */ + key: string; + /** A body of the joint's own standing for an end that has no rigidbody: it goes where that end is. */ + anchor: RigidBody | null; + anchorPose: Pose | null; + /** The length and the angle the two ends were placed at, kept while the same two bodies are held. */ + placed: { between: string; length: number; angle: number } | null; + collide: boolean; +} + +const NO_RIGIDBODY = 'sits on an object without a rigidbody, so it holds nothing.'; +const GONE = 'lost its connected object, so it holds nothing.'; +const SAME_BODY = 'is connected to the object it sits on, so it holds nothing.'; + +/** The object a joint names: none (a point of the scene), one that is there, or one that is gone. */ +function connectedOf(world: World, joint: Entity): Entity | null | 'gone' { + const value = joint.props[JOINT_CONNECTED_KEY] as PropertyValue | Entity | undefined; + // A graph wrote the object itself (`Properties`); the scene's reference names it by id (#258). + if (isEntity(value)) return world.entities.includes(value) ? value : 'gone'; + const ref = referencedId(value); + if (!ref) return null; + return world.findReferenced(joint, ref) ?? 'gone'; +} + +/** A world point as a body at `pose` has it, in the engine's metres. */ +function inBody(pose: Pose, point: Vec2): Vec2 { + const dx = point.x - pose.x; + const dy = point.y - pose.y; + const cos = Math.cos(pose.angle); + const sin = Math.sin(pose.angle); + return { x: toMetres(dx * cos + dy * sin), y: toMetres(-dx * sin + dy * cos) }; +} + +const ORIGIN: Pose = { x: 0, y: 0, angle: 0 }; + +/** + * The joints of the played game (#154): one engine joint per joint entity whose object has a + * rigidbody. The other end is the connected object's body, or – where that has none, or nothing is + * connected – a body of the joint's own that goes wherever that end is and is pulled by nothing. + */ +export class JointSet { + private readonly held = new Map(); + + constructor(private readonly rapier: Rapier) {} + + /** Makes the engine's joints match the joint entities; call once the bodies are in place. */ + sync(world: World, engine: RapierWorld, host: JointHost): void { + const seen = new Set(); + for (const entity of world.entities) { + if (entity.type !== 'joint') continue; + seen.add(entity); + this.syncOne(world, engine, host, entity); + } + for (const [entity, held] of this.held) { + if (seen.has(entity)) continue; + this.release(engine, held); + this.dropAnchor(engine, held); + this.held.delete(entity); + } + } + + /** Whether the engine holds a joint for this entity right now. */ + holds(entity: Entity): boolean { + return Boolean(this.held.get(entity)?.joint?.isValid()); + } + + /** The engine is freed as a whole; what was held in it is only forgotten. */ + clear(): void { + this.held.clear(); + } + + private syncOne(world: World, engine: RapierWorld, host: JointHost, entity: Entity): void { + let held = this.held.get(entity); + if (!held) { + held = { + joint: null, + key: '', + anchor: null, + anchorPose: null, + placed: null, + collide: false, + }; + this.held.set(entity, held); + } + const idle = (why?: string) => { + this.release(engine, held); + this.dropAnchor(engine, held); + if (why) host.hint(world, entity, why); + }; + if (!isHandleEnabled(entity)) return idle(); + const own = host.rigidAbove(entity.parent); + if (!own) return idle(NO_RIGIDBODY); + const connected = connectedOf(world, entity); + if (connected === 'gone') return idle(GONE); + const rigid = connected ? host.rigidAbove(connected) : null; + if (rigid?.body === own.body) return idle(SAME_BODY); + if (!own.body.isEnabled() || (rigid && !rigid.body.isEnabled())) return idle(); + + const settings = jointSettings(entity.props); + const ownPose = matrixPose(worldMatrixOf(own.entity)); + const otherPose = rigid + ? matrixPose(worldMatrixOf(rigid.entity)) + : connected + ? matrixPose(worldMatrixOf(connected)) + : ORIGIN; + const other = rigid ? rigid.body : this.anchorAt(engine, held, otherPose); + if (rigid) this.dropAnchor(engine, held); + + const m = worldMatrixOf(entity); + const at = { x: m.tx, y: m.ty }; + const far = jointFarEnd(settings, at, connected ? worldMatrixOf(connected) : null); + const onOwn = inBody(ownPose, at); + const twoEnds = hasConnectedAnchor(settings.type); + // A hinge's and a fixed joint's point on the other body is wherever the joint was when it was + // made, so it is no part of the key – the bodies move, and the point with them. + const key = JSON.stringify([ + settings.type, + own.body.handle, + other.handle, + [round(onOwn.x), round(onOwn.y)], + twoEnds ? [round(inBody(otherPose, far).x), round(inBody(otherPose, far).y)] : 0, + settings.length, + settings.stiffness, + settings.damping, + settings.limits, + ]); + if (!held.joint || key !== held.key) { + this.release(engine, held); + const between = `${settings.type}|${own.body.handle}|${other.handle}`; + if (held.placed?.between !== between) + held.placed = { + between, + length: Math.hypot(far.x - at.x, far.y - at.y), + angle: angleBetween(other.rotation(), own.body.rotation()), + }; + const data = this.dataOf(settings, held.placed, inBody(otherPose, far), onOwn, other, own.body); + // The other end first: a hinge's angle is then the held object's turn against it. + held.joint = engine.createImpulseJoint(data, other, own.body, true); + // Only a hinge has limits, and a hinge is the engine's revolute joint. + if (settings.limits) + (held.joint as RevoluteImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle)); + held.joint.setContactsEnabled(settings.collideConnected); + held.key = key; + held.collide = settings.collideConnected; + return; + } + if (held.collide !== settings.collideConnected) { + held.collide = settings.collideConnected; + held.joint.setContactsEnabled(held.collide); + own.body.wakeUp(); + } + } + + private dataOf( + settings: ReturnType, + placed: NonNullable, + onOther: Vec2, + onOwn: Vec2, + other: RigidBody, + own: RigidBody, + ): JointData { + const { JointData } = this.rapier; + switch (settings.type) { + case 'hinge': + return JointData.revolute(onOther, onOwn); + case 'spring': + return JointData.spring( + toMetres(jointLength(settings, placed.length)), + settings.stiffness, + settings.damping, + onOther, + onOwn, + ); + case 'rope': + return JointData.rope(toMetres(jointLength(settings, placed.length)), onOther, onOwn); + case 'fixed': + // The frames differ by how the two are turned against each other right now, which is kept. + return JointData.fixed(onOther, 0, onOwn, other.rotation() - own.rotation()); + } + } + + /** + * The joint's own body for an end without a rigidbody, put where that end is. It is moved the way + * a kinematic body is, so what hangs on it is dragged along rather than snapped. + */ + private anchorAt(engine: RapierWorld, held: Held, pose: Pose): RigidBody { + const t = { x: toMetres(pose.x), y: toMetres(pose.y) }; + if (!held.anchor) { + const desc = this.rapier.RigidBodyDesc.kinematicPositionBased(); + held.anchor = engine.createRigidBody(desc.setTranslation(t.x, t.y).setRotation(pose.angle)); + held.anchorPose = pose; + return held.anchor; + } + if (!held.anchorPose || !samePose(pose, held.anchorPose)) { + held.anchor.setNextKinematicTranslation(t); + held.anchor.setNextKinematicRotation(pose.angle); + held.anchorPose = pose; + } + return held.anchor; + } + + private release(engine: RapierWorld, held: Held): void { + // A body that left the engine took its joints along: such a joint is only forgotten. + if (held.joint?.isValid()) engine.removeImpulseJoint(held.joint, true); + held.joint = null; + held.key = ''; + } + + private dropAnchor(engine: RapierWorld, held: Held): void { + if (!held.anchor) return; + // Its joint goes with it, so that is let go of first. + this.release(engine, held); + engine.removeRigidBody(held.anchor); + held.anchor = null; + held.anchorPose = null; + } +} diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 6f866eb8..a764231d 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -7,7 +7,9 @@ import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../ import { keyName } from '../../core/keys'; import { collisionFilterOf } from '../../core/model/collisionLayers'; import type { Vec2 } from '../../core/model/Properties'; -import { findAssetOf, findScene, type Project } from '../../core/model/Project'; +import { JOINT_CONNECTED_KEY } from '../../core/model/joint'; +import { objectReference } from '../../core/model/objectRefs'; +import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project'; import { soundFileOf } from '../../core/model/sound'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; @@ -153,6 +155,15 @@ export function behaviorContext( if (assetId === '' || file) run.audio.stop(file); }, spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), + addJoint: (assetId, owner, connected, at) => { + const on = resolve(owner); + if (!on || findAsset(project, assetId)?.type !== 'joint') return null; + const joint = world.spawn(assetId, { ...at, z: 0 }, on); + const to = resolve(connected); + // By id, as a pick in the inspector stores it: the scene view of the run then draws the link too. + if (joint && to) joint.props[JOINT_CONNECTED_KEY] = objectReference(to.objectId); + return joint; + }, destroy: (target, keepChildren) => { const found = resolve(target); if (!found) return []; diff --git a/src/viewport/jointLinks.ts b/src/viewport/jointLinks.ts new file mode 100644 index 00000000..a50a655e --- /dev/null +++ b/src/viewport/jointLinks.ts @@ -0,0 +1,49 @@ +import { hasConnectedAnchor, JOINT_CONNECTED_KEY } from '../core/model/joint'; +import type { Mat } from '../core/model/matrix'; +import { referenceCandidates, referencedId } from '../core/model/objectRefs'; +import type { Vec2 } from '../core/model/Properties'; +import { jointFarEnd, jointSettings } from '../core/properties/joint'; +import type { DrawItem } from './items'; + +/** What the scene view draws of a joint (#154): from where it sits to what it holds on to. */ +export interface JointLink { + id: string; + /** Where the joint sits – the point of its own object it holds. */ + from: Vec2; + /** + * Where the line ends: a spring's or a rope's connected anchor, for a hinge and a fixed joint the + * connected object itself. Equal to `from` when such a joint is held to the scene. + */ + to: Vec2; + /** The object it is connected to as the view shows it; '' for the scene, or for one that is gone. */ + connectedId: string; + /** Whether `to` is a point of its own, which can be dragged. */ + anchored: boolean; + /** The frame the connected anchor is counted in; null for the scene's. */ + frame: Mat | null; +} + +/** The item a joint's reference names, looked up from where the joint sits (see `objectRefs.ts`). */ +function connectedItem(item: DrawItem, byId: ReadonlyMap): DrawItem | null { + const ref = referencedId(item.props[JOINT_CONNECTED_KEY]); + for (const id of referenceCandidates(item.id, ref)) { + const found = byId.get(id); + if (found) return found; + } + return null; +} + +export function jointLinkOf(item: DrawItem, byId: ReadonlyMap): JointLink | null { + if (item.type !== 'joint') return null; + const settings = jointSettings(item.props); + const connected = connectedItem(item, byId); + const from = { x: item.x, y: item.y }; + const anchored = hasConnectedAnchor(settings.type); + const frame = connected?.m ?? null; + const to = anchored + ? jointFarEnd(settings, from, frame) + : connected + ? { x: connected.x, y: connected.y } + : from; + return { id: item.id, from, to, connectedId: connected?.id ?? '', anchored, frame }; +} diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts index 8230c75e..c23635ef 100644 --- a/src/viewport/symbolIcons.ts +++ b/src/viewport/symbolIcons.ts @@ -9,16 +9,19 @@ import behavior from '../editor/panels/icons/behavior.svg?raw'; import camera from '../editor/panels/icons/camera.svg?raw'; import collider from '../editor/panels/icons/collider.svg?raw'; import container from '../editor/panels/icons/container.svg?raw'; +import joint from '../editor/panels/icons/joint.svg?raw'; import particles from '../editor/panels/icons/particles.svg?raw'; import rigidbody from '../editor/panels/icons/rigidbody.svg?raw'; -export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody'; +export type SymbolIcon = + 'behavior' | 'camera' | 'collider' | 'container' | 'joint' | 'particles' | 'rigidbody'; export const SYMBOL_SVG: Record = { behavior, camera, collider, container, + joint, particles, rigidbody, }; diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 1142a931..8ad652f9 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -176,6 +176,7 @@ export function drawItem( case 'behavior': case 'container': case 'rigidbody': + case 'joint': return; // Only the dashed outline can be grabbed, so a click inside reaches the content it cuts (#260). case 'clip': { diff --git a/tests/core/joints.test.ts b/tests/core/joints.test.ts new file mode 100644 index 00000000..9ae25227 --- /dev/null +++ b/tests/core/joints.test.ts @@ -0,0 +1,706 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { + AddColliderCommand, + AddJointCommand, + AddRigidbodyCommand, +} from '../../src/core/commands/colliderCommands'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; +import { + MoveSceneObjectCommand, + PlaceAssetCommand, + RemoveSceneObjectsCommand, + RenameSceneObjectCommand, + SetOverrideCommand, +} from '../../src/core/commands/sceneObjectCommands'; +import { copyObjects } from '../../src/core/model/clipboard'; +import { worldPositionOf } from '../../src/core/graph'; +import { objectReference } from '../../src/core/model/objectRefs'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import { findAsset } from '../../src/core/model/Project'; +import { hingeLimits, jointFarEnd, jointLength, jointSettings } from '../../src/core/properties/joint'; +import { translation } from '../../src/core/model/matrix'; +import { defaultsFor, isRowShown, schemaFor } from '../../src/core/properties/PropertySchema'; +import { loadPhysics } from '../../src/runtime/physics/engine'; +import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem'; +import { + fakeContext, + fakeEntity, + graphEditor, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + runDef, + type SceneFixture, + startWorld, +} from '../helpers'; + +beforeAll(async () => { + await loadPhysics(); +}); + +type Values = Record; + +interface Thing { + w: number; + h?: number; + /** With a rigidbody, and these overrides on it; without the key the object has none. */ + body?: Values; + /** Without a collider (a bare anchor). */ + bare?: boolean; +} + +/** A scene of boxes, each maybe with a rigidbody, and joints between them. */ +function jointScene() { + const f = makeSpriteScene('Box'); + const set = (objectId: string, key: string, value: PropertyValue) => + f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, key, value)); + const add = (x: number, y: number, thing: Thing): string => { + const id = place(f, f.spriteId, { x, y }); + set(id, 'width', thing.w); + set(id, 'height', thing.h ?? thing.w); + if (!thing.bare) f.stack.execute(new AddColliderCommand(f.sceneId, id)); + 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); + } + return id; + }; + /** A joint on `owner` at (x, y) of its own, connected to `to` (none: the scene). */ + const joint = (owner: string, at: { x: number; y: number }, to: string | null, values: Values): string => { + const command = new AddJointCommand(f.sceneId, owner); + f.stack.execute(command); + f.stack.execute(new MoveSceneObjectCommand(f.sceneId, command.objectId, { ...at, z: 0 })); + if (to) set(command.objectId, 'connected', objectReference(to)); + for (const [key, value] of Object.entries(values)) set(command.objectId, key, value); + return command.objectId; + }; + 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, joint, set, gravity }; +} + +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?: (time: number) => void) => { + for (let i = 0; i < Math.round(s * 60); i++) { + started.frame(1 / 60); + each?.((i + 1) / 60); + } + }; + const entity = (id: string) => started.world.findByObjectId(id)!; + const at = (id: string) => worldPositionOf(entity(id)); + return { ...started, physics, seconds, entity, at }; +} + +const DYNAMIC = {}; +const distance = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y); + +describe('AddJointCommand', () => { + it('puts a joint below the object, as many as wanted, and undo takes it away', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const command = new AddJointCommand(f.sceneId, box); + f.stack.execute(command); + const asset = findAsset(f.project, command.assetId)!; + expect(asset).toMatchObject({ type: 'joint', name: 'Box joint' }); + expect(asset.props).toMatchObject({ jointType: 'hinge', connected: { $object: '' }, length: 0 }); + expect(f.object(command.objectId).parentId).toBe(box); + const second = new AddJointCommand(f.sceneId, box); + f.stack.execute(second); + expect(f.object(second.objectId).parentId).toBe(box); + expect(() => f.stack.execute(new AddJointCommand(f.sceneId, command.objectId))).toThrow( + /joint cannot have a joint/, + ); + expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, command.objectId))).toThrow( + /joint 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('places an existing joint asset again instead of making a new one', () => { + const f = jointScene(); + const a = f.add(0, 0, { w: 40, body: DYNAMIC }); + const b = f.add(100, 0, { w: 40, body: DYNAMIC }); + const first = new AddJointCommand(f.sceneId, a); + f.stack.execute(first); + const again = new AddJointCommand(f.sceneId, b, first.assetId); + f.stack.execute(again); + expect(f.object(again.objectId).assetId).toBe(first.assetId); + expect(f.project.assets.filter((asset) => asset.type === 'joint')).toHaveLength(1); + }); + + it('keeps what a joint is connected to and how through a round trip', () => { + const f = jointScene(); + const a = f.add(0, 0, { w: 40, body: DYNAMIC }); + const b = f.add(100, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(a, { x: 5, y: -7 }, b, { jointType: 'spring', length: 80, stiffness: 12 }); + const loaded = roundTrip(f.project); + const scene = loaded.assets.find((asset) => asset.id === f.sceneId)!; + const stored = (scene.type === 'scene' ? scene.objects : []).find((o) => o.id === joint)!; + expect(stored.position).toMatchObject({ x: 5, y: -7 }); + expect(stored.overrides).toMatchObject({ + jointType: 'spring', + length: 80, + stiffness: 12, + connected: { $object: b }, + }); + }); +}); + +describe('what a joint reads off its rows', () => { + it('takes the defaults, and only a hinge with the switch on has limits', () => { + const defaults = jointSettings(defaultsFor('joint')); + expect(defaults).toMatchObject({ type: 'hinge', length: 0, limits: null, collideConnected: false }); + expect( + jointSettings({ jointType: 'hinge', limitAngle: true, minAngle: -20, maxAngle: 35 }).limits, + ).toEqual({ + min: -20, + max: 35, + }); + expect(jointSettings({ jointType: 'rope', limitAngle: true }).limits).toBeNull(); + expect(jointSettings({ jointType: 'nonsense' }).type).toBe('hinge'); + }); + + it('keeps the limits on their own side of how the hinge was placed', () => { + expect(jointSettings({ limitAngle: true, minAngle: 10, maxAngle: -10 }).limits).toEqual({ + min: 0, + max: 0, + }); + const [min, max] = hingeLimits({ min: -90, max: 45 }, 0.5); + expect(min).toBeCloseTo(0.5 - Math.PI / 2); + expect(max).toBeCloseTo(0.5 + Math.PI / 4); + }); + + it('ends a spring and a rope at their connected anchor, a hinge where it sits', () => { + const rope = jointSettings({ jointType: 'rope', connectedAnchorX: 10, connectedAnchorY: 20 }); + expect(jointFarEnd(rope, { x: 1, y: 2 }, null)).toEqual({ x: 10, y: 20 }); + expect(jointFarEnd(rope, { x: 1, y: 2 }, translation(100, 50))).toEqual({ x: 110, y: 70 }); + const hinge = jointSettings({ jointType: 'hinge', connectedAnchorX: 10, connectedAnchorY: 20 }); + expect(jointFarEnd(hinge, { x: 1, y: 2 }, translation(100, 50))).toEqual({ x: 1, y: 2 }); + }); + + it('takes the placed distance for a length of 0', () => { + expect(jointLength(jointSettings({ jointType: 'rope', length: 0 }), 73)).toBe(73); + expect(jointLength(jointSettings({ jointType: 'rope', length: 40 }), 73)).toBe(40); + }); + + it('shows only the rows of the chosen type', () => { + const shown = (values: Values) => + schemaFor('joint') + .filter((def) => isRowShown(def, { ...defaultsFor('joint'), ...values })) + .map((def) => def.key); + expect(shown({ jointType: 'fixed' })).not.toContain('length'); + expect(shown({ jointType: 'hinge' })).toEqual(expect.arrayContaining(['limitAngle'])); + expect(shown({ jointType: 'hinge' })).not.toContain('minAngle'); + expect(shown({ jointType: 'hinge', limitAngle: true })).toEqual( + expect.arrayContaining(['minAngle', 'maxAngle']), + ); + expect(shown({ jointType: 'rope', limitAngle: true })).not.toContain('minAngle'); + expect(shown({ jointType: 'rope' })).toEqual( + expect.arrayContaining(['connectedAnchorX', 'connectedAnchorY', 'length']), + ); + expect(shown({ jointType: 'rope' })).not.toContain('stiffness'); + expect(shown({ jointType: 'spring' })).toEqual(expect.arrayContaining(['stiffness', 'damping'])); + }); +}); + +describe('a rope', () => { + it('lets the object fall as far as its length and no further', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const rope = f.joint(box, { x: 0, y: 0 }, null, { + jointType: 'rope', + connectedAnchorX: 0, + connectedAnchorY: -100, + length: 150, + }); + const r = run(f); + let furthest = 0; + r.seconds(3, () => (furthest = Math.max(furthest, distance(r.at(box), { x: 0, y: -100 })))); + expect(r.physics.holdsJoint(r.entity(rope))).toBe(true); + expect(furthest).toBeLessThan(152); + expect(r.at(box).y).toBeGreaterThan(45); + expect(r.at(box).y).toBeLessThan(52); + }); + + it('with a length of 0 is as long as it was placed', () => { + const f = jointScene(); + const box = f.add(60, -20, { w: 40, body: DYNAMIC }); + f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', connectedAnchorX: 0, connectedAnchorY: -100 }); + const r = run(f); + let furthest = 0; + let lowest = -Infinity; + r.seconds(2, () => { + furthest = Math.max(furthest, distance(r.at(box), { x: 0, y: -100 })); + lowest = Math.max(lowest, r.at(box).y); + }); + // Placed 100 away: it swings on that, through the lowest point at y = 0. + expect(furthest).toBeLessThan(102); + expect(lowest).toBeGreaterThan(-3); + }); + + it('holds at the point of the object the joint sits on', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: { fixedRotation: true } }); + // Tied at the box's top edge, 20 above its middle. + f.joint(box, { x: 0, y: -20 }, null, { + jointType: 'rope', + connectedAnchorX: 0, + connectedAnchorY: -100, + length: 100, + }); + const r = run(f); + r.seconds(2); + expect(r.at(box).y).toBeGreaterThan(18); + expect(r.at(box).y).toBeLessThan(22); + }); +}); + +describe('a spring', () => { + it('swings about its rest length with the period its stiffness and the mass give', () => { + const f = jointScene(); + f.gravity(0, 0); + // 100 × 100 at density 1 is 1 kg; with 40 N/m the period is 2π·√(1/40) = 0.993 s. + const box = f.add(0, 150, { w: 100, body: { fixedRotation: true } }); + f.joint(box, { x: 0, y: 0 }, null, { jointType: 'spring', length: 100, stiffness: 40, damping: 0 }); + const r = run(f); + const ys: number[] = []; + r.seconds(1.05, () => ys.push(r.at(box).y)); + const half = ys[Math.round(0.5 * 60)]!; + const whole = ys[Math.round(1.0 * 60)]!; + expect(half).toBeGreaterThan(48); + expect(half).toBeLessThan(58); + expect(whole).toBeGreaterThan(140); + expect(Math.min(...ys)).toBeGreaterThan(48); + }); + + it('comes to rest at its length when it is damped', () => { + const f = jointScene(); + f.gravity(0, 0); + const box = f.add(0, 150, { w: 100, body: { fixedRotation: true } }); + f.joint(box, { x: 0, y: 0 }, null, { jointType: 'spring', length: 100, stiffness: 40, damping: 12 }); + const r = run(f); + r.seconds(4); + expect(r.at(box).y).toBeGreaterThan(99); + expect(r.at(box).y).toBeLessThan(101); + }); + + it('pulls two bodies towards each other alike', () => { + const f = jointScene(); + f.gravity(0, 0); + const a = f.add(0, 0, { w: 40, body: { fixedRotation: true } }); + const b = f.add(150, 0, { w: 40, body: { fixedRotation: true } }); + f.joint(a, { x: 0, y: 0 }, b, { jointType: 'spring', length: 50, stiffness: 40, damping: 0 }); + const r = run(f); + r.seconds(0.2); + expect(r.at(a).x).toBeGreaterThan(2); + expect(r.at(b).x).toBeLessThan(148); + expect(r.at(a).x + r.at(b).x).toBeCloseTo(150, 0); + }); +}); + +describe('a hinge', () => { + /** A box hung 100 below a pivot at the origin, pulled a little to the side. */ + function pendulum(values: Values = {}, start = { x: 10, y: 99.5 }) { + const f = jointScene(); + const bob = f.add(start.x, start.y, { w: 20, body: DYNAMIC }); + const hinge = f.joint(bob, { x: -start.x, y: -start.y }, null, { jointType: 'hinge', ...values }); + return { f, bob, hinge }; + } + + it('swings a pendulum of one metre with the period of one', () => { + const { f, bob } = pendulum(); + const r = run(f); + // 2π·√(L/g) = 2.007 s at L = 100 units and g = 980; the box's own size adds a third of a percent. + let crossed = 0; + let furthest = 0; + let nearest = Infinity; + r.seconds(1.2, (time) => { + const at = r.at(bob); + if (!crossed && at.x <= 0) crossed = time; + furthest = Math.max(furthest, distance(at, { x: 0, y: 0 })); + nearest = Math.min(nearest, distance(at, { x: 0, y: 0 })); + }); + expect(crossed).toBeGreaterThan(0.46); + expect(crossed).toBeLessThan(0.56); + expect(furthest).toBeLessThan(100.6); + expect(nearest).toBeGreaterThan(99.4); + // Half a period on it is as far out on the other side. + expect(r.at(bob).x).toBeLessThan(-6); + }); + + it('turns the object with it', () => { + const { f, bob } = pendulum({}, { x: 100, y: 0 }); + const r = run(f); + r.seconds(0.5); + expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(20); + }); + + it('stops at its limit and stays there', () => { + const { f, bob } = pendulum({ limitAngle: true, minAngle: -30, maxAngle: 30 }, { x: 100, y: 0 }); + const r = run(f); + let most = 0; + r.seconds(3, () => (most = Math.max(most, Number(r.entity(bob).props.rotation)))); + expect(most).toBeLessThan(33); + expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(27); + }); + + it('swings past that angle without a limit', () => { + const { f, bob } = pendulum({}, { x: 100, y: 0 }); + const r = run(f); + let most = 0; + r.seconds(1, () => (most = Math.max(most, Number(r.entity(bob).props.rotation)))); + expect(most).toBeGreaterThan(80); + }); + + it('counts the limit from how the object was placed, turned or not', () => { + const { f, bob } = pendulum({ limitAngle: true, minAngle: -30, maxAngle: 30 }, { x: 100, y: 0 }); + f.set(bob, 'rotation', 45); + const r = run(f); + r.seconds(3); + expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(72); + expect(Number(r.entity(bob).props.rotation)).toBeLessThan(78); + }); +}); + +describe('a fixed joint', () => { + it('holds a body to a static one as it was placed, until the joint is destroyed', () => { + const f = jointScene(); + const wall = f.add(0, 0, { w: 40, body: { bodyType: 'static' } }); + const shelf = f.add(60, 10, { w: 40, body: DYNAMIC }); + const joint = f.joint(shelf, { x: 0, y: 0 }, wall, { jointType: 'fixed' }); + const r = run(f); + r.seconds(1); + expect(r.at(shelf).x).toBeCloseTo(60, 0); + expect(r.at(shelf).y).toBeCloseTo(10, 0); + expect(Math.abs(Number(r.entity(shelf).props.rotation ?? 0))).toBeLessThan(1); + r.world.remove(r.entity(joint)); + r.seconds(0.5); + expect(r.at(shelf).y).toBeGreaterThan(60); + }); + + it('moves two bodies as one', () => { + const f = jointScene(); + f.gravity(0, 0); + const a = f.add(0, 0, { w: 40, body: DYNAMIC }); + const b = f.add(60, 0, { w: 40, body: DYNAMIC }); + f.joint(a, { x: 0, y: 0 }, b, { jointType: 'fixed' }); + const r = run(f); + r.seconds(0.1); + r.physics.setVelocity(r.entity(a), { x: 0, y: 100 }); + r.seconds(1); + expect(distance(r.at(a), r.at(b))).toBeGreaterThan(59); + expect(distance(r.at(a), r.at(b))).toBeLessThan(61); + // The pair turns about its common centre, so the second body is carried along. + expect(distance(r.at(b), { x: 60, y: 0 })).toBeGreaterThan(10); + }); +}); + +describe('the other end of a joint', () => { + it('follows a connected object without a rigidbody, which is pulled by nothing', () => { + const f = jointScene(); + const hook = f.add(0, -100, { w: 10, bare: true }); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 }); + const r = run(f); + r.seconds(0.5); + expect(r.at(hook)).toMatchObject({ x: 0, y: -100 }); + let furthest = 0; + r.seconds(1, () => { + r.entity(hook).x += 200 / 60; + furthest = Math.max(furthest, distance(r.at(box), r.at(hook))); + }); + r.seconds(1); + // The hook moves 3.3 a frame and the box is drawn a step behind, so the rope looks that much longer. + expect(furthest).toBeLessThan(108); + expect(r.at(box).x).toBeGreaterThan(120); + expect(r.at(hook).y).toBe(-100); + }); + + it('is the connected anchor in that object, wherever the object is turned to', () => { + const f = jointScene(); + const hook = f.add(200, 0, { w: 10, bare: true }); + f.set(hook, 'rotation', 90); + const box = f.add(200, 150, { w: 40, body: DYNAMIC }); + // 50 along the hook's own x, which points down: the rope ends at (200, 50). + f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', connectedAnchorX: 50, length: 60 }); + const r = run(f); + r.seconds(2); + expect(r.at(box).x).toBeCloseTo(200, 0); + expect(r.at(box).y).toBeGreaterThan(108); + expect(r.at(box).y).toBeLessThan(112); + }); + + it('takes an object a graph wrote into the row for the one to hold on to', () => { + const f = jointScene(); + const hook = f.add(300, -100, { w: 10, bare: true }); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 100 }); + const r = run(f); + // What `Properties` does with an object arriving at the port: the object itself lies in the bag. + r.entity(joint).props.connected = r.entity(hook) as never; + r.seconds(2); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(true); + expect(distance(r.at(box), { x: 300, y: -100 })).toBeLessThan(102); + expect(r.at(box).x).toBeGreaterThan(200); + }); + + it('finds the rigidbody of an object further up when its own object has none', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const arm = place(f, f.spriteId, { x: 0, y: -20 }, box); + const joint = f.joint(arm, { x: 0, y: 0 }, null, { + jointType: 'rope', + connectedAnchorY: -120, + length: 100, + }); + const r = run(f); + r.seconds(1); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(true); + expect(r.at(box).y).toBeLessThan(3); + }); + + it('holds nothing and says so when the object it sits on has no rigidbody', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40 }); + const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 50 }); + const r = run(f); + r.seconds(0.2); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(false); + expect(r.world.log.filter((line) => /without a rigidbody/.test(line))).toHaveLength(1); + }); + + it('lets go and says so when its connected object leaves the game', () => { + const f = jointScene(); + const hook = f.add(0, -100, { w: 10, bare: true }); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 }); + const r = run(f); + r.seconds(0.5); + expect(r.at(box).y).toBeLessThan(3); + r.world.remove(r.entity(hook)); + r.seconds(0.5); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(false); + expect(r.at(box).y).toBeGreaterThan(60); + expect(r.world.log.some((line) => /lost its connected object/.test(line))).toBe(true); + }); + + it('holds nothing when it is connected to the object it sits on', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, box, { jointType: 'fixed' }); + const r = run(f); + r.seconds(0.2); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(false); + expect(r.world.log.some((line) => /connected to the object it sits on/.test(line))).toBe(true); + }); + + it('lets go while the joint is switched off and holds again when it is back on', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, null, { + jointType: 'rope', + connectedAnchorY: -100, + length: 100, + }); + const r = run(f); + r.seconds(0.3); + r.entity(joint).props.enabled = false; + r.seconds(0.3); + const fallen = r.at(box).y; + expect(fallen).toBeGreaterThan(20); + r.entity(joint).props.enabled = true; + r.seconds(0.1); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(true); + }); +}); + +describe('a joint when its bodies change', () => { + it('lets go while the rigidbody of its object is switched off, and holds again after', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, null, { + jointType: 'rope', + connectedAnchorY: -100, + length: 100, + }); + const r = run(f); + r.seconds(0.3); + const rigidbody = r.world.childrenOf(r.entity(box)).find((e) => e.type === 'rigidbody')!; + rigidbody.props.enabled = false; + r.seconds(0.3); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(false); + rigidbody.props.enabled = true; + r.seconds(1); + expect(r.physics.holdsJoint(r.entity(joint))).toBe(true); + expect(distance(r.at(box), { x: 0, y: -100 })).toBeLessThan(102); + }); + + it('keeps a turned body at its turn in a fixed joint', () => { + const f = jointScene(); + const wall = f.add(0, 0, { w: 40, body: { bodyType: 'static' } }); + const shelf = f.add(60, 0, { w: 40, body: DYNAMIC }); + f.set(shelf, 'rotation', 30); + f.joint(shelf, { x: 0, y: 0 }, wall, { jointType: 'fixed' }); + const r = run(f); + r.seconds(1); + expect(Number(r.entity(shelf).props.rotation)).toBeCloseTo(30, 0); + expect(r.at(shelf).y).toBeCloseTo(0, 0); + }); + + it('goes with its object when that is destroyed and its children are kept', () => { + const f = jointScene(); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 100 }); + const r = run(f); + r.seconds(0.1); + const kept = r.world.removeKeepingChildren(r.entity(box)); + expect(kept.map((e) => e.type)).toEqual(['collider']); + expect(r.world.findByObjectId(joint)).toBeUndefined(); + r.seconds(0.1); + }); +}); + +describe('two connected bodies', () => { + function stacked(collide: boolean) { + const f = jointScene(); + const floor = f.add(0, 100, { w: 200, h: 20, body: { bodyType: 'static' } }); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + f.joint(box, { x: 0, y: 0 }, floor, { jointType: 'rope', length: 300, collideConnected: collide }); + const r = run(f); + r.seconds(1.5); + return r.at(box).y; + } + + it('pass through each other unless the joint says they collide', () => { + expect(stacked(false)).toBeGreaterThan(150); + const resting = stacked(true); + expect(resting).toBeGreaterThan(68); + expect(resting).toBeLessThan(72); + }); +}); + +describe('the Add joint node', () => { + /** A box with a behavior that ties it to the Hook above it on start, by a rope asset of 100. */ + function tying(connect: boolean, asset: 'rope' | 'sprite' = 'rope') { + const f = jointScene(); + const hook = f.add(0, -100, { w: 10, bare: true }); + f.stack.execute(new RenameSceneObjectCommand(f.sceneId, hook, 'Hook')); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const rope = new CreateAssetCommand('joint', 'Rope', { jointType: 'rope', length: 100 }); + f.stack.execute(rope); + const behavior = makeBehavior(f, 'Tie'); + place(f, behavior.id, { x: 0, y: 0 }, box); + const edit = graphEditor(f, behavior); + const add = edit.addNode('AddJoint', { jointAsset: asset === 'rope' ? rope.asset.id : f.spriteId }); + const log = edit.addNode('Log'); + edit.wire([ + [edit.addNode('OnStart'), 'exec', add, 'exec'], + [edit.addNode('Owner'), 'object', add, 'object'], + [add, 'exec', log, 'exec'], + [edit.addNode('NameOf'), 'name', log, 'message'], + ]); + if (connect) edit.connect(edit.addNode('ObjectByName', { name: 'Hook' }), 'object', add, 'connected'); + return { f, box, hook, rope: rope.asset.id }; + } + + it('ties the object to another while the game runs', () => { + const { f, box, hook } = tying(true); + const r = run(f); + r.seconds(1.5); + expect(r.at(box).y).toBeLessThan(3); + const joint = r.world.entities.find((e) => e.type === 'joint')!; + expect(joint.parent).toBe(r.entity(box)); + expect(joint.props.connected).toEqual({ $object: hook }); + expect(r.physics.holdsJoint(joint)).toBe(true); + }); + + it('ties it to the scene when nothing is connected', () => { + const { f, box } = tying(false); + const r = run(f); + r.seconds(2); + // The rope asset ends at the scene's origin, 100 long. + expect(distance(r.at(box), { x: 0, y: 0 })).toBeLessThan(102); + expect(r.at(box).y).toBeGreaterThan(95); + }); + + it('places nothing and says so for an asset that is no joint', () => { + const { f, box } = tying(true, 'sprite'); + const r = run(f); + r.seconds(0.5); + expect(r.world.entities.some((e) => e.type === 'joint')).toBe(false); + expect(r.at(box).y).toBeGreaterThan(50); + expect(r.world.log.some((line) => /is no joint/.test(line))).toBe(true); + }); + + it('says what is missing when nothing is picked or no object arrives', () => { + const ctx = fakeContext(); + runDef('AddJoint', ctx, { jointAsset: '', object: fakeEntity('box') }); + runDef('AddJoint', ctx, { jointAsset: 'rope', object: null }); + expect(ctx.logs).toEqual(['Add joint: no joint selected', 'Add joint: no object to put it on']); + }); +}); + +describe('a joint and the object it names', () => { + /** A hook and a box hung on it by a rope of 100, the hook 100 above the box. */ + function hung() { + const f = jointScene(); + const hook = f.add(0, -100, { w: 10, bare: true }); + const box = f.add(0, 0, { w: 40, body: DYNAMIC }); + const joint = f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 }); + return { f, hook, box, joint }; + } + + it('holds each placement of a container to its own copy', () => { + const { f, hook, box } = hung(); + const create = new CreateContainerCommand(f.sceneId, [hook, box]); + f.stack.execute(create); + const first = f.object(create.objectId).position; + f.stack.execute(new PlaceAssetCommand(f.sceneId, create.assetId, { x: first.x + 500, y: first.y })); + const r = run(f); + r.seconds(1.5); + const boxes = r.world.entities + .filter((e) => e.type === 'rigidbody') + .map((e) => worldPositionOf(e.parent!)) + .sort((a, b) => a.x - b.x); + expect(boxes).toHaveLength(2); + // Each hangs under its own hook; held to the other's it would have swung half the way over. + for (const at of boxes) expect(Math.abs(at.y - boxes[0]!.y)).toBeLessThan(2); + expect(boxes[1]!.x - boxes[0]!.x).toBeGreaterThan(498); + expect(boxes[1]!.x - boxes[0]!.x).toBeLessThan(502); + expect(r.world.entities.filter((e) => e.type === 'joint').every((e) => r.physics.holdsJoint(e))).toBe( + true, + ); + }); + + it('is connected to the copy when both are pasted together', () => { + const { f, hook, box, joint } = hung(); + const copied = copyObjects(f.project, f.sceneOf(), [hook, box])!; + const paste = new PasteObjectsCommand(f.sceneId, copied, {}, { x: 300, y: 0 }); + f.stack.execute(paste); + const [hookCopy, boxCopy] = paste.rootIds as [string, string]; + const jointCopy = f + .sceneOf() + .objects.find((o) => o.parentId === boxCopy && o.assetId === f.object(joint).assetId)!; + expect(jointCopy.overrides.connected).toEqual({ $object: hookCopy }); + }); + + it('holds to the scene once the object it named is removed in the editor', () => { + const { f, hook, joint } = hung(); + f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [hook])); + expect(f.object(joint).overrides.connected).toEqual({ $object: '' }); + f.stack.undo(); + expect(f.object(joint).overrides.connected).toEqual({ $object: hook }); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index ddc14f92..00d90d58 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1701,6 +1701,45 @@ const CASES: Record = { after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']), }, ], + AddJoint: [ + { + name: 'places the picked joint on the object at (x, y), connected to the other', + inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: fakeEntity('Hook'), x: 4, y: -6 }, + context: { + addJoint: (assetId, owner, connected, at) => + assetId === 'rope' && + owner.name === 'Box' && + connected?.name === 'Hook' && + at.x === 4 && + at.y === -6 + ? fakeEntity('Rope') + : null, + }, + next: 'exec', + outputs: { joint: fakeEntity('Rope') }, + }, + { + name: 'hands the scene on as null when nothing is connected', + inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: null }, + context: { addJoint: (_asset, _owner, connected) => (connected === null ? fakeEntity('Rope') : null) }, + next: 'exec', + outputs: { joint: fakeEntity('Rope') }, + }, + { + name: 'without a picked joint places nothing and says so', + inputs: { jointAsset: '', object: fakeEntity('Box') }, + next: 'exec', + outputs: { joint: null }, + after: (ctx) => expect(ctx.logs).toEqual(['Add joint: no joint selected']), + }, + { + name: 'an asset that is no joint places nothing and says so', + inputs: { jointAsset: 'crate', object: fakeEntity('Box') }, + next: 'exec', + outputs: { joint: null }, + after: (ctx) => expect(ctx.logs).toEqual(['Add joint: asset crate is no joint']), + }, + ], Destroy: [ { name: 'no object is a no-op that still continues the flow', diff --git a/tests/editor/scene-visibility.test.ts b/tests/editor/scene-visibility.test.ts index 6c31d2e6..df22dc29 100644 --- a/tests/editor/scene-visibility.test.ts +++ b/tests/editor/scene-visibility.test.ts @@ -28,6 +28,7 @@ describe('scene visibility', () => { 'clip', 'collider', 'container', + 'joint', 'particles', 'rigidbody', 'sprite', diff --git a/tests/helpers.ts b/tests/helpers.ts index 7618bf22..29b518e3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -260,6 +260,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { stopSound: (assetId) => ctx.silenced.push(assetId), loadScene: (sceneId) => ctx.loaded.push(sceneId), spawn: () => null, + addJoint: () => null, destroy: () => [], stop: () => { ctx.stopped = true; diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 0d04007b..d8948854 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -31,6 +31,7 @@ describe('icon files', () => { 'camera', 'collider', 'container', + 'joint', 'particles', 'rigidbody', ]); diff --git a/tests/viewport/joint-links.test.ts b/tests/viewport/joint-links.test.ts new file mode 100644 index 00000000..0ba1180d --- /dev/null +++ b/tests/viewport/joint-links.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { AddJointCommand } from '../../src/core/commands/colliderCommands'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { + MoveSceneObjectCommand, + PlaceAssetCommand, + SetOverrideCommand, +} from '../../src/core/commands/sceneObjectCommands'; +import { objectReference } from '../../src/core/model/objectRefs'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import { collectHolderItems } from '../../src/viewport/items'; +import { jointLinkOf } from '../../src/viewport/jointLinks'; +import { makeSpriteScene, place } from '../helpers'; + +/** A box at (0, 0) with a joint 10 to its right, and a hook at (200, -100) turned by a quarter. */ +function scene(values: Record, connect = true) { + const f = makeSpriteScene('Box'); + const box = place(f, f.spriteId, { x: 0, y: 0 }); + const hook = place(f, f.spriteId, { x: 200, y: -100 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, hook, 'rotation', 90)); + const add = new AddJointCommand(f.sceneId, box); + f.stack.execute(add); + const joint = add.objectId; + f.stack.execute(new MoveSceneObjectCommand(f.sceneId, joint, { x: 10, y: 0, z: 0 })); + if (connect) f.stack.execute(new SetOverrideCommand(f.sceneId, joint, 'connected', objectReference(hook))); + for (const [key, value] of Object.entries(values)) + f.stack.execute(new SetOverrideCommand(f.sceneId, joint, key, value)); + const links = () => { + const { items } = collectHolderItems(f.project, f.sceneOf(), 0); + const byId = new Map(items.map((i) => [i.id, i])); + return items.flatMap((item) => jointLinkOf(item, byId) ?? []); + }; + return { f, box, hook, joint, links }; +} + +describe('what the scene view draws of a joint', () => { + it('leads a hinge from where it sits to the connected object', () => { + const s = scene({ jointType: 'hinge' }); + expect(s.links()).toEqual([ + expect.objectContaining({ + id: s.joint, + from: { x: 10, y: 0 }, + to: { x: 200, y: -100 }, + connectedId: s.hook, + anchored: false, + }), + ]); + }); + + it('leads a rope to its connected anchor, counted in the connected object', () => { + const s = scene({ jointType: 'rope', connectedAnchorX: 50, connectedAnchorY: 0 }); + const [link] = s.links(); + // The hook is turned by a quarter, so 50 along its x is 50 down. + expect(link!.to.x).toBeCloseTo(200); + expect(link!.to.y).toBeCloseTo(-50); + expect(link!.anchored).toBe(true); + expect(link!.frame).not.toBeNull(); + }); + + it('counts the anchor of a rope held to the scene in the scene', () => { + const s = scene({ jointType: 'spring', connectedAnchorX: 50, connectedAnchorY: -70 }, false); + expect(s.links()[0]).toMatchObject({ to: { x: 50, y: -70 }, connectedId: '', frame: null }); + }); + + it('gives a hinge held to the scene no line: both ends are where it sits', () => { + const s = scene({ jointType: 'hinge' }, false); + expect(s.links()[0]).toMatchObject({ from: { x: 10, y: 0 }, to: { x: 10, y: 0 }, connectedId: '' }); + }); + + it('has no link for anything but a joint', () => { + const s = scene({}); + const { items } = collectHolderItems(s.f.project, s.f.sceneOf(), 0); + const byId = new Map(items.map((i) => [i.id, i])); + expect(jointLinkOf(byId.get(s.box)!, byId)).toBeNull(); + }); + + it("finds, inside each placement of a container, that placement's own copy", () => { + const s = scene({ jointType: 'hinge' }); + const create = new CreateContainerCommand(s.f.sceneId, [s.box, s.hook]); + s.f.stack.execute(create); + const first = s.f.object(create.objectId).position; + s.f.stack.execute(new PlaceAssetCommand(s.f.sceneId, create.assetId, { x: first.x + 500, y: first.y })); + const links = s.links().sort((a, b) => a.from.x - b.from.x); + expect(links).toHaveLength(2); + expect(links[1]!.from.x - links[0]!.from.x).toBeCloseTo(500); + expect(links[1]!.to.x - links[0]!.to.x).toBeCloseTo(500); + expect(links[0]!.connectedId).not.toBe(links[1]!.connectedId); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 9b730bde..723f3c45 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -1,4 +1,5 @@ import { colourDistance } from '../lib/harness.mjs'; +import { inView } from './lib/scaffold.mjs'; /** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ export async function assets(t) { @@ -80,8 +81,8 @@ export async function assets(t) { check('redo puts it back', (await objects()).length === 8); // 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 every type without scrolling. + // what can be placed in a scene, a line, then what only lives in the library. With the Joint + // (#154) its 25 types no longer fit under the button on a canvas 800 high: there it scrolls. await clickText('+'); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); @@ -94,7 +95,7 @@ export async function assets(t) { ); check( 'every asset type is offered once, each group sorted by label', - placeable.length + library.length === 24 && + placeable.length + library.length === 25 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), @@ -109,12 +110,17 @@ export async function assets(t) { ); // The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge, // where `locate` still finds them but a click lands on nothing (that is what took step 30 down). + // What does not fit is scrolled to, never drawn past the box. await shot('tmp-plus-menu'); const menuBox = await json('__gm.overlayRect()'); - const lastRow = await evaluate(`__gm.locate(${JSON.stringify(library.at(-1))}, true)`); + const lastRow = await inView( + t, + '__gm.overlayRect()', + `__gm.locate(${JSON.stringify(library.at(-1))}, true)`, + ); const screenH = await json('__gm.app.screen.height'); check( - 'the whole menu stands on screen, last entry clickable', + 'the whole menu stands on screen, last entry reached by scrolling at most', Boolean(lastRow) && lastRow.y < menuBox.y + menuBox.h && menuBox.y + menuBox.h <= screenH && @@ -449,7 +455,7 @@ export async function assets(t) { async function addMenuOnRightClick(t) { const { check, json, shot, click, clickText, press, undo, showTab } = t; const addRows = (rows) => - Array.isArray(rows) && rows.filter(Boolean).length === 24 && rows.includes('Sprite'); + Array.isArray(rows) && rows.filter(Boolean).length === 25 && rows.includes('Sprite'); const heading = await json("__gm.assetRowShown('group:scene')"); const sceneFolded = async () => !(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded; diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 72ad0034..b72e4f74 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -877,6 +877,7 @@ export async function tilemap(t) { 'Paste', 'Add collider…', 'Add rigidbody…', + 'Add joint…', 'Create container', 'Unpack container', 'Unparent (move to top level)', diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 9b84d74c..25f0f217 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -70,6 +70,7 @@ export async function physics(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, mouse, showTab } = t; + const { drag, undo } = t; await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 }); const status = await json('__gm.physics()'); @@ -523,4 +524,148 @@ export async function physics(t) { }), ); await clearPhysics(); + + // 41e. Joints (#154): a bob below a hook gets a joint from the hierarchy menu, is made a rope to + // the hook in the inspector, its far end is dragged by its handle in the scene view, and in + // the played scene the bob hangs where it would otherwise fall. + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + ${MAKE_OBJECT} + assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } }); + assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } }); + 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-hook', 'crate', '', 0, ${Y}, 'Physics hook', { width: 20, height: 20 }); + put('phys-bob', 'crate', '', 0, ${Y + 100}, 'Physics bob', { width: 40, height: 40 }); + put('phys-bob-col', 'phys-col-box', 'phys-bob', 0, 0); + put('phys-bob-rb', 'phys-rb-free', 'phys-bob', 0, 0); + put('phys-loose', 'crate', '', 200, ${Y + 100}, 'Physics loose', { width: 40, height: 40 }); + put('phys-loose-col', 'phys-col-box', 'phys-loose', 0, 0); + put('phys-loose-rb', 'phys-rb-free', 'phys-loose', 0, 0); + __gm.state.select(null)`); + await showTab('scene'); + await evaluate(`__gm.lookAt(0, ${Y + 50})`); + await selectObject('phys-bob'); + const bobRow = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('phys-bob')`); + const countBefore = (await objects()).length; + await click(bobRow.x, bobRow.y, 'right'); + await clickText('Add joint…'); + if (await has('Create new joint')) await clickText('Create new joint'); + await press('Escape'); + await settle(); + const jointSelection = (await state()).selection; + const joint = jointSelection?.kind === 'object' ? await object(jointSelection.objectId) : null; + const jointAsset = joint && (await asset(joint.assetId)); + check( + 'Add joint puts a hinge below the object and shows its rows', + (await objects()).length === countBefore + 1 && + joint?.parentId === 'phys-bob' && + jointAsset?.type === 'joint' && + jointAsset?.props?.jointType === 'hinge' && + (await has('Connected object')) && + (await has('Limit angle')) && + !(await has('Stiffness')) && + (await evaluate(`__gm.layerOf('${joint?.id}')`)) === 'overlay', + JSON.stringify({ joint, jointAsset }), + ); + const pressAt = async (at, what) => { + if (!at) throw new Error(`41e: ${what} is not on screen`); + await click(at.x, at.y); + await settle(); + }; + await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Type')"), 'the Type field'); + await pressAt(await json("__gm.locate('Rope', true)"), 'Rope in the menu'); + await pressAt( + await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Connected object')"), + 'the Connected object field', + ); + // The menu lists every object of the scene: found by typing, as in a scene that has many. + await typeText('Physics hook'); + await settle(); + await pressAt(await json("__gm.locate('Physics hook', true)"), 'Physics hook in the menu'); + const jointObject = async () => object(joint.id); + const links = async () => json('__gm.joints()'); + const roped = await links(); + const ropeLink = roped.links.find((l) => l.id === joint?.id); + check( + 'a rope connected to the hook is drawn from the bob to it, with a handle on its far end', + (await jointObject())?.overrides?.jointType === 'rope' && + (await jointObject())?.overrides?.connected?.$object === 'phys-hook' && + ropeLink?.connectedId === 'phys-hook' && + ropeLink.anchored === true && + Math.abs(ropeLink.from.x) < 0.01 && + Math.abs(ropeLink.from.y - (Y + 100)) < 0.01 && + Math.abs(ropeLink.to.x) < 0.01 && + Math.abs(ropeLink.to.y - Y) < 0.01 && + roped.handle?.objectId === joint?.id && + (await has('Connected anchor X')) && + (await has('Length (0 = as placed)')) && + !(await has('Limit angle')), + JSON.stringify({ roped, overrides: (await jointObject())?.overrides }), + ); + await shot('41-joint'); + // The scale of the view, read off the bob's 40 units. + const bobRect = await json(`__gm.objectRect('phys-bob')`); + const unit = bobRect.w / 40; + await drag(roped.handle.x, roped.handle.y, roped.handle.x + 30 * unit, roped.handle.y); + await settle(); + const moved = (await jointObject())?.overrides; + const movedLink = (await links()).links.find((l) => l.id === joint?.id); + await undo(); + await settle(); + const undone = (await jointObject())?.overrides; + check( + "dragging the handle moves the rope's connected anchor, and one undo puts it back", + Math.abs(moved?.connectedAnchorX - 30) <= 1 && + Math.abs(moved?.connectedAnchorY ?? 0) <= 1 && + Math.abs(movedLink?.to.x - moved?.connectedAnchorX) < 0.01 && + undone?.connectedAnchorX === undefined && + undone?.jointType === 'rope', + JSON.stringify({ moved, movedLink, undone, unit }), + ); + + const jointHints = consoleLog.filter((line) => line.includes('[physics]')).length; + await select(null); + await play(); + await evaluate(`(() => { + const out = (window.__jointSamples = []); + 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, bob: y('phys-bob'), loose: y('phys-loose') }); + if (performance.now() - start < 1500) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })()`); + await sleep(1800); + const hung = await json('window.__jointSamples'); + await stop(); + const bobYs = hung.map((s) => s.bob); + check( + 'in the played scene the bob hangs on its rope while the one without a joint falls', + hung.length > 30 && + bobYs.every((y) => Math.abs(y - (Y + 100)) < 2) && + hung.at(-1).loose > Y + 400 && + consoleLog.filter((line) => line.includes('[physics]')).length === jointHints, + JSON.stringify({ + frames: hung.length, + bob: [Math.min(...bobYs), Math.max(...bobYs)], + loose: hung.at(-1).loose, + hints: consoleLog.filter((line) => line.includes('[physics]')).slice(jointHints), + }), + ); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const joints = new Set(scene.objects.filter((o) => o.parentId === 'phys-bob').map((o) => o.assetId)); + scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && o.parentId !== 'phys-bob'); + p.assets = p.assets.filter((a) => !a.id.startsWith('phys-') && !(a.type === 'joint' && joints.has(a.id))); + __gm.state.select(null)`); } diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 9cc50f41..3f1245cb 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -311,6 +311,7 @@ export async function topbar(t) { 'clip', 'collider', 'container', + 'joint', 'particles', 'rigidbody', 'sprite',