diff --git a/README.md b/README.md index 6f0feccf..7dfa871c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index a1b735cf..14fe898a 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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; } /** diff --git a/src/core/graph/nodes/action/physicsNodes.ts b/src/core/graph/nodes/action/physicsNodes.ts index 7cf706ce..3cb733b3 100644 --- a/src/core/graph/nodes/action/physicsNodes.ts +++ b/src/core/graph/nodes/action/physicsNodes.ts @@ -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', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 72a4482e..c0cac67b 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/model/joint.ts b/src/core/model/joint.ts index de26114d..e33f1a03 100644 --- a/src/core/model/joint.ts +++ b/src/core/model/joint.ts @@ -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 = { @@ -12,6 +12,7 @@ export const JOINT_TYPE_LABELS: Record = { 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 = { + 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']; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 59d39855..1496e35f 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 '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 = { [P in Exclude]: { @@ -121,7 +133,7 @@ export type FlatDef = { /** What a new asset starts with when it differs from `default`, the value a missing key reads as. */ initial?: ValueOf

; shownWith?: string; - shownFor?: { key: string; values: readonly string[] }; + shownFor?: ShownFor | readonly ShownFor[]; } & Extras

; }[Exclude]; @@ -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 = new Set([ 'sprite', 'text', @@ -299,6 +321,7 @@ const TRANSFORM_TYPES: ReadonlySet = new Set([ 'clip', 'particles', 'tilemap', + 'joint', ]); export function hasTransformProps(type: AssetType): boolean { @@ -744,6 +767,68 @@ const ASSET_SCHEMAS: Record = { 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. diff --git a/src/core/properties/joint.ts b/src/core/properties/joint.ts index 199d9376..93013e2b 100644 --- a/src/core/properties/joint.ts +++ b/src/core/properties/joint.ts @@ -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; + 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 diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index bedc7018..83bbf3af 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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, 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() }; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 3a38fb76..d8fb42a6 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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. diff --git a/src/runtime/physics/joints.ts b/src/runtime/physics/joints.ts index 27a868e2..9fd483f1 100644 --- a/src/runtime/physics/joints.ts +++ b/src/runtime/physics/joints.ts @@ -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(); @@ -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, 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; } } diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index a764231d..7b19166c 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -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; + }, }; } diff --git a/src/viewport/jointLinks.ts b/src/viewport/jointLinks.ts index a50a655e..b22fa9bb 100644 --- a/src/viewport/jointLinks.ts +++ b/src/viewport/jointLinks.ts @@ -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) : 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 }; } diff --git a/tests/core/joints.test.ts b/tests/core/joints.test.ts index 9ae25227..5a4a49d2 100644 --- a/tests/core/joints.test.ts +++ b/tests/core/joints.test.ts @@ -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(); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 00d90d58..1b778cba 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -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 = { }, { 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', diff --git a/tests/viewport/joint-links.test.ts b/tests/viewport/joint-links.test.ts index 0ba1180d..679bb34e 100644 --- a/tests/viewport/joint-links.test.ts +++ b/tests/viewport/joint-links.test.ts @@ -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); diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 25f0f217..a5563fac 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -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;