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>
949 lines
39 KiB
TypeScript
949 lines
39 KiB
TypeScript
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 });
|
||
});
|
||
});
|