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 }); }); });