Tie two objects with a hinge, a spring, a rope or for good (#154)
Two bodies could not be connected: no door on a hinge, no lamp on
a rope, no wheel on an axle. This is the first step of the issue,
the four joints without a motor. The slider and the motors follow
in a second step under the same issue.
- Joint is a new asset type, placed as a child of the object it
holds, like a collider or a rigidbody. "Add joint…" in the
object menu makes one or places one the project has. An object
takes as many as it needs.
- Where the joint sits is the point it holds its object at. A
hinge and a fixed joint have that one point only. A spring and a
rope end at a connected anchor on the other object, counted in
that object's own units, or in the scene's without one.
- The rows follow the type: length, stiffness and damping for a
spring, length for a rope, an angle limit for a hinge. Length 0
takes the distance the two ends were placed at. The limit counts
from how the two were placed.
- The connected object is an object reference in the schema of the
type. Pasting, containers and removing treat it as they treat
every reference since #258.
- shownFor is a new schema option: a row is shown while an enum of
the same bag holds one of the named values.
- The object needs a rigidbody, its own or one further up. Without
one the joint holds nothing and the console says so once.
- A connected object without a rigidbody is a moving anchor: the
joint has a kinematic body of its own that follows it.
- A connected object destroyed while playing makes the joint let go
with a hint. Removed in the editor the reference empties, so the
joint holds on to the scene. The issue asked for "inactive" there.
- "Add joint" ties two objects while the game runs, from a joint
asset as the template. Destroy on the joint lets go, there is no
Break joint node. Destroy with keep children takes the joints
along with the object.
- The scene view draws a line from each joint to what it holds on
to and rings the held point. A selected spring or rope has a
round handle on its far end, and dragging it is one undo step.
- Joints hold in Play only. The editor run simulates nothing
(#148), and the inspector of a placed joint says so.
- Rapier counts a revolute joint's limits absolutely, not from the
start, so the placed angle is added. Measured with a probe.
- The + menu has 25 types now and no longer fits under its button
on a canvas 800 high. It scrolls there, and e2e 04 scrolls to
the last entry instead of expecting it in view.
Tests:
- Gate on melektaus-atx for this state: 218 files, 3390 tests
(3336 + 54), exit 0.
- e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all
pass. Module 41 alone: 25 checks (21 + 4).
- 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum,
limit, fixed, the other end, the node, containers, paste), 6 for
the line the scene view draws, 4 catalog cases of the node. The
remaining 4 come from tests that make a case per asset type.
- Four new e2e checks in module 41: Add joint from the menu, the
rope to the hook with its line and handle, dragging the handle
with one undo, the bob hanging in Play next to one that falls.
- e2e 04, 40 and 46 count the new type in their lists.
- 21 of 22 unit counter-probes fail their tests. The other one is
equivalent: Rapier ignores removing a joint that left with its
body, the guard stays.
- Three browser counter-probes fail their checks (2, 1 and 2).
- One screenshot looked at: icon, line, handle and the rope rows.
- The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI
on e066613). The full suite and the smokes did not run.
- Not seen in the browser: spring, hinge with limit and fixed
joint, the node Add joint, a connected object that moves, joints
inside a container, the released game. Unit tests cover them
but the last. Limits under a mirrored parent are not handled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e066613583
commit
7dd3ae1131
42 files changed
+1917
-27
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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1687 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1691 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
|||||||
return { ...base, type: 'collider' };
|
return { ...base, type: 'collider' };
|
||||||
case 'rigidbody':
|
case 'rigidbody':
|
||||||
return { ...base, type: 'rigidbody' };
|
return { ...base, type: 'rigidbody' };
|
||||||
|
case 'joint':
|
||||||
|
return { ...base, type: 'joint' };
|
||||||
case 'container':
|
case 'container':
|
||||||
return { ...base, type: 'container', objects: [] };
|
return { ...base, type: 'container', objects: [] };
|
||||||
// A drawing starts empty: the pen tool of #129 sets the first shape down, on its one layer.
|
// A drawing starts empty: the pen tool of #129 sets the first shape down, on its one layer.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import { nextAssetName } from './naming';
|
|||||||
import { requireAsset, requireHolder, requireStructuralObject } from './require';
|
import { requireAsset, requireHolder, requireStructuralObject } from './require';
|
||||||
import { ObjectEditCommand } from './sceneObjectCommands';
|
import { ObjectEditCommand } from './sceneObjectCommands';
|
||||||
|
|
||||||
type PartType = 'collider' | 'rigidbody';
|
type PartType = 'collider' | 'rigidbody' | 'joint';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A collider or a rigidbody placed as a child at the object's centre: a new asset, or – given
|
* A collider, a rigidbody or a joint placed as a child at the object's centre: a new asset, or – given
|
||||||
* `existingAssetId` (#222) – one that is there already, shared with wherever else it is placed.
|
* `existingAssetId` (#222) – one that is there already, shared with wherever else it is placed.
|
||||||
*/
|
*/
|
||||||
abstract class AddPartCommand implements Command {
|
abstract class AddPartCommand implements Command {
|
||||||
@@ -111,7 +111,7 @@ export class AddColliderCommand extends AddPartCommand {
|
|||||||
/** Why the object cannot take a rigidbody, or null when it can – the one rule command and menu share. */
|
/** Why the object cannot take a rigidbody, or null when it can – the one rule command and menu share. */
|
||||||
export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
|
export function rigidbodyRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
|
||||||
const type = findAsset(project, target.assetId)?.type;
|
const type = findAsset(project, target.assetId)?.type;
|
||||||
if (type === 'collider' || type === 'rigidbody' || type === 'behavior')
|
if (type === 'collider' || type === 'rigidbody' || type === 'behavior' || type === 'joint')
|
||||||
return `A ${type} cannot have a rigidbody`;
|
return `A ${type} cannot have a rigidbody`;
|
||||||
if (type === 'tilemap') return 'A tilemap is static ground and takes no rigidbody';
|
if (type === 'tilemap') return 'A tilemap is static ground and takes no rigidbody';
|
||||||
if (isLiveInstance(project, holder, target))
|
if (isLiveInstance(project, holder, target))
|
||||||
@@ -136,6 +136,35 @@ export class AddRigidbodyCommand extends AddPartCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the object cannot take a joint, or null when it can. It may hold several, and needs no
|
||||||
|
* rigidbody yet: a joint on an object without one holds nothing and says so when played (#154).
|
||||||
|
*/
|
||||||
|
export function jointRefusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
|
||||||
|
const type = findAsset(project, target.assetId)?.type;
|
||||||
|
if (!type) return 'The object has no asset';
|
||||||
|
if (type === 'collider' || type === 'rigidbody' || type === 'behavior' || type === 'joint')
|
||||||
|
return `A ${type} cannot have a joint`;
|
||||||
|
if (type === 'tilemap') return 'A tilemap is static ground and takes no joint';
|
||||||
|
if (isLiveInstance(project, holder, target))
|
||||||
|
return 'A placed container shows its template; add the joint inside it';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new joint asset has the schema defaults: a hinge at the object's centre, held to the scene. */
|
||||||
|
export class AddJointCommand extends AddPartCommand {
|
||||||
|
readonly label = 'Add joint';
|
||||||
|
protected readonly partType = 'joint';
|
||||||
|
|
||||||
|
protected refusal(project: Project, holder: ObjectHolder, target: SceneObject): string | null {
|
||||||
|
return jointRefusal(project, holder, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected build(project: Project, _target: SceneObject, _targetAsset: Asset, owner: string): Asset {
|
||||||
|
return buildAsset('joint', nextAssetName(project, `${owner} joint`), this.assetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function spriteSize(project: Project, asset: Asset, object: SceneObject): { width: number; height: number } {
|
function spriteSize(project: Project, asset: Asset, object: SceneObject): { width: number; height: number } {
|
||||||
const props = resolveSceneObjectProps(project, asset, object);
|
const props = resolveSceneObjectProps(project, asset, object);
|
||||||
return { width: Number(props.width), height: Number(props.height) };
|
return { width: Number(props.width), height: Number(props.height) };
|
||||||
|
|||||||
@@ -241,6 +241,16 @@ export interface NodeContext {
|
|||||||
position: { x: number; y: number; z: number },
|
position: { x: number; y: number; z: number },
|
||||||
parent: EntityHandle | null,
|
parent: EntityHandle | null,
|
||||||
): EntityHandle | null;
|
): EntityHandle | null;
|
||||||
|
/**
|
||||||
|
* Places the joint asset on `owner` at `at` of that object and connects it to `connected`, the
|
||||||
|
* scene when null (#154). Null when the asset is no joint or the object is gone.
|
||||||
|
*/
|
||||||
|
addJoint(
|
||||||
|
assetId: string,
|
||||||
|
owner: EntityHandle,
|
||||||
|
connected: EntityHandle | null,
|
||||||
|
at: { x: number; y: number },
|
||||||
|
): EntityHandle | null;
|
||||||
/**
|
/**
|
||||||
* Removes `target` with everything below it. With `keepChildren` only the object goes: its
|
* Removes `target` with everything below it. With `keepChildren` only the object goes: its
|
||||||
* children move up to its parent where they stand, and are answered, nearest level only.
|
* children move up to its parent where they stand, and are answered, nearest level only.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { PropertyDef } from '../../../properties/PropertySchema';
|
||||||
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
|
import { asEntity, asNumber, asString } from '../../values';
|
||||||
|
import { exec, num, obj } from '../shared';
|
||||||
|
|
||||||
|
const jointParam: PropertyDef = {
|
||||||
|
key: 'jointAsset',
|
||||||
|
label: 'Joint',
|
||||||
|
kind: 'asset',
|
||||||
|
default: '',
|
||||||
|
assetTypes: ['joint'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The joint asset a new joint takes its type and values from; unconnected, it shows the picker. */
|
||||||
|
const jointPort = (): PortDef => ({
|
||||||
|
key: 'jointAsset',
|
||||||
|
label: 'joint asset',
|
||||||
|
type: 'string',
|
||||||
|
field: jointParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddJoint: NodeDef = {
|
||||||
|
type: 'AddJoint',
|
||||||
|
label: 'Add joint',
|
||||||
|
category: 'action',
|
||||||
|
description:
|
||||||
|
'Ties an object to another while the game runs: places the picked Joint asset on "object" at ' +
|
||||||
|
'(x, y) of that object and connects it to "connected" – or, with nothing there, to the scene. The ' +
|
||||||
|
'asset says what kind of joint it is and how long, stiff or limited. Outputs the new joint; Destroy ' +
|
||||||
|
'on it lets go again. The object needs a rigidbody, and joints only hold while the game is played.',
|
||||||
|
inputs: [exec(), jointPort(), obj('object'), obj('connected'), num('x'), num('y')],
|
||||||
|
outputs: [exec(), obj('joint')],
|
||||||
|
params: [],
|
||||||
|
run: (ctx, inputs) => {
|
||||||
|
const assetId = asString(inputs.jointAsset);
|
||||||
|
const owner = asEntity(inputs.object);
|
||||||
|
const at = { x: asNumber(inputs.x), y: asNumber(inputs.y) };
|
||||||
|
const joint = assetId && owner ? ctx.addJoint(assetId, owner, asEntity(inputs.connected), at) : null;
|
||||||
|
if (!joint)
|
||||||
|
ctx.log(
|
||||||
|
!assetId
|
||||||
|
? 'Add joint: no joint selected'
|
||||||
|
: !owner
|
||||||
|
? 'Add joint: no object to put it on'
|
||||||
|
: `Add joint: asset ${assetId} is no joint`,
|
||||||
|
);
|
||||||
|
return { next: 'exec', outputs: { joint } };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ export const Destroy: NodeDef = {
|
|||||||
description:
|
description:
|
||||||
'Removes an object from the running game, with everything below it. With "keep children" only the ' +
|
'Removes an object from the running game, with everything below it. With "keep children" only the ' +
|
||||||
'object goes: its children move up to its parent and stay where they are in the world, and come out ' +
|
'object goes: its children move up to its parent and stay where they are in the world, and come out ' +
|
||||||
'as "children" – the nearest level only. Its behaviors and its rigidbody go with it either way.',
|
'as "children" – the nearest level only. Its behaviors, its rigidbody and its joints go with it either way.',
|
||||||
inputs: [exec(), obj('object'), withDefault(bool('keepChildren', 'keep children'), false)],
|
inputs: [exec(), obj('object'), withDefault(bool('keepChildren', 'keep children'), false)],
|
||||||
outputs: [exec(), list('children')],
|
outputs: [exec(), list('children')],
|
||||||
params: [],
|
params: [],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type NodeDef, registerNode } from '../NodeRegistry';
|
import { type NodeDef, registerNode } from '../NodeRegistry';
|
||||||
|
import { AddJoint } from './action/AddJoint';
|
||||||
import { Destroy } from './action/Destroy';
|
import { Destroy } from './action/Destroy';
|
||||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||||
import { Emit } from './action/Emit';
|
import { Emit } from './action/Emit';
|
||||||
@@ -275,6 +276,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
TileLiteral,
|
TileLiteral,
|
||||||
Spawn,
|
Spawn,
|
||||||
Destroy,
|
Destroy,
|
||||||
|
AddJoint,
|
||||||
Emit,
|
Emit,
|
||||||
PlayAnimation,
|
PlayAnimation,
|
||||||
PauseAnimation,
|
PauseAnimation,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const PROPERTY_TARGETS = [
|
|||||||
'text',
|
'text',
|
||||||
'collider',
|
'collider',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
|
'joint',
|
||||||
'camera',
|
'camera',
|
||||||
'container',
|
'container',
|
||||||
'clip',
|
'clip',
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ export interface RigidbodyAsset extends AssetBase {
|
|||||||
type: 'rigidbody';
|
type: 'rigidbody';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placed as a child of the object it holds – as many as that object needs. Where it sits is the
|
||||||
|
* point it holds the object at; its props say to what and how (see `properties/joint.ts`, #154).
|
||||||
|
*/
|
||||||
|
export interface JointAsset extends AssetBase {
|
||||||
|
type: 'joint';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `objects` is a template, not content: a placed container shows it without copying it, so an
|
* `objects` is a template, not content: a placed container shows it without copying it, so an
|
||||||
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
|
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
|
||||||
@@ -525,6 +533,7 @@ export type Asset =
|
|||||||
| StructAsset
|
| StructAsset
|
||||||
| ColliderAsset
|
| ColliderAsset
|
||||||
| RigidbodyAsset
|
| RigidbodyAsset
|
||||||
|
| JointAsset
|
||||||
| ContainerAsset
|
| ContainerAsset
|
||||||
| TextureAsset
|
| TextureAsset
|
||||||
| VectorArtAsset
|
| VectorArtAsset
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const ASSET_TYPES = [
|
|||||||
'struct',
|
'struct',
|
||||||
'collider',
|
'collider',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
|
'joint',
|
||||||
'container',
|
'container',
|
||||||
'clip',
|
'clip',
|
||||||
'texture',
|
'texture',
|
||||||
@@ -37,6 +38,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
|||||||
struct: 'Struct',
|
struct: 'Struct',
|
||||||
collider: 'Collider',
|
collider: 'Collider',
|
||||||
rigidbody: 'Rigidbody',
|
rigidbody: 'Rigidbody',
|
||||||
|
joint: 'Joint',
|
||||||
container: 'Container',
|
container: 'Container',
|
||||||
clip: 'Clip area',
|
clip: 'Clip area',
|
||||||
texture: 'Texture',
|
texture: 'Texture',
|
||||||
@@ -113,6 +115,7 @@ export function isSymbolType(type: AssetType): boolean {
|
|||||||
type === 'behavior' ||
|
type === 'behavior' ||
|
||||||
type === 'collider' ||
|
type === 'collider' ||
|
||||||
type === 'rigidbody' ||
|
type === 'rigidbody' ||
|
||||||
|
type === 'joint' ||
|
||||||
type === 'container' ||
|
type === 'container' ||
|
||||||
type === 'clip'
|
type === 'clip'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
|
export const JOINT_TYPES = ['hinge', 'spring', 'rope', 'fixed'] as const;
|
||||||
|
export type JointType = (typeof JOINT_TYPES)[number];
|
||||||
|
|
||||||
|
export const JOINT_TYPE_LABELS: Record<JointType, string> = {
|
||||||
|
hinge: 'Hinge',
|
||||||
|
spring: 'Spring',
|
||||||
|
rope: 'Rope',
|
||||||
|
fixed: 'Fixed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const JOINT_TYPE_KEY = 'jointType';
|
||||||
|
export const JOINT_CONNECTED_KEY = 'connected';
|
||||||
|
export const JOINT_ANCHOR_X_KEY = 'connectedAnchorX';
|
||||||
|
export const JOINT_ANCHOR_Y_KEY = 'connectedAnchorY';
|
||||||
|
export const JOINT_LENGTH_KEY = 'length';
|
||||||
|
export const JOINT_STIFFNESS_KEY = 'stiffness';
|
||||||
|
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_COLLIDE_KEY = 'collideConnected';
|
||||||
|
|
||||||
|
/** 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'];
|
||||||
|
|
||||||
|
export function hasConnectedAnchor(type: JointType): boolean {
|
||||||
|
return TWO_ANCHOR_TYPES.includes(type);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
|
|||||||
'clip',
|
'clip',
|
||||||
'collider',
|
'collider',
|
||||||
'container',
|
'container',
|
||||||
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
'sprite',
|
'sprite',
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ import {
|
|||||||
TILE_TRIGGER_KEY,
|
TILE_TRIGGER_KEY,
|
||||||
} from '../model/tile';
|
} from '../model/tile';
|
||||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||||
|
import {
|
||||||
|
JOINT_ANCHOR_X_KEY,
|
||||||
|
JOINT_ANCHOR_Y_KEY,
|
||||||
|
JOINT_COLLIDE_KEY,
|
||||||
|
JOINT_CONNECTED_KEY,
|
||||||
|
JOINT_DAMPING_KEY,
|
||||||
|
JOINT_LENGTH_KEY,
|
||||||
|
JOINT_LIMIT_KEY,
|
||||||
|
JOINT_MAX_ANGLE_KEY,
|
||||||
|
JOINT_MIN_ANGLE_KEY,
|
||||||
|
JOINT_STIFFNESS_KEY,
|
||||||
|
JOINT_TYPE_KEY,
|
||||||
|
JOINT_TYPE_LABELS,
|
||||||
|
JOINT_TYPES,
|
||||||
|
type JointType,
|
||||||
|
TWO_ANCHOR_TYPES,
|
||||||
|
} from '../model/joint';
|
||||||
import { PALETTE_KEY } from '../model/pixelart';
|
import { PALETTE_KEY } from '../model/pixelart';
|
||||||
import { SLICE_SCALE_KEY } from '../model/texture';
|
import { SLICE_SCALE_KEY } from '../model/texture';
|
||||||
import {
|
import {
|
||||||
@@ -92,7 +109,8 @@ type Extras<K extends FlatKind> = K extends 'number'
|
|||||||
* The definition of a flat kind: its default is a value of that kind, as the table says. Not an
|
* The definition of a flat kind: its default is a value of that kind, as the table says. Not an
|
||||||
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
|
* 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
|
* `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.
|
* (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).
|
||||||
*/
|
*/
|
||||||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||||
[P in Exclude<K, 'object'>]: {
|
[P in Exclude<K, 'object'>]: {
|
||||||
@@ -103,6 +121,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. */
|
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
|
||||||
initial?: ValueOf<P>;
|
initial?: ValueOf<P>;
|
||||||
shownWith?: string;
|
shownWith?: string;
|
||||||
|
shownFor?: { key: string; values: readonly string[] };
|
||||||
} & Extras<P>;
|
} & Extras<P>;
|
||||||
}[Exclude<K, 'object'>];
|
}[Exclude<K, 'object'>];
|
||||||
|
|
||||||
@@ -113,6 +132,12 @@ export function initialOf(def: PropertyDef): PropertyValue {
|
|||||||
|
|
||||||
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
||||||
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
||||||
|
if (
|
||||||
|
'shownFor' in def &&
|
||||||
|
def.shownFor &&
|
||||||
|
!def.shownFor.values.includes(textOf(values[def.shownFor.key] ?? ''))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
|
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +185,11 @@ const COLLISION_LAYER_ROWS: readonly FlatDef[] = [
|
|||||||
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
|
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY, values } });
|
||||||
|
const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES);
|
||||||
|
const ONLY_SPRING = jointRows('spring');
|
||||||
|
const ONLY_HINGE = jointRows('hinge');
|
||||||
|
|
||||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||||
export type PropertyDef =
|
export type PropertyDef =
|
||||||
| FlatDef
|
| FlatDef
|
||||||
@@ -632,6 +662,86 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
|||||||
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
|
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
|
||||||
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
|
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
|
||||||
],
|
],
|
||||||
|
// What it ties its object to, and how (#154). Lengths are world units, angles degrees counted
|
||||||
|
// from how the two were placed, stiffness a force per 100 units as the engine weighs things.
|
||||||
|
joint: [
|
||||||
|
{
|
||||||
|
key: JOINT_TYPE_KEY,
|
||||||
|
label: 'Type',
|
||||||
|
kind: 'enum',
|
||||||
|
default: 'hinge',
|
||||||
|
options: JOINT_TYPES.map((value) => ({ value, label: JOINT_TYPE_LABELS[value] })),
|
||||||
|
},
|
||||||
|
// Empty is a point of the scene that never moves.
|
||||||
|
{ key: JOINT_CONNECTED_KEY, label: 'Connected object', kind: 'object', default: NO_OBJECT },
|
||||||
|
// Where a spring or a rope ends on the other object, in that object's own units – or, with no
|
||||||
|
// object, in the scene's. A hinge and a fixed joint have one point only: where the joint sits.
|
||||||
|
{
|
||||||
|
key: JOINT_ANCHOR_X_KEY,
|
||||||
|
label: 'Connected anchor X',
|
||||||
|
kind: 'number',
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
...JOINT_ENDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOINT_ANCHOR_Y_KEY,
|
||||||
|
label: 'Connected anchor Y',
|
||||||
|
kind: 'number',
|
||||||
|
default: 0,
|
||||||
|
step: 1,
|
||||||
|
...JOINT_ENDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOINT_LENGTH_KEY,
|
||||||
|
label: 'Length (0 = as placed)',
|
||||||
|
kind: 'number',
|
||||||
|
default: 0,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
...JOINT_ENDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOINT_STIFFNESS_KEY,
|
||||||
|
label: 'Stiffness',
|
||||||
|
kind: 'number',
|
||||||
|
default: 50,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
...ONLY_SPRING,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOINT_DAMPING_KEY,
|
||||||
|
label: 'Damping',
|
||||||
|
kind: 'number',
|
||||||
|
default: 1,
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
...ONLY_SPRING,
|
||||||
|
},
|
||||||
|
{ key: JOINT_LIMIT_KEY, label: 'Limit angle', kind: 'boolean', default: false, ...ONLY_HINGE },
|
||||||
|
{
|
||||||
|
key: JOINT_MIN_ANGLE_KEY,
|
||||||
|
label: 'Min angle',
|
||||||
|
kind: 'number',
|
||||||
|
default: -45,
|
||||||
|
max: 0,
|
||||||
|
step: 1,
|
||||||
|
shownWith: JOINT_LIMIT_KEY,
|
||||||
|
...ONLY_HINGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOINT_MAX_ANGLE_KEY,
|
||||||
|
label: 'Max angle',
|
||||||
|
kind: 'number',
|
||||||
|
default: 45,
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
shownWith: JOINT_LIMIT_KEY,
|
||||||
|
...ONLY_HINGE,
|
||||||
|
},
|
||||||
|
{ 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.
|
// A container is pure structure: its content is the template, not a property.
|
||||||
container: [],
|
container: [],
|
||||||
// What its children are cut to (#260). Units, not pixels: it turns and grows with its parent.
|
// What its children are cut to (#260). Units, not pixels: it turns and grows with its parent.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const BEHAVIOR_SYMBOL = 28;
|
|||||||
const CONTAINER_SYMBOL = 24;
|
const CONTAINER_SYMBOL = 24;
|
||||||
const PARTICLES_SYMBOL = 24;
|
const PARTICLES_SYMBOL = 24;
|
||||||
const RIGIDBODY_SYMBOL = 24;
|
const RIGIDBODY_SYMBOL = 24;
|
||||||
|
const JOINT_SYMBOL = 24;
|
||||||
|
|
||||||
/** Rough width of one character relative to the font size, for a plain sans. */
|
/** Rough width of one character relative to the font size, for a plain sans. */
|
||||||
const CHAR_WIDTH = 0.55;
|
const CHAR_WIDTH = 0.55;
|
||||||
@@ -73,6 +74,8 @@ export function footprintOf(
|
|||||||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||||||
case 'rigidbody':
|
case 'rigidbody':
|
||||||
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
||||||
|
case 'joint':
|
||||||
|
return { w: JOINT_SYMBOL, h: JOINT_SYMBOL };
|
||||||
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
|
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
|
||||||
// framed is the source, which is a point with a handle around it.
|
// framed is the source, which is a point with a handle around it.
|
||||||
case 'particles':
|
case 'particles':
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** What the physics engine and the scene view read off a joint (#154). */
|
||||||
|
import {
|
||||||
|
hasConnectedAnchor,
|
||||||
|
JOINT_ANCHOR_X_KEY,
|
||||||
|
JOINT_ANCHOR_Y_KEY,
|
||||||
|
JOINT_COLLIDE_KEY,
|
||||||
|
JOINT_DAMPING_KEY,
|
||||||
|
JOINT_LENGTH_KEY,
|
||||||
|
JOINT_LIMIT_KEY,
|
||||||
|
JOINT_MAX_ANGLE_KEY,
|
||||||
|
JOINT_MIN_ANGLE_KEY,
|
||||||
|
JOINT_STIFFNESS_KEY,
|
||||||
|
JOINT_TYPE_KEY,
|
||||||
|
JOINT_TYPES,
|
||||||
|
type JointType,
|
||||||
|
} from '../model/joint';
|
||||||
|
import { apply, type Mat } from '../model/matrix';
|
||||||
|
import type { PropertyBag, Vec2 } from '../model/Properties';
|
||||||
|
import { defaultsFor } from './PropertySchema';
|
||||||
|
|
||||||
|
const JOINT = defaultsFor('joint');
|
||||||
|
|
||||||
|
export interface JointSettings {
|
||||||
|
type: JointType;
|
||||||
|
/** Where a spring or a rope ends, in the connected object's units – the scene's without one. */
|
||||||
|
connectedAnchor: Vec2;
|
||||||
|
/** World units; 0 takes the distance the two ends were placed at. */
|
||||||
|
length: number;
|
||||||
|
stiffness: number;
|
||||||
|
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;
|
||||||
|
collideConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value: unknown, fallback: unknown): number {
|
||||||
|
const n = Number(value ?? fallback);
|
||||||
|
return Number.isFinite(n) ? n : Number(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jointTypeOf(props: PropertyBag): JointType {
|
||||||
|
const type = props[JOINT_TYPE_KEY];
|
||||||
|
return (JOINT_TYPES as readonly unknown[]).includes(type) ? (type as JointType) : 'hinge';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jointSettings(props: PropertyBag): JointSettings {
|
||||||
|
const type = jointTypeOf(props);
|
||||||
|
const limited = type === 'hinge' && props[JOINT_LIMIT_KEY] === true;
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
connectedAnchor: {
|
||||||
|
x: num(props[JOINT_ANCHOR_X_KEY], JOINT[JOINT_ANCHOR_X_KEY]),
|
||||||
|
y: num(props[JOINT_ANCHOR_Y_KEY], JOINT[JOINT_ANCHOR_Y_KEY]),
|
||||||
|
},
|
||||||
|
length: Math.max(0, num(props[JOINT_LENGTH_KEY], JOINT[JOINT_LENGTH_KEY])),
|
||||||
|
stiffness: Math.max(0, num(props[JOINT_STIFFNESS_KEY], JOINT[JOINT_STIFFNESS_KEY])),
|
||||||
|
damping: Math.max(0, num(props[JOINT_DAMPING_KEY], JOINT[JOINT_DAMPING_KEY])),
|
||||||
|
limits: limited
|
||||||
|
? {
|
||||||
|
min: Math.min(0, num(props[JOINT_MIN_ANGLE_KEY], JOINT[JOINT_MIN_ANGLE_KEY])),
|
||||||
|
max: Math.max(0, num(props[JOINT_MAX_ANGLE_KEY], JOINT[JOINT_MAX_ANGLE_KEY])),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
collideConnected: props[JOINT_COLLIDE_KEY] === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* point: `at`, where the joint itself sits, is the one both objects are held at.
|
||||||
|
*/
|
||||||
|
export function jointFarEnd(settings: JointSettings, at: Vec2, connected: Mat | null): Vec2 {
|
||||||
|
if (!hasConnectedAnchor(settings.type)) return at;
|
||||||
|
return connected ? apply(connected, settings.connectedAnchor) : settings.connectedAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The length a spring rests at or a rope allows: the one set, else the distance it was placed at. */
|
||||||
|
export function jointLength(settings: JointSettings, placed: number): number {
|
||||||
|
return settings.length > 0 ? settings.length : placed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hinge's limits as the engine counts them (radians, the held body's turn against the other's):
|
||||||
|
* the degrees either way, from the angle the two were placed at.
|
||||||
|
*/
|
||||||
|
export function hingeLimits(limits: { min: number; max: number }, placed: number): [number, number] {
|
||||||
|
const rad = Math.PI / 180;
|
||||||
|
return [placed + limits.min * rad, placed + limits.max * rad];
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annota
|
|||||||
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||||
import {
|
import {
|
||||||
AddColliderCommand,
|
AddColliderCommand,
|
||||||
|
AddJointCommand,
|
||||||
AddRigidbodyCommand,
|
AddRigidbodyCommand,
|
||||||
colliderRefusal,
|
colliderRefusal,
|
||||||
|
jointRefusal,
|
||||||
rigidbodyRefusal,
|
rigidbodyRefusal,
|
||||||
} from '../core/commands/colliderCommands';
|
} from '../core/commands/colliderCommands';
|
||||||
import { oneStep } from '../core/commands/CompoundCommand';
|
import { oneStep } from '../core/commands/CompoundCommand';
|
||||||
@@ -84,6 +86,19 @@ export function addRigidbody(state: EditorState, holderId: string, objectId: str
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The test `AddJointCommand` makes; template content has no structure of its own to change. */
|
||||||
|
export function canAddJoint(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
|
||||||
|
return !isTemplateContent(holder, object.id) && jointRefusal(project, holder, object) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives the object a joint child (#154), new or the existing `assetId`; selects the joint. */
|
||||||
|
export function addJoint(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
|
||||||
|
const command = new AddJointCommand(holderId, objectId, assetId);
|
||||||
|
if (!state.execute(command)) return false;
|
||||||
|
state.select(objectSelection(holderId, [command.objectId]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Removes the objects (with their subtrees) as one undo step. */
|
/** Removes the objects (with their subtrees) as one undo step. */
|
||||||
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||||||
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export function sceneHooks({ state, editor }: HookContext) {
|
|||||||
gizmos: () => viewport.gizmoInfo(),
|
gizmos: () => viewport.gizmoInfo(),
|
||||||
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
||||||
polygonCorners: () => viewport.cornerInfo(),
|
polygonCorners: () => viewport.cornerInfo(),
|
||||||
|
/** The lines of the joints in the Scene tab, in world units, and the anchor handle in global pixels (#154). */
|
||||||
|
joints: () => viewport.jointInfo(),
|
||||||
/** The size gizmo on the selected sprite or collider, in global pixels (#223). */
|
/** The size gizmo on the selected sprite or collider, in global pixels (#223). */
|
||||||
sizeGizmo: () => viewport.sizeGizmoInfo(),
|
sizeGizmo: () => viewport.sizeGizmoInfo(),
|
||||||
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
|
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
|||||||
buildOwnRows(host, holderId, objectId);
|
buildOwnRows(host, holderId, objectId);
|
||||||
markObjectRows(host, object, asset, target);
|
markObjectRows(host, object, asset, target);
|
||||||
|
|
||||||
|
// The editor run does not simulate (#148), so nothing shows a joint at work there.
|
||||||
|
if (asset.type === 'joint') {
|
||||||
|
addNote(host, 'Holds while the game is played,', 'small');
|
||||||
|
addNote(host, 'not in the editor run.', 'small');
|
||||||
|
}
|
||||||
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
|
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
|
||||||
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
|
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { Menu, type MenuItem } from '../ui';
|
|||||||
import {
|
import {
|
||||||
activateAsset,
|
activateAsset,
|
||||||
addCollider,
|
addCollider,
|
||||||
|
addJoint,
|
||||||
addRigidbody,
|
addRigidbody,
|
||||||
canAddCollider,
|
canAddCollider,
|
||||||
|
canAddJoint,
|
||||||
canAddRigidbody,
|
canAddRigidbody,
|
||||||
createContainer,
|
createContainer,
|
||||||
removeObjects,
|
removeObjects,
|
||||||
@@ -89,6 +91,11 @@ export function openObjectMenu(
|
|||||||
disabled: !object || many || !canAddRigidbody(state.project, source, object),
|
disabled: !object || many || !canAddRigidbody(state.project, source, object),
|
||||||
onSelect: () => openPartMenu(state, 'rigidbody', holder, id, x, y),
|
onSelect: () => openPartMenu(state, 'rigidbody', holder, id, x, y),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Add joint…',
|
||||||
|
disabled: !object || many || !canAddJoint(state.project, source, object),
|
||||||
|
onSelect: () => openPartMenu(state, 'joint', holder, id, x, y),
|
||||||
|
},
|
||||||
...(object && asset?.type === 'collider'
|
...(object && asset?.type === 'collider'
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -129,18 +136,18 @@ export function openObjectMenu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which collider or rigidbody to add (#222): a new one, or any the project has, found by typing. One
|
* Which collider, rigidbody or joint to add (#222): a new one, or any the project has, found by
|
||||||
* the object wears already is greyed out – a second copy of it would only double up.
|
* typing. One the object wears already is greyed out – a second copy of it would only double up.
|
||||||
*/
|
*/
|
||||||
function openPartMenu(
|
function openPartMenu(
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
type: 'collider' | 'rigidbody',
|
type: 'collider' | 'rigidbody' | 'joint',
|
||||||
holder: ObjectHolder,
|
holder: ObjectHolder,
|
||||||
id: string,
|
id: string,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): Menu {
|
): Menu {
|
||||||
const add = type === 'collider' ? addCollider : addRigidbody;
|
const add = { collider: addCollider, rigidbody: addRigidbody, joint: addJoint }[type];
|
||||||
const worn = new Set(holder.objects.filter((o) => o.parentId === id).map((o) => o.assetId));
|
const worn = new Set(holder.objects.filter((o) => o.parentId === id).map((o) => o.assetId));
|
||||||
const existing = state.project.assets
|
const existing = state.project.assets
|
||||||
.filter((a) => a.type === type)
|
.filter((a) => a.type === type)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
|||||||
struct: 'Structs',
|
struct: 'Structs',
|
||||||
collider: 'Colliders',
|
collider: 'Colliders',
|
||||||
rigidbody: 'Rigidbodies',
|
rigidbody: 'Rigidbodies',
|
||||||
|
joint: 'Joints',
|
||||||
container: 'Containers',
|
container: 'Containers',
|
||||||
clip: 'Clip areas',
|
clip: 'Clip areas',
|
||||||
texture: 'Textures',
|
texture: 'Textures',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import font from './icons/font.svg?raw';
|
|||||||
import game from './icons/game.svg?raw';
|
import game from './icons/game.svg?raw';
|
||||||
import group from './icons/group.svg?raw';
|
import group from './icons/group.svg?raw';
|
||||||
import list from './icons/list.svg?raw';
|
import list from './icons/list.svg?raw';
|
||||||
|
import joint from './icons/joint.svg?raw';
|
||||||
import palette from './icons/palette.svg?raw';
|
import palette from './icons/palette.svg?raw';
|
||||||
import particles from './icons/particles.svg?raw';
|
import particles from './icons/particles.svg?raw';
|
||||||
import pixelart from './icons/pixelart.svg?raw';
|
import pixelart from './icons/pixelart.svg?raw';
|
||||||
@@ -42,6 +43,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
|||||||
container,
|
container,
|
||||||
curve,
|
curve,
|
||||||
font,
|
font,
|
||||||
|
joint,
|
||||||
palette,
|
palette,
|
||||||
particles,
|
particles,
|
||||||
pixelart,
|
pixelart,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
|
<!-- Two ends held together by a bar: what a joint does to two objects. -->
|
||||||
|
<path d="M-5.3 4.1 L4.1 -5.3 L5.3 -4.1 L-4.1 5.3 Z" fill="currentColor" />
|
||||||
|
<circle cx="-4.7" cy="4.7" r="2.7" fill="currentColor" />
|
||||||
|
<circle cx="4.7" cy="-4.7" r="2.7" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 368 B |
@@ -49,12 +49,14 @@ import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
|||||||
import { shows, type VisibilityPart } from '../../core/model/sceneVisibility';
|
import { shows, type VisibilityPart } from '../../core/model/sceneVisibility';
|
||||||
import { dashedLine } from '../../viewport/symbols';
|
import { dashedLine } from '../../viewport/symbols';
|
||||||
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
||||||
|
import { type JointLink, jointLinkOf } from '../../viewport/jointLinks';
|
||||||
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
|
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
|
||||||
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
|
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
|
||||||
import { cameraOfItems } from '../../viewport/activeCamera';
|
import { cameraOfItems } from '../../viewport/activeCamera';
|
||||||
import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||||
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||||||
import { FreeViewport } from './FreeViewport';
|
import { FreeViewport } from './FreeViewport';
|
||||||
|
import { JointAnchorEditor } from './jointAnchor';
|
||||||
import { PolygonCornerEditor } from './polygonCorners';
|
import { PolygonCornerEditor } from './polygonCorners';
|
||||||
import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
|
import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
|
||||||
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
||||||
@@ -109,13 +111,14 @@ const LABEL_PAD_Y = 1;
|
|||||||
const LABEL_ROW_GAP = 1;
|
const LABEL_ROW_GAP = 1;
|
||||||
const LABEL_PLATE_ALPHA = 0.7;
|
const LABEL_PLATE_ALPHA = 0.7;
|
||||||
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */
|
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */
|
||||||
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody']);
|
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody', 'joint']);
|
||||||
|
|
||||||
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
|
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
|
||||||
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||||
behavior: Theme.colors.behavior,
|
behavior: Theme.colors.behavior,
|
||||||
particles: Theme.colors.particles,
|
particles: Theme.colors.particles,
|
||||||
rigidbody: Theme.colors.collider,
|
rigidbody: Theme.colors.collider,
|
||||||
|
joint: Theme.colors.collider,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SceneViewport extends FreeViewport {
|
export class SceneViewport extends FreeViewport {
|
||||||
@@ -152,6 +155,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
};
|
};
|
||||||
private readonly painter = new TilemapPainter(this.host);
|
private readonly painter = new TilemapPainter(this.host);
|
||||||
private readonly corners = new PolygonCornerEditor(this.host);
|
private readonly corners = new PolygonCornerEditor(this.host);
|
||||||
|
private readonly anchors = new JointAnchorEditor(this.host);
|
||||||
private readonly sizer = new SizeGizmoEditor(this.host);
|
private readonly sizer = new SizeGizmoEditor(this.host);
|
||||||
private editorWorld: World | null = null;
|
private editorWorld: World | null = null;
|
||||||
private lastRays: CastRay[] = [];
|
private lastRays: CastRay[] = [];
|
||||||
@@ -163,6 +167,8 @@ export class SceneViewport extends FreeViewport {
|
|||||||
private lastPickOutline: string | null = null;
|
private lastPickOutline: string | null = null;
|
||||||
/** Ids that got an owner link this frame (test hook). */
|
/** Ids that got an owner link this frame (test hook). */
|
||||||
private lastLinks: string[] = [];
|
private lastLinks: string[] = [];
|
||||||
|
/** What was drawn of each joint this frame, in world units (test hook). */
|
||||||
|
private lastJointLinks: JointLink[] = [];
|
||||||
/** Last place the pointer was seen, in global coordinates – where a paste lands. */
|
/** Last place the pointer was seen, in global coordinates – where a paste lands. */
|
||||||
private pointerGlobal: Vec2 | null = null;
|
private pointerGlobal: Vec2 | null = null;
|
||||||
|
|
||||||
@@ -316,7 +322,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.openEmptyMenu(e);
|
this.openEmptyMenu(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return;
|
if (e.button === 0 && (this.corners.press(e) || this.anchors.press(e) || this.sizer.press(e))) return;
|
||||||
if (e.button === 0 && e.target === this) this.pressEmpty(e);
|
if (e.button === 0 && e.target === this) this.pressEmpty(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +351,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
if (
|
if (
|
||||||
this.panTo(e) ||
|
this.panTo(e) ||
|
||||||
this.corners.move(e.global) ||
|
this.corners.move(e.global) ||
|
||||||
|
this.anchors.move(e.global) ||
|
||||||
this.sizer.move(e.global, e.shiftKey) ||
|
this.sizer.move(e.global, e.shiftKey) ||
|
||||||
this.painter.move(e.global)
|
this.painter.move(e.global)
|
||||||
)
|
)
|
||||||
@@ -427,7 +434,8 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.endPan();
|
this.endPan();
|
||||||
if (this.corners.release() || this.sizer.release() || this.painter.release()) return;
|
if (this.corners.release() || this.anchors.release() || this.sizer.release() || this.painter.release())
|
||||||
|
return;
|
||||||
const marquee = this.marquee;
|
const marquee = this.marquee;
|
||||||
this.marquee = null;
|
this.marquee = null;
|
||||||
if (marquee) {
|
if (marquee) {
|
||||||
@@ -504,7 +512,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
if (this.corners.press(e) || this.sizer.press(e)) return;
|
if (this.corners.press(e) || this.anchors.press(e) || this.sizer.press(e)) return;
|
||||||
const holder = this.resolved();
|
const holder = this.resolved();
|
||||||
if (!holder) return;
|
if (!holder) return;
|
||||||
const stack = this.stackUnder(e);
|
const stack = this.stackUnder(e);
|
||||||
@@ -575,6 +583,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.dragging ||
|
this.dragging ||
|
||||||
this.painter.active ||
|
this.painter.active ||
|
||||||
this.corners.active ||
|
this.corners.active ||
|
||||||
|
this.anchors.active ||
|
||||||
this.sizer.active ||
|
this.sizer.active ||
|
||||||
this.marquee
|
this.marquee
|
||||||
)
|
)
|
||||||
@@ -686,6 +695,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
protected override afterItems(items: DrawItem[], view: View): void {
|
protected override afterItems(items: DrawItem[], view: View): void {
|
||||||
this.placeIcons(items, view.scale);
|
this.placeIcons(items, view.scale);
|
||||||
this.drawParentLinks(items, view.scale);
|
this.drawParentLinks(items, view.scale);
|
||||||
|
this.drawJointLinks(items, view.scale);
|
||||||
this.syncCursors();
|
this.syncCursors();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,6 +954,36 @@ export class SceneViewport extends FreeViewport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line from every joint to what it holds on to (#154): the connected anchor of a spring or a
|
||||||
|
* rope, the connected object of a hinge or a fixed joint. The point the joint itself holds is
|
||||||
|
* ringed, since its icon may stand aside when it shares the spot with others.
|
||||||
|
*/
|
||||||
|
private drawJointLinks(items: DrawItem[], scale: number): void {
|
||||||
|
this.lastJointLinks = [];
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
const color = Theme.colors.collider;
|
||||||
|
for (const item of items) {
|
||||||
|
if (!this.spots.has(item.id)) continue;
|
||||||
|
const link = jointLinkOf(item, byId);
|
||||||
|
if (!link) continue;
|
||||||
|
this.lastJointLinks.push(link);
|
||||||
|
const { from, to } = link;
|
||||||
|
this.links.circle(from.x, from.y, 4 / scale).stroke({ width: 1.5 / scale, color });
|
||||||
|
if (from.x === to.x && from.y === to.y) continue;
|
||||||
|
this.links
|
||||||
|
.moveTo(from.x, from.y)
|
||||||
|
.lineTo(to.x, to.y)
|
||||||
|
.stroke({ width: 1.5 / scale, color });
|
||||||
|
this.links.circle(to.x, to.y, 3 / scale).fill(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() };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name of an object whose type shows names; `placeLabels` puts it beside the icon once every
|
* The name of an object whose type shows names; `placeLabels` puts it beside the icon once every
|
||||||
* icon has its spot. One off screen is kept up to date but hidden: it still takes its row.
|
* icon has its spot. One off screen is kept up to date but hidden: it still takes its row.
|
||||||
@@ -1032,6 +1072,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
if (!picking) {
|
if (!picking) {
|
||||||
this.sizer.draw(this.gizmos, view);
|
this.sizer.draw(this.gizmos, view);
|
||||||
this.corners.draw(this.gizmos, view);
|
this.corners.draw(this.gizmos, view);
|
||||||
|
this.anchors.draw(this.gizmos, view);
|
||||||
}
|
}
|
||||||
this.drawDropPreview(view);
|
this.drawDropPreview(view);
|
||||||
if (picking) return;
|
if (picking) return;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||||
|
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
|
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||||
|
import { objectRef } from '../../core/model/instance';
|
||||||
|
import { JOINT_ANCHOR_X_KEY, JOINT_ANCHOR_Y_KEY } from '../../core/model/joint';
|
||||||
|
import { apply as applyPoint, IDENTITY, invert, type Mat } from '../../core/model/matrix';
|
||||||
|
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
|
||||||
|
import { Theme } from '../../ui';
|
||||||
|
import { type JointLink, jointLinkOf } from '../../viewport/jointLinks';
|
||||||
|
import { type View, worldToScreen } from '../../viewport/view';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
|
import { type SceneViewHost, selectedItem } from './sceneViewHost';
|
||||||
|
|
||||||
|
/** Screen pixels around the anchor that grab it. */
|
||||||
|
const ANCHOR_GRIP = 8;
|
||||||
|
/** Diameter of the handle in screen pixels. */
|
||||||
|
const ANCHOR_SIZE = 10;
|
||||||
|
/** Pixels of travel before a press turns into a drag. */
|
||||||
|
const DRAG_THRESHOLD = 2;
|
||||||
|
|
||||||
|
/** The connected anchor being dragged; `before` are the overrides put back on release. */
|
||||||
|
interface AnchorDrag {
|
||||||
|
holderId: string;
|
||||||
|
objectId: string;
|
||||||
|
before: [PropertyValue | undefined, PropertyValue | undefined];
|
||||||
|
/** From the world into the frame the anchor is counted in. */
|
||||||
|
toLocal: Mat;
|
||||||
|
at: Vec2;
|
||||||
|
pointer: Vec2;
|
||||||
|
moved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handle on the far end of the selected spring or rope (#154): dragging it moves the joint's
|
||||||
|
* connected anchor, as a preview while it goes and as one undo step on release. A hinge and a fixed
|
||||||
|
* joint have no far end of their own – they are moved by moving the joint.
|
||||||
|
*/
|
||||||
|
export class JointAnchorEditor {
|
||||||
|
private drag: AnchorDrag | null = null;
|
||||||
|
/** The handle drawn last, in global pixels (test hook). */
|
||||||
|
private lastHandle: { objectId: string; x: number; y: number } | null = null;
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
|
constructor(private readonly host: SceneViewHost) {
|
||||||
|
this.perFrame = new FramePreview(host.state, 'joint-anchor');
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): boolean {
|
||||||
|
return this.drag !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected joint's link, if it has an anchor to drag. */
|
||||||
|
private edited(): JointLink | null {
|
||||||
|
const item = selectedItem(this.host);
|
||||||
|
if (item?.type !== 'joint') return null;
|
||||||
|
const link = jointLinkOf(item, new Map(this.host.items().map((i) => [i.id, i])));
|
||||||
|
return link?.anchored ? link : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A press on the handle starts dragging it; false when the press was elsewhere. */
|
||||||
|
press(e: FederatedPointerEvent): boolean {
|
||||||
|
const link = this.edited();
|
||||||
|
const holder = this.host.holder();
|
||||||
|
const ref = link && holder && objectRef(this.host.state.project, holder, link.id);
|
||||||
|
if (!link || !holder || !ref) return false;
|
||||||
|
const at = this.host.worldAt(e.global);
|
||||||
|
if (Math.hypot(at.x - link.to.x, at.y - link.to.y) > ANCHOR_GRIP / this.host.scale()) return false;
|
||||||
|
e.stopPropagation();
|
||||||
|
this.drag = {
|
||||||
|
holderId: holder.id,
|
||||||
|
objectId: link.id,
|
||||||
|
before: [ref.object.overrides[JOINT_ANCHOR_X_KEY], ref.object.overrides[JOINT_ANCHOR_Y_KEY]],
|
||||||
|
toLocal: invert(link.frame ?? IDENTITY),
|
||||||
|
at: applyPoint(invert(link.frame ?? IDENTITY), link.to),
|
||||||
|
pointer: { x: e.global.x, y: e.global.y },
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows the pointer with the anchor under way; false when there is none. */
|
||||||
|
move(global: Vec2): boolean {
|
||||||
|
const drag = this.drag;
|
||||||
|
if (!drag) return false;
|
||||||
|
if (!drag.moved && Math.hypot(global.x - drag.pointer.x, global.y - drag.pointer.y) < DRAG_THRESHOLD)
|
||||||
|
return true;
|
||||||
|
drag.moved = true;
|
||||||
|
const at = applyPoint(drag.toLocal, this.host.worldAt(global));
|
||||||
|
drag.at = { x: Math.round(at.x), y: Math.round(at.y) };
|
||||||
|
const holder = this.host.holder();
|
||||||
|
const ref = holder && objectRef(this.host.state.project, holder, drag.objectId);
|
||||||
|
if (!ref) return true;
|
||||||
|
ref.setOverride(JOINT_ANCHOR_X_KEY, drag.at.x);
|
||||||
|
ref.setOverride(JOINT_ANCHOR_Y_KEY, drag.at.y);
|
||||||
|
this.perFrame.request();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the preview back and lets the commands do the edit, so one drag is one undo step. */
|
||||||
|
release(): boolean {
|
||||||
|
const drag = this.drag;
|
||||||
|
if (!drag) return false;
|
||||||
|
this.drag = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
|
const state = this.host.state;
|
||||||
|
const holder = this.host.holder();
|
||||||
|
const ref = holder && objectRef(state.project, holder, drag.objectId);
|
||||||
|
if (!ref) return true;
|
||||||
|
ref.setOverride(JOINT_ANCHOR_X_KEY, drag.before[0]);
|
||||||
|
ref.setOverride(JOINT_ANCHOR_Y_KEY, drag.before[1]);
|
||||||
|
if (drag.moved)
|
||||||
|
state.execute(
|
||||||
|
oneStep('Move joint anchor', [
|
||||||
|
new SetOverrideCommand(drag.holderId, drag.objectId, JOINT_ANCHOR_X_KEY, drag.at.x),
|
||||||
|
new SetOverrideCommand(drag.holderId, drag.objectId, JOINT_ANCHOR_Y_KEY, drag.at.y),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
else state.preview();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the handle is drawn, in global pixels; null while there is none (test hook). */
|
||||||
|
info(): { objectId: string; x: number; y: number } | null {
|
||||||
|
return this.lastHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(g: Graphics, view: View): void {
|
||||||
|
const link = this.edited();
|
||||||
|
this.lastHandle = null;
|
||||||
|
if (!link) return;
|
||||||
|
const at = worldToScreen(view, link.to.x, link.to.y);
|
||||||
|
g.circle(at.x, at.y, ANCHOR_SIZE / 2)
|
||||||
|
.fill(this.drag ? Theme.colors.accent : Theme.colors.accentText)
|
||||||
|
.stroke({ width: 1, color: this.drag ? Theme.colors.accentText : Theme.colors.accent });
|
||||||
|
const global = g.toGlobal(at);
|
||||||
|
this.lastHandle = { objectId: link.id, x: global.x, y: global.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ const TURN_SNAP = 15;
|
|||||||
/** How strongly the frame round a container's content is drawn. */
|
/** How strongly the frame round a container's content is drawn. */
|
||||||
const FRAME_ALPHA = 0.6;
|
const FRAME_ALPHA = 0.6;
|
||||||
/** Content that is only an icon or a view, and so takes no room in a container's frame. */
|
/** Content that is only an icon or a view, and so takes no room in a container's frame. */
|
||||||
const SHAPELESS = new Set<DrawItem['type']>(['behavior', 'rigidbody', 'camera', 'container']);
|
const SHAPELESS = new Set<DrawItem['type']>(['behavior', 'rigidbody', 'joint', 'camera', 'container']);
|
||||||
|
|
||||||
/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */
|
/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */
|
||||||
type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
|
type Writes = Partial<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
|
||||||
|
|||||||
@@ -615,8 +615,8 @@ export class World {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes only the entity: its children move up to its parent (top level when it had none) and
|
* Removes only the entity: its children move up to its parent (top level when it had none) and
|
||||||
* stay where they are in the world. Its behaviors and its rigidbody are part of it rather than
|
* stay where they are in the world. Its behaviors, its rigidbody and its joints are part of it rather
|
||||||
* things it carries, so they go with it – moved up they would drive the parent instead.
|
* than things it carries, so they go with it – moved up they would drive the parent instead.
|
||||||
* Answers the children that moved up.
|
* Answers the children that moved up.
|
||||||
*/
|
*/
|
||||||
removeKeepingChildren(entity: Entity): Entity[] {
|
removeKeepingChildren(entity: Entity): Entity[] {
|
||||||
@@ -624,7 +624,7 @@ export class World {
|
|||||||
const own = localMatrixOf(entity, transformOf(entity.props));
|
const own = localMatrixOf(entity, transformOf(entity.props));
|
||||||
const kept: Entity[] = [];
|
const kept: Entity[] = [];
|
||||||
for (const child of this.childrenOf(entity)) {
|
for (const child of this.childrenOf(entity)) {
|
||||||
if (child.type === 'behavior' || child.type === 'rigidbody') continue;
|
if (child.type === 'behavior' || child.type === 'rigidbody' || child.type === 'joint') continue;
|
||||||
const at = apply(own, child);
|
const at = apply(own, child);
|
||||||
child.x = at.x;
|
child.x = at.x;
|
||||||
child.y = at.y;
|
child.y = at.y;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
samePose,
|
samePose,
|
||||||
} from './bodyGeometry';
|
} from './bodyGeometry';
|
||||||
import { type Rapier, toMetres, toPixels } from './engine';
|
import { type Rapier, toMetres, toPixels } from './engine';
|
||||||
|
import { type JointHost, JointSet, type RigidOwner } from './joints';
|
||||||
import { passesOneWay, reachOf } from './oneWay';
|
import { passesOneWay, reachOf } from './oneWay';
|
||||||
|
|
||||||
/** The engine always steps this long, whatever the frame took. */
|
/** The engine always steps this long, whatever the frame took. */
|
||||||
@@ -159,7 +160,15 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
/** How bodies moved in the world before (#165), until the body of each is made here. */
|
/** How bodies moved in the world before (#165), until the body of each is made here. */
|
||||||
private resumed = new Map<Entity, BodyMotion>();
|
private resumed = new Map<Entity, BodyMotion>();
|
||||||
|
|
||||||
constructor(private readonly rapier: Rapier) {}
|
private readonly joints: JointSet;
|
||||||
|
private readonly jointHost: JointHost = {
|
||||||
|
rigidAbove: (from) => this.rigidAbove(from),
|
||||||
|
hint: (world, target, text) => this.hint(world, target, text),
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly rapier: Rapier) {
|
||||||
|
this.joints = new JointSet(rapier);
|
||||||
|
}
|
||||||
|
|
||||||
start(world: World): void {
|
start(world: World): void {
|
||||||
const engine = new this.rapier.World(this.gravity(world));
|
const engine = new this.rapier.World(this.gravity(world));
|
||||||
@@ -176,6 +185,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.joints.clear();
|
||||||
this.engine?.free();
|
this.engine?.free();
|
||||||
this.queue?.free();
|
this.queue?.free();
|
||||||
this.engine = null;
|
this.engine = null;
|
||||||
@@ -421,6 +431,21 @@ export class PhysicsSystem implements System, CollisionSource {
|
|||||||
}
|
}
|
||||||
// Only the body an object arrives with moves on: one it gets later starts at rest like any other.
|
// Only the body an object arrives with moves on: one it gets later starts at rest like any other.
|
||||||
this.resumed.clear();
|
this.resumed.clear();
|
||||||
|
this.joints.sync(world, this.engine!, this.jointHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest object from `from` upwards whose body the engine moves by a rigidbody of its own. */
|
||||||
|
private rigidAbove(from: Entity | null): RigidOwner | null {
|
||||||
|
for (let entity = from; entity; entity = entity.parent) {
|
||||||
|
const body = this.bodies.get(entity);
|
||||||
|
if (body?.kind === 'rigid') return { entity, body: body.body };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a joint holds its two ends right now (#154): it does not without a rigidbody to hold. */
|
||||||
|
holdsJoint(joint: Entity): boolean {
|
||||||
|
return this.joints.holds(joint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type {
|
||||||
|
ImpulseJoint,
|
||||||
|
JointData,
|
||||||
|
RevoluteImpulseJoint,
|
||||||
|
RigidBody,
|
||||||
|
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 { referencedId } from '../../core/model/objectRefs';
|
||||||
|
import type { PropertyValue, Vec2 } from '../../core/model/Properties';
|
||||||
|
import { hingeLimits, jointFarEnd, jointLength, jointSettings } 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';
|
||||||
|
|
||||||
|
/** The body the engine moves for an object, and that object. */
|
||||||
|
export interface RigidOwner {
|
||||||
|
entity: Entity;
|
||||||
|
body: RigidBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the joints need of the system that owns the bodies. */
|
||||||
|
export interface JointHost {
|
||||||
|
/** The nearest object from `from` upwards that has a rigidbody switched on; null when none has. */
|
||||||
|
rigidAbove(from: Entity | null): RigidOwner | null;
|
||||||
|
hint(world: World, target: Entity, text: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One joint entity as the engine has it. `joint` is null while it holds nothing. */
|
||||||
|
interface Held {
|
||||||
|
joint: ImpulseJoint | null;
|
||||||
|
/** What the engine joint was built from; it is built again when that changes. */
|
||||||
|
key: string;
|
||||||
|
/** A body of the joint's own standing for an end that has no rigidbody: it goes where that end is. */
|
||||||
|
anchor: RigidBody | null;
|
||||||
|
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;
|
||||||
|
collide: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.';
|
||||||
|
|
||||||
|
/** 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' {
|
||||||
|
const value = joint.props[JOINT_CONNECTED_KEY] as PropertyValue | Entity | undefined;
|
||||||
|
// A graph wrote the object itself (`Properties`); the scene's reference names it by id (#258).
|
||||||
|
if (isEntity(value)) return world.entities.includes(value) ? value : 'gone';
|
||||||
|
const ref = referencedId(value);
|
||||||
|
if (!ref) return null;
|
||||||
|
return world.findReferenced(joint, ref) ?? 'gone';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A world point as a body at `pose` has it, in the engine's metres. */
|
||||||
|
function inBody(pose: Pose, point: Vec2): Vec2 {
|
||||||
|
const dx = point.x - pose.x;
|
||||||
|
const dy = point.y - pose.y;
|
||||||
|
const cos = Math.cos(pose.angle);
|
||||||
|
const sin = Math.sin(pose.angle);
|
||||||
|
return { x: toMetres(dx * cos + dy * sin), y: toMetres(-dx * sin + dy * cos) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export class JointSet {
|
||||||
|
private readonly held = new Map<Entity, Held>();
|
||||||
|
|
||||||
|
constructor(private readonly rapier: Rapier) {}
|
||||||
|
|
||||||
|
/** Makes the engine's joints match the joint entities; call once the bodies are in place. */
|
||||||
|
sync(world: World, engine: RapierWorld, host: JointHost): void {
|
||||||
|
const seen = new Set<Entity>();
|
||||||
|
for (const entity of world.entities) {
|
||||||
|
if (entity.type !== 'joint') continue;
|
||||||
|
seen.add(entity);
|
||||||
|
this.syncOne(world, engine, host, entity);
|
||||||
|
}
|
||||||
|
for (const [entity, held] of this.held) {
|
||||||
|
if (seen.has(entity)) continue;
|
||||||
|
this.release(engine, held);
|
||||||
|
this.dropAnchor(engine, held);
|
||||||
|
this.held.delete(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the engine holds a joint for this entity right now. */
|
||||||
|
holds(entity: Entity): boolean {
|
||||||
|
return Boolean(this.held.get(entity)?.joint?.isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The engine is freed as a whole; what was held in it is only forgotten. */
|
||||||
|
clear(): void {
|
||||||
|
this.held.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncOne(world: World, engine: RapierWorld, host: JointHost, entity: Entity): void {
|
||||||
|
let held = this.held.get(entity);
|
||||||
|
if (!held) {
|
||||||
|
held = {
|
||||||
|
joint: null,
|
||||||
|
key: '',
|
||||||
|
anchor: null,
|
||||||
|
anchorPose: null,
|
||||||
|
placed: null,
|
||||||
|
collide: false,
|
||||||
|
};
|
||||||
|
this.held.set(entity, held);
|
||||||
|
}
|
||||||
|
const idle = (why?: string) => {
|
||||||
|
this.release(engine, held);
|
||||||
|
this.dropAnchor(engine, held);
|
||||||
|
if (why) host.hint(world, entity, why);
|
||||||
|
};
|
||||||
|
if (!isHandleEnabled(entity)) return idle();
|
||||||
|
const own = host.rigidAbove(entity.parent);
|
||||||
|
if (!own) return idle(NO_RIGIDBODY);
|
||||||
|
const connected = connectedOf(world, entity);
|
||||||
|
if (connected === 'gone') return idle(GONE);
|
||||||
|
const rigid = connected ? host.rigidAbove(connected) : null;
|
||||||
|
if (rigid?.body === own.body) return idle(SAME_BODY);
|
||||||
|
if (!own.body.isEnabled() || (rigid && !rigid.body.isEnabled())) return idle();
|
||||||
|
|
||||||
|
const settings = jointSettings(entity.props);
|
||||||
|
const ownPose = matrixPose(worldMatrixOf(own.entity));
|
||||||
|
const otherPose = rigid
|
||||||
|
? matrixPose(worldMatrixOf(rigid.entity))
|
||||||
|
: connected
|
||||||
|
? matrixPose(worldMatrixOf(connected))
|
||||||
|
: ORIGIN;
|
||||||
|
const other = rigid ? rigid.body : this.anchorAt(engine, held, otherPose);
|
||||||
|
if (rigid) this.dropAnchor(engine, held);
|
||||||
|
|
||||||
|
const m = worldMatrixOf(entity);
|
||||||
|
const at = { x: m.tx, y: m.ty };
|
||||||
|
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.
|
||||||
|
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,
|
||||||
|
settings.length,
|
||||||
|
settings.stiffness,
|
||||||
|
settings.damping,
|
||||||
|
settings.limits,
|
||||||
|
]);
|
||||||
|
if (!held.joint || key !== held.key) {
|
||||||
|
this.release(engine, held);
|
||||||
|
const between = `${settings.type}|${own.body.handle}|${other.handle}`;
|
||||||
|
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);
|
||||||
|
// 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.
|
||||||
|
if (settings.limits)
|
||||||
|
(held.joint as RevoluteImpulseJoint).setLimits(...hingeLimits(settings.limits, held.placed.angle));
|
||||||
|
held.joint.setContactsEnabled(settings.collideConnected);
|
||||||
|
held.key = key;
|
||||||
|
held.collide = settings.collideConnected;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (held.collide !== settings.collideConnected) {
|
||||||
|
held.collide = settings.collideConnected;
|
||||||
|
held.joint.setContactsEnabled(held.collide);
|
||||||
|
own.body.wakeUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dataOf(
|
||||||
|
settings: ReturnType<typeof jointSettings>,
|
||||||
|
placed: NonNullable<Held['placed']>,
|
||||||
|
onOther: Vec2,
|
||||||
|
onOwn: Vec2,
|
||||||
|
other: RigidBody,
|
||||||
|
own: RigidBody,
|
||||||
|
): JointData {
|
||||||
|
const { JointData } = this.rapier;
|
||||||
|
switch (settings.type) {
|
||||||
|
case 'hinge':
|
||||||
|
return JointData.revolute(onOther, onOwn);
|
||||||
|
case 'spring':
|
||||||
|
return JointData.spring(
|
||||||
|
toMetres(jointLength(settings, placed.length)),
|
||||||
|
settings.stiffness,
|
||||||
|
settings.damping,
|
||||||
|
onOther,
|
||||||
|
onOwn,
|
||||||
|
);
|
||||||
|
case 'rope':
|
||||||
|
return JointData.rope(toMetres(jointLength(settings, placed.length)), onOther, onOwn);
|
||||||
|
case 'fixed':
|
||||||
|
// The frames differ by how the two are turned against each other right now, which is kept.
|
||||||
|
return JointData.fixed(onOther, 0, onOwn, other.rotation() - own.rotation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The joint's own body for an end without a rigidbody, put where that end is. It is moved the way
|
||||||
|
* a kinematic body is, so what hangs on it is dragged along rather than snapped.
|
||||||
|
*/
|
||||||
|
private anchorAt(engine: RapierWorld, held: Held, pose: Pose): RigidBody {
|
||||||
|
const t = { x: toMetres(pose.x), y: toMetres(pose.y) };
|
||||||
|
if (!held.anchor) {
|
||||||
|
const desc = this.rapier.RigidBodyDesc.kinematicPositionBased();
|
||||||
|
held.anchor = engine.createRigidBody(desc.setTranslation(t.x, t.y).setRotation(pose.angle));
|
||||||
|
held.anchorPose = pose;
|
||||||
|
return held.anchor;
|
||||||
|
}
|
||||||
|
if (!held.anchorPose || !samePose(pose, held.anchorPose)) {
|
||||||
|
held.anchor.setNextKinematicTranslation(t);
|
||||||
|
held.anchor.setNextKinematicRotation(pose.angle);
|
||||||
|
held.anchorPose = pose;
|
||||||
|
}
|
||||||
|
return held.anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(engine: RapierWorld, held: Held): void {
|
||||||
|
// A body that left the engine took its joints along: such a joint is only forgotten.
|
||||||
|
if (held.joint?.isValid()) engine.removeImpulseJoint(held.joint, true);
|
||||||
|
held.joint = null;
|
||||||
|
held.key = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropAnchor(engine: RapierWorld, held: Held): void {
|
||||||
|
if (!held.anchor) return;
|
||||||
|
// Its joint goes with it, so that is let go of first.
|
||||||
|
this.release(engine, held);
|
||||||
|
engine.removeRigidBody(held.anchor);
|
||||||
|
held.anchor = null;
|
||||||
|
held.anchorPose = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import type { EntityHandle, NodeContext, PhysicsControl, SharedState } from '../
|
|||||||
import { keyName } from '../../core/keys';
|
import { keyName } from '../../core/keys';
|
||||||
import { collisionFilterOf } from '../../core/model/collisionLayers';
|
import { collisionFilterOf } from '../../core/model/collisionLayers';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
import { JOINT_CONNECTED_KEY } from '../../core/model/joint';
|
||||||
|
import { objectReference } from '../../core/model/objectRefs';
|
||||||
|
import { findAsset, findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||||
import { soundFileOf } from '../../core/model/sound';
|
import { soundFileOf } from '../../core/model/sound';
|
||||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||||
@@ -153,6 +155,15 @@ export function behaviorContext(
|
|||||||
if (assetId === '' || file) run.audio.stop(file);
|
if (assetId === '' || file) run.audio.stop(file);
|
||||||
},
|
},
|
||||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||||
|
addJoint: (assetId, owner, connected, at) => {
|
||||||
|
const on = resolve(owner);
|
||||||
|
if (!on || findAsset(project, assetId)?.type !== 'joint') return null;
|
||||||
|
const joint = world.spawn(assetId, { ...at, z: 0 }, on);
|
||||||
|
const to = resolve(connected);
|
||||||
|
// By id, as a pick in the inspector stores it: the scene view of the run then draws the link too.
|
||||||
|
if (joint && to) joint.props[JOINT_CONNECTED_KEY] = objectReference(to.objectId);
|
||||||
|
return joint;
|
||||||
|
},
|
||||||
destroy: (target, keepChildren) => {
|
destroy: (target, keepChildren) => {
|
||||||
const found = resolve(target);
|
const found = resolve(target);
|
||||||
if (!found) return [];
|
if (!found) return [];
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 type { DrawItem } from './items';
|
||||||
|
|
||||||
|
/** What the scene view draws of a joint (#154): from where it sits to what it holds on to. */
|
||||||
|
export interface JointLink {
|
||||||
|
id: string;
|
||||||
|
/** Where the joint sits – the point of its own object it holds. */
|
||||||
|
from: Vec2;
|
||||||
|
/**
|
||||||
|
* Where the line ends: a spring's or a rope's connected anchor, for a hinge and a fixed joint the
|
||||||
|
* connected object itself. Equal to `from` when such a joint is held to the scene.
|
||||||
|
*/
|
||||||
|
to: Vec2;
|
||||||
|
/** The object it is connected to as the view shows it; '' for the scene, or for one that is gone. */
|
||||||
|
connectedId: string;
|
||||||
|
/** Whether `to` is a point of its own, which can be dragged. */
|
||||||
|
anchored: boolean;
|
||||||
|
/** The frame the connected anchor is counted in; null for the scene's. */
|
||||||
|
frame: Mat | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The item a joint's reference names, looked up from where the joint sits (see `objectRefs.ts`). */
|
||||||
|
function connectedItem(item: DrawItem, byId: ReadonlyMap<string, DrawItem>): DrawItem | null {
|
||||||
|
const ref = referencedId(item.props[JOINT_CONNECTED_KEY]);
|
||||||
|
for (const id of referenceCandidates(item.id, ref)) {
|
||||||
|
const found = byId.get(id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jointLinkOf(item: DrawItem, byId: ReadonlyMap<string, DrawItem>): JointLink | null {
|
||||||
|
if (item.type !== 'joint') return null;
|
||||||
|
const settings = jointSettings(item.props);
|
||||||
|
const connected = connectedItem(item, byId);
|
||||||
|
const from = { x: item.x, y: item.y };
|
||||||
|
const anchored = hasConnectedAnchor(settings.type);
|
||||||
|
const frame = connected?.m ?? null;
|
||||||
|
const to = anchored
|
||||||
|
? jointFarEnd(settings, from, frame)
|
||||||
|
: connected
|
||||||
|
? { x: connected.x, y: connected.y }
|
||||||
|
: from;
|
||||||
|
return { id: item.id, from, to, connectedId: connected?.id ?? '', anchored, frame };
|
||||||
|
}
|
||||||
@@ -9,16 +9,19 @@ import behavior from '../editor/panels/icons/behavior.svg?raw';
|
|||||||
import camera from '../editor/panels/icons/camera.svg?raw';
|
import camera from '../editor/panels/icons/camera.svg?raw';
|
||||||
import collider from '../editor/panels/icons/collider.svg?raw';
|
import collider from '../editor/panels/icons/collider.svg?raw';
|
||||||
import container from '../editor/panels/icons/container.svg?raw';
|
import container from '../editor/panels/icons/container.svg?raw';
|
||||||
|
import joint from '../editor/panels/icons/joint.svg?raw';
|
||||||
import particles from '../editor/panels/icons/particles.svg?raw';
|
import particles from '../editor/panels/icons/particles.svg?raw';
|
||||||
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
|
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
|
||||||
|
|
||||||
export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody';
|
export type SymbolIcon =
|
||||||
|
'behavior' | 'camera' | 'collider' | 'container' | 'joint' | 'particles' | 'rigidbody';
|
||||||
|
|
||||||
export const SYMBOL_SVG: Record<SymbolIcon, string> = {
|
export const SYMBOL_SVG: Record<SymbolIcon, string> = {
|
||||||
behavior,
|
behavior,
|
||||||
camera,
|
camera,
|
||||||
collider,
|
collider,
|
||||||
container,
|
container,
|
||||||
|
joint,
|
||||||
particles,
|
particles,
|
||||||
rigidbody,
|
rigidbody,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export function drawItem(
|
|||||||
case 'behavior':
|
case 'behavior':
|
||||||
case 'container':
|
case 'container':
|
||||||
case 'rigidbody':
|
case 'rigidbody':
|
||||||
|
case 'joint':
|
||||||
return;
|
return;
|
||||||
// Only the dashed outline can be grabbed, so a click inside reaches the content it cuts (#260).
|
// Only the dashed outline can be grabbed, so a click inside reaches the content it cuts (#260).
|
||||||
case 'clip': {
|
case 'clip': {
|
||||||
|
|||||||
@@ -0,0 +1,706 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import {
|
||||||
|
AddColliderCommand,
|
||||||
|
AddJointCommand,
|
||||||
|
AddRigidbodyCommand,
|
||||||
|
} from '../../src/core/commands/colliderCommands';
|
||||||
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||||
|
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
|
||||||
|
import {
|
||||||
|
MoveSceneObjectCommand,
|
||||||
|
PlaceAssetCommand,
|
||||||
|
RemoveSceneObjectsCommand,
|
||||||
|
RenameSceneObjectCommand,
|
||||||
|
SetOverrideCommand,
|
||||||
|
} from '../../src/core/commands/sceneObjectCommands';
|
||||||
|
import { copyObjects } from '../../src/core/model/clipboard';
|
||||||
|
import { worldPositionOf } from '../../src/core/graph';
|
||||||
|
import { objectReference } from '../../src/core/model/objectRefs';
|
||||||
|
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||||
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
|
import { hingeLimits, jointFarEnd, jointLength, jointSettings } from '../../src/core/properties/joint';
|
||||||
|
import { translation } from '../../src/core/model/matrix';
|
||||||
|
import { defaultsFor, isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||||
|
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||||
|
import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem';
|
||||||
|
import {
|
||||||
|
fakeContext,
|
||||||
|
fakeEntity,
|
||||||
|
graphEditor,
|
||||||
|
makeBehavior,
|
||||||
|
makeSpriteScene,
|
||||||
|
place,
|
||||||
|
roundTrip,
|
||||||
|
runDef,
|
||||||
|
type SceneFixture,
|
||||||
|
startWorld,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await loadPhysics();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Values = Record<string, PropertyValue>;
|
||||||
|
|
||||||
|
interface Thing {
|
||||||
|
w: number;
|
||||||
|
h?: number;
|
||||||
|
/** With a rigidbody, and these overrides on it; without the key the object has none. */
|
||||||
|
body?: Values;
|
||||||
|
/** Without a collider (a bare anchor). */
|
||||||
|
bare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scene of boxes, each maybe with a rigidbody, and joints between them. */
|
||||||
|
function jointScene() {
|
||||||
|
const f = makeSpriteScene('Box');
|
||||||
|
const set = (objectId: string, key: string, value: PropertyValue) =>
|
||||||
|
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, key, value));
|
||||||
|
const add = (x: number, y: number, thing: Thing): string => {
|
||||||
|
const id = place(f, f.spriteId, { x, y });
|
||||||
|
set(id, 'width', thing.w);
|
||||||
|
set(id, 'height', thing.h ?? thing.w);
|
||||||
|
if (!thing.bare) f.stack.execute(new AddColliderCommand(f.sceneId, id));
|
||||||
|
if (thing.body) {
|
||||||
|
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
|
||||||
|
f.stack.execute(rigidbody);
|
||||||
|
for (const [key, value] of Object.entries(thing.body)) set(rigidbody.objectId, key, value);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
/** A joint on `owner` at (x, y) of its own, connected to `to` (none: the scene). */
|
||||||
|
const joint = (owner: string, at: { x: number; y: number }, to: string | null, values: Values): string => {
|
||||||
|
const command = new AddJointCommand(f.sceneId, owner);
|
||||||
|
f.stack.execute(command);
|
||||||
|
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, command.objectId, { ...at, z: 0 }));
|
||||||
|
if (to) set(command.objectId, 'connected', objectReference(to));
|
||||||
|
for (const [key, value] of Object.entries(values)) set(command.objectId, key, value);
|
||||||
|
return command.objectId;
|
||||||
|
};
|
||||||
|
const gravity = (x: number, y: number) => {
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityX', x));
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(f.sceneId, 'gravityY', y));
|
||||||
|
};
|
||||||
|
return { ...f, add, joint, set, gravity };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(f: SceneFixture) {
|
||||||
|
const started = startWorld(f, { physics: true, collisions: true, behaviors: true });
|
||||||
|
const physics = started.collisions as PhysicsSystem;
|
||||||
|
const seconds = (s: number, each?: (time: number) => void) => {
|
||||||
|
for (let i = 0; i < Math.round(s * 60); i++) {
|
||||||
|
started.frame(1 / 60);
|
||||||
|
each?.((i + 1) / 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const entity = (id: string) => started.world.findByObjectId(id)!;
|
||||||
|
const at = (id: string) => worldPositionOf(entity(id));
|
||||||
|
return { ...started, physics, seconds, entity, at };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DYNAMIC = {};
|
||||||
|
const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
describe('AddJointCommand', () => {
|
||||||
|
it('puts a joint below the object, as many as wanted, and undo takes it away', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const command = new AddJointCommand(f.sceneId, box);
|
||||||
|
f.stack.execute(command);
|
||||||
|
const asset = findAsset(f.project, command.assetId)!;
|
||||||
|
expect(asset).toMatchObject({ type: 'joint', name: 'Box joint' });
|
||||||
|
expect(asset.props).toMatchObject({ jointType: 'hinge', connected: { $object: '' }, length: 0 });
|
||||||
|
expect(f.object(command.objectId).parentId).toBe(box);
|
||||||
|
const second = new AddJointCommand(f.sceneId, box);
|
||||||
|
f.stack.execute(second);
|
||||||
|
expect(f.object(second.objectId).parentId).toBe(box);
|
||||||
|
expect(() => f.stack.execute(new AddJointCommand(f.sceneId, command.objectId))).toThrow(
|
||||||
|
/joint cannot have a joint/,
|
||||||
|
);
|
||||||
|
expect(() => f.stack.execute(new AddRigidbodyCommand(f.sceneId, command.objectId))).toThrow(
|
||||||
|
/joint cannot have a rigidbody/,
|
||||||
|
);
|
||||||
|
f.stack.undo();
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.project.assets.some((a) => a.id === command.assetId)).toBe(false);
|
||||||
|
expect(f.sceneOf().objects.some((o) => o.id === command.objectId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places an existing joint asset again instead of making a new one', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const a = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const b = f.add(100, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const first = new AddJointCommand(f.sceneId, a);
|
||||||
|
f.stack.execute(first);
|
||||||
|
const again = new AddJointCommand(f.sceneId, b, first.assetId);
|
||||||
|
f.stack.execute(again);
|
||||||
|
expect(f.object(again.objectId).assetId).toBe(first.assetId);
|
||||||
|
expect(f.project.assets.filter((asset) => asset.type === 'joint')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps what a joint is connected to and how through a round trip', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const a = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const b = f.add(100, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(a, { x: 5, y: -7 }, b, { jointType: 'spring', length: 80, stiffness: 12 });
|
||||||
|
const loaded = roundTrip(f.project);
|
||||||
|
const scene = loaded.assets.find((asset) => asset.id === f.sceneId)!;
|
||||||
|
const stored = (scene.type === 'scene' ? scene.objects : []).find((o) => o.id === joint)!;
|
||||||
|
expect(stored.position).toMatchObject({ x: 5, y: -7 });
|
||||||
|
expect(stored.overrides).toMatchObject({
|
||||||
|
jointType: 'spring',
|
||||||
|
length: 80,
|
||||||
|
stiffness: 12,
|
||||||
|
connected: { $object: b },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what a joint reads off its rows', () => {
|
||||||
|
it('takes the defaults, and only a hinge with the switch on has limits', () => {
|
||||||
|
const defaults = jointSettings(defaultsFor('joint'));
|
||||||
|
expect(defaults).toMatchObject({ type: 'hinge', length: 0, limits: null, collideConnected: false });
|
||||||
|
expect(
|
||||||
|
jointSettings({ jointType: 'hinge', limitAngle: true, minAngle: -20, maxAngle: 35 }).limits,
|
||||||
|
).toEqual({
|
||||||
|
min: -20,
|
||||||
|
max: 35,
|
||||||
|
});
|
||||||
|
expect(jointSettings({ jointType: 'rope', limitAngle: true }).limits).toBeNull();
|
||||||
|
expect(jointSettings({ jointType: 'nonsense' }).type).toBe('hinge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the limits on their own side of how the hinge was placed', () => {
|
||||||
|
expect(jointSettings({ limitAngle: true, minAngle: 10, maxAngle: -10 }).limits).toEqual({
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
});
|
||||||
|
const [min, max] = hingeLimits({ min: -90, max: 45 }, 0.5);
|
||||||
|
expect(min).toBeCloseTo(0.5 - Math.PI / 2);
|
||||||
|
expect(max).toBeCloseTo(0.5 + Math.PI / 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends a spring and a rope at their connected anchor, a hinge where it sits', () => {
|
||||||
|
const rope = jointSettings({ jointType: 'rope', connectedAnchorX: 10, connectedAnchorY: 20 });
|
||||||
|
expect(jointFarEnd(rope, { x: 1, y: 2 }, null)).toEqual({ x: 10, y: 20 });
|
||||||
|
expect(jointFarEnd(rope, { x: 1, y: 2 }, translation(100, 50))).toEqual({ x: 110, y: 70 });
|
||||||
|
const hinge = jointSettings({ jointType: 'hinge', connectedAnchorX: 10, connectedAnchorY: 20 });
|
||||||
|
expect(jointFarEnd(hinge, { x: 1, y: 2 }, translation(100, 50))).toEqual({ x: 1, y: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the placed distance for a length of 0', () => {
|
||||||
|
expect(jointLength(jointSettings({ jointType: 'rope', length: 0 }), 73)).toBe(73);
|
||||||
|
expect(jointLength(jointSettings({ jointType: 'rope', length: 40 }), 73)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only the rows of the chosen type', () => {
|
||||||
|
const shown = (values: Values) =>
|
||||||
|
schemaFor('joint')
|
||||||
|
.filter((def) => isRowShown(def, { ...defaultsFor('joint'), ...values }))
|
||||||
|
.map((def) => def.key);
|
||||||
|
expect(shown({ jointType: 'fixed' })).not.toContain('length');
|
||||||
|
expect(shown({ jointType: 'hinge' })).toEqual(expect.arrayContaining(['limitAngle']));
|
||||||
|
expect(shown({ jointType: 'hinge' })).not.toContain('minAngle');
|
||||||
|
expect(shown({ jointType: 'hinge', limitAngle: true })).toEqual(
|
||||||
|
expect.arrayContaining(['minAngle', 'maxAngle']),
|
||||||
|
);
|
||||||
|
expect(shown({ jointType: 'rope', limitAngle: true })).not.toContain('minAngle');
|
||||||
|
expect(shown({ jointType: 'rope' })).toEqual(
|
||||||
|
expect.arrayContaining(['connectedAnchorX', 'connectedAnchorY', 'length']),
|
||||||
|
);
|
||||||
|
expect(shown({ jointType: 'rope' })).not.toContain('stiffness');
|
||||||
|
expect(shown({ jointType: 'spring' })).toEqual(expect.arrayContaining(['stiffness', 'damping']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a rope', () => {
|
||||||
|
it('lets the object fall as far as its length and no further', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const rope = f.joint(box, { x: 0, y: 0 }, null, {
|
||||||
|
jointType: 'rope',
|
||||||
|
connectedAnchorX: 0,
|
||||||
|
connectedAnchorY: -100,
|
||||||
|
length: 150,
|
||||||
|
});
|
||||||
|
const r = run(f);
|
||||||
|
let furthest = 0;
|
||||||
|
r.seconds(3, () => (furthest = Math.max(furthest, distance(r.at(box), { x: 0, y: -100 }))));
|
||||||
|
expect(r.physics.holdsJoint(r.entity(rope))).toBe(true);
|
||||||
|
expect(furthest).toBeLessThan(152);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(45);
|
||||||
|
expect(r.at(box).y).toBeLessThan(52);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a length of 0 is as long as it was placed', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(60, -20, { w: 40, body: DYNAMIC });
|
||||||
|
f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', connectedAnchorX: 0, connectedAnchorY: -100 });
|
||||||
|
const r = run(f);
|
||||||
|
let furthest = 0;
|
||||||
|
let lowest = -Infinity;
|
||||||
|
r.seconds(2, () => {
|
||||||
|
furthest = Math.max(furthest, distance(r.at(box), { x: 0, y: -100 }));
|
||||||
|
lowest = Math.max(lowest, r.at(box).y);
|
||||||
|
});
|
||||||
|
// Placed 100 away: it swings on that, through the lowest point at y = 0.
|
||||||
|
expect(furthest).toBeLessThan(102);
|
||||||
|
expect(lowest).toBeGreaterThan(-3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds at the point of the object the joint sits on', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: { fixedRotation: true } });
|
||||||
|
// Tied at the box's top edge, 20 above its middle.
|
||||||
|
f.joint(box, { x: 0, y: -20 }, null, {
|
||||||
|
jointType: 'rope',
|
||||||
|
connectedAnchorX: 0,
|
||||||
|
connectedAnchorY: -100,
|
||||||
|
length: 100,
|
||||||
|
});
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(2);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(18);
|
||||||
|
expect(r.at(box).y).toBeLessThan(22);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a spring', () => {
|
||||||
|
it('swings about its rest length with the period its stiffness and the mass give', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
f.gravity(0, 0);
|
||||||
|
// 100 × 100 at density 1 is 1 kg; with 40 N/m the period is 2π·√(1/40) = 0.993 s.
|
||||||
|
const box = f.add(0, 150, { w: 100, body: { fixedRotation: true } });
|
||||||
|
f.joint(box, { x: 0, y: 0 }, null, { jointType: 'spring', length: 100, stiffness: 40, damping: 0 });
|
||||||
|
const r = run(f);
|
||||||
|
const ys: number[] = [];
|
||||||
|
r.seconds(1.05, () => ys.push(r.at(box).y));
|
||||||
|
const half = ys[Math.round(0.5 * 60)]!;
|
||||||
|
const whole = ys[Math.round(1.0 * 60)]!;
|
||||||
|
expect(half).toBeGreaterThan(48);
|
||||||
|
expect(half).toBeLessThan(58);
|
||||||
|
expect(whole).toBeGreaterThan(140);
|
||||||
|
expect(Math.min(...ys)).toBeGreaterThan(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('comes to rest at its length when it is damped', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
f.gravity(0, 0);
|
||||||
|
const box = f.add(0, 150, { w: 100, body: { fixedRotation: true } });
|
||||||
|
f.joint(box, { x: 0, y: 0 }, null, { jointType: 'spring', length: 100, stiffness: 40, damping: 12 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(4);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(99);
|
||||||
|
expect(r.at(box).y).toBeLessThan(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls two bodies towards each other alike', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
f.gravity(0, 0);
|
||||||
|
const a = f.add(0, 0, { w: 40, body: { fixedRotation: true } });
|
||||||
|
const b = f.add(150, 0, { w: 40, body: { fixedRotation: true } });
|
||||||
|
f.joint(a, { x: 0, y: 0 }, b, { jointType: 'spring', length: 50, stiffness: 40, damping: 0 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.2);
|
||||||
|
expect(r.at(a).x).toBeGreaterThan(2);
|
||||||
|
expect(r.at(b).x).toBeLessThan(148);
|
||||||
|
expect(r.at(a).x + r.at(b).x).toBeCloseTo(150, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a hinge', () => {
|
||||||
|
/** A box hung 100 below a pivot at the origin, pulled a little to the side. */
|
||||||
|
function pendulum(values: Values = {}, start = { x: 10, y: 99.5 }) {
|
||||||
|
const f = jointScene();
|
||||||
|
const bob = f.add(start.x, start.y, { w: 20, body: DYNAMIC });
|
||||||
|
const hinge = f.joint(bob, { x: -start.x, y: -start.y }, null, { jointType: 'hinge', ...values });
|
||||||
|
return { f, bob, hinge };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('swings a pendulum of one metre with the period of one', () => {
|
||||||
|
const { f, bob } = pendulum();
|
||||||
|
const r = run(f);
|
||||||
|
// 2π·√(L/g) = 2.007 s at L = 100 units and g = 980; the box's own size adds a third of a percent.
|
||||||
|
let crossed = 0;
|
||||||
|
let furthest = 0;
|
||||||
|
let nearest = Infinity;
|
||||||
|
r.seconds(1.2, (time) => {
|
||||||
|
const at = r.at(bob);
|
||||||
|
if (!crossed && at.x <= 0) crossed = time;
|
||||||
|
furthest = Math.max(furthest, distance(at, { x: 0, y: 0 }));
|
||||||
|
nearest = Math.min(nearest, distance(at, { x: 0, y: 0 }));
|
||||||
|
});
|
||||||
|
expect(crossed).toBeGreaterThan(0.46);
|
||||||
|
expect(crossed).toBeLessThan(0.56);
|
||||||
|
expect(furthest).toBeLessThan(100.6);
|
||||||
|
expect(nearest).toBeGreaterThan(99.4);
|
||||||
|
// Half a period on it is as far out on the other side.
|
||||||
|
expect(r.at(bob).x).toBeLessThan(-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the object with it', () => {
|
||||||
|
const { f, bob } = pendulum({}, { x: 100, y: 0 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at its limit and stays there', () => {
|
||||||
|
const { f, bob } = pendulum({ limitAngle: true, minAngle: -30, maxAngle: 30 }, { x: 100, y: 0 });
|
||||||
|
const r = run(f);
|
||||||
|
let most = 0;
|
||||||
|
r.seconds(3, () => (most = Math.max(most, Number(r.entity(bob).props.rotation))));
|
||||||
|
expect(most).toBeLessThan(33);
|
||||||
|
expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swings past that angle without a limit', () => {
|
||||||
|
const { f, bob } = pendulum({}, { x: 100, y: 0 });
|
||||||
|
const r = run(f);
|
||||||
|
let most = 0;
|
||||||
|
r.seconds(1, () => (most = Math.max(most, Number(r.entity(bob).props.rotation))));
|
||||||
|
expect(most).toBeGreaterThan(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the limit from how the object was placed, turned or not', () => {
|
||||||
|
const { f, bob } = pendulum({ limitAngle: true, minAngle: -30, maxAngle: 30 }, { x: 100, y: 0 });
|
||||||
|
f.set(bob, 'rotation', 45);
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(3);
|
||||||
|
expect(Number(r.entity(bob).props.rotation)).toBeGreaterThan(72);
|
||||||
|
expect(Number(r.entity(bob).props.rotation)).toBeLessThan(78);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a fixed joint', () => {
|
||||||
|
it('holds a body to a static one as it was placed, until the joint is destroyed', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const wall = f.add(0, 0, { w: 40, body: { bodyType: 'static' } });
|
||||||
|
const shelf = f.add(60, 10, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(shelf, { x: 0, y: 0 }, wall, { jointType: 'fixed' });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1);
|
||||||
|
expect(r.at(shelf).x).toBeCloseTo(60, 0);
|
||||||
|
expect(r.at(shelf).y).toBeCloseTo(10, 0);
|
||||||
|
expect(Math.abs(Number(r.entity(shelf).props.rotation ?? 0))).toBeLessThan(1);
|
||||||
|
r.world.remove(r.entity(joint));
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(r.at(shelf).y).toBeGreaterThan(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves two bodies as one', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
f.gravity(0, 0);
|
||||||
|
const a = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const b = f.add(60, 0, { w: 40, body: DYNAMIC });
|
||||||
|
f.joint(a, { x: 0, y: 0 }, b, { jointType: 'fixed' });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.1);
|
||||||
|
r.physics.setVelocity(r.entity(a), { x: 0, y: 100 });
|
||||||
|
r.seconds(1);
|
||||||
|
expect(distance(r.at(a), r.at(b))).toBeGreaterThan(59);
|
||||||
|
expect(distance(r.at(a), r.at(b))).toBeLessThan(61);
|
||||||
|
// The pair turns about its common centre, so the second body is carried along.
|
||||||
|
expect(distance(r.at(b), { x: 60, y: 0 })).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the other end of a joint', () => {
|
||||||
|
it('follows a connected object without a rigidbody, which is pulled by nothing', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(0, -100, { w: 10, bare: true });
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(r.at(hook)).toMatchObject({ x: 0, y: -100 });
|
||||||
|
let furthest = 0;
|
||||||
|
r.seconds(1, () => {
|
||||||
|
r.entity(hook).x += 200 / 60;
|
||||||
|
furthest = Math.max(furthest, distance(r.at(box), r.at(hook)));
|
||||||
|
});
|
||||||
|
r.seconds(1);
|
||||||
|
// The hook moves 3.3 a frame and the box is drawn a step behind, so the rope looks that much longer.
|
||||||
|
expect(furthest).toBeLessThan(108);
|
||||||
|
expect(r.at(box).x).toBeGreaterThan(120);
|
||||||
|
expect(r.at(hook).y).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the connected anchor in that object, wherever the object is turned to', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(200, 0, { w: 10, bare: true });
|
||||||
|
f.set(hook, 'rotation', 90);
|
||||||
|
const box = f.add(200, 150, { w: 40, body: DYNAMIC });
|
||||||
|
// 50 along the hook's own x, which points down: the rope ends at (200, 50).
|
||||||
|
f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', connectedAnchorX: 50, length: 60 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(2);
|
||||||
|
expect(r.at(box).x).toBeCloseTo(200, 0);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(108);
|
||||||
|
expect(r.at(box).y).toBeLessThan(112);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes an object a graph wrote into the row for the one to hold on to', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(300, -100, { w: 10, bare: true });
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 100 });
|
||||||
|
const r = run(f);
|
||||||
|
// What `Properties` does with an object arriving at the port: the object itself lies in the bag.
|
||||||
|
r.entity(joint).props.connected = r.entity(hook) as never;
|
||||||
|
r.seconds(2);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(true);
|
||||||
|
expect(distance(r.at(box), { x: 300, y: -100 })).toBeLessThan(102);
|
||||||
|
expect(r.at(box).x).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the rigidbody of an object further up when its own object has none', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const arm = place(f, f.spriteId, { x: 0, y: -20 }, box);
|
||||||
|
const joint = f.joint(arm, { x: 0, y: 0 }, null, {
|
||||||
|
jointType: 'rope',
|
||||||
|
connectedAnchorY: -120,
|
||||||
|
length: 100,
|
||||||
|
});
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(true);
|
||||||
|
expect(r.at(box).y).toBeLessThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds nothing and says so when the object it sits on has no rigidbody', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40 });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 50 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.2);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(false);
|
||||||
|
expect(r.world.log.filter((line) => /without a rigidbody/.test(line))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets go and says so when its connected object leaves the game', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(0, -100, { w: 10, bare: true });
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(r.at(box).y).toBeLessThan(3);
|
||||||
|
r.world.remove(r.entity(hook));
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(false);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(60);
|
||||||
|
expect(r.world.log.some((line) => /lost its connected object/.test(line))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds nothing when it is connected to the object it sits on', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, box, { jointType: 'fixed' });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.2);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(false);
|
||||||
|
expect(r.world.log.some((line) => /connected to the object it sits on/.test(line))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets go while the joint is switched off and holds again when it is back on', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, null, {
|
||||||
|
jointType: 'rope',
|
||||||
|
connectedAnchorY: -100,
|
||||||
|
length: 100,
|
||||||
|
});
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.3);
|
||||||
|
r.entity(joint).props.enabled = false;
|
||||||
|
r.seconds(0.3);
|
||||||
|
const fallen = r.at(box).y;
|
||||||
|
expect(fallen).toBeGreaterThan(20);
|
||||||
|
r.entity(joint).props.enabled = true;
|
||||||
|
r.seconds(0.1);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a joint when its bodies change', () => {
|
||||||
|
it('lets go while the rigidbody of its object is switched off, and holds again after', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, null, {
|
||||||
|
jointType: 'rope',
|
||||||
|
connectedAnchorY: -100,
|
||||||
|
length: 100,
|
||||||
|
});
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.3);
|
||||||
|
const rigidbody = r.world.childrenOf(r.entity(box)).find((e) => e.type === 'rigidbody')!;
|
||||||
|
rigidbody.props.enabled = false;
|
||||||
|
r.seconds(0.3);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(false);
|
||||||
|
rigidbody.props.enabled = true;
|
||||||
|
r.seconds(1);
|
||||||
|
expect(r.physics.holdsJoint(r.entity(joint))).toBe(true);
|
||||||
|
expect(distance(r.at(box), { x: 0, y: -100 })).toBeLessThan(102);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a turned body at its turn in a fixed joint', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const wall = f.add(0, 0, { w: 40, body: { bodyType: 'static' } });
|
||||||
|
const shelf = f.add(60, 0, { w: 40, body: DYNAMIC });
|
||||||
|
f.set(shelf, 'rotation', 30);
|
||||||
|
f.joint(shelf, { x: 0, y: 0 }, wall, { jointType: 'fixed' });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1);
|
||||||
|
expect(Number(r.entity(shelf).props.rotation)).toBeCloseTo(30, 0);
|
||||||
|
expect(r.at(shelf).y).toBeCloseTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes with its object when that is destroyed and its children are kept', () => {
|
||||||
|
const f = jointScene();
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, null, { jointType: 'rope', length: 100 });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.1);
|
||||||
|
const kept = r.world.removeKeepingChildren(r.entity(box));
|
||||||
|
expect(kept.map((e) => e.type)).toEqual(['collider']);
|
||||||
|
expect(r.world.findByObjectId(joint)).toBeUndefined();
|
||||||
|
r.seconds(0.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('two connected bodies', () => {
|
||||||
|
function stacked(collide: boolean) {
|
||||||
|
const f = jointScene();
|
||||||
|
const floor = f.add(0, 100, { w: 200, h: 20, body: { bodyType: 'static' } });
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
f.joint(box, { x: 0, y: 0 }, floor, { jointType: 'rope', length: 300, collideConnected: collide });
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1.5);
|
||||||
|
return r.at(box).y;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pass through each other unless the joint says they collide', () => {
|
||||||
|
expect(stacked(false)).toBeGreaterThan(150);
|
||||||
|
const resting = stacked(true);
|
||||||
|
expect(resting).toBeGreaterThan(68);
|
||||||
|
expect(resting).toBeLessThan(72);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Add joint node', () => {
|
||||||
|
/** A box with a behavior that ties it to the Hook above it on start, by a rope asset of 100. */
|
||||||
|
function tying(connect: boolean, asset: 'rope' | 'sprite' = 'rope') {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(0, -100, { w: 10, bare: true });
|
||||||
|
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, hook, 'Hook'));
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const rope = new CreateAssetCommand('joint', 'Rope', { jointType: 'rope', length: 100 });
|
||||||
|
f.stack.execute(rope);
|
||||||
|
const behavior = makeBehavior(f, 'Tie');
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const add = edit.addNode('AddJoint', { jointAsset: asset === 'rope' ? rope.asset.id : f.spriteId });
|
||||||
|
const log = edit.addNode('Log');
|
||||||
|
edit.wire([
|
||||||
|
[edit.addNode('OnStart'), 'exec', add, 'exec'],
|
||||||
|
[edit.addNode('Owner'), 'object', add, 'object'],
|
||||||
|
[add, 'exec', log, 'exec'],
|
||||||
|
[edit.addNode('NameOf'), 'name', log, 'message'],
|
||||||
|
]);
|
||||||
|
if (connect) edit.connect(edit.addNode('ObjectByName', { name: 'Hook' }), 'object', add, 'connected');
|
||||||
|
return { f, box, hook, rope: rope.asset.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ties the object to another while the game runs', () => {
|
||||||
|
const { f, box, hook } = tying(true);
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1.5);
|
||||||
|
expect(r.at(box).y).toBeLessThan(3);
|
||||||
|
const joint = r.world.entities.find((e) => e.type === 'joint')!;
|
||||||
|
expect(joint.parent).toBe(r.entity(box));
|
||||||
|
expect(joint.props.connected).toEqual({ $object: hook });
|
||||||
|
expect(r.physics.holdsJoint(joint)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ties it to the scene when nothing is connected', () => {
|
||||||
|
const { f, box } = tying(false);
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(2);
|
||||||
|
// The rope asset ends at the scene's origin, 100 long.
|
||||||
|
expect(distance(r.at(box), { x: 0, y: 0 })).toBeLessThan(102);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places nothing and says so for an asset that is no joint', () => {
|
||||||
|
const { f, box } = tying(true, 'sprite');
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(0.5);
|
||||||
|
expect(r.world.entities.some((e) => e.type === 'joint')).toBe(false);
|
||||||
|
expect(r.at(box).y).toBeGreaterThan(50);
|
||||||
|
expect(r.world.log.some((line) => /is no joint/.test(line))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what is missing when nothing is picked or no object arrives', () => {
|
||||||
|
const ctx = fakeContext();
|
||||||
|
runDef('AddJoint', ctx, { jointAsset: '', object: fakeEntity('box') });
|
||||||
|
runDef('AddJoint', ctx, { jointAsset: 'rope', object: null });
|
||||||
|
expect(ctx.logs).toEqual(['Add joint: no joint selected', 'Add joint: no object to put it on']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a joint and the object it names', () => {
|
||||||
|
/** A hook and a box hung on it by a rope of 100, the hook 100 above the box. */
|
||||||
|
function hung() {
|
||||||
|
const f = jointScene();
|
||||||
|
const hook = f.add(0, -100, { w: 10, bare: true });
|
||||||
|
const box = f.add(0, 0, { w: 40, body: DYNAMIC });
|
||||||
|
const joint = f.joint(box, { x: 0, y: 0 }, hook, { jointType: 'rope', length: 100 });
|
||||||
|
return { f, hook, box, joint };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('holds each placement of a container to its own copy', () => {
|
||||||
|
const { f, hook, box } = hung();
|
||||||
|
const create = new CreateContainerCommand(f.sceneId, [hook, box]);
|
||||||
|
f.stack.execute(create);
|
||||||
|
const first = f.object(create.objectId).position;
|
||||||
|
f.stack.execute(new PlaceAssetCommand(f.sceneId, create.assetId, { x: first.x + 500, y: first.y }));
|
||||||
|
const r = run(f);
|
||||||
|
r.seconds(1.5);
|
||||||
|
const boxes = r.world.entities
|
||||||
|
.filter((e) => e.type === 'rigidbody')
|
||||||
|
.map((e) => worldPositionOf(e.parent!))
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
expect(boxes).toHaveLength(2);
|
||||||
|
// Each hangs under its own hook; held to the other's it would have swung half the way over.
|
||||||
|
for (const at of boxes) expect(Math.abs(at.y - boxes[0]!.y)).toBeLessThan(2);
|
||||||
|
expect(boxes[1]!.x - boxes[0]!.x).toBeGreaterThan(498);
|
||||||
|
expect(boxes[1]!.x - boxes[0]!.x).toBeLessThan(502);
|
||||||
|
expect(r.world.entities.filter((e) => e.type === 'joint').every((e) => r.physics.holdsJoint(e))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is connected to the copy when both are pasted together', () => {
|
||||||
|
const { f, hook, box, joint } = hung();
|
||||||
|
const copied = copyObjects(f.project, f.sceneOf(), [hook, box])!;
|
||||||
|
const paste = new PasteObjectsCommand(f.sceneId, copied, {}, { x: 300, y: 0 });
|
||||||
|
f.stack.execute(paste);
|
||||||
|
const [hookCopy, boxCopy] = paste.rootIds as [string, string];
|
||||||
|
const jointCopy = f
|
||||||
|
.sceneOf()
|
||||||
|
.objects.find((o) => o.parentId === boxCopy && o.assetId === f.object(joint).assetId)!;
|
||||||
|
expect(jointCopy.overrides.connected).toEqual({ $object: hookCopy });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds to the scene once the object it named is removed in the editor', () => {
|
||||||
|
const { f, hook, joint } = hung();
|
||||||
|
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [hook]));
|
||||||
|
expect(f.object(joint).overrides.connected).toEqual({ $object: '' });
|
||||||
|
f.stack.undo();
|
||||||
|
expect(f.object(joint).overrides.connected).toEqual({ $object: hook });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1701,6 +1701,45 @@ const CASES: Record<string, Case[]> = {
|
|||||||
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
|
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
AddJoint: [
|
||||||
|
{
|
||||||
|
name: 'places the picked joint on the object at (x, y), connected to the other',
|
||||||
|
inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: fakeEntity('Hook'), x: 4, y: -6 },
|
||||||
|
context: {
|
||||||
|
addJoint: (assetId, owner, connected, at) =>
|
||||||
|
assetId === 'rope' &&
|
||||||
|
owner.name === 'Box' &&
|
||||||
|
connected?.name === 'Hook' &&
|
||||||
|
at.x === 4 &&
|
||||||
|
at.y === -6
|
||||||
|
? fakeEntity('Rope')
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
next: 'exec',
|
||||||
|
outputs: { joint: fakeEntity('Rope') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hands the scene on as null when nothing is connected',
|
||||||
|
inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: null },
|
||||||
|
context: { addJoint: (_asset, _owner, connected) => (connected === null ? fakeEntity('Rope') : null) },
|
||||||
|
next: 'exec',
|
||||||
|
outputs: { joint: fakeEntity('Rope') },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'without a picked joint places nothing and says so',
|
||||||
|
inputs: { jointAsset: '', object: fakeEntity('Box') },
|
||||||
|
next: 'exec',
|
||||||
|
outputs: { joint: null },
|
||||||
|
after: (ctx) => expect(ctx.logs).toEqual(['Add joint: no joint selected']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'an asset that is no joint places nothing and says so',
|
||||||
|
inputs: { jointAsset: 'crate', object: fakeEntity('Box') },
|
||||||
|
next: 'exec',
|
||||||
|
outputs: { joint: null },
|
||||||
|
after: (ctx) => expect(ctx.logs).toEqual(['Add joint: asset crate is no joint']),
|
||||||
|
},
|
||||||
|
],
|
||||||
Destroy: [
|
Destroy: [
|
||||||
{
|
{
|
||||||
name: 'no object is a no-op that still continues the flow',
|
name: 'no object is a no-op that still continues the flow',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('scene visibility', () => {
|
|||||||
'clip',
|
'clip',
|
||||||
'collider',
|
'collider',
|
||||||
'container',
|
'container',
|
||||||
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
'sprite',
|
'sprite',
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
stopSound: (assetId) => ctx.silenced.push(assetId),
|
stopSound: (assetId) => ctx.silenced.push(assetId),
|
||||||
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
loadScene: (sceneId) => ctx.loaded.push(sceneId),
|
||||||
spawn: () => null,
|
spawn: () => null,
|
||||||
|
addJoint: () => null,
|
||||||
destroy: () => [],
|
destroy: () => [],
|
||||||
stop: () => {
|
stop: () => {
|
||||||
ctx.stopped = true;
|
ctx.stopped = true;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('icon files', () => {
|
|||||||
'camera',
|
'camera',
|
||||||
'collider',
|
'collider',
|
||||||
'container',
|
'container',
|
||||||
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { AddJointCommand } from '../../src/core/commands/colliderCommands';
|
||||||
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||||
|
import {
|
||||||
|
MoveSceneObjectCommand,
|
||||||
|
PlaceAssetCommand,
|
||||||
|
SetOverrideCommand,
|
||||||
|
} from '../../src/core/commands/sceneObjectCommands';
|
||||||
|
import { objectReference } from '../../src/core/model/objectRefs';
|
||||||
|
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||||
|
import { collectHolderItems } from '../../src/viewport/items';
|
||||||
|
import { jointLinkOf } from '../../src/viewport/jointLinks';
|
||||||
|
import { makeSpriteScene, place } from '../helpers';
|
||||||
|
|
||||||
|
/** A box at (0, 0) with a joint 10 to its right, and a hook at (200, -100) turned by a quarter. */
|
||||||
|
function scene(values: Record<string, PropertyValue>, connect = true) {
|
||||||
|
const f = makeSpriteScene('Box');
|
||||||
|
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||||||
|
const hook = place(f, f.spriteId, { x: 200, y: -100 });
|
||||||
|
f.stack.execute(new SetOverrideCommand(f.sceneId, hook, 'rotation', 90));
|
||||||
|
const add = new AddJointCommand(f.sceneId, box);
|
||||||
|
f.stack.execute(add);
|
||||||
|
const joint = add.objectId;
|
||||||
|
f.stack.execute(new MoveSceneObjectCommand(f.sceneId, joint, { x: 10, y: 0, z: 0 }));
|
||||||
|
if (connect) f.stack.execute(new SetOverrideCommand(f.sceneId, joint, 'connected', objectReference(hook)));
|
||||||
|
for (const [key, value] of Object.entries(values))
|
||||||
|
f.stack.execute(new SetOverrideCommand(f.sceneId, joint, key, value));
|
||||||
|
const links = () => {
|
||||||
|
const { items } = collectHolderItems(f.project, f.sceneOf(), 0);
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
return items.flatMap((item) => jointLinkOf(item, byId) ?? []);
|
||||||
|
};
|
||||||
|
return { f, box, hook, joint, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('what the scene view draws of a joint', () => {
|
||||||
|
it('leads a hinge from where it sits to the connected object', () => {
|
||||||
|
const s = scene({ jointType: 'hinge' });
|
||||||
|
expect(s.links()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: s.joint,
|
||||||
|
from: { x: 10, y: 0 },
|
||||||
|
to: { x: 200, y: -100 },
|
||||||
|
connectedId: s.hook,
|
||||||
|
anchored: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads a rope to its connected anchor, counted in the connected object', () => {
|
||||||
|
const s = scene({ jointType: 'rope', connectedAnchorX: 50, connectedAnchorY: 0 });
|
||||||
|
const [link] = s.links();
|
||||||
|
// The hook is turned by a quarter, so 50 along its x is 50 down.
|
||||||
|
expect(link!.to.x).toBeCloseTo(200);
|
||||||
|
expect(link!.to.y).toBeCloseTo(-50);
|
||||||
|
expect(link!.anchored).toBe(true);
|
||||||
|
expect(link!.frame).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the anchor of a rope held to the scene in the scene', () => {
|
||||||
|
const s = scene({ jointType: 'spring', connectedAnchorX: 50, connectedAnchorY: -70 }, false);
|
||||||
|
expect(s.links()[0]).toMatchObject({ to: { x: 50, y: -70 }, connectedId: '', frame: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a hinge held to the scene no line: both ends are where it sits', () => {
|
||||||
|
const s = scene({ jointType: 'hinge' }, false);
|
||||||
|
expect(s.links()[0]).toMatchObject({ from: { x: 10, y: 0 }, to: { x: 10, y: 0 }, connectedId: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no link for anything but a joint', () => {
|
||||||
|
const s = scene({});
|
||||||
|
const { items } = collectHolderItems(s.f.project, s.f.sceneOf(), 0);
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
expect(jointLinkOf(byId.get(s.box)!, byId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds, inside each placement of a container, that placement's own copy", () => {
|
||||||
|
const s = scene({ jointType: 'hinge' });
|
||||||
|
const create = new CreateContainerCommand(s.f.sceneId, [s.box, s.hook]);
|
||||||
|
s.f.stack.execute(create);
|
||||||
|
const first = s.f.object(create.objectId).position;
|
||||||
|
s.f.stack.execute(new PlaceAssetCommand(s.f.sceneId, create.assetId, { x: first.x + 500, y: first.y }));
|
||||||
|
const links = s.links().sort((a, b) => a.from.x - b.from.x);
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
expect(links[1]!.from.x - links[0]!.from.x).toBeCloseTo(500);
|
||||||
|
expect(links[1]!.to.x - links[0]!.to.x).toBeCloseTo(500);
|
||||||
|
expect(links[0]!.connectedId).not.toBe(links[1]!.connectedId);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-6
@@ -1,4 +1,5 @@
|
|||||||
import { colourDistance } from '../lib/harness.mjs';
|
import { colourDistance } from '../lib/harness.mjs';
|
||||||
|
import { inView } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||||||
export async function assets(t) {
|
export async function assets(t) {
|
||||||
@@ -80,8 +81,8 @@ export async function assets(t) {
|
|||||||
check('redo puts it back', (await objects()).length === 8);
|
check('redo puts it back', (await objects()).length === 8);
|
||||||
|
|
||||||
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
||||||
// what can be placed in a scene, a line, then what only lives in the library – and it is
|
// what can be placed in a scene, a line, then what only lives in the library. With the Joint
|
||||||
// tall enough to show every type without scrolling.
|
// (#154) its 25 types no longer fit under the button on a canvas 800 high: there it scrolls.
|
||||||
await clickText('+');
|
await clickText('+');
|
||||||
const rows = await json('__gm.menuRows()');
|
const rows = await json('__gm.menuRows()');
|
||||||
const cut = rows.indexOf(null);
|
const cut = rows.indexOf(null);
|
||||||
@@ -94,7 +95,7 @@ export async function assets(t) {
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'every asset type is offered once, each group sorted by label',
|
'every asset type is offered once, each group sorted by label',
|
||||||
placeable.length + library.length === 24 &&
|
placeable.length + library.length === 25 &&
|
||||||
String(placeable) === String([...placeable].sort()) &&
|
String(placeable) === String([...placeable].sort()) &&
|
||||||
String(library) === String([...library].sort()),
|
String(library) === String([...library].sort()),
|
||||||
JSON.stringify({ placeable, library }),
|
JSON.stringify({ placeable, library }),
|
||||||
@@ -109,12 +110,17 @@ export async function assets(t) {
|
|||||||
);
|
);
|
||||||
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
|
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
|
||||||
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
|
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
|
||||||
|
// What does not fit is scrolled to, never drawn past the box.
|
||||||
await shot('tmp-plus-menu');
|
await shot('tmp-plus-menu');
|
||||||
const menuBox = await json('__gm.overlayRect()');
|
const menuBox = await json('__gm.overlayRect()');
|
||||||
const lastRow = await evaluate(`__gm.locate(${JSON.stringify(library.at(-1))}, true)`);
|
const lastRow = await inView(
|
||||||
|
t,
|
||||||
|
'__gm.overlayRect()',
|
||||||
|
`__gm.locate(${JSON.stringify(library.at(-1))}, true)`,
|
||||||
|
);
|
||||||
const screenH = await json('__gm.app.screen.height');
|
const screenH = await json('__gm.app.screen.height');
|
||||||
check(
|
check(
|
||||||
'the whole menu stands on screen, last entry clickable',
|
'the whole menu stands on screen, last entry reached by scrolling at most',
|
||||||
Boolean(lastRow) &&
|
Boolean(lastRow) &&
|
||||||
lastRow.y < menuBox.y + menuBox.h &&
|
lastRow.y < menuBox.y + menuBox.h &&
|
||||||
menuBox.y + menuBox.h <= screenH &&
|
menuBox.y + menuBox.h <= screenH &&
|
||||||
@@ -449,7 +455,7 @@ export async function assets(t) {
|
|||||||
async function addMenuOnRightClick(t) {
|
async function addMenuOnRightClick(t) {
|
||||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||||
const addRows = (rows) =>
|
const addRows = (rows) =>
|
||||||
Array.isArray(rows) && rows.filter(Boolean).length === 24 && rows.includes('Sprite');
|
Array.isArray(rows) && rows.filter(Boolean).length === 25 && rows.includes('Sprite');
|
||||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||||
const sceneFolded = async () =>
|
const sceneFolded = async () =>
|
||||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||||
|
|||||||
@@ -877,6 +877,7 @@ export async function tilemap(t) {
|
|||||||
'Paste',
|
'Paste',
|
||||||
'Add collider…',
|
'Add collider…',
|
||||||
'Add rigidbody…',
|
'Add rigidbody…',
|
||||||
|
'Add joint…',
|
||||||
'Create container',
|
'Create container',
|
||||||
'Unpack container',
|
'Unpack container',
|
||||||
'Unparent (move to top level)',
|
'Unparent (move to top level)',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export async function physics(t) {
|
|||||||
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
|
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
|
||||||
t;
|
t;
|
||||||
const { has, object, asset, objects, state, settle, sleep, play, stop, shot, mouse, showTab } = t;
|
const { has, object, asset, objects, state, settle, sleep, play, stop, shot, mouse, showTab } = t;
|
||||||
|
const { drag, undo } = t;
|
||||||
|
|
||||||
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
|
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
|
||||||
const status = await json('__gm.physics()');
|
const status = await json('__gm.physics()');
|
||||||
@@ -523,4 +524,148 @@ export async function physics(t) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await clearPhysics();
|
await clearPhysics();
|
||||||
|
|
||||||
|
// 41e. Joints (#154): a bob below a hook gets a joint from the hierarchy menu, is made a rope to
|
||||||
|
// the hook in the inspector, its far end is dragged by its handle in the scene view, and in
|
||||||
|
// the played scene the bob hangs where it would otherwise fall.
|
||||||
|
await mutate(`
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
const assets = __gm.state.project.assets;
|
||||||
|
${MAKE_OBJECT}
|
||||||
|
assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
|
||||||
|
assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } });
|
||||||
|
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||||||
|
const o = make(id, assetId, parentId, y, name);
|
||||||
|
o.position.x = x;
|
||||||
|
o.overrides = overrides;
|
||||||
|
scene.objects.push(o);
|
||||||
|
};
|
||||||
|
put('phys-hook', 'crate', '', 0, ${Y}, 'Physics hook', { width: 20, height: 20 });
|
||||||
|
put('phys-bob', 'crate', '', 0, ${Y + 100}, 'Physics bob', { width: 40, height: 40 });
|
||||||
|
put('phys-bob-col', 'phys-col-box', 'phys-bob', 0, 0);
|
||||||
|
put('phys-bob-rb', 'phys-rb-free', 'phys-bob', 0, 0);
|
||||||
|
put('phys-loose', 'crate', '', 200, ${Y + 100}, 'Physics loose', { width: 40, height: 40 });
|
||||||
|
put('phys-loose-col', 'phys-col-box', 'phys-loose', 0, 0);
|
||||||
|
put('phys-loose-rb', 'phys-rb-free', 'phys-loose', 0, 0);
|
||||||
|
__gm.state.select(null)`);
|
||||||
|
await showTab('scene');
|
||||||
|
await evaluate(`__gm.lookAt(0, ${Y + 50})`);
|
||||||
|
await selectObject('phys-bob');
|
||||||
|
const bobRow = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('phys-bob')`);
|
||||||
|
const countBefore = (await objects()).length;
|
||||||
|
await click(bobRow.x, bobRow.y, 'right');
|
||||||
|
await clickText('Add joint…');
|
||||||
|
if (await has('Create new joint')) await clickText('Create new joint');
|
||||||
|
await press('Escape');
|
||||||
|
await settle();
|
||||||
|
const jointSelection = (await state()).selection;
|
||||||
|
const joint = jointSelection?.kind === 'object' ? await object(jointSelection.objectId) : null;
|
||||||
|
const jointAsset = joint && (await asset(joint.assetId));
|
||||||
|
check(
|
||||||
|
'Add joint puts a hinge below the object and shows its rows',
|
||||||
|
(await objects()).length === countBefore + 1 &&
|
||||||
|
joint?.parentId === 'phys-bob' &&
|
||||||
|
jointAsset?.type === 'joint' &&
|
||||||
|
jointAsset?.props?.jointType === 'hinge' &&
|
||||||
|
(await has('Connected object')) &&
|
||||||
|
(await has('Limit angle')) &&
|
||||||
|
!(await has('Stiffness')) &&
|
||||||
|
(await evaluate(`__gm.layerOf('${joint?.id}')`)) === 'overlay',
|
||||||
|
JSON.stringify({ joint, jointAsset }),
|
||||||
|
);
|
||||||
|
const pressAt = async (at, what) => {
|
||||||
|
if (!at) throw new Error(`41e: ${what} is not on screen`);
|
||||||
|
await click(at.x, at.y);
|
||||||
|
await settle();
|
||||||
|
};
|
||||||
|
await pressAt(await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Type')"), 'the Type field');
|
||||||
|
await pressAt(await json("__gm.locate('Rope', true)"), 'Rope in the menu');
|
||||||
|
await pressAt(
|
||||||
|
await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Connected object')"),
|
||||||
|
'the Connected object field',
|
||||||
|
);
|
||||||
|
// The menu lists every object of the scene: found by typing, as in a scene that has many.
|
||||||
|
await typeText('Physics hook');
|
||||||
|
await settle();
|
||||||
|
await pressAt(await json("__gm.locate('Physics hook', true)"), 'Physics hook in the menu');
|
||||||
|
const jointObject = async () => object(joint.id);
|
||||||
|
const links = async () => json('__gm.joints()');
|
||||||
|
const roped = await links();
|
||||||
|
const ropeLink = roped.links.find((l) => l.id === joint?.id);
|
||||||
|
check(
|
||||||
|
'a rope connected to the hook is drawn from the bob to it, with a handle on its far end',
|
||||||
|
(await jointObject())?.overrides?.jointType === 'rope' &&
|
||||||
|
(await jointObject())?.overrides?.connected?.$object === 'phys-hook' &&
|
||||||
|
ropeLink?.connectedId === 'phys-hook' &&
|
||||||
|
ropeLink.anchored === true &&
|
||||||
|
Math.abs(ropeLink.from.x) < 0.01 &&
|
||||||
|
Math.abs(ropeLink.from.y - (Y + 100)) < 0.01 &&
|
||||||
|
Math.abs(ropeLink.to.x) < 0.01 &&
|
||||||
|
Math.abs(ropeLink.to.y - Y) < 0.01 &&
|
||||||
|
roped.handle?.objectId === joint?.id &&
|
||||||
|
(await has('Connected anchor X')) &&
|
||||||
|
(await has('Length (0 = as placed)')) &&
|
||||||
|
!(await has('Limit angle')),
|
||||||
|
JSON.stringify({ roped, overrides: (await jointObject())?.overrides }),
|
||||||
|
);
|
||||||
|
await shot('41-joint');
|
||||||
|
// The scale of the view, read off the bob's 40 units.
|
||||||
|
const bobRect = await json(`__gm.objectRect('phys-bob')`);
|
||||||
|
const unit = bobRect.w / 40;
|
||||||
|
await drag(roped.handle.x, roped.handle.y, roped.handle.x + 30 * unit, roped.handle.y);
|
||||||
|
await settle();
|
||||||
|
const moved = (await jointObject())?.overrides;
|
||||||
|
const movedLink = (await links()).links.find((l) => l.id === joint?.id);
|
||||||
|
await undo();
|
||||||
|
await settle();
|
||||||
|
const undone = (await jointObject())?.overrides;
|
||||||
|
check(
|
||||||
|
"dragging the handle moves the rope's connected anchor, and one undo puts it back",
|
||||||
|
Math.abs(moved?.connectedAnchorX - 30) <= 1 &&
|
||||||
|
Math.abs(moved?.connectedAnchorY ?? 0) <= 1 &&
|
||||||
|
Math.abs(movedLink?.to.x - moved?.connectedAnchorX) < 0.01 &&
|
||||||
|
undone?.connectedAnchorX === undefined &&
|
||||||
|
undone?.jointType === 'rope',
|
||||||
|
JSON.stringify({ moved, movedLink, undone, unit }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jointHints = consoleLog.filter((line) => line.includes('[physics]')).length;
|
||||||
|
await select(null);
|
||||||
|
await play();
|
||||||
|
await evaluate(`(() => {
|
||||||
|
const out = (window.__jointSamples = []);
|
||||||
|
const start = performance.now();
|
||||||
|
const sample = () => {
|
||||||
|
const w = __gm.world();
|
||||||
|
if (!w) return;
|
||||||
|
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
|
||||||
|
out.push({ t: performance.now() - start, bob: y('phys-bob'), loose: y('phys-loose') });
|
||||||
|
if (performance.now() - start < 1500) requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
})()`);
|
||||||
|
await sleep(1800);
|
||||||
|
const hung = await json('window.__jointSamples');
|
||||||
|
await stop();
|
||||||
|
const bobYs = hung.map((s) => s.bob);
|
||||||
|
check(
|
||||||
|
'in the played scene the bob hangs on its rope while the one without a joint falls',
|
||||||
|
hung.length > 30 &&
|
||||||
|
bobYs.every((y) => Math.abs(y - (Y + 100)) < 2) &&
|
||||||
|
hung.at(-1).loose > Y + 400 &&
|
||||||
|
consoleLog.filter((line) => line.includes('[physics]')).length === jointHints,
|
||||||
|
JSON.stringify({
|
||||||
|
frames: hung.length,
|
||||||
|
bob: [Math.min(...bobYs), Math.max(...bobYs)],
|
||||||
|
loose: hung.at(-1).loose,
|
||||||
|
hints: consoleLog.filter((line) => line.includes('[physics]')).slice(jointHints),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mutate(`
|
||||||
|
const p = __gm.state.project;
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
const joints = new Set(scene.objects.filter((o) => o.parentId === 'phys-bob').map((o) => o.assetId));
|
||||||
|
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && o.parentId !== 'phys-bob');
|
||||||
|
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-') && !(a.type === 'joint' && joints.has(a.id)));
|
||||||
|
__gm.state.select(null)`);
|
||||||
}
|
}
|
||||||
@@ -311,6 +311,7 @@ export async function topbar(t) {
|
|||||||
'clip',
|
'clip',
|
||||||
'collider',
|
'collider',
|
||||||
'container',
|
'container',
|
||||||
|
'joint',
|
||||||
'particles',
|
'particles',
|
||||||
'rigidbody',
|
'rigidbody',
|
||||||
'sprite',
|
'sprite',
|
||||||
|
|||||||
Reference in new issue
Block a user