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>
This commit is contained in:
1 parent
4716726b6b
commit
f1e3bd8378
16 files changed
+738
-34
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1699 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1701 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -97,6 +97,11 @@ export interface PhysicsControl {
|
||||
applyImpulse(target: EntityHandle, x: number, y: number): boolean;
|
||||
/** World units per second squared, for the rest of the run. */
|
||||
setGravity(x: number, y: number): void;
|
||||
/**
|
||||
* How far a hinge has turned (degrees) or a slider has glided (world units) from how it was placed,
|
||||
* and how fast; null for any other joint, for one that holds nothing, and for anything else (#154).
|
||||
*/
|
||||
jointStateOf(target: EntityHandle): { value: number; speed: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,24 @@ export const GetVelocity: NodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
export const JointPosition: NodeDef = {
|
||||
type: 'JointPosition',
|
||||
label: 'Joint position',
|
||||
category: 'data',
|
||||
description:
|
||||
'Where a hinge or a slider stands while the game is played: how far it has turned (degrees, ' +
|
||||
'a wheel counts on past a full round) or glided (world units) from how it was placed, and how fast ' +
|
||||
'that changes. 0 for a spring, a rope, a fixed joint, a joint that holds nothing, and in the editor run.',
|
||||
inputs: [obj('joint')],
|
||||
outputs: [num('value'), num('speed')],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const joint = asEntity(inputs.joint);
|
||||
const state = (joint && ctx.physics?.jointStateOf(joint)) || { value: 0, speed: 0 };
|
||||
return { outputs: { value: state.value, speed: state.speed } };
|
||||
},
|
||||
};
|
||||
|
||||
export const SetGravity: NodeDef = {
|
||||
type: 'SetGravity',
|
||||
label: 'Set gravity',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ApplyForce,
|
||||
ApplyImpulse,
|
||||
GetVelocity,
|
||||
JointPosition,
|
||||
SetAngularVelocity,
|
||||
SetGravity,
|
||||
SetVelocity,
|
||||
@@ -277,6 +278,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
Spawn,
|
||||
Destroy,
|
||||
AddJoint,
|
||||
JointPosition,
|
||||
Emit,
|
||||
PlayAnimation,
|
||||
PauseAnimation,
|
||||
|
||||
+24
-3
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* A joint ties the object it is placed below to another one (#154): a hinge turns round one point, a
|
||||
* spring pulls towards a length, a rope allows no more than one, and a fixed joint keeps the two as
|
||||
* they were placed. These are the keys of its rows; what the engine reads off them is in
|
||||
* `properties/joint.ts`.
|
||||
* they were placed, and a slider lets it glide along one axis only. These are the keys of its rows;
|
||||
* what the engine reads off them is in `properties/joint.ts`.
|
||||
*/
|
||||
export const JOINT_TYPES = ['hinge', 'spring', 'rope', 'fixed'] as const;
|
||||
export const JOINT_TYPES = ['hinge', 'spring', 'rope', 'fixed', 'slider'] as const;
|
||||
export type JointType = (typeof JOINT_TYPES)[number];
|
||||
|
||||
export const JOINT_TYPE_LABELS: Record<JointType, string> = {
|
||||
@@ -12,6 +12,7 @@ export const JOINT_TYPE_LABELS: Record<JointType, string> = {
|
||||
spring: 'Spring',
|
||||
rope: 'Rope',
|
||||
fixed: 'Fixed',
|
||||
slider: 'Slider',
|
||||
};
|
||||
|
||||
export const JOINT_TYPE_KEY = 'jointType';
|
||||
@@ -24,8 +25,28 @@ export const JOINT_DAMPING_KEY = 'damping';
|
||||
export const JOINT_LIMIT_KEY = 'limitAngle';
|
||||
export const JOINT_MIN_ANGLE_KEY = 'minAngle';
|
||||
export const JOINT_MAX_ANGLE_KEY = 'maxAngle';
|
||||
export const JOINT_LIMIT_DISTANCE_KEY = 'limitDistance';
|
||||
export const JOINT_MIN_DISTANCE_KEY = 'minDistance';
|
||||
export const JOINT_MAX_DISTANCE_KEY = 'maxDistance';
|
||||
export const JOINT_MOTOR_KEY = 'motor';
|
||||
export const JOINT_MOTOR_SPEED_KEY = 'motorSpeed';
|
||||
export const JOINT_MOTOR_TARGET_KEY = 'motorTarget';
|
||||
export const JOINT_MAX_FORCE_KEY = 'maxForce';
|
||||
export const JOINT_COLLIDE_KEY = 'collideConnected';
|
||||
|
||||
/** What a motor on a hinge or a slider does: nothing, turn or glide on, or go to a place and stay. */
|
||||
export const JOINT_MOTORS = ['off', 'speed', 'target'] as const;
|
||||
export type JointMotor = (typeof JOINT_MOTORS)[number];
|
||||
|
||||
export const JOINT_MOTOR_LABELS: Record<JointMotor, string> = {
|
||||
off: 'Off',
|
||||
speed: 'Speed',
|
||||
target: 'Target',
|
||||
};
|
||||
|
||||
/** The types that move along one axis, which a motor can drive and a position can be read of. */
|
||||
export const AXIS_TYPES: readonly JointType[] = ['hinge', 'slider'];
|
||||
|
||||
/** The types with a point of their own on the other end; a hinge and a fixed joint have the one they sit on. */
|
||||
export const TWO_ANCHOR_TYPES: readonly JointType[] = ['spring', 'rope'];
|
||||
|
||||
|
||||
@@ -41,15 +41,26 @@ import {
|
||||
} from '../model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||
import {
|
||||
AXIS_TYPES,
|
||||
JOINT_ANCHOR_X_KEY,
|
||||
JOINT_ANCHOR_Y_KEY,
|
||||
JOINT_COLLIDE_KEY,
|
||||
JOINT_CONNECTED_KEY,
|
||||
JOINT_DAMPING_KEY,
|
||||
JOINT_LENGTH_KEY,
|
||||
JOINT_LIMIT_DISTANCE_KEY,
|
||||
JOINT_LIMIT_KEY,
|
||||
JOINT_MAX_ANGLE_KEY,
|
||||
JOINT_MAX_DISTANCE_KEY,
|
||||
JOINT_MAX_FORCE_KEY,
|
||||
JOINT_MIN_ANGLE_KEY,
|
||||
JOINT_MIN_DISTANCE_KEY,
|
||||
JOINT_MOTOR_KEY,
|
||||
JOINT_MOTOR_LABELS,
|
||||
JOINT_MOTOR_SPEED_KEY,
|
||||
JOINT_MOTOR_TARGET_KEY,
|
||||
JOINT_MOTORS,
|
||||
type JointMotor,
|
||||
JOINT_STIFFNESS_KEY,
|
||||
JOINT_TYPE_KEY,
|
||||
JOINT_TYPE_LABELS,
|
||||
@@ -110,7 +121,8 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
|
||||
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
|
||||
* (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the
|
||||
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154).
|
||||
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a
|
||||
* list of such choices must all hold.
|
||||
*/
|
||||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
[P in Exclude<K, 'object'>]: {
|
||||
@@ -121,7 +133,7 @@ export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
|
||||
initial?: ValueOf<P>;
|
||||
shownWith?: string;
|
||||
shownFor?: { key: string; values: readonly string[] };
|
||||
shownFor?: ShownFor | readonly ShownFor[];
|
||||
} & Extras<P>;
|
||||
}[Exclude<K, 'object'>];
|
||||
|
||||
@@ -130,15 +142,17 @@ export function initialOf(def: PropertyDef): PropertyValue {
|
||||
return 'initial' in def && def.initial !== undefined ? def.initial : def.default;
|
||||
}
|
||||
|
||||
interface ShownFor {
|
||||
key: string;
|
||||
values: readonly string[];
|
||||
}
|
||||
|
||||
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
||||
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
||||
if ('noRow' in def && def.noRow) return false;
|
||||
if (
|
||||
'shownFor' in def &&
|
||||
def.shownFor &&
|
||||
!def.shownFor.values.includes(textOf(values[def.shownFor.key] ?? ''))
|
||||
)
|
||||
return false;
|
||||
const choices: readonly ShownFor[] =
|
||||
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : [];
|
||||
if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false;
|
||||
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
|
||||
}
|
||||
|
||||
@@ -190,6 +204,11 @@ const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY
|
||||
const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES);
|
||||
const ONLY_SPRING = jointRows('spring');
|
||||
const ONLY_HINGE = jointRows('hinge');
|
||||
const ONLY_SLIDER = jointRows('slider');
|
||||
const ON_AXIS = jointRows(...AXIS_TYPES);
|
||||
const motorRows = (...values: JointMotor[]) => ({
|
||||
shownFor: [ON_AXIS.shownFor, { key: JOINT_MOTOR_KEY, values }],
|
||||
});
|
||||
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
export type PropertyDef =
|
||||
@@ -290,7 +309,10 @@ export const TRANSFORM_PROPS: PropertySchema = [
|
||||
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
|
||||
];
|
||||
|
||||
/** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */
|
||||
/**
|
||||
* Cameras and behaviors are placed by the chain but have no look of their own to turn or size. A
|
||||
* joint turns for a slider's sake, whose rail is its x axis (#154).
|
||||
*/
|
||||
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'sprite',
|
||||
'text',
|
||||
@@ -299,6 +321,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'clip',
|
||||
'particles',
|
||||
'tilemap',
|
||||
'joint',
|
||||
]);
|
||||
|
||||
export function hasTransformProps(type: AssetType): boolean {
|
||||
@@ -744,6 +767,68 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
shownWith: JOINT_LIMIT_KEY,
|
||||
...ONLY_HINGE,
|
||||
},
|
||||
// A slider glides along the joint's own x axis: turning the joint turns the rail.
|
||||
{
|
||||
key: JOINT_LIMIT_DISTANCE_KEY,
|
||||
label: 'Limit distance',
|
||||
kind: 'boolean',
|
||||
default: false,
|
||||
...ONLY_SLIDER,
|
||||
},
|
||||
{
|
||||
key: JOINT_MIN_DISTANCE_KEY,
|
||||
label: 'Min distance',
|
||||
kind: 'number',
|
||||
default: -100,
|
||||
max: 0,
|
||||
step: 1,
|
||||
shownWith: JOINT_LIMIT_DISTANCE_KEY,
|
||||
...ONLY_SLIDER,
|
||||
},
|
||||
{
|
||||
key: JOINT_MAX_DISTANCE_KEY,
|
||||
label: 'Max distance',
|
||||
kind: 'number',
|
||||
default: 100,
|
||||
min: 0,
|
||||
step: 1,
|
||||
shownWith: JOINT_LIMIT_DISTANCE_KEY,
|
||||
...ONLY_SLIDER,
|
||||
},
|
||||
// Degrees for a hinge, world units for a slider, both counted from how it was placed.
|
||||
{
|
||||
key: JOINT_MOTOR_KEY,
|
||||
label: 'Motor',
|
||||
kind: 'enum',
|
||||
default: 'off',
|
||||
options: JOINT_MOTORS.map((value) => ({ value, label: JOINT_MOTOR_LABELS[value] })),
|
||||
...ON_AXIS,
|
||||
},
|
||||
{
|
||||
key: JOINT_MOTOR_SPEED_KEY,
|
||||
label: 'Motor speed',
|
||||
kind: 'number',
|
||||
default: 90,
|
||||
step: 1,
|
||||
...motorRows('speed', 'target'),
|
||||
},
|
||||
{
|
||||
key: JOINT_MOTOR_TARGET_KEY,
|
||||
label: 'Motor target',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
step: 1,
|
||||
...motorRows('target'),
|
||||
},
|
||||
{
|
||||
key: JOINT_MAX_FORCE_KEY,
|
||||
label: 'Max force (0 = no limit)',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 10,
|
||||
...motorRows('speed', 'target'),
|
||||
},
|
||||
{ key: JOINT_COLLIDE_KEY, label: 'Collide with connected', kind: 'boolean', default: false },
|
||||
],
|
||||
// A container is pure structure: its content is the template, not a property.
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
/** What the physics engine and the scene view read off a joint (#154). */
|
||||
import {
|
||||
AXIS_TYPES,
|
||||
hasConnectedAnchor,
|
||||
JOINT_ANCHOR_X_KEY,
|
||||
JOINT_ANCHOR_Y_KEY,
|
||||
JOINT_COLLIDE_KEY,
|
||||
JOINT_DAMPING_KEY,
|
||||
JOINT_LENGTH_KEY,
|
||||
JOINT_LIMIT_DISTANCE_KEY,
|
||||
JOINT_LIMIT_KEY,
|
||||
JOINT_MAX_ANGLE_KEY,
|
||||
JOINT_MAX_DISTANCE_KEY,
|
||||
JOINT_MAX_FORCE_KEY,
|
||||
JOINT_MIN_ANGLE_KEY,
|
||||
JOINT_MIN_DISTANCE_KEY,
|
||||
JOINT_MOTOR_KEY,
|
||||
JOINT_MOTOR_SPEED_KEY,
|
||||
JOINT_MOTOR_TARGET_KEY,
|
||||
type JointMotor,
|
||||
JOINT_STIFFNESS_KEY,
|
||||
JOINT_TYPE_KEY,
|
||||
JOINT_TYPES,
|
||||
type JointType,
|
||||
} from '../model/joint';
|
||||
import { apply, type Mat } from '../model/matrix';
|
||||
import { apply, applyVector, type Mat } from '../model/matrix';
|
||||
import type { PropertyBag, Vec2 } from '../model/Properties';
|
||||
import { defaultsFor } from './PropertySchema';
|
||||
|
||||
@@ -30,9 +39,22 @@ export interface JointSettings {
|
||||
damping: number;
|
||||
/** How far a hinge turns either way from how it was placed, in degrees; null for all the way round. */
|
||||
limits: { min: number; max: number } | null;
|
||||
/** How far a slider glides either way from where it was placed, in world units; null for no end. */
|
||||
distanceLimits: { min: number; max: number } | null;
|
||||
/** What drives a hinge or a slider; null for every other type and with the motor off. */
|
||||
motor: JointMotorSettings | null;
|
||||
collideConnected: boolean;
|
||||
}
|
||||
|
||||
/** Degrees for a hinge, world units for a slider; positions count from how the joint was placed. */
|
||||
export interface JointMotorSettings {
|
||||
mode: Exclude<JointMotor, 'off'>;
|
||||
speed: number;
|
||||
target: number;
|
||||
/** World units × kg per second² – for a hinge, pushing 100 units from its pin; 0 for no limit. */
|
||||
maxForce: number;
|
||||
}
|
||||
|
||||
function num(value: unknown, fallback: unknown): number {
|
||||
const n = Number(value ?? fallback);
|
||||
return Number.isFinite(n) ? n : Number(fallback);
|
||||
@@ -43,9 +65,21 @@ export function jointTypeOf(props: PropertyBag): JointType {
|
||||
return (JOINT_TYPES as readonly unknown[]).includes(type) ? (type as JointType) : 'hinge';
|
||||
}
|
||||
|
||||
function motorOf(type: JointType, props: PropertyBag): JointMotorSettings | null {
|
||||
const mode = props[JOINT_MOTOR_KEY];
|
||||
if (!AXIS_TYPES.includes(type) || (mode !== 'speed' && mode !== 'target')) return null;
|
||||
return {
|
||||
mode,
|
||||
speed: num(props[JOINT_MOTOR_SPEED_KEY], JOINT[JOINT_MOTOR_SPEED_KEY]),
|
||||
target: num(props[JOINT_MOTOR_TARGET_KEY], JOINT[JOINT_MOTOR_TARGET_KEY]),
|
||||
maxForce: Math.max(0, num(props[JOINT_MAX_FORCE_KEY], JOINT[JOINT_MAX_FORCE_KEY])),
|
||||
};
|
||||
}
|
||||
|
||||
export function jointSettings(props: PropertyBag): JointSettings {
|
||||
const type = jointTypeOf(props);
|
||||
const limited = type === 'hinge' && props[JOINT_LIMIT_KEY] === true;
|
||||
const railed = type === 'slider' && props[JOINT_LIMIT_DISTANCE_KEY] === true;
|
||||
return {
|
||||
type,
|
||||
connectedAnchor: {
|
||||
@@ -61,10 +95,47 @@ export function jointSettings(props: PropertyBag): JointSettings {
|
||||
max: Math.max(0, num(props[JOINT_MAX_ANGLE_KEY], JOINT[JOINT_MAX_ANGLE_KEY])),
|
||||
}
|
||||
: null,
|
||||
distanceLimits: railed
|
||||
? {
|
||||
min: Math.min(0, num(props[JOINT_MIN_DISTANCE_KEY], JOINT[JOINT_MIN_DISTANCE_KEY])),
|
||||
max: Math.max(0, num(props[JOINT_MAX_DISTANCE_KEY], JOINT[JOINT_MAX_DISTANCE_KEY])),
|
||||
}
|
||||
: null,
|
||||
motor: motorOf(type, props),
|
||||
collideConnected: props[JOINT_COLLIDE_KEY] === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The direction a slider glides in: the x axis of the joint as it stands in the world. */
|
||||
export function sliderAxis(m: Mat): Vec2 {
|
||||
const v = applyVector(m, { x: 1, y: 0 });
|
||||
const length = Math.hypot(v.x, v.y) || 1;
|
||||
return { x: v.x / length, y: v.y / length };
|
||||
}
|
||||
|
||||
/** How far the scene view draws a slider's rail either way when it has no limits, in world units. */
|
||||
export const OPEN_RAIL = 60;
|
||||
|
||||
/** The stretch of a slider's rail the scene view draws through `at`: its limits, or a piece of it. */
|
||||
export function sliderRail(settings: JointSettings, at: Vec2, axis: Vec2): { from: Vec2; to: Vec2 } {
|
||||
const { min, max } = settings.distanceLimits ?? { min: -OPEN_RAIL, max: OPEN_RAIL };
|
||||
const along = (d: number) => ({ x: at.x + axis.x * d, y: at.y + axis.y * d });
|
||||
return { from: along(min), to: along(max) };
|
||||
}
|
||||
|
||||
/** How quickly a motor going to its target slows down near it: the speed per unit still to go, per second. */
|
||||
export const MOTOR_GAIN = 10;
|
||||
|
||||
/**
|
||||
* The speed a motor asks for this frame: the one set, or towards its target at no more than that
|
||||
* speed – slowing down for the last stretch, so it stops there rather than going past.
|
||||
*/
|
||||
export function motorSpeed(motor: JointMotorSettings, position: number): number {
|
||||
if (motor.mode === 'speed') return motor.speed;
|
||||
const most = Math.abs(motor.speed);
|
||||
return Math.max(-most, Math.min(most, (motor.target - position) * MOTOR_GAIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the joint's other end is in the world. A spring and a rope end at their connected anchor,
|
||||
* on the connected object or – without one – in the scene. A hinge and a fixed joint have no second
|
||||
|
||||
@@ -997,8 +997,9 @@ export class SceneViewport extends FreeViewport {
|
||||
const link = jointLinkOf(item, byId);
|
||||
if (!link) continue;
|
||||
this.lastJointLinks.push(link);
|
||||
const { from, to } = link;
|
||||
const { from, to, rail } = link;
|
||||
this.links.circle(from.x, from.y, 4 / scale).stroke({ width: 1.5 / scale, color });
|
||||
if (rail) this.drawRail(rail, scale, color);
|
||||
if (from.x === to.x && from.y === to.y) continue;
|
||||
this.links
|
||||
.moveTo(from.x, from.y)
|
||||
@@ -1008,6 +1009,27 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
}
|
||||
|
||||
/** A slider's rail, dashed so it reads apart from the line to what it holds on to; a tick at each limit. */
|
||||
private drawRail(rail: NonNullable<JointLink['rail']>, scale: number, color: number): void {
|
||||
const { from, to } = rail;
|
||||
const length = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
if (length === 0) return;
|
||||
const dir = { x: (to.x - from.x) / length, y: (to.y - from.y) / length };
|
||||
const dash = 6 / scale;
|
||||
for (let d = 0; d < length; d += 2 * dash) {
|
||||
const end = Math.min(length, d + dash);
|
||||
this.links
|
||||
.moveTo(from.x + dir.x * d, from.y + dir.y * d)
|
||||
.lineTo(from.x + dir.x * end, from.y + dir.y * end);
|
||||
}
|
||||
if (rail.limited) {
|
||||
const tick = { x: (-dir.y * 6) / scale, y: (dir.x * 6) / scale };
|
||||
for (const p of [from, to])
|
||||
this.links.moveTo(p.x - tick.x, p.y - tick.y).lineTo(p.x + tick.x, p.y + tick.y);
|
||||
}
|
||||
this.links.stroke({ width: 1.5 / scale, color });
|
||||
}
|
||||
|
||||
/** The joints' lines as drawn, in world units, and the handle on the selected one's far end (test hook). */
|
||||
jointInfo(): { links: JointLink[]; handle: { objectId: string; x: number; y: number } | null } {
|
||||
return { links: this.lastJointLinks, handle: this.anchors.info() };
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
samePose,
|
||||
} from './bodyGeometry';
|
||||
import { type Rapier, toMetres, toPixels } from './engine';
|
||||
import { type JointHost, JointSet, type RigidOwner } from './joints';
|
||||
import { type JointHost, JointSet, type JointState, type RigidOwner } from './joints';
|
||||
import { passesOneWay, reachOf } from './oneWay';
|
||||
|
||||
/** The engine always steps this long, whatever the frame took. */
|
||||
@@ -448,6 +448,11 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
return this.joints.holds(joint);
|
||||
}
|
||||
|
||||
/** Where a hinge or a slider stands (#154): degrees or world units from how it was placed. */
|
||||
jointStateOf(joint: Entity): JointState | null {
|
||||
return this.joints.stateOf(joint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every body the entities ask for, keyed by the entity that stands for it. A collider belongs to
|
||||
* the nearest object above it with a rigidbody switched on; without one it is a body of its own.
|
||||
|
||||
+149
-14
@@ -1,20 +1,28 @@
|
||||
import type {
|
||||
ImpulseJoint,
|
||||
JointData,
|
||||
RevoluteImpulseJoint,
|
||||
RigidBody,
|
||||
UnitImpulseJoint,
|
||||
World as RapierWorld,
|
||||
} from '@dimforge/rapier2d-compat';
|
||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { isEntity } from '../../core/graph/values';
|
||||
import { hasConnectedAnchor, JOINT_CONNECTED_KEY } from '../../core/model/joint';
|
||||
import { hasConnectedAnchor, JOINT_CONNECTED_KEY, type JointType } from '../../core/model/joint';
|
||||
import { referencedId } from '../../core/model/objectRefs';
|
||||
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
|
||||
import { hingeLimits, jointFarEnd, jointLength, jointSettings } from '../../core/properties/joint';
|
||||
import {
|
||||
hingeLimits,
|
||||
jointFarEnd,
|
||||
jointLength,
|
||||
jointSettings,
|
||||
type JointMotorSettings,
|
||||
motorSpeed,
|
||||
sliderAxis,
|
||||
} from '../../core/properties/joint';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { angleBetween, matrixPose, type Pose, round, samePose } from './bodyGeometry';
|
||||
import { type Rapier, toMetres } from './engine';
|
||||
import { type Rapier, toMetres, toPixels } from './engine';
|
||||
|
||||
/** The body the engine moves for an object, and that object. */
|
||||
export interface RigidOwner {
|
||||
@@ -39,12 +47,53 @@ interface Held {
|
||||
anchorPose: Pose | null;
|
||||
/** The length and the angle the two ends were placed at, kept while the same two bodies are held. */
|
||||
placed: { between: string; length: number; angle: number } | null;
|
||||
/**
|
||||
* How a slider's object was turned against its own anchor body when it was placed; that body
|
||||
* is turned along by as much, since the engine holds a slider's two ends at one angle.
|
||||
*/
|
||||
turn: number | null;
|
||||
/** The held object's turn against the other end, counted on past a full round. */
|
||||
wound: number;
|
||||
/** What a slider's position is read off: the two points as placed and its axis, in the bodies' metres. */
|
||||
rail: { onOther: Vec2; onOwn: Vec2; axis: Vec2 } | null;
|
||||
type: JointType;
|
||||
/** The speed and the force the motor was last asked for, so a sleeping body is woken on a change. */
|
||||
motorCall: string;
|
||||
collide: boolean;
|
||||
}
|
||||
|
||||
/** How a hinge or a slider stands: degrees or world units from how it was placed, and how fast that changes. */
|
||||
export interface JointState {
|
||||
value: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
const NO_RIGIDBODY = 'sits on an object without a rigidbody, so it holds nothing.';
|
||||
const GONE = 'lost its connected object, so it holds nothing.';
|
||||
const SAME_BODY = 'is connected to the object it sits on, so it holds nothing.';
|
||||
const TURNED_TO_MATCH =
|
||||
'is a slider between two objects turned against each other; the engine keeps them at one angle, so it turns the object to match.';
|
||||
|
||||
/** How hard a motor holds its speed; the engine's own unit, measured to reach it within a frame. */
|
||||
const MOTOR_FACTOR = 1000;
|
||||
/** What the engine takes for a motor without a limit. */
|
||||
const NO_FORCE_LIMIT = 1e30;
|
||||
const DEGREES = 180 / Math.PI;
|
||||
|
||||
const rotate = (v: Vec2, angle: number): Vec2 => {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
|
||||
};
|
||||
|
||||
/** Where a body's local point is in the world and how fast it moves there, in metres. */
|
||||
function pointOf(body: RigidBody, local: Vec2): { at: Vec2; velocity: Vec2 } {
|
||||
const t = body.translation();
|
||||
const r = rotate(local, body.rotation());
|
||||
const v = body.linvel();
|
||||
const w = body.angvel();
|
||||
return { at: { x: t.x + r.x, y: t.y + r.y }, velocity: { x: v.x - w * r.y, y: v.y + w * r.x } };
|
||||
}
|
||||
|
||||
/** The object a joint names: none (a point of the scene), one that is there, or one that is gone. */
|
||||
function connectedOf(world: World, joint: Entity): Entity | null | 'gone' {
|
||||
@@ -71,6 +120,7 @@ const ORIGIN: Pose = { x: 0, y: 0, angle: 0 };
|
||||
* The joints of the played game (#154): one engine joint per joint entity whose object has a
|
||||
* rigidbody. The other end is the connected object's body, or – where that has none, or nothing is
|
||||
* connected – a body of the joint's own that goes wherever that end is and is pulled by nothing.
|
||||
* The engine reads no angle or distance back, so a hinge's and a slider's are worked out here.
|
||||
*/
|
||||
export class JointSet {
|
||||
private readonly held = new Map<Entity, Held>();
|
||||
@@ -98,11 +148,62 @@ export class JointSet {
|
||||
return Boolean(this.held.get(entity)?.joint?.isValid());
|
||||
}
|
||||
|
||||
/** Where a hinge or a slider stands right now; null while it holds nothing or is of another type. */
|
||||
stateOf(entity: Entity): JointState | null {
|
||||
const held = this.held.get(entity);
|
||||
return held?.joint?.isValid() ? this.measure(held) : null;
|
||||
}
|
||||
|
||||
/** The engine is freed as a whole; what was held in it is only forgotten. */
|
||||
clear(): void {
|
||||
this.held.clear();
|
||||
}
|
||||
|
||||
private measure(held: Held): JointState | null {
|
||||
const joint = held.joint!;
|
||||
const other = joint.body1();
|
||||
const own = joint.body2();
|
||||
if (held.type === 'hinge' && held.placed) {
|
||||
held.wound += angleBetween(held.wound, angleBetween(other.rotation(), own.rotation()));
|
||||
return {
|
||||
value: (held.wound - held.placed.angle) * DEGREES,
|
||||
speed: (own.angvel() - other.angvel()) * DEGREES,
|
||||
};
|
||||
}
|
||||
if (held.type === 'slider' && held.rail) {
|
||||
const a = pointOf(other, held.rail.onOther);
|
||||
const b = pointOf(own, held.rail.onOwn);
|
||||
const axis = rotate(held.rail.axis, other.rotation());
|
||||
const along = (v: Vec2) => toPixels(v.x * axis.x + v.y * axis.y);
|
||||
return {
|
||||
value: along({ x: b.at.x - a.at.x, y: b.at.y - a.at.y }),
|
||||
speed: along({ x: b.velocity.x - a.velocity.x, y: b.velocity.y - a.velocity.y }),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the motor for this frame's speed – towards its target, that changes as it gets there – and
|
||||
* wakes the bodies while it moves them or when it is asked for something new.
|
||||
*/
|
||||
private drive(held: Held, motor: JointMotorSettings): void {
|
||||
const state = this.measure(held);
|
||||
if (!state) return;
|
||||
const joint = held.joint as UnitImpulseJoint;
|
||||
const speed = motorSpeed(motor, state.value);
|
||||
const toEngine = held.type === 'hinge' ? 1 / DEGREES : toMetres(1);
|
||||
// A hinge's force pushes 100 units – one of the engine's metres – from its pin.
|
||||
const force = motor.maxForce > 0 ? toMetres(motor.maxForce) : NO_FORCE_LIMIT;
|
||||
const call = `${round(speed)}|${force}`;
|
||||
if (call === held.motorCall && Math.abs(speed) < 1e-6) return;
|
||||
joint.configureMotorVelocity(speed * toEngine, MOTOR_FACTOR);
|
||||
joint.setMotorMaxForce(force);
|
||||
held.motorCall = call;
|
||||
joint.body1().wakeUp();
|
||||
joint.body2().wakeUp();
|
||||
}
|
||||
|
||||
private syncOne(world: World, engine: RapierWorld, host: JointHost, entity: Entity): void {
|
||||
let held = this.held.get(entity);
|
||||
if (!held) {
|
||||
@@ -112,6 +213,11 @@ export class JointSet {
|
||||
anchor: null,
|
||||
anchorPose: null,
|
||||
placed: null,
|
||||
turn: null,
|
||||
wound: 0,
|
||||
rail: null,
|
||||
type: 'hinge',
|
||||
motorCall: '',
|
||||
collide: false,
|
||||
};
|
||||
this.held.set(entity, held);
|
||||
@@ -131,12 +237,16 @@ export class JointSet {
|
||||
if (!own.body.isEnabled() || (rigid && !rigid.body.isEnabled())) return idle();
|
||||
|
||||
const settings = jointSettings(entity.props);
|
||||
const slider = settings.type === 'slider';
|
||||
const ownPose = matrixPose(worldMatrixOf(own.entity));
|
||||
const otherPose = rigid
|
||||
const connectedPose = rigid
|
||||
? matrixPose(worldMatrixOf(rigid.entity))
|
||||
: connected
|
||||
? matrixPose(worldMatrixOf(connected))
|
||||
: ORIGIN;
|
||||
if (!slider || rigid) held.turn = null;
|
||||
else held.turn ??= angleBetween(connectedPose.angle, own.body.rotation());
|
||||
const otherPose = { ...connectedPose, angle: connectedPose.angle + (held.turn ?? 0) };
|
||||
const other = rigid ? rigid.body : this.anchorAt(engine, held, otherPose);
|
||||
if (rigid) this.dropAnchor(engine, held);
|
||||
|
||||
@@ -145,44 +255,65 @@ export class JointSet {
|
||||
const far = jointFarEnd(settings, at, connected ? worldMatrixOf(connected) : null);
|
||||
const onOwn = inBody(ownPose, at);
|
||||
const twoEnds = hasConnectedAnchor(settings.type);
|
||||
// A hinge's and a fixed joint's point on the other body is wherever the joint was when it was
|
||||
// made, so it is no part of the key – the bodies move, and the point with them.
|
||||
// Counted in the held object, which the joint sits on: the rail turns with it, not as it glides.
|
||||
const axis = slider ? rotate(sliderAxis(m), -ownPose.angle) : null;
|
||||
// A hinge's, a fixed joint's and a slider's point on the other body is wherever the joint was
|
||||
// when it was made, so it is no part of the key – the bodies move, and the point with them.
|
||||
const key = JSON.stringify([
|
||||
settings.type,
|
||||
own.body.handle,
|
||||
other.handle,
|
||||
[round(onOwn.x), round(onOwn.y)],
|
||||
twoEnds ? [round(inBody(otherPose, far).x), round(inBody(otherPose, far).y)] : 0,
|
||||
axis ? [round(axis.x), round(axis.y)] : 0,
|
||||
settings.length,
|
||||
settings.stiffness,
|
||||
settings.damping,
|
||||
settings.limits,
|
||||
settings.distanceLimits,
|
||||
// Switching the motor off is a joint without one; its speed and target change on the way.
|
||||
Boolean(settings.motor),
|
||||
]);
|
||||
if (!held.joint || key !== held.key) {
|
||||
this.release(engine, held);
|
||||
const between = `${settings.type}|${own.body.handle}|${other.handle}`;
|
||||
if (held.placed?.between !== between)
|
||||
if (held.placed?.between !== between) {
|
||||
held.placed = {
|
||||
between,
|
||||
length: Math.hypot(far.x - at.x, far.y - at.y),
|
||||
angle: angleBetween(other.rotation(), own.body.rotation()),
|
||||
};
|
||||
const data = this.dataOf(settings, held.placed, inBody(otherPose, far), onOwn, other, own.body);
|
||||
held.wound = held.placed.angle;
|
||||
if (slider && rigid && Math.abs(held.placed.angle) > 1e-3) host.hint(world, entity, TURNED_TO_MATCH);
|
||||
}
|
||||
const onOther = inBody(otherPose, far);
|
||||
// The engine reads a slider's axis in the other body; its turn is the held one's while it holds.
|
||||
const railAxis = axis ? rotate(axis, ownPose.angle - otherPose.angle) : null;
|
||||
held.rail = railAxis ? { onOther, onOwn, axis: railAxis } : null;
|
||||
held.type = settings.type;
|
||||
const data = this.dataOf(settings, held.placed, onOther, onOwn, railAxis, other, own.body);
|
||||
// The other end first: a hinge's angle is then the held object's turn against it.
|
||||
held.joint = engine.createImpulseJoint(data, other, own.body, true);
|
||||
// Only a hinge has limits, and a hinge is the engine's revolute joint.
|
||||
// Only a hinge and a slider have limits; they are the engine's revolute and prismatic joints.
|
||||
if (settings.limits)
|
||||
(held.joint as RevoluteImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle));
|
||||
(held.joint as UnitImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle));
|
||||
if (settings.distanceLimits)
|
||||
(held.joint as UnitImpulseJoint).setLimits(
|
||||
toMetres(settings.distanceLimits.min),
|
||||
toMetres(settings.distanceLimits.max),
|
||||
);
|
||||
held.joint.setContactsEnabled(settings.collideConnected);
|
||||
held.key = key;
|
||||
held.collide = settings.collideConnected;
|
||||
return;
|
||||
}
|
||||
if (held.collide !== settings.collideConnected) {
|
||||
held.motorCall = '';
|
||||
} else if (held.collide !== settings.collideConnected) {
|
||||
held.collide = settings.collideConnected;
|
||||
held.joint.setContactsEnabled(held.collide);
|
||||
own.body.wakeUp();
|
||||
}
|
||||
if (settings.motor) this.drive(held, settings.motor);
|
||||
// A hinge is followed every frame, so a wheel that turns on counts its rounds.
|
||||
else if (held.type === 'hinge') this.measure(held);
|
||||
}
|
||||
|
||||
private dataOf(
|
||||
@@ -190,6 +321,7 @@ export class JointSet {
|
||||
placed: NonNullable<Held['placed']>,
|
||||
onOther: Vec2,
|
||||
onOwn: Vec2,
|
||||
axis: Vec2 | null,
|
||||
other: RigidBody,
|
||||
own: RigidBody,
|
||||
): JointData {
|
||||
@@ -197,6 +329,8 @@ export class JointSet {
|
||||
switch (settings.type) {
|
||||
case 'hinge':
|
||||
return JointData.revolute(onOther, onOwn);
|
||||
case 'slider':
|
||||
return JointData.prismatic(onOther, onOwn, axis!);
|
||||
case 'spring':
|
||||
return JointData.spring(
|
||||
toMetres(jointLength(settings, placed.length)),
|
||||
@@ -247,5 +381,6 @@ export class JointSet {
|
||||
engine.removeRigidBody(held.anchor);
|
||||
held.anchor = null;
|
||||
held.anchorPose = null;
|
||||
held.turn = null;
|
||||
}
|
||||
}
|
||||
@@ -244,5 +244,9 @@ function physicsControl(
|
||||
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
|
||||
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
|
||||
setGravity: (x, y) => physics.setGravity(world, { x, y }),
|
||||
jointStateOf: (handle) => {
|
||||
const found = resolve(handle);
|
||||
return found?.type === 'joint' ? physics.jointStateOf(found) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { hasConnectedAnchor, JOINT_CONNECTED_KEY } from '../core/model/joint';
|
||||
import type { Mat } from '../core/model/matrix';
|
||||
import { referenceCandidates, referencedId } from '../core/model/objectRefs';
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
import { jointFarEnd, jointSettings } from '../core/properties/joint';
|
||||
import { jointFarEnd, jointSettings, sliderAxis, sliderRail } from '../core/properties/joint';
|
||||
import type { DrawItem } from './items';
|
||||
|
||||
/** What the scene view draws of a joint (#154): from where it sits to what it holds on to. */
|
||||
@@ -21,6 +21,8 @@ export interface JointLink {
|
||||
anchored: boolean;
|
||||
/** The frame the connected anchor is counted in; null for the scene's. */
|
||||
frame: Mat | null;
|
||||
/** A slider's rail through where it sits; `limited` when its ends are the limits, which get a tick. */
|
||||
rail: { from: Vec2; to: Vec2; limited: boolean } | null;
|
||||
}
|
||||
|
||||
/** The item a joint's reference names, looked up from where the joint sits (see `objectRefs.ts`). */
|
||||
@@ -45,5 +47,9 @@ export function jointLinkOf(item: DrawItem, byId: ReadonlyMap<string, DrawItem>)
|
||||
: connected
|
||||
? { x: connected.x, y: connected.y }
|
||||
: from;
|
||||
return { id: item.id, from, to, connectedId: connected?.id ?? '', anchored, frame };
|
||||
const rail =
|
||||
settings.type === 'slider'
|
||||
? { ...sliderRail(settings, from, sliderAxis(item.m)), limited: settings.distanceLimits !== null }
|
||||
: null;
|
||||
return { id: item.id, from, to, connectedId: connected?.id ?? '', anchored, frame, rail };
|
||||
}
|
||||
+244
-2
@@ -19,8 +19,18 @@ 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 {
|
||||
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';
|
||||
@@ -213,6 +223,66 @@ describe('what a joint reads off its rows', () => {
|
||||
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', () => {
|
||||
@@ -407,6 +477,178 @@ describe('a fixed joint', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -135,6 +135,7 @@ const physicsDouble = (): PhysicsDouble => {
|
||||
),
|
||||
applyImpulse: (target, x, y) => (calls.push(['applyImpulse', target.id, x, y]), has(target)),
|
||||
setGravity: (x, y) => void calls.push(['setGravity', x, y]),
|
||||
jointStateOf: (target) => (target.id === 'hinge' ? { value: 30, speed: -12 } : null),
|
||||
};
|
||||
};
|
||||
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
|
||||
@@ -1462,6 +1463,25 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'without physics it reads 0', inputs: { object: crate }, outputs: { x: 0, y: 0, angular: 0 } },
|
||||
],
|
||||
JointPosition: [
|
||||
{
|
||||
name: 'reads how far the joint stands from where it was placed, and how fast',
|
||||
inputs: { joint: fakeEntity('hinge') },
|
||||
context: { physics: physicsDouble() },
|
||||
outputs: { value: 30, speed: -12 },
|
||||
},
|
||||
{
|
||||
name: 'a joint the engine does not read reads 0',
|
||||
inputs: { joint: fakeEntity('rope') },
|
||||
context: { physics: physicsDouble() },
|
||||
outputs: { value: 0, speed: 0 },
|
||||
},
|
||||
{
|
||||
name: 'without physics it reads 0',
|
||||
inputs: { joint: fakeEntity('hinge') },
|
||||
outputs: { value: 0, speed: 0 },
|
||||
},
|
||||
],
|
||||
SetGravity: [
|
||||
{
|
||||
name: 'sets the scene gravity',
|
||||
|
||||
@@ -67,6 +67,19 @@ describe('what the scene view draws of a joint', () => {
|
||||
expect(s.links()[0]).toMatchObject({ from: { x: 10, y: 0 }, to: { x: 10, y: 0 }, connectedId: '' });
|
||||
});
|
||||
|
||||
it('lays a slider rail along the joint as it is turned, between its limits once it has some', () => {
|
||||
const open = scene({ jointType: 'slider', rotation: 90 });
|
||||
const [link] = open.links();
|
||||
expect(link!.to).toEqual({ x: 200, y: -100 });
|
||||
expect(link!.rail!.limited).toBe(false);
|
||||
expect(link!.rail!.from.x).toBeCloseTo(10);
|
||||
expect(link!.rail!.from.y).toBeCloseTo(-60);
|
||||
expect(link!.rail!.to.y).toBeCloseTo(60);
|
||||
const limited = scene({ jointType: 'slider', limitDistance: true, minDistance: -20, maxDistance: 80 });
|
||||
expect(limited.links()[0]!.rail).toEqual({ from: { x: -10, y: 0 }, to: { x: 90, y: 0 }, limited: true });
|
||||
expect(scene({ jointType: 'hinge' }).links()[0]!.rail).toBeNull();
|
||||
});
|
||||
|
||||
it('has no link for anything but a joint', () => {
|
||||
const s = scene({});
|
||||
const { items } = collectHolderItems(s.f.project, s.f.sceneOf(), 0);
|
||||
|
||||
@@ -661,6 +661,61 @@ export async function physics(t) {
|
||||
hints: consoleLog.filter((line) => line.includes('[physics]')).slice(jointHints),
|
||||
}),
|
||||
);
|
||||
|
||||
// 41f. The same joint made a slider with a motor (#154, step 2): its rows follow the choices, its
|
||||
// rail is drawn along the joint as it is turned, and in play the motor drives the bob up it.
|
||||
await selectObject(joint.id);
|
||||
await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Type')"), 'the Type field');
|
||||
await pressAt(await json("__gm.locate('Slider', true)"), 'Slider in the menu');
|
||||
await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Motor')"), 'the Motor field');
|
||||
await pressAt(await json("__gm.locate('Speed', true)"), 'Speed in the menu');
|
||||
await mutate(`
|
||||
const o = __gm.state.activeScene.objects.find((o) => o.id === '${joint.id}');
|
||||
Object.assign(o.overrides, { rotation: 90, motorSpeed: -60 })`);
|
||||
await settle();
|
||||
const railLink = (await links()).links.find((l) => l.id === joint.id);
|
||||
check(
|
||||
'a slider with a speed motor shows its rows and draws its rail along the turned joint',
|
||||
(await jointObject())?.overrides?.jointType === 'slider' &&
|
||||
(await jointObject())?.overrides?.motor === 'speed' &&
|
||||
(await has('Limit distance')) &&
|
||||
(await has('Motor speed')) &&
|
||||
(await has('Max force (0 = no limit)')) &&
|
||||
!(await has('Motor target')) &&
|
||||
!(await has('Connected anchor X')) &&
|
||||
railLink?.rail?.limited === false &&
|
||||
Math.abs(railLink.rail.from.x) < 0.01 &&
|
||||
Math.abs(railLink.rail.to.y - railLink.rail.from.y - 120) < 0.01,
|
||||
JSON.stringify({ railLink, overrides: (await jointObject())?.overrides }),
|
||||
);
|
||||
await select(null);
|
||||
await play();
|
||||
await evaluate(`(() => {
|
||||
const out = (window.__sliderSamples = []);
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
const w = __gm.world();
|
||||
if (!w) return;
|
||||
const bob = w.entities.find((e) => e.objectId === 'phys-bob');
|
||||
out.push({ t: performance.now() - start, x: bob?.worldX, y: bob?.worldY });
|
||||
if (performance.now() - start < 1000) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
})()`);
|
||||
await sleep(1300);
|
||||
const slid = await json('window.__sliderSamples');
|
||||
await stop();
|
||||
// From the first sample to the last, at 60 units a second up the rail.
|
||||
const span = slid.length > 1 ? (slid.at(-1).t - slid[0].t) / 1000 : 0;
|
||||
const climbed = slid.length > 1 ? slid[0].y - slid.at(-1).y : 0;
|
||||
check(
|
||||
'in the played scene the motor drives the bob up its rail at its speed, and not off it',
|
||||
slid.length >= 5 &&
|
||||
span > 0.8 &&
|
||||
Math.abs(climbed - 60 * span) < 6 &&
|
||||
slid.every((s) => Math.abs(s.x) < 0.5),
|
||||
JSON.stringify({ frames: slid.length, span, climbed, first: slid[0], last: slid.at(-1) }),
|
||||
);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
|
||||
Reference in new issue
Block a user