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',