Files
feuerwerk/tests/core/joints.test.ts
T
adminandClaude Opus 5.5 7dd3ae1131
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Failing after 11m41s
Tie two objects with a hinge, a spring, a rope or for good (#154)
Two bodies could not be connected: no door on a hinge, no lamp on
a rope, no wheel on an axle. This is the first step of the issue,
the four joints without a motor. The slider and the motors follow
in a second step under the same issue.

- Joint is a new asset type, placed as a child of the object it
  holds, like a collider or a rigidbody. "Add joint…" in the
  object menu makes one or places one the project has. An object
  takes as many as it needs.
- Where the joint sits is the point it holds its object at. A
  hinge and a fixed joint have that one point only. A spring and a
  rope end at a connected anchor on the other object, counted in
  that object's own units, or in the scene's without one.
- The rows follow the type: length, stiffness and damping for a
  spring, length for a rope, an angle limit for a hinge. Length 0
  takes the distance the two ends were placed at. The limit counts
  from how the two were placed.
- The connected object is an object reference in the schema of the
  type. Pasting, containers and removing treat it as they treat
  every reference since #258.
- shownFor is a new schema option: a row is shown while an enum of
  the same bag holds one of the named values.
- The object needs a rigidbody, its own or one further up. Without
  one the joint holds nothing and the console says so once.
- A connected object without a rigidbody is a moving anchor: the
  joint has a kinematic body of its own that follows it.
- A connected object destroyed while playing makes the joint let go
  with a hint. Removed in the editor the reference empties, so the
  joint holds on to the scene. The issue asked for "inactive" there.
- "Add joint" ties two objects while the game runs, from a joint
  asset as the template. Destroy on the joint lets go, there is no
  Break joint node. Destroy with keep children takes the joints
  along with the object.
- The scene view draws a line from each joint to what it holds on
  to and rings the held point. A selected spring or rope has a
  round handle on its far end, and dragging it is one undo step.
- Joints hold in Play only. The editor run simulates nothing
  (#148), and the inspector of a placed joint says so.
- Rapier counts a revolute joint's limits absolutely, not from the
  start, so the placed angle is added. Measured with a probe.
- The + menu has 25 types now and no longer fits under its button
  on a canvas 800 high. It scrolls there, and e2e 04 scrolls to
  the last entry instead of expecting it in view.

Tests:
- Gate on melektaus-atx for this state: 218 files, 3390 tests
  (3336 + 54), exit 0.
- e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all
  pass. Module 41 alone: 25 checks (21 + 4).
- 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum,
  limit, fixed, the other end, the node, containers, paste), 6 for
  the line the scene view draws, 4 catalog cases of the node. The
  remaining 4 come from tests that make a case per asset type.
- Four new e2e checks in module 41: Add joint from the menu, the
  rope to the hook with its line and handle, dragging the handle
  with one undo, the bob hanging in Play next to one that falls.
- e2e 04, 40 and 46 count the new type in their lists.
- 21 of 22 unit counter-probes fail their tests. The other one is
  equivalent: Rapier ignores removing a joint that left with its
  body, the guard stays.
- Three browser counter-probes fail their checks (2, 1 and 2).
- One screenshot looked at: icon, line, handle and the rope rows.
- The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI
  on e066613). The full suite and the smokes did not run.
- Not seen in the browser: spring, hinge with limit and fixed
  joint, the node Add joint, a connected object that moves, joints
  inside a container, the released game. Unit tests cover them
  but the last. Limits under a mirrored parent are not handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:43:12 +02:00

707 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PropertyValue>;
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 });
});
});