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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user