Files
feuerwerk/tests/core/joints.test.ts
T
adminandClaude Opus 5.5 f1e3bd8378
game-maker / gate (push) Successful in 3m4s
game-maker / suite (push) Successful in 14m2s
Let a joint glide on a rail and drive it with a motor (#154)
Step 2 of the joints: a door that opens by itself, a lift and a
piston need a joint that slides along one line and a motor that turns
or pushes. A graph also needs to read how far such a joint stands.

- Slider: a fifth joint type on Rapier's prismatic joint. Its rail is
  the joint's own x axis, so turning the joint turns the rail. Joints
  now carry Scale, Rotation and Mirror like a sprite.
- Limit distance with Min and Max, in world units from where the
  object was placed.
- Motor on hinge and slider: Off, Speed or Target, with Motor speed,
  Motor target and Max force (0 = no limit). Target goes there at no
  more than the speed and slows down for the last stretch.
- Changing speed, target or force keeps the engine joint. Switching
  the motor on or off builds it again.
- New node Joint position: value and speed of a hinge (degrees,
  counting past a full round) or a slider (world units).
- Scene view draws a slider's rail dashed, with a tick at each limit.
- shownFor in the schema takes a list of choices that must all hold.

Findings, measured with a probe against Rapier 0.20:
- A prismatic joint holds both bodies at one angle. Held to the scene
  or to an object without a rigidbody, the joint's own anchor body is
  turned by the placed difference, so nothing jumps. Between two
  rigidbodies the held one is turned to match, and the console says
  so once.
- Rapier reads no joint angle back. The position is worked out from
  the two bodies.
- A hinge motor is weak on a small body at the end of a long arm
  (box 20 on an arm of 100 does not lift itself). More solver
  iterations would help but cost every scene. A door of 100 x 20
  reaches -90 and holds it. This is written in the wiki.

Tests:
- Unit: 16 new in joints.test.ts, 1 in joint-links, 3 catalog cases.
- e2e 41: 27 checks (25 + 2), block 41f with a real mouse in the
  inspector and the motor in play (59.1 of 60 units in 1 s).
- 9 of 9 unit counter-probes fail as they should.
- Gate: 220 files, 3436 tests (3416 + 20), CHECK EXIT 0.
- e2e total 1701 is counted (1699 + 2), the full suite did not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:09:45 +02:00

949 lines
39 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,
MOTOR_GAIN,
motorSpeed,
OPEN_RAIL,
sliderAxis,
sliderRail,
} from '../../src/core/properties/joint';
import { transformMatrix, 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']));
});
it('shows the limits of a slider with their switch, and the motor rows by its kind', () => {
const shown = (values: Values) =>
schemaFor('joint')
.filter((def) => isRowShown(def, { ...defaultsFor('joint'), ...values }))
.map((def) => def.key);
const motorRows = ['motorSpeed', 'motorTarget', 'maxForce'];
expect(shown({ jointType: 'slider' })).toEqual(expect.arrayContaining(['limitDistance', 'motor']));
expect(shown({ jointType: 'slider' })).not.toContain('minDistance');
expect(shown({ jointType: 'slider', limitDistance: true })).toEqual(
expect.arrayContaining(['minDistance', 'maxDistance']),
);
expect(shown({ jointType: 'hinge', limitDistance: true })).not.toContain('minDistance');
expect(shown({ jointType: 'slider' }).filter((k) => motorRows.includes(k))).toEqual([]);
expect(shown({ jointType: 'hinge', motor: 'speed' }).filter((k) => motorRows.includes(k))).toEqual([
'motorSpeed',
'maxForce',
]);
expect(shown({ jointType: 'slider', motor: 'target' }).filter((k) => motorRows.includes(k))).toEqual(
motorRows,
);
expect(shown({ jointType: 'rope', motor: 'target' })).not.toContain('motor');
expect(shown({ jointType: 'rope', motor: 'target' })).not.toContain('motorTarget');
});
it('gives only a slider limits of distance, and only a hinge or a slider a motor', () => {
expect(jointSettings({ jointType: 'slider' })).toMatchObject({ distanceLimits: null, motor: null });
expect(
jointSettings({ jointType: 'slider', limitDistance: true, minDistance: 30, maxDistance: 70 })
.distanceLimits,
).toEqual({ min: 0, max: 70 });
expect(jointSettings({ jointType: 'hinge', limitDistance: true }).distanceLimits).toBeNull();
expect(jointSettings({ jointType: 'hinge', motor: 'speed', motorSpeed: -45 }).motor).toEqual({
mode: 'speed',
speed: -45,
target: 0,
maxForce: 0,
});
expect(jointSettings({ jointType: 'slider', motor: 'target', maxForce: -5 }).motor?.maxForce).toBe(0);
expect(jointSettings({ jointType: 'rope', motor: 'speed' }).motor).toBeNull();
expect(jointSettings({ jointType: 'hinge', motor: 'nonsense' }).motor).toBeNull();
});
it('asks a motor for its speed, or towards its target at no more than it and slower near it', () => {
const motor = { mode: 'target' as const, speed: 90, target: 100, maxForce: 0 };
expect(motorSpeed({ ...motor, mode: 'speed' }, 40)).toBe(90);
expect(motorSpeed(motor, 0)).toBe(90);
expect(motorSpeed(motor, 200)).toBe(-90);
expect(motorSpeed(motor, 99)).toBeCloseTo(MOTOR_GAIN);
expect(motorSpeed(motor, 100)).toBe(0);
expect(motorSpeed({ ...motor, speed: -90 }, 0)).toBe(90);
});
it("runs a slider's rail along the joint's own x axis", () => {
const axis = sliderAxis(transformMatrix(5, 5, 90, 3, false, false));
expect(axis.x).toBeCloseTo(0);
expect(axis.y).toBeCloseTo(1);
const open = sliderRail(jointSettings({ jointType: 'slider' }), { x: 0, y: 0 }, { x: 1, y: 0 });
expect(open).toEqual({ from: { x: -OPEN_RAIL, y: 0 }, to: { x: OPEN_RAIL, y: 0 } });
});
});
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('a slider', () => {
/** A box at the origin on a slider at its centre, held to the scene; the joint turned by `angle`. */
function railed(values: Values = {}, angle = 0, box: Thing = { w: 20, body: DYNAMIC }) {
const f = jointScene();
const id = f.add(0, 0, box);
const joint = f.joint(id, { x: 0, y: 0 }, null, { jointType: 'slider', ...values });
f.set(joint, 'rotation', angle);
return { f, box: id, joint };
}
it('holds the object on its rail, so it does not fall across it', () => {
const { f, box } = railed();
const r = run(f);
r.seconds(1);
expect(Math.abs(r.at(box).x)).toBeLessThan(0.5);
expect(Math.abs(r.at(box).y)).toBeLessThan(0.5);
});
it('lets it glide along the rail the joint is turned to, without turning it', () => {
const { f, box, joint } = railed({}, 45);
const r = run(f);
r.seconds(0.5);
const at = r.at(box);
expect(at.x).toBeGreaterThan(40);
expect(at.y).toBeCloseTo(at.x, 0);
expect(Math.abs(Number(r.entity(box).props.rotation ?? 0))).toBeLessThan(0.5);
// The box is drawn up to a step behind the engine, which glides 5.8 a step by now.
expect(Math.abs(r.physics.jointStateOf(r.entity(joint))!.value - Math.hypot(at.x, at.y))).toBeLessThan(7);
});
it('stops at its limits, counted from where it was placed', () => {
const { f, box } = railed({ limitDistance: true, minDistance: -10, maxDistance: 50 }, 90);
const r = run(f);
r.seconds(1.5);
expect(r.at(box).y).toBeGreaterThan(48);
expect(r.at(box).y).toBeLessThan(52);
});
it('keeps an object placed turned as it was when it is held to the scene', () => {
// The joint turns with the box it sits on: 30 and 60 make an upright rail.
const { f, box } = railed({}, 60);
f.set(box, 'rotation', 30);
const r = run(f);
r.seconds(1);
expect(Number(r.entity(box).props.rotation)).toBeCloseTo(30, 0);
expect(r.at(box).y).toBeGreaterThan(100);
expect(Math.abs(r.at(box).x)).toBeLessThan(0.5);
});
it('turns the object to match between two rigidbodies, and says so', () => {
const f = jointScene();
const wall = f.add(-100, 0, { w: 20, body: { bodyType: 'static' } });
const box = f.add(0, 0, { w: 20, body: DYNAMIC });
f.set(box, 'rotation', 30);
f.joint(box, { x: 0, y: 0 }, wall, { jointType: 'slider' });
const r = run(f);
r.seconds(1);
expect(Math.abs(Number(r.entity(box).props.rotation))).toBeLessThan(1);
expect(r.world.log.filter((line) => /keeps them at one angle/.test(line))).toHaveLength(1);
});
});
describe('a motor', () => {
/** A wheel turning about its own centre, pinned to the scene, with no gravity. */
function wheel(values: Values) {
const f = jointScene();
f.gravity(0, 0);
const id = f.add(0, 0, { w: 40, body: DYNAMIC });
const joint = f.joint(id, { x: 0, y: 0 }, null, { jointType: 'hinge', ...values });
return { f, wheel: id, joint };
}
/** A lift: a box on an upright rail held to the scene; the rail's x points down. */
function lift(values: Values) {
const f = jointScene();
const box = f.add(0, 0, { w: 20, body: DYNAMIC });
const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'slider', ...values });
f.set(joint, 'rotation', 90);
return { f, box, joint };
}
it('turns a hinge at its speed and counts on past a full round', () => {
const { f, wheel: id, joint } = wheel({ motor: 'speed', motorSpeed: 360 });
const r = run(f);
r.seconds(2.5);
const state = r.physics.jointStateOf(r.entity(joint))!;
expect(state.value).toBeGreaterThan(880);
expect(state.value).toBeLessThan(905);
expect(state.speed).toBeCloseTo(360, 0);
// Drawn up to a step – 6 degrees – behind the engine.
expect(Math.abs((Number(r.entity(id).props.rotation) % 360) - (state.value % 360))).toBeLessThan(7);
});
it('turns a hinge to its target and holds it there against the weight of the door', () => {
const f = jointScene();
const door = f.add(50, 0, { w: 100, h: 20, body: DYNAMIC });
const joint = f.joint(door, { x: -50, y: 0 }, null, {
jointType: 'hinge',
motor: 'target',
motorTarget: -90,
motorSpeed: 180,
});
const r = run(f);
r.seconds(0.25);
// On its way at 180 degrees a second, not there at once.
expect(r.physics.jointStateOf(r.entity(joint))!.value).toBeGreaterThan(-60);
expect(r.physics.jointStateOf(r.entity(joint))!.value).toBeLessThan(-20);
r.seconds(1.75);
expect(r.physics.jointStateOf(r.entity(joint))!.value).toBeCloseTo(-90, 0);
expect(r.at(door).x).toBeCloseTo(0, 0);
expect(r.at(door).y).toBeCloseTo(-50, 0);
});
it('glides a slider at its speed', () => {
const { f, box, joint } = lift({ motor: 'speed', motorSpeed: 50 });
f.gravity(0, 0);
const r = run(f);
r.seconds(2);
expect(Math.abs(r.at(box).y - 100)).toBeLessThan(1.5);
expect(r.physics.jointStateOf(r.entity(joint))!.speed).toBeCloseTo(50, 0);
});
it('lifts a box to its target, holds it there, and goes on to a target set while it runs', () => {
const { f, box, joint } = lift({ motor: 'target', motorTarget: -200, motorSpeed: 100 });
const r = run(f);
r.seconds(1);
expect(r.at(box).y).toBeGreaterThan(-110);
expect(r.at(box).y).toBeLessThan(-90);
r.seconds(2);
expect(r.at(box).y).toBeCloseTo(-200, 0);
expect(r.physics.jointStateOf(r.entity(joint))!.value).toBeCloseTo(-200, 0);
// What `Properties` writes: the target only, so the engine keeps the joint it has.
r.entity(joint).props.motorTarget = -50;
r.seconds(2.5);
expect(r.at(box).y).toBeCloseTo(-50, 0);
});
it('cannot lift more than its force allows', () => {
const weak = lift({ motor: 'speed', motorSpeed: -100, maxForce: 1 });
const r = run(weak.f);
r.seconds(0.5);
expect(r.at(weak.box).y).toBeGreaterThan(50);
const strong = lift({ motor: 'speed', motorSpeed: -100, maxForce: 100000 });
const s = run(strong.f);
s.seconds(0.5);
expect(s.at(strong.box).y).toBeLessThan(-45);
});
it('lets go once it is switched off while the game runs', () => {
const { f, box, joint } = lift({ motor: 'target', motorTarget: 0 });
const r = run(f);
r.seconds(0.5);
expect(Math.abs(r.at(box).y)).toBeLessThan(1);
r.entity(joint).props.motor = 'off';
r.seconds(0.5);
expect(r.at(box).y).toBeGreaterThan(100);
expect(r.physics.holdsJoint(r.entity(joint))).toBe(true);
});
it('reads no position off a rope, nor off a joint that holds nothing', () => {
const f = jointScene();
const box = f.add(0, 0, { w: 20, body: DYNAMIC });
const rope = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', connectedAnchorY: -50 });
const loose = f.add(100, 0, { w: 20 });
const idle = f.joint(loose, { x: 0, y: 0 }, null, { jointType: 'hinge' });
const r = run(f);
r.seconds(0.1);
expect(r.physics.jointStateOf(r.entity(rope))).toBeNull();
expect(r.physics.jointStateOf(r.entity(idle))).toBeNull();
});
});
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 });
});
});