One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
947a6b1977
commit
6a31f5e0ad
33 files changed
+2064
-125
No files matched your search
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1068 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1073 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
@@ -56,7 +56,7 @@ beside it – so locally take the gate plus the step your change touches and lea
|
||||
| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
|
||||
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
|
||||
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
|
||||
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events and blocking movement |
|
||||
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts |
|
||||
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step |
|
||||
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
|
||||
| [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to |
|
||||
|
||||
@@ -108,6 +108,15 @@ export interface MoveOutcome {
|
||||
normal: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** What a ray met (#153): the object and the collider (or tilemap), where, the face and how far. */
|
||||
export interface RayOutcome {
|
||||
object: EntityHandle;
|
||||
collider: EntityHandle;
|
||||
point: { x: number; y: number };
|
||||
normal: { x: number; y: number };
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the graph does to the world goes through here, so the core stays free of runtime
|
||||
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
|
||||
@@ -147,6 +156,18 @@ export interface NodeContext {
|
||||
* and the rest of the move goes on along the surface, less what friction takes.
|
||||
*/
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
|
||||
/**
|
||||
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
|
||||
* null for none. `ignore` and whatever is parented below it are not met.
|
||||
*/
|
||||
raycast(
|
||||
from: { x: number; y: number },
|
||||
direction: { x: number; y: number },
|
||||
length: number,
|
||||
options: { ignore: EntityHandle | null; triggers: boolean },
|
||||
): RayOutcome | null;
|
||||
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
||||
ignoreOneWay(target: EntityHandle, seconds: number): void;
|
||||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||
physics?: PhysicsControl;
|
||||
log(message: string): void;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asEntity, asNumber } from '../../values';
|
||||
import { exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
export const IgnoreOneWay: NodeDef = {
|
||||
type: 'IgnoreOneWay',
|
||||
label: 'Ignore one-way',
|
||||
category: 'action',
|
||||
description:
|
||||
'Lets an object fall through one-way colliders and one-way tiles for the given seconds – drop down ' +
|
||||
'from a platform. Once they are over, it still passes whatever it is inside of, and lands again on ' +
|
||||
'the next one it comes down on. Works for Move with "Collide with solids" and for dynamic rigidbodies.',
|
||||
inputs: [exec(), obj('object'), withDefault(num('seconds'), 0.25)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.ignoreOneWay(target, asNumber(inputs.seconds));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber } from '../../values';
|
||||
import { bool, exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
const MISSED = {
|
||||
hit: false,
|
||||
object: null,
|
||||
collider: null,
|
||||
pointX: 0,
|
||||
pointY: 0,
|
||||
normalX: 0,
|
||||
normalY: 0,
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
export const Raycast: NodeDef = {
|
||||
type: 'Raycast',
|
||||
label: 'Raycast',
|
||||
category: 'action',
|
||||
description:
|
||||
'Casts a ray from (from x, from y) along (dir x, dir y) for up to length world units and says what ' +
|
||||
'it meets first: the object, its collider (or the tilemap), where, the normal of the face (pointing ' +
|
||||
'out of it) and how far. Solid colliders and tilemap cells are met, triggers only with "Hit ' +
|
||||
'triggers", a one-way one only from the side it blocks from. What the ray starts inside is not met, ' +
|
||||
'and with "Ignore owner" neither is the owner or anything parented below it.',
|
||||
inputs: [
|
||||
exec(),
|
||||
num('fromX', 'from x'),
|
||||
num('fromY', 'from y'),
|
||||
withDefault(num('dirX', 'dir x'), 1),
|
||||
num('dirY', 'dir y'),
|
||||
withDefault(num('length'), 1000),
|
||||
],
|
||||
outputs: [
|
||||
exec(),
|
||||
bool('hit'),
|
||||
obj('object'),
|
||||
obj('collider'),
|
||||
num('pointX', 'point x'),
|
||||
num('pointY', 'point y'),
|
||||
num('normalX', 'normal x'),
|
||||
num('normalY', 'normal y'),
|
||||
num('distance'),
|
||||
],
|
||||
params: [
|
||||
{ key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true },
|
||||
{ key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false },
|
||||
],
|
||||
run: (ctx, inputs, params) => {
|
||||
const hit = ctx.raycast(
|
||||
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
|
||||
{ x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) },
|
||||
asNumber(inputs.length),
|
||||
{
|
||||
ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null,
|
||||
triggers: asBoolean(params.triggers ?? false),
|
||||
},
|
||||
);
|
||||
if (!hit) return { next: 'exec', outputs: MISSED };
|
||||
return {
|
||||
next: 'exec',
|
||||
outputs: {
|
||||
hit: true,
|
||||
object: hit.object,
|
||||
collider: hit.collider,
|
||||
pointX: hit.point.x,
|
||||
pointY: hit.point.y,
|
||||
normalX: hit.normal.x,
|
||||
normalY: hit.normal.y,
|
||||
distance: hit.distance,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -7,8 +7,8 @@ export const OnCollisionEnter: NodeDef = {
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
|
||||
'normal x/y point from the other collider towards the own one and impulse says how hard they met ' +
|
||||
'(both 0 for a trigger).',
|
||||
'normal x/y point from the other collider towards the own one, impulse says how hard they met and ' +
|
||||
'point x/y where (all 0 for a trigger).',
|
||||
inputs: [],
|
||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
||||
params: [],
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { collisionOutputs } from '../shared';
|
||||
import { collisionOutputs, contactOutputs } from '../shared';
|
||||
|
||||
export const OnCollisionStay: NodeDef = {
|
||||
type: 'OnCollisionStay',
|
||||
label: 'On collision stay',
|
||||
category: 'event',
|
||||
description: 'Runs every frame while a collider of the owner overlaps another collider.',
|
||||
description:
|
||||
'Runs while a collider of the owner overlaps another collider: every frame, or with an interval once ' +
|
||||
'each time that long has passed since they met. While playing, normal x/y, impulse and point x/y tell ' +
|
||||
'how they touch right now (all 0 for a trigger and in the editor run).',
|
||||
inputs: [],
|
||||
outputs: collisionOutputs(),
|
||||
params: [],
|
||||
outputs: [...collisionOutputs(), ...contactOutputs()],
|
||||
params: [
|
||||
{
|
||||
key: 'interval',
|
||||
label: 'Interval (s, 0 = every frame)',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animation
|
||||
import { Emit } from './action/Emit';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
import { Log } from './action/Log';
|
||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||
import { Move } from './action/Move';
|
||||
import {
|
||||
ApplyForce,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SetVelocity,
|
||||
} from './action/physicsNodes';
|
||||
import { PlaySound } from './action/PlaySound';
|
||||
import { Raycast } from './action/Raycast';
|
||||
import { RestartGame } from './action/RestartGame';
|
||||
import { SetPosition } from './action/SetPosition';
|
||||
import { Spawn } from './action/Spawn';
|
||||
@@ -218,6 +220,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
SetVelocity,
|
||||
SetAngularVelocity,
|
||||
SetGravity,
|
||||
Raycast,
|
||||
IgnoreOneWay,
|
||||
Spawn,
|
||||
Destroy,
|
||||
Emit,
|
||||
|
||||
@@ -32,13 +32,16 @@ export const collisionOutputs = (): PortDef[] => [
|
||||
];
|
||||
|
||||
/**
|
||||
* What only the start of a contact knows: which way the other side pushes (a unit vector pointing
|
||||
* from it towards the own collider) and how hard they met. The physics engine works both out (#146).
|
||||
* How a contact touches: which way the other side pushes (a unit vector pointing from it towards the
|
||||
* own collider), how hard, and the middle of the contact points in the world. The physics engine
|
||||
* works them out (#146, #149).
|
||||
*/
|
||||
export const contactOutputs = (): PortDef[] => [
|
||||
num('normalX', 'normal x'),
|
||||
num('normalY', 'normal y'),
|
||||
num('impulse'),
|
||||
num('pointX', 'point x'),
|
||||
num('pointY', 'point y'),
|
||||
];
|
||||
|
||||
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||
|
||||
@@ -20,6 +20,8 @@ export const TILE_FPS_KEY = 'fps';
|
||||
export const TILE_PLAYBACK_KEY = 'playback';
|
||||
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
||||
export const TILE_SOLID_KEY = 'solid';
|
||||
/** Whether a solid tile only blocks from above, as a platform one jumps through from below (#155). */
|
||||
export const TILE_ONE_WAY_KEY = 'oneWay';
|
||||
/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
|
||||
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
|
||||
export const TILE_COLLISION_SCALE_MIN = 0.1;
|
||||
@@ -49,6 +51,10 @@ export function isTileSolid(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_SOLID_KEY] === true;
|
||||
}
|
||||
|
||||
export function isTileOneWay(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_ONE_WAY_KEY] === true;
|
||||
}
|
||||
|
||||
/** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */
|
||||
export function tileCollisionScale(asset: TileAsset): number {
|
||||
const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1);
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* autotile's cell is pieced together from four quarters of its block, picked by the neighbours – so
|
||||
* its outlines are cut along the same quarters, and each cell takes the pieces of exactly the
|
||||
* quarters it is drawn from (see `tileShape`).
|
||||
*
|
||||
* One-way tiles (#155) block only from above. They are worked out on their own, by the same rules –
|
||||
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle.
|
||||
*/
|
||||
import type { TileAsset, TilemapAsset } from './Asset';
|
||||
import { tileQuadrants } from './autotile';
|
||||
@@ -25,7 +28,7 @@ import { clipToRect, convexPieces, signedArea } from './polygon';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { findFrame } from './texture';
|
||||
import { isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
|
||||
import { isTileOneWay, isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import type { ColliderShape } from '../properties/collider';
|
||||
|
||||
@@ -169,13 +172,20 @@ function placed(outline: TileOutline, x: number, y: number, size: number): TileO
|
||||
return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) };
|
||||
}
|
||||
|
||||
function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] {
|
||||
function blockingCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay: boolean,
|
||||
): CellBlock[] {
|
||||
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
|
||||
const tileOf = (id: string) => {
|
||||
if (tiles.has(id)) return tiles.get(id)!;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value =
|
||||
tile && isTileSolid(tile) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null;
|
||||
tile && isTileSolid(tile) && isTileOneWay(tile) === oneWay
|
||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||
: null;
|
||||
tiles.set(id, value);
|
||||
return value;
|
||||
};
|
||||
@@ -225,8 +235,13 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset
|
||||
* as any does. An autotile cell built only from quarters that block whole is a box too, at scale 1.
|
||||
* A cell whose only solid tiles block with outlines is left to `solidPolygons`.
|
||||
*/
|
||||
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset)
|
||||
export function solidCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset, oneWay)
|
||||
.filter((cell) => cell.box !== null)
|
||||
.map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||||
}
|
||||
@@ -237,9 +252,14 @@ export function solidCells(project: Project, props: PropertyBag, asset: TilemapA
|
||||
* blocks whole. A tile blocking as a box on another layer of the same cell covers the whole cell
|
||||
* already, so there the outlines are left out.
|
||||
*/
|
||||
export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] {
|
||||
export function solidPolygons(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): CellPolygon[] {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of blockingCells(project, props, asset)) {
|
||||
for (const cell of blockingCells(project, props, asset, oneWay)) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
@@ -346,8 +366,13 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
}
|
||||
|
||||
/** The blocking rectangles of a map, in cells. */
|
||||
export function solidRects(project: Project, props: PropertyBag, asset: TilemapAsset): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset));
|
||||
export function solidRects(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay = false,
|
||||
): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset, oneWay));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TILE_COLLISION_SCALE_MIN,
|
||||
TILE_FPS_KEY,
|
||||
TILE_KIND_KEY,
|
||||
TILE_ONE_WAY_KEY,
|
||||
TILE_PLAYBACK_KEY,
|
||||
TILE_SOLID_KEY,
|
||||
} from '../model/tile';
|
||||
@@ -262,6 +263,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
},
|
||||
// Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132).
|
||||
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
|
||||
// A solid tile that only blocks from above, like a platform one jumps through (#155).
|
||||
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
|
||||
// Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a
|
||||
// blocking neighbour, so a wall of such cells stays closed (#140).
|
||||
{
|
||||
@@ -373,6 +376,20 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
|
||||
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 },
|
||||
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||||
// Blocks only what comes from that side, turned with the object; the rest passes through (#155).
|
||||
{ key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
|
||||
{
|
||||
key: 'oneWayDirection',
|
||||
label: 'Blocks from',
|
||||
kind: 'enum',
|
||||
default: 'up',
|
||||
options: [
|
||||
{ value: 'up', label: 'Above' },
|
||||
{ value: 'down', label: 'Below' },
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
],
|
||||
},
|
||||
],
|
||||
rigidbody: [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
||||
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from '../model/Properties';
|
||||
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
|
||||
import {
|
||||
roundedContact,
|
||||
roundedContains,
|
||||
@@ -103,6 +103,59 @@ export function isTrigger(props: PropertyBag): boolean {
|
||||
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
|
||||
}
|
||||
|
||||
/** The side a one-way collider blocks from, in its own axes (y points down). */
|
||||
const ONE_WAY_SIDES: Record<string, Vec2> = {
|
||||
up: { x: 0, y: -1 },
|
||||
down: { x: 0, y: 1 },
|
||||
left: { x: -1, y: 0 },
|
||||
right: { x: 1, y: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A contact normal at most this far from a one-way collider's side (45°) is a landing on it; any
|
||||
* other contact passes through (#155).
|
||||
*/
|
||||
const ONE_WAY_CONE = Math.cos(Math.PI / 4) - 1e-9;
|
||||
|
||||
/**
|
||||
* The world direction a one-way collider blocks from – its top face for the default "up", turned
|
||||
* and mirrored with it – or null for a collider that blocks from everywhere.
|
||||
*/
|
||||
export function oneWayFacing(props: PropertyBag, m: Mat): Vec2 | null {
|
||||
if (!(props.oneWay ?? DEFAULTS.oneWay)) return null;
|
||||
const side = ONE_WAY_SIDES[textOf(props.oneWayDirection ?? DEFAULTS.oneWayDirection)] ?? ONE_WAY_SIDES.up!;
|
||||
const x = m.a * side.x + m.c * side.y;
|
||||
const y = m.b * side.x + m.d * side.y;
|
||||
const length = Math.hypot(x, y);
|
||||
return length > 0 ? { x: x / length, y: y / length } : null;
|
||||
}
|
||||
|
||||
/** Whether a contact with that normal (towards the mover) lands on the side a one-way shape blocks from. */
|
||||
export function landsOn(normal: Vec2, facing: Vec2): boolean {
|
||||
return dot(normal, facing) >= ONE_WAY_CONE;
|
||||
}
|
||||
|
||||
/** The shape with every point moved by `move` and turned by `turn` more – a body carrying it moved rigidly. */
|
||||
export function movedShape(shape: ColliderShape, move: (p: Vec2) => Vec2, turn: number): ColliderShape {
|
||||
const centre = move(shape);
|
||||
switch (shape.kind) {
|
||||
case 'circle':
|
||||
return { ...shape, ...centre };
|
||||
case 'box':
|
||||
return { ...shape, ...centre, angle: shape.angle + turn };
|
||||
case 'capsule':
|
||||
return { ...shape, ...centre, angle: shape.angle + turn, a: move(shape.a), b: move(shape.b) };
|
||||
case 'polygon':
|
||||
return {
|
||||
...shape,
|
||||
...centre,
|
||||
angle: shape.angle + turn,
|
||||
outline: shape.outline.map(move),
|
||||
pieces: shape.pieces.map((piece) => piece.map(move)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The size in the collider's own units, before any transform. */
|
||||
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
||||
// Centred like every footprint, so it reaches as far as the farthest corner on each axis.
|
||||
@@ -149,7 +202,6 @@ function reach(box: Box, n: Vec2): number {
|
||||
return (box.w / 2) * Math.abs(dot(ux, n)) + (box.h / 2) * Math.abs(dot(uy, n));
|
||||
}
|
||||
|
||||
/** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */
|
||||
/**
|
||||
* How wide the shape is along the unit direction `n` – a turned box measures its own slant. A
|
||||
* polygon measures its thinnest piece: that is what a step along `n` must not jump over.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Where a ray meets a collider shape (#153), in f64 like the rest of the collision, so the editor run
|
||||
* and the played game answer alike and always about where things are now. Every shape is taken as
|
||||
* its rounded convex pieces (see `convex.ts`), and each piece as convex parts a ray is easy to meet:
|
||||
* the core polygon, a circle at each corner and a band along each edge as wide as the radius.
|
||||
*/
|
||||
import type { Vec2 } from '../model/Properties';
|
||||
import { signedArea } from '../model/polygon';
|
||||
import { type ColliderShape, piecesOf } from './collider';
|
||||
import { type Rounded, roundedContact } from './convex';
|
||||
|
||||
export interface RayHit {
|
||||
/** How far along the ray it met the shape (world units). */
|
||||
distance: number;
|
||||
/** The face it met, pointing out of the shape. */
|
||||
normal: Vec2;
|
||||
}
|
||||
|
||||
/** A start this deep inside a shape is inside it; on its outline it is not. */
|
||||
const INSIDE = 1e-9;
|
||||
|
||||
type Part = { kind: 'circle'; c: Vec2; r: number } | { kind: 'polygon'; points: readonly Vec2[] };
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the ray from `from` along the unit direction `dir` first meets `shape` within `length`, or
|
||||
* null. A shape the ray starts inside is not met – so a ray cast from the middle of the thing casting
|
||||
* it finds what is around, not itself; starting on its outline and running in does meet it, at 0.
|
||||
* Grazing a corner counts as meeting it.
|
||||
*/
|
||||
export function rayCast(shape: ColliderShape, from: Vec2, dir: Vec2, length: number): RayHit | null {
|
||||
const pieces = piecesOf(shape);
|
||||
const start: Rounded = { points: [from], r: 0 };
|
||||
if (pieces.some((piece) => roundedContact(start, piece, dir).separation < -INSIDE)) return null;
|
||||
let best: RayHit | null = null;
|
||||
for (const piece of pieces)
|
||||
for (const part of partsOf(piece)) {
|
||||
const hit =
|
||||
part.kind === 'circle'
|
||||
? rayCircle(part, from, dir, length)
|
||||
: rayPolygon(part.points, from, dir, length);
|
||||
if (hit && (!best || hit.distance < best.distance)) best = hit;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The parts whose union is the rounded piece. */
|
||||
function partsOf(piece: Rounded): Part[] {
|
||||
const { points, r } = piece;
|
||||
if (r <= 0) return points.length >= 3 ? [{ kind: 'polygon', points }] : [];
|
||||
const parts: Part[] = points.map((c) => ({ kind: 'circle', c, r }));
|
||||
if (points.length === 2) {
|
||||
const [a, b] = points as [Vec2, Vec2];
|
||||
const n = normalOf(a, b);
|
||||
if (n) {
|
||||
const off = { x: n.x * r, y: n.y * r };
|
||||
parts.push({
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
{ x: a.x + off.x, y: a.y + off.y },
|
||||
{ x: b.x + off.x, y: b.y + off.y },
|
||||
{ x: b.x - off.x, y: b.y - off.y },
|
||||
{ x: a.x - off.x, y: a.y - off.y },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (points.length >= 3) {
|
||||
parts.push({ kind: 'polygon', points });
|
||||
const outward = signedArea(points) > 0 ? 1 : -1;
|
||||
points.forEach((a, i) => {
|
||||
const b = points[(i + 1) % points.length]!;
|
||||
const n = normalOf(a, b);
|
||||
if (!n) return;
|
||||
const off = { x: outward * n.x * r, y: outward * n.y * r };
|
||||
parts.push({
|
||||
kind: 'polygon',
|
||||
points: [a, b, { x: b.x + off.x, y: b.y + off.y }, { x: a.x + off.x, y: a.y + off.y }],
|
||||
});
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The unit normal of ab turned the way a positive signed area turns outward; null for no length. */
|
||||
function normalOf(a: Vec2, b: Vec2): Vec2 | null {
|
||||
const d = { x: b.x - a.x, y: b.y - a.y };
|
||||
const length = Math.hypot(d.x, d.y);
|
||||
return length > 0 ? { x: d.y / length, y: -d.x / length } : null;
|
||||
}
|
||||
|
||||
function rayCircle({ c, r }: { c: Vec2; r: number }, from: Vec2, dir: Vec2, length: number): RayHit | null {
|
||||
const m = { x: from.x - c.x, y: from.y - c.y };
|
||||
const b = dot(m, dir);
|
||||
const k = dot(m, m) - r * r;
|
||||
const disc = b * b - k;
|
||||
if (disc < 0) return null;
|
||||
const t = -b - Math.sqrt(disc);
|
||||
if (t < -INSIDE || t > length) return null;
|
||||
const at = Math.max(0, t);
|
||||
return { distance: at, normal: { x: (m.x + dir.x * at) / r + 0, y: (m.y + dir.y * at) / r + 0 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A convex polygon, whichever way round its corners run: the ray is clipped by every edge's half
|
||||
* plane, and the last edge it comes in through is the one it meets.
|
||||
*/
|
||||
function rayPolygon(points: readonly Vec2[], from: Vec2, dir: Vec2, length: number): RayHit | null {
|
||||
const outward = signedArea(points) > 0 ? 1 : -1;
|
||||
let enter = -Infinity;
|
||||
let leave = Infinity;
|
||||
let normal: Vec2 | null = null;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i]!;
|
||||
const n0 = normalOf(a, points[(i + 1) % points.length]!);
|
||||
if (!n0) continue;
|
||||
const n = { x: outward * n0.x, y: outward * n0.y };
|
||||
// Inside this edge's half plane: dot(n, from + t·dir − a) ≤ 0.
|
||||
const gap = dot(n, { x: a.x - from.x, y: a.y - from.y });
|
||||
const speed = dot(n, dir);
|
||||
if (Math.abs(speed) < 1e-12) {
|
||||
if (gap < 0) return null;
|
||||
continue;
|
||||
}
|
||||
const t = gap / speed;
|
||||
if (speed < 0) {
|
||||
if (t > enter) {
|
||||
enter = t;
|
||||
normal = n;
|
||||
}
|
||||
} else leave = Math.min(leave, t);
|
||||
}
|
||||
if (!normal || enter > leave || enter < -INSIDE || enter > length) return null;
|
||||
return { distance: Math.max(0, enter), normal: { x: normal.x + 0, y: normal.y + 0 } };
|
||||
}
|
||||
@@ -208,7 +208,10 @@ export class Editor implements PlayHost {
|
||||
// The game view is the one place where no key means anything to the editor, so that is where
|
||||
// a behavior may have them – unless a widget in it is focused and wants them itself.
|
||||
focused: () => this.tab === 'game' && ui().focus.focused === null,
|
||||
onWorld: (world) => this.gameView.setEditorWorld(world),
|
||||
onWorld: (world) => {
|
||||
this.gameView.setEditorWorld(world);
|
||||
this.viewport.setEditorWorld(world);
|
||||
},
|
||||
});
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
|
||||
@@ -313,6 +313,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
gizmos: () => viewport.gizmoInfo(),
|
||||
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
||||
polygonCorners: () => viewport.cornerInfo(),
|
||||
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
|
||||
rays: () => viewport.rayInfo(),
|
||||
layerOf: (id: string) => viewport.layerOf(id),
|
||||
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
|
||||
sceneIcon: (id: string) => viewport.iconInfo(id),
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
tilemapRows,
|
||||
tilemapTileSize,
|
||||
} from '../../core/model/tilemap';
|
||||
import type { CellRect } from '../../core/model/tilemapCollision';
|
||||
import type { CellPolygon, CellRect } from '../../core/model/tilemapCollision';
|
||||
import { footprintCorners } from '../../core/properties/bounds';
|
||||
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
|
||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||
@@ -60,8 +60,9 @@ import {
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import type { LayerFocus } from '../../viewport/tilemap';
|
||||
import { dashedLine, drawSourceIcon, placeNode } from '../../viewport/symbols';
|
||||
import { dashedLine, drawSourceIcon, markBlockingSide, placeNode } from '../../viewport/symbols';
|
||||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||||
import { FreeViewport } from './FreeViewport';
|
||||
import type { Frame } from './WorldViewport';
|
||||
|
||||
@@ -161,6 +162,8 @@ export class SceneViewport extends FreeViewport {
|
||||
private readonly icons = new Map<string, Graphics>();
|
||||
/** Where each placed tilemap blocks, outlined like a collider (#132). */
|
||||
private readonly solids = new Map<string, Graphics>();
|
||||
private editorWorld: World | null = null;
|
||||
private lastRays: CastRay[] = [];
|
||||
private dragging: ObjectDrag | null = null;
|
||||
private corner: CornerDrag | null = null;
|
||||
/** The corners drawn for the edited polygon, in global pixels (test hook). */
|
||||
@@ -709,6 +712,31 @@ export class SceneViewport extends FreeViewport {
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
this.drawParentLinks(items, view.scale);
|
||||
this.drawGizmos(items, view);
|
||||
this.drawRays(view);
|
||||
}
|
||||
|
||||
/** The editor run's world, whose rays are drawn over the scene (#153); null when nothing runs. */
|
||||
setEditorWorld(world: World | null): void {
|
||||
this.editorWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** The rays drawn right now, in world units (test hook). */
|
||||
rayInfo(): { from: Vec2; to: Vec2; hit: boolean }[] {
|
||||
return this.lastRays.map(({ from, to, hit }) => ({ from: { ...from }, to: { ...to }, hit }));
|
||||
}
|
||||
|
||||
/** The rays the editor run cast in the last moment: to what they met, or as far as they reached. */
|
||||
private drawRays(view: View): void {
|
||||
const world = this.editorWorld;
|
||||
this.lastRays = world ? world.rays.filter((ray) => ray.time >= world.time - RAY_SHOWN) : [];
|
||||
for (const { from, to, hit } of this.lastRays) {
|
||||
const color = hit ? Theme.colors.collider : Theme.colors.textMuted;
|
||||
const a = worldToScreen(view, from.x, from.y);
|
||||
const b = worldToScreen(view, to.x, to.y);
|
||||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ width: 1.5, color });
|
||||
if (hit) this.gizmos.circle(b.x, b.y, 3).fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void {
|
||||
@@ -727,7 +755,13 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
|
||||
/** The blocking rectangles and outlined cells of a placed tilemap as outlined, in cells (test hook). */
|
||||
solidInfo(id: string): { visible: boolean; rects: CellRect[]; outlines: Vec2[][] } | null {
|
||||
solidInfo(id: string): {
|
||||
visible: boolean;
|
||||
rects: CellRect[];
|
||||
outlines: Vec2[][];
|
||||
oneWay: CellRect[];
|
||||
oneWayOutlines: Vec2[][];
|
||||
} | null {
|
||||
const node = this.solids.get(id);
|
||||
const item = this.lastItems.find((i) => i.id === id);
|
||||
if (!node || !item?.tilemap) return null;
|
||||
@@ -735,6 +769,8 @@ export class SceneViewport extends FreeViewport {
|
||||
visible: node.visible,
|
||||
rects: item.tilemap.solids.map((rect) => ({ ...rect })),
|
||||
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||||
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
|
||||
oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -747,7 +783,8 @@ export class SceneViewport extends FreeViewport {
|
||||
let node = this.solids.get(item.id);
|
||||
const rects = item.tilemap?.solids ?? [];
|
||||
const outlines = item.tilemap?.outlines ?? [];
|
||||
if (!show || rects.length + outlines.length === 0) {
|
||||
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
|
||||
if (!show || rects.length + outlines.length + oneWay.solids.length + oneWay.outlines.length === 0) {
|
||||
if (node) node.visible = false;
|
||||
return;
|
||||
}
|
||||
@@ -762,16 +799,34 @@ export class SceneViewport extends FreeViewport {
|
||||
const tile = tilemapTileSize(item.props);
|
||||
const width = 2 / (scale * scaleOf(item.m));
|
||||
node.clear();
|
||||
for (const rect of rects) {
|
||||
const corner = cellOrigin(item.props, rect.x, rect.y);
|
||||
node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile);
|
||||
}
|
||||
for (const polygon of outlines)
|
||||
node.poly(
|
||||
polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)),
|
||||
true,
|
||||
const color = Theme.colors.collider;
|
||||
const trace = (rects: readonly CellRect[], outlines: readonly CellPolygon[]) => {
|
||||
for (const rect of rects) {
|
||||
const corner = cellOrigin(item.props, rect.x, rect.y);
|
||||
node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile);
|
||||
}
|
||||
for (const polygon of outlines)
|
||||
node.poly(
|
||||
polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)),
|
||||
true,
|
||||
);
|
||||
};
|
||||
trace(rects, outlines);
|
||||
node.fill({ color, alpha: 0.12 }).stroke({ width, color });
|
||||
trace(oneWay.solids, oneWay.outlines);
|
||||
node.fill({ color, alpha: 0.06 }).stroke({ width: width / 2, color });
|
||||
// A platform's top is what blocks, so that is what stands out.
|
||||
for (const rect of oneWay.solids) {
|
||||
const a = cellOrigin(item.props, rect.x, rect.y);
|
||||
const b = cellOrigin(item.props, rect.x + rect.w, rect.y + rect.h);
|
||||
markBlockingSide(
|
||||
node,
|
||||
{ left: a.x, top: a.y, right: b.x, bottom: b.y },
|
||||
{ x: 0, y: -1 },
|
||||
width * 2,
|
||||
color,
|
||||
);
|
||||
node.fill({ color: Theme.colors.collider, alpha: 0.12 }).stroke({ width, color: Theme.colors.collider });
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */
|
||||
|
||||
+37
-1
@@ -1,7 +1,7 @@
|
||||
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
||||
import { newId } from '../core/ids';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||
import { treeOrder } from '../core/model/hierarchy';
|
||||
@@ -14,6 +14,17 @@ import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { Entity } from './Entity';
|
||||
import type { System } from './systems/System';
|
||||
|
||||
/** How long (seconds of run time) a cast ray stays drawn, and how many at most. */
|
||||
export const RAY_SHOWN = 0.25;
|
||||
const MAX_RAYS = 64;
|
||||
|
||||
export interface CastRay {
|
||||
from: Vec2;
|
||||
to: Vec2;
|
||||
hit: boolean;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface WorldOptions {
|
||||
/**
|
||||
* Whether a placed container's content may be written into the scene (see `syncFromModel`).
|
||||
@@ -63,6 +74,10 @@ export class World {
|
||||
};
|
||||
/** Messages logged by behaviors (Log node), newest last. */
|
||||
readonly log: string[] = [];
|
||||
/** The rays cast lately (#153), for the editor run to draw: where from, where to, whether they met anything. */
|
||||
readonly rays: CastRay[] = [];
|
||||
/** Until when (run time) an object falls through one-way colliders and tiles – `Ignore one-way` (#155). */
|
||||
private readonly ignoringOneWay = new Map<Entity, number>();
|
||||
private readonly systems: System[] = [];
|
||||
private elapsed = 0;
|
||||
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
|
||||
@@ -117,6 +132,27 @@ export class World {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers a ray for a moment, forgetting those cast longer ago than `RAY_SHOWN`. */
|
||||
noteRay(from: Vec2, to: Vec2, hit: boolean): void {
|
||||
const since = this.time - RAY_SHOWN;
|
||||
const kept = this.rays.filter((ray) => ray.time >= since);
|
||||
this.rays.splice(0, this.rays.length, ...kept.slice(-(MAX_RAYS - 1)), { from, to, hit, time: this.time });
|
||||
}
|
||||
|
||||
/** Lets the object, and anything below it, fall through one-way colliders for `seconds` from now. */
|
||||
ignoreOneWay(entity: Entity, seconds: number): void {
|
||||
this.ignoringOneWay.set(entity, this.time + Math.max(0, seconds));
|
||||
}
|
||||
|
||||
/** Whether one-way colliders let the entity through right now, asked of it or any object above it. */
|
||||
ignoresOneWay(entity: Entity): boolean {
|
||||
for (let e: Entity | null = entity; e; e = e.parent) {
|
||||
const until = this.ignoringOneWay.get(e);
|
||||
if (until !== undefined && until > this.time) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sets a scene property for the rest of the run; the scene in the model keeps its own. */
|
||||
setSceneProp(key: string, value: PropertyBag[string]): void {
|
||||
this.sceneProps[key] = value;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Collider,
|
||||
ColliderDesc,
|
||||
EventQueue,
|
||||
PhysicsHooks,
|
||||
RigidBody,
|
||||
World as RapierWorld,
|
||||
} from '@dimforge/rapier2d-compat';
|
||||
@@ -10,7 +11,16 @@ import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { signedArea } from '../../core/model/polygon';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { type ColliderShape, isTrigger, shapeArea, shapesOverlap } from '../../core/properties/collider';
|
||||
import {
|
||||
type ColliderShape,
|
||||
contactBetween,
|
||||
isTrigger,
|
||||
landsOn,
|
||||
movedShape,
|
||||
piecesOf,
|
||||
shapeArea,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import { hasTransformProps } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
type BodyType,
|
||||
@@ -27,9 +37,13 @@ import {
|
||||
bodyOf,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
type Contact,
|
||||
type MoveResult,
|
||||
partsOf,
|
||||
type RayFilter,
|
||||
type RayResult,
|
||||
shapeOf,
|
||||
type SolidShape,
|
||||
Solids,
|
||||
} from '../systems/solids';
|
||||
import type { System } from '../systems/System';
|
||||
@@ -41,6 +55,11 @@ export const PHYSICS_STEP = 1 / 60;
|
||||
const MAX_STEPS_PER_TICK = 8;
|
||||
/** A pose that moved less than this (world units, radians) was not moved by anyone. */
|
||||
const POSE_EPSILON = 1e-6;
|
||||
/**
|
||||
* How deep (world units) a body may sit in a one-way collider and still count as on top of it: the
|
||||
* engine lets a landing sink in a little before it pushes it out (1.9 px measured at 60 Hz, #155).
|
||||
*/
|
||||
const ONE_WAY_SLOP = 2;
|
||||
|
||||
interface Pose {
|
||||
x: number;
|
||||
@@ -56,9 +75,10 @@ interface Pose {
|
||||
interface Body {
|
||||
kind: 'rigid' | 'free' | 'tilemap';
|
||||
body: RigidBody;
|
||||
/** The engine's colliders – several for a polygon, one per convex piece – and whose each is. */
|
||||
/** The engine's colliders – several for a polygon, one per convex piece – whose each is, and its shape. */
|
||||
colliders: Collider[];
|
||||
sources: Entity[];
|
||||
solids: SolidShape[];
|
||||
/** What the colliders were built from; they are built again when it changes (a scaled object). */
|
||||
shapeKey: string;
|
||||
settingsKey: string;
|
||||
@@ -73,10 +93,14 @@ interface Body {
|
||||
current: Pose;
|
||||
}
|
||||
|
||||
/** An engine collider: the entity it stands for and its world shape at the last sync. */
|
||||
/**
|
||||
* An engine collider: the entity it stands for, its world shape at the last sync and, for a one-way
|
||||
* one, the side it blocks from (#155).
|
||||
*/
|
||||
interface Part {
|
||||
entity: Entity;
|
||||
shape: ColliderShape;
|
||||
facing: Vec2 | null;
|
||||
owner: Body;
|
||||
}
|
||||
|
||||
@@ -101,6 +125,14 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
/** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */
|
||||
private readonly candidates = new Map<string, [number, number]>();
|
||||
private current = new Map<string, CollisionPair>();
|
||||
/** The engine colliders each current pair was found with, `a`'s first – where its contact is read. */
|
||||
private pairColliders = new Map<string, [number, number]>();
|
||||
/**
|
||||
* Pairs of a dynamic body and a one-way collider near each other, keyed `h1|h2`: whether the body
|
||||
* passes through (#155). Decided before each step, since the engine's hook must not touch the engine.
|
||||
*/
|
||||
private passes = new Map<string, boolean>();
|
||||
private hooks: PhysicsHooks | null = null;
|
||||
private readonly solids = new Solids();
|
||||
private backlog = 0;
|
||||
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
||||
@@ -116,6 +148,12 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.engine = engine;
|
||||
this.queue = new this.rapier.EventQueue(true);
|
||||
this.world = world;
|
||||
const { SolverFlags } = this.rapier;
|
||||
this.hooks = {
|
||||
filterContactPair: (h1, h2) =>
|
||||
this.passes.get(handlePairKey(h1, h2)) ? SolverFlags.EMPTY : SolverFlags.COMPUTE_IMPULSE,
|
||||
filterIntersectionPair: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -128,6 +166,9 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.parts.clear();
|
||||
this.candidates.clear();
|
||||
this.current = new Map();
|
||||
this.pairColliders = new Map();
|
||||
this.passes = new Map();
|
||||
this.hooks = null;
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
@@ -150,7 +191,8 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
const entered = new Set<string>();
|
||||
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) {
|
||||
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
|
||||
engine.step(this.queue!);
|
||||
this.decideOneWay(world);
|
||||
engine.step(this.queue!, this.hooks ?? undefined);
|
||||
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
|
||||
this.collect(world, entered);
|
||||
this.backlog -= PHYSICS_STEP;
|
||||
@@ -177,6 +219,17 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked));
|
||||
}
|
||||
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
return this.solids.raycast(world, from, direction, length, filter);
|
||||
}
|
||||
|
||||
/** How a current pair touches as of the last step: read from the engine's contact. */
|
||||
contactOf(pair: CollisionPair): Contact | null {
|
||||
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
|
||||
if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null;
|
||||
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
|
||||
}
|
||||
|
||||
/** A dynamic body's velocity in world units per second; null for an object the engine does not move. */
|
||||
velocityOf(target: Entity): Vec2 | null {
|
||||
const body = this.dynamicBodyOf(target)?.body;
|
||||
@@ -355,6 +408,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
body: this.engine!.createRigidBody(desc),
|
||||
colliders: [],
|
||||
sources: [],
|
||||
solids: [],
|
||||
shapeKey: '',
|
||||
settingsKey: '',
|
||||
turns: false,
|
||||
@@ -437,12 +491,13 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
private build(body: Body, want: Wanted, key: Entity, world: World): void {
|
||||
const shapes = this.shapesWanted(want, key, world);
|
||||
const origin = body.placed;
|
||||
const local = shapes.map(({ entity, shape }) => ({ entity, shape, pieces: piecesInBody(origin, shape) }));
|
||||
const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
|
||||
const shapeKey =
|
||||
JSON.stringify(
|
||||
local.map(({ entity, pieces }) => [
|
||||
local.map(({ entity, pieces, facing }) => [
|
||||
entity.id,
|
||||
pieces.map(pieceKey),
|
||||
facing && [round(facing.x), round(facing.y)],
|
||||
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
|
||||
]),
|
||||
) + JSON.stringify(want.settings?.mass ?? 0);
|
||||
@@ -454,27 +509,31 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
}
|
||||
body.colliders = [];
|
||||
body.sources = [];
|
||||
body.solids = [];
|
||||
const mass = want.settings?.mass ?? 0;
|
||||
const area = local.reduce(
|
||||
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
|
||||
0,
|
||||
);
|
||||
for (const { entity, shape, pieces } of local)
|
||||
for (const { entity, shape, facing, pieces } of local)
|
||||
for (const piece of pieces) {
|
||||
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0;
|
||||
const desc = this.colliderDesc(entity, piece, share);
|
||||
const desc = this.colliderDesc(entity, piece, share, facing !== null);
|
||||
if (!desc) continue;
|
||||
const collider = this.engine!.createCollider(desc, body.body);
|
||||
body.colliders.push(collider);
|
||||
body.sources.push(entity);
|
||||
this.parts.set(collider.handle, { entity, shape, owner: body });
|
||||
body.solids.push({ shape, facing });
|
||||
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
|
||||
}
|
||||
}
|
||||
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them.
|
||||
const shapeOfEntity = new Map(local.map(({ entity, shape }) => [entity, shape]));
|
||||
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
|
||||
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
|
||||
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
|
||||
body.colliders.forEach((collider, i) => {
|
||||
const entity = body.sources[i]!;
|
||||
this.parts.set(collider.handle, { entity, shape: shapeOfEntity.get(entity)!, owner: body });
|
||||
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
|
||||
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
|
||||
const on = isHandleEnabled(entity);
|
||||
if (collider.isEnabled() === on) return;
|
||||
collider.setEnabled(on);
|
||||
@@ -491,14 +550,16 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
}
|
||||
}
|
||||
|
||||
private shapesWanted(want: Wanted, key: Entity, world: World): { entity: Entity; shape: ColliderShape }[] {
|
||||
private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] {
|
||||
if (want.kind === 'tilemap')
|
||||
return this.solids.shapesOf(world, key).map((shape) => ({ entity: key, shape }));
|
||||
return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) }));
|
||||
return this.solids.solidShapesOf(world, key).map((solid) => ({ ...solid, entity: key }));
|
||||
return want.colliders.flatMap((entity) =>
|
||||
this.solids.solidShapesOf(world, entity).map((solid) => ({ ...solid, entity })),
|
||||
);
|
||||
}
|
||||
|
||||
/** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */
|
||||
private colliderDesc(entity: Entity, piece: Piece, mass: number): ColliderDesc | null {
|
||||
private colliderDesc(entity: Entity, piece: Piece, mass: number, oneWay: boolean): ColliderDesc | null {
|
||||
const R = this.rapier;
|
||||
if (pieceArea(piece) <= 0) return null;
|
||||
let desc: ColliderDesc | null;
|
||||
@@ -531,9 +592,80 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
else desc.setDensity(material.density);
|
||||
desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS);
|
||||
desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
|
||||
if (oneWay && isSolid(entity)) desc.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether each dynamic body near a one-way collider passes through it in the coming step (#155).
|
||||
* It lands when it is on the blocking side (or sunk in no deeper than a landing sinks), the contact
|
||||
* faces that side within 45° and it is not moving away from it; a body that landed is held as long
|
||||
* as the two overlap, however deep a fast fall took it in one step. Otherwise it passes – from
|
||||
* below, from the side, from inside – and so does a body told to ignore one-way colliders. One that
|
||||
* is to pass is woken: a resting body sleeps, and a new answer alone would not move it.
|
||||
*/
|
||||
private decideOneWay(world: World): void {
|
||||
const platforms: [number, Part][] = [];
|
||||
const movers: [number, Part][] = [];
|
||||
for (const [handle, part] of this.parts) {
|
||||
if (!isHandleEnabled(part.entity) || !isSolid(part.entity)) continue;
|
||||
if (part.facing) platforms.push([handle, part]);
|
||||
else if (part.owner.kind === 'rigid' && part.owner.body.isDynamic()) movers.push([handle, part]);
|
||||
}
|
||||
const decided = new Map<string, boolean>();
|
||||
for (const [hp, platform] of platforms) {
|
||||
const surface = this.shapeNow(platform);
|
||||
const facing = this.facingNow(platform);
|
||||
const vp = platform.owner.body.linvel();
|
||||
for (const [hm, mover] of movers) {
|
||||
if (mover.owner === platform.owner) continue;
|
||||
const shape = this.shapeNow(mover);
|
||||
const vm = mover.owner.body.linvel();
|
||||
const relative = { x: toPixels(vm.x - vp.x), y: toPixels(vm.y - vp.y) };
|
||||
const margin = Math.hypot(relative.x, relative.y) * PHYSICS_STEP * 2 + 16;
|
||||
if (Math.hypot(shape.x - surface.x, shape.y - surface.y) > reachOf(shape) + reachOf(surface) + margin)
|
||||
continue;
|
||||
const key = handlePairKey(hp, hm);
|
||||
const contact = contactBetween(shape, surface, relative);
|
||||
const lands =
|
||||
contact.separation >= -ONE_WAY_SLOP &&
|
||||
landsOn(contact.normal, facing) &&
|
||||
relative.x * facing.x + relative.y * facing.y <= 1e-3;
|
||||
const held = this.passes.get(key) === false && contact.separation < 0;
|
||||
const passes = world.ignoresOneWay(mover.entity) || (!lands && !held);
|
||||
if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp();
|
||||
decided.set(key, passes);
|
||||
}
|
||||
}
|
||||
this.passes = decided;
|
||||
}
|
||||
|
||||
/** A part's shape where the engine has its body now: a dynamic body has moved on since the sync. */
|
||||
private shapeNow(part: Part): ColliderShape {
|
||||
const body = part.owner;
|
||||
if (!body.body.isDynamic()) return part.shape;
|
||||
const from = body.placed;
|
||||
const to = poseOf(body.body);
|
||||
const turn = to.angle - from.angle;
|
||||
const cos = Math.cos(turn);
|
||||
const sin = Math.sin(turn);
|
||||
const move = (p: Vec2): Vec2 => {
|
||||
const dx = p.x - from.x;
|
||||
const dy = p.y - from.y;
|
||||
return { x: to.x + dx * cos - dy * sin, y: to.y + dx * sin + dy * cos };
|
||||
};
|
||||
return movedShape(part.shape, move, turn);
|
||||
}
|
||||
|
||||
private facingNow(part: Part): Vec2 {
|
||||
const facing = part.facing!;
|
||||
if (!part.owner.body.isDynamic()) return facing;
|
||||
const turn = part.owner.body.rotation() - part.owner.placed.angle;
|
||||
const cos = Math.cos(turn);
|
||||
const sin = Math.sin(turn);
|
||||
return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pairs in contact after a step, compared with those before it. The engine proposes the
|
||||
* pairs; where no dynamic body takes part, the overlap test decides as it always did.
|
||||
@@ -571,32 +703,58 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
now.set(pairKey, { a: p.entity, b: q.entity });
|
||||
first.set(pairKey, [this.engine!.getCollider(c1), this.engine!.getCollider(c2)]);
|
||||
}
|
||||
this.pairColliders = new Map();
|
||||
for (const [key, pair] of now) {
|
||||
const [c1, c2] = first.get(key)!;
|
||||
this.pairColliders.set(key, [c1.handle, c2.handle]);
|
||||
if (this.current.has(key)) {
|
||||
now.set(key, this.current.get(key)!);
|
||||
continue;
|
||||
}
|
||||
const [c1, c2] = first.get(key)!;
|
||||
this.enters.push(Object.assign(pair, this.contactOf(c1, c2)));
|
||||
this.enters.push(Object.assign(pair, this.measure(c1, c2)));
|
||||
entered.add(key);
|
||||
}
|
||||
for (const [key, pair] of this.current) if (!now.has(key)) this.exits.push(pair);
|
||||
this.current = now;
|
||||
}
|
||||
|
||||
/** How the pair met: the normal from the second collider towards the first, and the impulse. */
|
||||
private contactOf(c1: Collider, c2: Collider): { normal: Vec2; impulse: number } {
|
||||
/**
|
||||
* How the pair touches: the normal from the second collider towards the first, the impulse of the
|
||||
* last step and the middle of the contact points (world units; zero without a contact).
|
||||
*/
|
||||
private measure(c1: Collider, c2: Collider): Contact {
|
||||
let normal = { x: 0, y: 0 };
|
||||
let impulse = 0;
|
||||
let sum = { x: 0, y: 0 };
|
||||
let points = 0;
|
||||
this.engine!.contactPair(c1, c2, (manifold, flipped) => {
|
||||
if (manifold.numContacts() === 0) return;
|
||||
const n = manifold.normal();
|
||||
// The manifold's normal runs from its first collider to its second; ours runs back to `a`.
|
||||
const sign = flipped ? 1 : -1;
|
||||
if (normal.x === 0 && normal.y === 0) normal = { x: sign * n.x, y: sign * n.y };
|
||||
for (let i = 0; i < manifold.numContacts(); i++) impulse += manifold.contactImpulse(i);
|
||||
// Each of its points has a side on either collider, in that collider's space: the contact
|
||||
// point is the middle of the two, which is also where two bodies that sank in meet.
|
||||
const [first, second] = flipped ? [c2, c1] : [c1, c2];
|
||||
const toWorld = (collider: Collider, p: { x: number; y: number }) => {
|
||||
const at = collider.translation();
|
||||
const cos = Math.cos(collider.rotation());
|
||||
const sin = Math.sin(collider.rotation());
|
||||
return { x: at.x + p.x * cos - p.y * sin, y: at.y + p.x * sin + p.y * cos };
|
||||
};
|
||||
for (let i = 0; i < manifold.numContacts(); i++) {
|
||||
impulse += manifold.contactImpulse(i);
|
||||
const p1 = manifold.localContactPoint1(i);
|
||||
const p2 = manifold.localContactPoint2(i);
|
||||
if (!p1 || !p2) continue;
|
||||
const a = toWorld(first, p1);
|
||||
const b = toWorld(second, p2);
|
||||
sum = { x: sum.x + (a.x + b.x) / 2, y: sum.y + (a.y + b.y) / 2 };
|
||||
points++;
|
||||
}
|
||||
});
|
||||
return { normal, impulse: toPixels(impulse) };
|
||||
const point = points > 0 ? { x: toPixels(sum.x / points), y: toPixels(sum.y / points) } : { x: 0, y: 0 };
|
||||
return { normal, impulse: toPixels(impulse), point };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -628,6 +786,18 @@ interface Wanted {
|
||||
colliders: Entity[];
|
||||
}
|
||||
|
||||
function handlePairKey(h1: number, h2: number): string {
|
||||
return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
|
||||
}
|
||||
|
||||
/** How far the shape reaches from its centre. */
|
||||
function reachOf(shape: ColliderShape): number {
|
||||
let reach = 0;
|
||||
for (const { points, r } of piecesOf(shape))
|
||||
for (const p of points) reach = Math.max(reach, Math.hypot(p.x - shape.x, p.y - shape.y) + r);
|
||||
return reach;
|
||||
}
|
||||
|
||||
function poseOf(body: RigidBody): Pose {
|
||||
const t = body.translation();
|
||||
return { x: toPixels(t.x), y: toPixels(t.y), angle: body.rotation() };
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '../../core/graph';
|
||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
|
||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||
import { soundFileOf } from '../../core/model/sound';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
@@ -30,7 +30,7 @@ import type { World } from '../World';
|
||||
import type { Rapier } from '../physics/engine';
|
||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||
import { CollisionSystem } from './CollisionSystem';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, NOT_BLOCKED } from './solids';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids';
|
||||
import { AnimationSystem } from './AnimationSystem';
|
||||
import type { Input } from './Input';
|
||||
import type { System } from './System';
|
||||
@@ -68,6 +68,13 @@ const NO_DYNAMIC_BODY =
|
||||
/** How often state events may cause the next move before the run is called endless. */
|
||||
const MAX_STATE_ROUNDS = 100;
|
||||
|
||||
/** A collision pair seen from one behavior's body: its own collider and the one across. */
|
||||
interface Side {
|
||||
pair: CollisionPair;
|
||||
own: Entity;
|
||||
other: Entity;
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
entity: Entity;
|
||||
interpreter: GraphInterpreter;
|
||||
@@ -387,7 +394,7 @@ export class BehaviorSystem implements System {
|
||||
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
|
||||
}
|
||||
}
|
||||
this.fireCollisions(instance, i, ctx);
|
||||
this.fireCollisions(instance, i, ctx, dt);
|
||||
this.fireAnimationsFinished(instance, i, ctx);
|
||||
// With an owner the click has to land on it; a behavior without one has no shape to
|
||||
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
|
||||
@@ -433,31 +440,65 @@ export class BehaviorSystem implements System {
|
||||
|
||||
/**
|
||||
* Collision events for this behavior: every pair in which one collider belongs to the
|
||||
* behavior's body (its owner, or itself when it has none) fires once, with `other` being
|
||||
* the body on the far side.
|
||||
* behavior's body (its owner, or itself when it has none) fires, with `other` being the body on
|
||||
* the far side – enter and exit once, stay every frame or, with an interval, once each time that
|
||||
* long has passed since the pair met (#149). Each stay node keeps a clock per pair in its node
|
||||
* state, and a pair that is gone takes its clock with it.
|
||||
*/
|
||||
private fireCollisions(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
|
||||
if (!this.collisions) return;
|
||||
private fireCollisions(
|
||||
instance: Instance,
|
||||
interpreter: GraphInterpreter,
|
||||
ctx: NodeContext,
|
||||
dt: number,
|
||||
): void {
|
||||
const collisions = this.collisions;
|
||||
if (!collisions) return;
|
||||
const body = instance.entity.parent ?? instance.entity;
|
||||
const fire = (type: string, pairs: CollisionPair[], contact = false) => {
|
||||
for (const { a, b, normal, impulse } of pairs) {
|
||||
const own = bodyOf(a) === body ? a : bodyOf(b) === body ? b : null;
|
||||
if (!own) continue;
|
||||
const other = own === a ? b : a;
|
||||
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
||||
if (contact) {
|
||||
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
||||
const sign = own === a ? 1 : -1;
|
||||
outputs.normalX = sign * (normal?.x ?? 0) || 0;
|
||||
outputs.normalY = sign * (normal?.y ?? 0) || 0;
|
||||
outputs.impulse = impulse ?? 0;
|
||||
}
|
||||
interpreter.fire(type, ctx, outputs);
|
||||
const mine = (pairs: CollisionPair[]) =>
|
||||
pairs.flatMap((pair) => {
|
||||
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
|
||||
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
|
||||
});
|
||||
const outputsOf = ({ own, other, pair }: Side, contact?: Partial<Contact> | null) => {
|
||||
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
|
||||
if (contact !== undefined) {
|
||||
// The pair's normal points towards `a`; seen from `b` it points the other way.
|
||||
const sign = own === pair.a ? 1 : -1;
|
||||
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
|
||||
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
|
||||
outputs.impulse = contact?.impulse ?? 0;
|
||||
outputs.pointX = contact?.point?.x ?? 0;
|
||||
outputs.pointY = contact?.point?.y ?? 0;
|
||||
}
|
||||
return outputs;
|
||||
};
|
||||
fire('OnCollisionEnter', this.collisions.enters, true);
|
||||
fire('OnCollisionStay', this.collisions.stays);
|
||||
fire('OnCollisionExit', this.collisions.exits);
|
||||
for (const side of mine(collisions.enters))
|
||||
interpreter.fire('OnCollisionEnter', ctx, outputsOf(side, side.pair));
|
||||
const stays = mine(collisions.stays);
|
||||
const contacts = new Map<CollisionPair, Contact | null>();
|
||||
const contactOf = (pair: CollisionPair) => {
|
||||
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
|
||||
return contacts.get(pair)!;
|
||||
};
|
||||
for (const node of interpreter.eventNodes('OnCollisionStay')) {
|
||||
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
|
||||
const clocks = interpreter.nodeState.get(node.id) ?? {};
|
||||
const running: Record<string, number> = {};
|
||||
for (const side of stays) {
|
||||
let fires = 1;
|
||||
if (interval > 0) {
|
||||
const key = `${side.pair.a.id}|${side.pair.b.id}`;
|
||||
let elapsed = (clocks[key] ?? 0) + dt;
|
||||
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
|
||||
running[key] = elapsed;
|
||||
}
|
||||
for (let i = 0; i < fires; i++)
|
||||
interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node);
|
||||
}
|
||||
if (interval > 0) interpreter.nodeState.set(node.id, running);
|
||||
else interpreter.nodeState.delete(node.id);
|
||||
}
|
||||
for (const side of mine(collisions.exits)) interpreter.fire('OnCollisionExit', ctx, outputsOf(side));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,6 +610,18 @@ export class BehaviorSystem implements System {
|
||||
found.y += dy;
|
||||
return NOT_BLOCKED;
|
||||
},
|
||||
raycast: (from, direction, length, { ignore, triggers }) => {
|
||||
const hit = this.collisions?.raycast(world, from, direction, length, {
|
||||
ignore: resolve(ignore) ?? null,
|
||||
triggers,
|
||||
});
|
||||
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
|
||||
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
|
||||
},
|
||||
ignoreOneWay: (target, seconds) => {
|
||||
const found = resolve(target);
|
||||
if (found) world.ignoreOneWay(found, seconds);
|
||||
},
|
||||
physics:
|
||||
this.collisions instanceof PhysicsSystem
|
||||
? physicsControl(world, this.collisions, resolve)
|
||||
@@ -608,6 +661,13 @@ export class BehaviorSystem implements System {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a ray of that length ends; where it has no direction, where it starts. */
|
||||
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
|
||||
const d = Math.hypot(direction.x, direction.y);
|
||||
if (!(d > 0) || !(length > 0)) return from;
|
||||
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
|
||||
}
|
||||
|
||||
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
|
||||
function physicsControl(
|
||||
world: World,
|
||||
|
||||
@@ -2,13 +2,17 @@ import { isHandleEnabled } from '../../core/graph';
|
||||
import { shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import {
|
||||
bodyOf,
|
||||
type Contact,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
isCollisionBody,
|
||||
type MoveResult,
|
||||
partsOf,
|
||||
type RayFilter,
|
||||
type RayResult,
|
||||
Solids,
|
||||
} from './solids';
|
||||
import type { System } from './System';
|
||||
@@ -78,4 +82,13 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy);
|
||||
}
|
||||
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
return this.solids.raycast(world, from, direction, length, filter);
|
||||
}
|
||||
|
||||
/** Nothing is simulated here, so a contact has no normal or impulse to tell. */
|
||||
contactOf(): Contact | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+129
-23
@@ -17,9 +17,14 @@ import {
|
||||
contactBetween,
|
||||
exitDistance,
|
||||
isTrigger,
|
||||
landsOn,
|
||||
movedShape,
|
||||
oneWayFacing,
|
||||
overlapDepth,
|
||||
shapeExtent,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import { rayCast } from '../../core/properties/ray';
|
||||
import { colliderMaterial } from '../../core/properties/rigidbody';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -36,14 +41,15 @@ const SQUARE = 1e-6;
|
||||
/**
|
||||
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
|
||||
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
|
||||
* `normal` points from `b` towards `a` and `impulse` is how hard they met; both are only known
|
||||
* where the physics engine saw the contact begin (#146), and zero elsewhere.
|
||||
* `normal` points from `b` towards `a`, `impulse` is how hard they met and `point` where; they are
|
||||
* only known where the physics engine saw the contact begin (#146), and zero elsewhere.
|
||||
*/
|
||||
export interface CollisionPair {
|
||||
a: Entity;
|
||||
b: Entity;
|
||||
normal?: Vec2;
|
||||
impulse?: number;
|
||||
point?: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,14 +81,36 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({
|
||||
normal: Object.freeze({ x: 0, y: 0 }),
|
||||
});
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle {
|
||||
/**
|
||||
* A shape of a collider or tilemap, and for a one-way one the world direction it blocks from (#155):
|
||||
* only a contact whose normal lies within 45° of it stops anything.
|
||||
*/
|
||||
export interface SolidShape {
|
||||
shape: ColliderShape;
|
||||
facing: Vec2 | null;
|
||||
}
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle extends SolidShape {
|
||||
body: Entity;
|
||||
}
|
||||
|
||||
/** What a ray met (#153): the collider or tilemap, where, the face's normal and how far along the ray. */
|
||||
export interface RayResult {
|
||||
collider: Entity;
|
||||
point: Vec2;
|
||||
normal: Vec2;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked. */
|
||||
export interface RayFilter {
|
||||
ignore: Entity | null;
|
||||
triggers: boolean;
|
||||
}
|
||||
|
||||
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
|
||||
interface Contact {
|
||||
interface SweepContact {
|
||||
by: Entity;
|
||||
own: Entity;
|
||||
normal: Vec2;
|
||||
@@ -96,6 +124,20 @@ export interface CollisionSource {
|
||||
readonly exits: CollisionPair[];
|
||||
touching(world: World, a: Entity, b: Entity): boolean;
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null;
|
||||
/**
|
||||
* How a pair that stays in contact touches right now (#149): the normal from `b` towards `a`, the
|
||||
* impulse of the last physics step and the middle of the contact points; null where nothing
|
||||
* simulates the contact (the editor run).
|
||||
*/
|
||||
contactOf(pair: CollisionPair): Contact | null;
|
||||
}
|
||||
|
||||
/** A contact as the physics engine knows it (see `CollisionSource.contactOf`). */
|
||||
export interface Contact {
|
||||
normal: Vec2;
|
||||
impulse: number;
|
||||
point: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,23 +150,27 @@ export class Solids {
|
||||
* painting, or a behavior changing the map, is picked up by the next one – and turned into world
|
||||
* shapes only when asked, since the map may move in between.
|
||||
*/
|
||||
private blocks = new Map<Entity, { rects: CellRect[]; polygons: CellPolygon[] }>();
|
||||
private blocks = new Map<Entity, TilemapBlocks>();
|
||||
|
||||
/** Forgets the cells, so the next tick works them out afresh. */
|
||||
reset(): void {
|
||||
this.blocks = new Map();
|
||||
}
|
||||
|
||||
private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } {
|
||||
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
|
||||
let blocks = this.blocks.get(tilemap);
|
||||
if (!blocks) {
|
||||
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
|
||||
const { project } = world;
|
||||
const { props } = tilemap;
|
||||
blocks = asset
|
||||
? {
|
||||
rects: solidRects(world.project, tilemap.props, asset),
|
||||
polygons: solidPolygons(world.project, tilemap.props, asset),
|
||||
rects: solidRects(project, props, asset),
|
||||
polygons: solidPolygons(project, props, asset),
|
||||
oneWayRects: solidRects(project, props, asset, true),
|
||||
oneWayPolygons: solidPolygons(project, props, asset, true),
|
||||
}
|
||||
: { rects: [], polygons: [] };
|
||||
: { rects: [], polygons: [], oneWayRects: [], oneWayPolygons: [] };
|
||||
this.blocks.set(tilemap, blocks);
|
||||
}
|
||||
return blocks;
|
||||
@@ -132,17 +178,48 @@ export class Solids {
|
||||
|
||||
/**
|
||||
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
|
||||
* rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is
|
||||
* worked out on the spot.
|
||||
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
|
||||
* top (#155). A tilemap placed before the first tick is worked out on the spot.
|
||||
*/
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
if (body.type !== 'tilemap') return [shapeOf(body)];
|
||||
solidShapesOf(world: World, body: Entity): SolidShape[] {
|
||||
const m = worldMatrixOf(body);
|
||||
const { rects, polygons } = this.blocksOf(world, body);
|
||||
return [
|
||||
...rects.map((rect) => cellRectShape(body.props, rect, m)),
|
||||
...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)),
|
||||
if (body.type !== 'tilemap')
|
||||
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }];
|
||||
const { rects, polygons, oneWayRects, oneWayPolygons } = this.blocksOf(world, body);
|
||||
const up = oneWayFacing({ oneWay: true }, m);
|
||||
const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: Vec2 | null) => [
|
||||
...cells.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
|
||||
...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
|
||||
];
|
||||
return [...shapes(rects, polygons, null), ...shapes(oneWayRects, oneWayPolygons, up)];
|
||||
}
|
||||
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
return this.solidShapesOf(world, body).map(({ shape }) => shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first solid the ray from `from` along `direction` meets within `length` (world units): any
|
||||
* blocking collider or tilemap cell, triggers when asked, a one-way one only on the side it blocks
|
||||
* from. What the ray starts inside is not met (see `rayCast`).
|
||||
*/
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
const d = unitOf(direction);
|
||||
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
|
||||
let best: RayResult | null = null;
|
||||
for (const body of world.entities) {
|
||||
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
|
||||
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
|
||||
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body)) {
|
||||
const hit = rayCast(shape, from, d, best?.distance ?? length);
|
||||
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
|
||||
if (best && hit.distance >= best.distance) continue;
|
||||
const point = { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance };
|
||||
best = { collider: body, point, normal: hit.normal, distance: hit.distance };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,13 +247,19 @@ export class Solids {
|
||||
return NOT_BLOCKED;
|
||||
}
|
||||
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
|
||||
const obstacles = world.entities
|
||||
const obstacles: Obstacle[] = world.entities
|
||||
.filter((c) => blocksMover(c, entity))
|
||||
.flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body })));
|
||||
const contacts: Contact[] = [];
|
||||
.flatMap((body) => this.solidShapesOf(world, body).map((solid) => ({ ...solid, body })));
|
||||
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
|
||||
// whole move: it is on its way through, from below or from the side.
|
||||
const ignoring = world.ignoresOneWay(entity);
|
||||
const passing = new Set(
|
||||
obstacles.filter((o) => o.facing && (ignoring || own.some((a) => shapesOverlap(shapeOf(a), o.shape)))),
|
||||
);
|
||||
const contacts: SweepContact[] = [];
|
||||
let rest = wanted;
|
||||
for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) {
|
||||
const contact = sweep(entity, frame, own, obstacles, rest);
|
||||
const contact = sweep(entity, frame, own, obstacles, passing, rest);
|
||||
if (!contact) break;
|
||||
contacts.push(contact);
|
||||
rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by));
|
||||
@@ -213,8 +296,16 @@ function parentFrame(entity: Entity): Mat {
|
||||
/**
|
||||
* Moves the entity along `move` (world) in steps no solid is thinner than, until a step runs into
|
||||
* one: then it is set back flush against it and the contact is reported with what is left of the move.
|
||||
* A one-way solid met anywhere but on its blocking side joins `passing` instead.
|
||||
*/
|
||||
function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[], move: Vec2): Contact | null {
|
||||
function sweep(
|
||||
entity: Entity,
|
||||
frame: Mat,
|
||||
own: Entity[],
|
||||
obstacles: Obstacle[],
|
||||
passing: Set<Obstacle>,
|
||||
move: Vec2,
|
||||
): SweepContact | null {
|
||||
const distance = Math.hypot(move.x, move.y);
|
||||
const forward = { x: move.x / distance, y: move.y / distance };
|
||||
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, forward))));
|
||||
@@ -238,6 +329,7 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
|
||||
for (const [k, a] of own.entries()) {
|
||||
const mover = shapeOf(a);
|
||||
for (const [j, obstacle] of obstacles.entries()) {
|
||||
if (passing.has(obstacle)) continue;
|
||||
const was = before[k]![j]!;
|
||||
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
|
||||
// Coming from outside, the step goes back exactly to where the two met; from inside, all
|
||||
@@ -246,6 +338,13 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
|
||||
was === 0
|
||||
? Math.min(step, exitDistance(mover, obstacle.shape, { x: -forward.x, y: -forward.y }))
|
||||
: step;
|
||||
if (obstacle.facing) {
|
||||
const met = movedShape(mover, (p) => ({ x: p.x - forward.x * back, y: p.y - forward.y * back }), 0);
|
||||
if (!landsOn(contactBetween(met, obstacle.shape, forward).normal, obstacle.facing)) {
|
||||
passing.add(obstacle);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (back > correction || !found) {
|
||||
correction = back;
|
||||
found = { own: a, obstacle };
|
||||
@@ -299,6 +398,13 @@ function unitOf(v: Vec2): Vec2 {
|
||||
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
||||
}
|
||||
|
||||
interface TilemapBlocks {
|
||||
rects: CellRect[];
|
||||
polygons: CellPolygon[];
|
||||
oneWayRects: CellRect[];
|
||||
oneWayPolygons: CellPolygon[];
|
||||
}
|
||||
|
||||
export function shapeOf(collider: Entity): ColliderShape {
|
||||
return colliderShape(collider.props, worldMatrixOf(collider));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface TilemapDraw {
|
||||
solids: CellRect[];
|
||||
/** The cells that block with an outline (#141), outlined the same way. */
|
||||
outlines: CellPolygon[];
|
||||
/** The same for one-way tiles (#155), which block only from above. */
|
||||
oneWay: { solids: CellRect[]; outlines: CellPolygon[] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +104,10 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag):
|
||||
tiles: used,
|
||||
solids: solidRects(project, props, asset),
|
||||
outlines: solidPolygons(project, props, asset),
|
||||
oneWay: {
|
||||
solids: solidRects(project, props, asset, true),
|
||||
outlines: solidPolygons(project, props, asset, true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+54
-1
@@ -9,7 +9,7 @@ import {
|
||||
RIGIDBODY_SYMBOL,
|
||||
textAnchor,
|
||||
} from '../core/properties/bounds';
|
||||
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
|
||||
import { type ColliderShape, colliderShape, isTrigger, oneWayFacing } from '../core/properties/collider';
|
||||
import { SHEET_EDGE } from '../core/shader/nodes';
|
||||
import type { NineSlice } from '../core/model/texture';
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
@@ -192,6 +192,8 @@ export function drawItem(
|
||||
? Theme.colors.trigger
|
||||
: Theme.colors.collider;
|
||||
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color });
|
||||
const facing = oneWayFacing(item.props, IDENTITY);
|
||||
if (facing) markBlockingSide(node, shapeBounds(shape), facing, 5 / scale, color);
|
||||
node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / scale);
|
||||
return;
|
||||
}
|
||||
@@ -223,6 +225,57 @@ const DIAGONALS = [
|
||||
[-Math.SQRT1_2, -Math.SQRT1_2],
|
||||
] as const;
|
||||
|
||||
interface Bounds {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function shapeBounds(shape: ColliderShape): Bounds {
|
||||
const points =
|
||||
shape.kind === 'circle'
|
||||
? [
|
||||
{ x: -shape.r, y: -shape.r },
|
||||
{ x: shape.r, y: shape.r },
|
||||
]
|
||||
: shape.kind === 'box'
|
||||
? [
|
||||
{ x: -shape.w / 2, y: -shape.h / 2 },
|
||||
{ x: shape.w / 2, y: shape.h / 2 },
|
||||
]
|
||||
: shape.kind === 'capsule'
|
||||
? [shape.a, shape.b].flatMap((p) => [
|
||||
{ x: p.x - shape.r, y: p.y - shape.r },
|
||||
{ x: p.x + shape.r, y: p.y + shape.r },
|
||||
])
|
||||
: shape.outline;
|
||||
return {
|
||||
left: Math.min(...points.map((p) => p.x)),
|
||||
top: Math.min(...points.map((p) => p.y)),
|
||||
right: Math.max(...points.map((p) => p.x)),
|
||||
bottom: Math.max(...points.map((p) => p.y)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-way collider or tile (#155): a thick line along the side of its bounds it blocks from – the
|
||||
* top of a platform. `facing` is one of the four axis directions in the same units.
|
||||
*/
|
||||
export function markBlockingSide(
|
||||
node: Graphics,
|
||||
b: Bounds,
|
||||
facing: Vec2,
|
||||
width: number,
|
||||
color: number,
|
||||
): void {
|
||||
if (facing.y < -0.5) node.moveTo(b.left, b.top).lineTo(b.right, b.top);
|
||||
else if (facing.y > 0.5) node.moveTo(b.left, b.bottom).lineTo(b.right, b.bottom);
|
||||
else if (facing.x < 0) node.moveTo(b.left, b.top).lineTo(b.left, b.bottom);
|
||||
else node.moveTo(b.right, b.top).lineTo(b.right, b.bottom);
|
||||
node.stroke({ width, color });
|
||||
}
|
||||
|
||||
/** Adds the outline of a collider shape in its own units (see `colliderShape` with `IDENTITY`). */
|
||||
function traceShape(node: Graphics, shape: ColliderShape): void {
|
||||
switch (shape.kind) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import type { GraphNode } from '../../src/core/graph';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import {
|
||||
COLLISION_SYSTEMS,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type SceneFixture,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
/** Wires `event → Log(port) → Log(port) …`, one entry in `world.log` per port, or `message` when none. */
|
||||
function logOn(
|
||||
f: SceneFixture,
|
||||
behavior: BehaviorAsset,
|
||||
event: string,
|
||||
params: Record<string, number>,
|
||||
ports: string[],
|
||||
message = event,
|
||||
): GraphNode {
|
||||
const edit = graphEditor(f, behavior);
|
||||
const node = edit.addNode(event, params);
|
||||
let from = node;
|
||||
for (const port of ports.length > 0 ? ports : ['']) {
|
||||
const log = edit.addNode('Log', port ? {} : { message });
|
||||
edit.connect(from, 'exec', log, 'exec');
|
||||
if (port) edit.connect(node, port, log, 'message');
|
||||
from = log;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Two 100 × 60 crates with a collider each, the second 300 to the right; a behavior sits on the first. */
|
||||
function crates() {
|
||||
const f = makeSpriteScene('Crate');
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||||
const behavior = makeBehavior(f, 'Hit');
|
||||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, a));
|
||||
f.stack.execute(new AddColliderCommand(f.sceneId, b));
|
||||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||||
return { ...f, behavior, b };
|
||||
}
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: On collision stay with an interval (#149)', ({ physics }) => {
|
||||
it('fires each time the interval has passed since the pair met, and starts over when they meet again', () => {
|
||||
const f = crates();
|
||||
logOn(f, f.behavior, 'OnCollisionStay', { interval: 0.5 }, [], 'slow');
|
||||
logOn(f, f.behavior, 'OnCollisionStay', {}, [], 'each');
|
||||
const { world, frame } = startWorld(f, { physics, collisions: true, behaviors: true });
|
||||
const other = world.findByObjectId(f.b)!;
|
||||
const slow = () => world.log.filter((line) => line === 'slow').length;
|
||||
const each = () => world.log.filter((line) => line === 'each').length;
|
||||
|
||||
other.x = 50;
|
||||
frame(0.1); // they meet
|
||||
for (let i = 0; i < 4; i++) frame(0.1);
|
||||
expect([slow(), each()]).toEqual([0, 4]);
|
||||
frame(0.1); // half a second since they met
|
||||
expect([slow(), each()]).toEqual([1, 5]);
|
||||
for (let i = 0; i < 5; i++) frame(0.1);
|
||||
expect([slow(), each()]).toEqual([2, 10]);
|
||||
frame(0.35); // a long frame counts as long as it is
|
||||
frame(0.35);
|
||||
expect(slow()).toBe(3);
|
||||
|
||||
other.x = 500;
|
||||
frame(0.1);
|
||||
other.x = 50;
|
||||
frame(0.1); // they meet again: the clock starts over
|
||||
for (let i = 0; i < 4; i++) frame(0.1);
|
||||
expect(slow()).toBe(3);
|
||||
frame(0.1);
|
||||
expect(slow()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what On collision stay knows of the contact (#149)', () => {
|
||||
/** A 32 px crate with a rigidbody falling onto a 400 × 40 ground whose top is at y = 180. */
|
||||
function landing(density = 1) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const behavior = makeBehavior(f, 'Feel');
|
||||
const size = (id: string, w: number, h: number) => {
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', w));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'height', h));
|
||||
};
|
||||
const ground = place(f, f.spriteId, { x: 0, y: 200 });
|
||||
size(ground, 400, 40);
|
||||
const groundCollider = new AddColliderCommand(f.sceneId, ground);
|
||||
f.stack.execute(groundCollider);
|
||||
size(groundCollider.objectId, 400, 40);
|
||||
const box = place(f, f.spriteId, { x: 10, y: 100 });
|
||||
size(box, 32, 32);
|
||||
const boxCollider = new AddColliderCommand(f.sceneId, box);
|
||||
f.stack.execute(boxCollider);
|
||||
size(boxCollider.objectId, 32, 32);
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, boxCollider.objectId, 'density', density));
|
||||
const rigidbody = new AddRigidbodyCommand(f.sceneId, box);
|
||||
f.stack.execute(rigidbody);
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, 'fixedRotation', true));
|
||||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||||
return { ...f, behavior };
|
||||
}
|
||||
|
||||
it('while playing: the normal from the ground towards the box, the impulse that holds it up and the contact point', () => {
|
||||
const rest = (density: number) => {
|
||||
const f = landing(density);
|
||||
logOn(f, f.behavior, 'OnCollisionStay', { interval: 1 }, [
|
||||
'normalX',
|
||||
'normalY',
|
||||
'impulse',
|
||||
'pointX',
|
||||
'pointY',
|
||||
]);
|
||||
const { world, frame } = startWorld(f, { physics: true, collisions: true, behaviors: true });
|
||||
for (let i = 0; i < 150; i++) frame(1 / 60);
|
||||
return world.log.slice(-5).map(Number);
|
||||
};
|
||||
const [nx, ny, light, px, py] = rest(1);
|
||||
expect([nx, ny]).toEqual([0, -1]);
|
||||
expect(px).toBeCloseTo(10, 3);
|
||||
expect(py).toBeCloseTo(180, 1);
|
||||
// The engine's own number: it is not exactly m·g·dt (1.25 times that, measured), but it grows with the weight.
|
||||
const [, , heavy] = rest(3);
|
||||
expect(light).toBeGreaterThan(0);
|
||||
expect(heavy! / light!).toBeCloseTo(3, 3);
|
||||
});
|
||||
|
||||
it('On collision enter says where the pair met as well', () => {
|
||||
const f = landing();
|
||||
logOn(f, f.behavior, 'OnCollisionEnter', {}, ['pointX', 'pointY']);
|
||||
const { world, frame } = startWorld(f, { physics: true, collisions: true, behaviors: true });
|
||||
for (let i = 0; i < 60; i++) frame(1 / 60);
|
||||
const [px, py] = world.log.slice(0, 2).map(Number);
|
||||
expect(px).toBeCloseTo(10, 3);
|
||||
expect(Math.abs(py! - 180)).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('in the editor run nothing is simulated: the contact outputs stay 0', () => {
|
||||
const f = landing();
|
||||
logOn(f, f.behavior, 'OnCollisionStay', {}, ['normalY', 'impulse', 'pointY']);
|
||||
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
|
||||
const box = world.entities.find((e) => e.type === 'sprite' && e.y === 100)!;
|
||||
box.y = 170; // overlapping the ground
|
||||
frame(1 / 60);
|
||||
frame(1 / 60);
|
||||
expect(world.log).toEqual(['0', '0', '0']);
|
||||
});
|
||||
});
|
||||
@@ -944,6 +944,72 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'without physics it continues', inputs: { x: 0, y: 1 }, next: 'exec' },
|
||||
],
|
||||
Raycast: [
|
||||
{
|
||||
name: 'hands the ray to the context, ignoring the owner, and puts out what it met',
|
||||
inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300 },
|
||||
context: {
|
||||
raycast: (from, direction, length, options) => {
|
||||
expect([from, direction, length, options.ignore?.id, options.triggers]).toEqual([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 0, y: 1 },
|
||||
300,
|
||||
'owner',
|
||||
false,
|
||||
]);
|
||||
return {
|
||||
object: crate,
|
||||
collider: painted,
|
||||
point: { x: 10, y: 120 },
|
||||
normal: { x: 0, y: -1 },
|
||||
distance: 100,
|
||||
};
|
||||
},
|
||||
},
|
||||
next: 'exec',
|
||||
outputs: {
|
||||
hit: true,
|
||||
object: crate,
|
||||
collider: painted,
|
||||
pointX: 10,
|
||||
pointY: 120,
|
||||
normalX: 0,
|
||||
normalY: -1,
|
||||
distance: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'without "Ignore owner" and with "Hit triggers" it says so; a miss puts out nothing',
|
||||
inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50 },
|
||||
params: { ignoreOwner: false, triggers: true },
|
||||
context: {
|
||||
raycast: (_from, _direction, _length, options) => {
|
||||
expect([options.ignore, options.triggers]).toEqual([null, true]);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
next: 'exec',
|
||||
outputs: {
|
||||
hit: false,
|
||||
object: null,
|
||||
collider: null,
|
||||
pointX: 0,
|
||||
pointY: 0,
|
||||
normalX: 0,
|
||||
normalY: 0,
|
||||
distance: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
IgnoreOneWay: [
|
||||
{
|
||||
name: 'asks the context to let the object through for the seconds given',
|
||||
inputs: { object: crate, seconds: 0.4 },
|
||||
context: { ignoreOneWay: (target, seconds) => expect([target.id, seconds]).toEqual(['crate', 0.4]) },
|
||||
next: 'exec',
|
||||
},
|
||||
{ name: 'no object is a no-op that continues', inputs: { object: null, seconds: 1 }, next: 'exec' },
|
||||
],
|
||||
Spawn: [
|
||||
{
|
||||
name: 'no asset chosen logs a hint and continues with no object',
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||
import { solidRects } from '../../src/core/model/tilemapCollision';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import type { PhysicsSystem } from '../../src/runtime/physics/PhysicsSystem';
|
||||
import { COLLISION_SYSTEMS, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
/** A scene of objects with one collider each; `add` sets the collider's (and rigidbody's) properties. */
|
||||
function scene() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
|
||||
const id = place(f, f.spriteId, at);
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
for (const [key, value] of Object.entries(collider))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
|
||||
if (body) {
|
||||
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
|
||||
f.stack.execute(rigidbody);
|
||||
for (const [key, value] of Object.entries(body))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
|
||||
}
|
||||
return id;
|
||||
};
|
||||
return { ...f, add };
|
||||
}
|
||||
|
||||
function run(f: SceneFixture, physics: boolean) {
|
||||
const started = startWorld(f, { physics, collisions: true });
|
||||
const entity = (id: string) => started.world.findByObjectId(id)!;
|
||||
const move = (id: string, dx: number, dy: number) =>
|
||||
started.collisions.moveAndSlide(started.world, entity(id), dx, dy);
|
||||
const seconds = (s: number, each?: () => void) => {
|
||||
for (let i = 0; i < Math.round(s * 60); i++) {
|
||||
started.frame(1 / 60);
|
||||
each?.();
|
||||
}
|
||||
};
|
||||
return { ...started, entity, move, seconds };
|
||||
}
|
||||
|
||||
/** A platform 200 wide and 8 thick with its top face at y = 100, blocking from above. */
|
||||
const PLATFORM = { width: 200, height: 8, oneWay: true };
|
||||
const PLATFORM_AT = { x: 0, y: 104 };
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: Move and one-way colliders (#155)', ({ physics }) => {
|
||||
it('a box falling onto the platform lands flush on its top and the move reports the hit', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
let last = r.move(box, 0, 7);
|
||||
for (let i = 0; i < 20; i++) last = r.move(box, 0, 7);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 9);
|
||||
expect(last).toMatchObject({ hit: true, blockedY: true, normal: { x: 0, y: -1 } });
|
||||
});
|
||||
|
||||
it('a box rising from below passes through, and lands on top once it falls back', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: 150 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 15; i++) expect(r.move(box, 0, -6).hit).toBe(false);
|
||||
expect(r.entity(box).y).toBeCloseTo(60, 9); // clear above the top at 100
|
||||
for (let i = 0; i < 20; i++) r.move(box, 0, 6);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 9);
|
||||
});
|
||||
|
||||
it('a box running into its side passes through, and one stuck halfway falls back out below', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 });
|
||||
const stuck = s.add({ x: 60, y: 100 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 20; i++) expect(r.move(walker, 10, 0).hit).toBe(false);
|
||||
expect(r.entity(walker).x).toBeCloseTo(0, 9);
|
||||
for (let i = 0; i < 10; i++) r.move(stuck, 0, 5);
|
||||
expect(r.entity(stuck).y).toBeCloseTo(150, 9);
|
||||
});
|
||||
|
||||
it('a fast fall does not tunnel: 150 px in one move still lands on the 8 px platform', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: -200 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
r.move(box, 0, 150);
|
||||
r.move(box, 0, 150);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 9);
|
||||
});
|
||||
|
||||
it('"Blocks from" turns the blocking side: left stops a mover coming from the left only', () => {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 0 }, { width: 8, height: 200, oneWay: true, oneWayDirection: 'left' });
|
||||
const fromLeft = s.add({ x: -100, y: -50 }, { width: 20, height: 20 });
|
||||
const fromRight = s.add({ x: 100, y: 50 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 20; i++) r.move(fromLeft, 10, 0);
|
||||
for (let i = 0; i < 20; i++) r.move(fromRight, -10, 0);
|
||||
expect(r.entity(fromLeft).x).toBeCloseTo(-14, 9);
|
||||
expect(r.entity(fromRight).x).toBeCloseTo(-100, 9);
|
||||
});
|
||||
|
||||
it('the side turns with the object: a platform turned 90° blocks from the right', () => {
|
||||
const s = scene();
|
||||
const wall = s.add({ x: 0, y: 0 }, { width: 200, height: 8, oneWay: true });
|
||||
s.stack.execute(new SetOverrideCommand(s.sceneId, wall, 'rotation', 90));
|
||||
const fromRight = s.add({ x: 100, y: 0 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 20; i++) r.move(fromRight, -10, 0);
|
||||
expect(r.entity(fromRight).x).toBeCloseTo(14, 9);
|
||||
});
|
||||
|
||||
it('Ignore one-way lets an object standing on it drop through, then it lands on the next one', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
s.add({ x: 0, y: 304 }, PLATFORM); // a second platform 200 lower
|
||||
const box = s.add({ x: 0, y: 90 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
expect(r.move(box, 0, 5).hit).toBe(true);
|
||||
r.world.ignoreOneWay(r.entity(box), 0.1);
|
||||
r.frame(1 / 60);
|
||||
expect(r.move(box, 0, 5).hit).toBe(false);
|
||||
r.seconds(0.2); // the time is up while the box is still inside: it goes on through
|
||||
for (let i = 0; i < 60; i++) r.move(box, 0, 5);
|
||||
expect(r.entity(box).y).toBeCloseTo(290, 9);
|
||||
});
|
||||
|
||||
it('a row of one-way tiles blocks from above only and never merges with the solid tiles', () => {
|
||||
const s = scene();
|
||||
const platform = new CreateAssetCommand('tile', 'Platform');
|
||||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [platform, wall, map]) s.stack.execute(command);
|
||||
for (const id of [platform.asset.id, wall.asset.id])
|
||||
s.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_ONE_WAY_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
place(s, map.asset.id, { x: 0, y: 0 });
|
||||
const asset = () => findAsset(s.project, map.asset.id) as TilemapAsset;
|
||||
// Cells of 48, centred: cell (x, y) spans x·48 − 240. Row 5 is a platform from x = 0 to 5,
|
||||
// with wall tiles right next to it in the same row.
|
||||
const cells = [
|
||||
...[0, 1, 2, 3, 4, 5].map((x) => ({ x, y: 5, tileId: platform.asset.id })),
|
||||
...[6, 7].map((x) => ({ x, y: 5, tileId: wall.asset.id })),
|
||||
];
|
||||
s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells));
|
||||
expect(solidRects(s.project, {}, asset())).toEqual([{ x: 6, y: 5, w: 2, h: 1 }]);
|
||||
expect(solidRects(s.project, {}, asset(), true)).toEqual([{ x: 0, y: 5, w: 6, h: 1 }]);
|
||||
const lander = s.add({ x: -100, y: -40 }, { width: 20, height: 20 });
|
||||
const jumper = s.add({ x: -150, y: 100 }, { width: 20, height: 20 });
|
||||
const r = run(s, physics);
|
||||
for (let i = 0; i < 20; i++) r.move(lander, 0, 5);
|
||||
expect(r.entity(lander).y).toBeCloseTo(-10, 9); // on the top at 0
|
||||
for (let i = 0; i < 20; i++) r.move(jumper, 0, -10);
|
||||
expect(r.entity(jumper).y).toBeCloseTo(-100, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the physics engine and one-way colliders (#155)', () => {
|
||||
function engine(s: ReturnType<typeof scene>) {
|
||||
const r = run(s, true);
|
||||
const physics = r.collisions as PhysicsSystem;
|
||||
return { ...r, physics };
|
||||
}
|
||||
|
||||
it('a dynamic box falls onto the platform and comes to rest on its top', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: 0 }, { width: 20, height: 20 }, {});
|
||||
const r = engine(s);
|
||||
r.seconds(2);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it('one shot up from below passes through and lands on top when it comes down', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: 160 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const r = engine(s);
|
||||
r.physics.setVelocity(r.entity(box), { x: 0, y: -600 });
|
||||
let highest = Infinity;
|
||||
r.seconds(3, () => (highest = Math.min(highest, r.entity(box).y)));
|
||||
expect(highest).toBeLessThan(70);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it('one sliding along without gravity passes through its side', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: -200, y: 104 }, { width: 20, height: 20 }, { gravityScale: 0 });
|
||||
const r = engine(s);
|
||||
r.physics.setVelocity(r.entity(box), { x: 300, y: 0 });
|
||||
r.seconds(1.5);
|
||||
expect(r.entity(box).x).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('a fast one does not tunnel through the 8 px platform', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
const box = s.add({ x: 0, y: -400 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const r = engine(s);
|
||||
r.physics.setVelocity(r.entity(box), { x: 0, y: 2000 });
|
||||
r.seconds(2);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it('Ignore one-way lets a resting body drop through onto the platform below', () => {
|
||||
const s = scene();
|
||||
s.add(PLATFORM_AT, PLATFORM);
|
||||
s.add({ x: 0, y: 304 }, PLATFORM);
|
||||
const box = s.add({ x: 0, y: 80 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const r = engine(s);
|
||||
r.seconds(1);
|
||||
expect(r.entity(box).y).toBeCloseTo(90, 1);
|
||||
r.world.ignoreOneWay(r.entity(box), 0.25);
|
||||
r.seconds(2);
|
||||
expect(r.entity(box).y).toBeCloseTo(290, 1);
|
||||
});
|
||||
|
||||
it('a dynamic box rests on a row of one-way tiles', () => {
|
||||
const s = scene();
|
||||
const platform = new CreateAssetCommand('tile', 'Platform');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [platform, map]) s.stack.execute(command);
|
||||
s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_SOLID_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(platform.asset.id, TILE_ONE_WAY_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
place(s, map.asset.id, { x: 0, y: 0 });
|
||||
const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id;
|
||||
const row = Array.from({ length: 10 }, (_, x) => ({ x, y: 5, tileId: platform.asset.id }));
|
||||
s.stack.execute(new PaintTilesCommand(map.asset.id, layer, row));
|
||||
const falls = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const rises = s.add({ x: 100, y: 100 }, { width: 20, height: 20 }, { fixedRotation: true });
|
||||
const r = engine(s);
|
||||
r.physics.setVelocity(r.entity(rises), { x: 0, y: -600 });
|
||||
r.seconds(3);
|
||||
expect(r.entity(falls).y).toBeCloseTo(-10, 1);
|
||||
expect(r.entity(rises).y).toBeCloseTo(-10, 1);
|
||||
});
|
||||
});
|
||||
@@ -697,3 +697,31 @@ describe('Move with "Collide with solids" pushes dynamic bodies (#147)', () => {
|
||||
expect(gap).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tilemap in the engine keeps every cell its own shape', () => {
|
||||
it('a collider without a rigidbody overlapping the first of two solid cells collides with the map', () => {
|
||||
const f = physicsScene();
|
||||
const solid = new CreateAssetCommand('tile', 'Wall');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
f.stack.execute(solid);
|
||||
f.stack.execute(map);
|
||||
f.stack.execute(new SetAssetPropertyCommand(solid.asset.id, TILE_SOLID_KEY, true));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
const mapObject = place(f, map.asset.id, { x: 0, y: 0 });
|
||||
const layer = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id;
|
||||
// Cells of 48, centred: cell (0, 0) spans −240 … −192, cell (9, 9) spans 192 … 240.
|
||||
const cells = [
|
||||
{ x: 0, y: 0, tileId: solid.asset.id },
|
||||
{ x: 9, y: 9, tileId: solid.asset.id },
|
||||
];
|
||||
f.stack.execute(new PaintTilesCommand(map.asset.id, layer, cells));
|
||||
const first = f.add(-216, -216, { w: 20 });
|
||||
const last = f.add(216, 216, { w: 20 });
|
||||
const { physics, world, entity, frame } = run(f);
|
||||
frame(1 / 60);
|
||||
frame(1 / 60);
|
||||
expect(physics.touching(world, entity(first), entity(mapObject))).toBe(true);
|
||||
expect(physics.touching(world, entity(last), entity(mapObject))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { IDENTITY, type Mat } from '../../src/core/model/matrix';
|
||||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||
import { colliderShape, type ColliderShape } from '../../src/core/properties/collider';
|
||||
import { rayCast } from '../../src/core/properties/ray';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import { RAY_SHOWN } from '../../src/runtime/World';
|
||||
import {
|
||||
COLLISION_SYSTEMS,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type SceneFixture,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
const P = (...xy: number[]) =>
|
||||
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
|
||||
|
||||
const turned = (degrees: number, x = 0, y = 0): Mat => {
|
||||
const a = (degrees * Math.PI) / 180;
|
||||
return { a: Math.cos(a), b: Math.sin(a), c: -Math.sin(a), d: Math.cos(a), tx: x, ty: y };
|
||||
};
|
||||
|
||||
const shape = (props: Record<string, PropertyValue>, m: Mat = IDENTITY): ColliderShape =>
|
||||
colliderShape(props, m);
|
||||
const RIGHT = { x: 1, y: 0 };
|
||||
const DOWN = { x: 0, y: 1 };
|
||||
|
||||
describe('where a ray meets a shape (#153)', () => {
|
||||
it('meets a box on the face it comes in through, from every side', () => {
|
||||
const box = shape({ width: 40, height: 20 });
|
||||
expect(rayCast(box, { x: -100, y: 5 }, RIGHT, 1000)).toEqual({ distance: 80, normal: { x: -1, y: 0 } });
|
||||
expect(rayCast(box, { x: 3, y: -50 }, DOWN, 1000)).toEqual({ distance: 40, normal: { x: 0, y: -1 } });
|
||||
expect(rayCast(box, { x: 100, y: 0 }, { x: -1, y: 0 }, 1000)).toEqual({
|
||||
distance: 80,
|
||||
normal: { x: 1, y: 0 },
|
||||
});
|
||||
expect(rayCast(box, { x: 0, y: 60 }, { x: 0, y: -1 }, 1000)).toEqual({
|
||||
distance: 50,
|
||||
normal: { x: 0, y: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('meets a turned box on its slanted face', () => {
|
||||
const box = shape({ width: 40, height: 40 }, turned(45));
|
||||
const hit = rayCast(box, { x: -100, y: 0 }, RIGHT, 1000)!;
|
||||
// The corner of the diamond points left, at x = −20·√2.
|
||||
expect(hit.distance).toBeCloseTo(100 - 20 * Math.SQRT2, 9);
|
||||
expect(Math.abs(hit.normal.y)).toBeCloseTo(Math.SQRT1_2, 9);
|
||||
expect(hit.normal.x).toBeCloseTo(-Math.SQRT1_2, 9);
|
||||
});
|
||||
|
||||
it('meets a circle where it is round, off-centre too', () => {
|
||||
const circle = shape({ shape: 'circle', radius: 10 });
|
||||
expect(rayCast(circle, { x: -50, y: 0 }, RIGHT, 1000)).toEqual({ distance: 40, normal: { x: -1, y: 0 } });
|
||||
const hit = rayCast(circle, { x: -50, y: 6 }, RIGHT, 1000)!;
|
||||
expect(hit.distance).toBeCloseTo(50 - 8, 9);
|
||||
expect(hit.normal.x).toBeCloseTo(-0.8, 9);
|
||||
expect(hit.normal.y).toBeCloseTo(0.6, 9);
|
||||
});
|
||||
|
||||
it('meets a capsule on its flat side and on its round end', () => {
|
||||
const capsule = shape({ shape: 'capsule', width: 20, height: 60 }); // round ends at y = ±20, radius 10
|
||||
expect(rayCast(capsule, { x: -50, y: 0 }, RIGHT, 1000)).toEqual({
|
||||
distance: 40,
|
||||
normal: { x: -1, y: 0 },
|
||||
});
|
||||
expect(rayCast(capsule, { x: 0, y: -100 }, DOWN, 1000)).toEqual({
|
||||
distance: 70,
|
||||
normal: { x: 0, y: -1 },
|
||||
});
|
||||
const hit = rayCast(capsule, { x: 6, y: -100 }, DOWN, 1000)!;
|
||||
expect(hit.distance).toBeCloseTo(100 - 20 - 8, 9);
|
||||
expect(hit.normal.x).toBeCloseTo(0.6, 9);
|
||||
});
|
||||
|
||||
it('goes down into the notch of a concave polygon and meets its inner floor, not the hull', () => {
|
||||
const u = shape({
|
||||
shape: 'polygon',
|
||||
points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50),
|
||||
});
|
||||
expect(rayCast(u, { x: 0, y: -100 }, DOWN, 1000)).toEqual({ distance: 130, normal: { x: 0, y: -1 } });
|
||||
expect(rayCast(u, { x: -50, y: -100 }, DOWN, 1000)).toEqual({ distance: 50, normal: { x: 0, y: -1 } });
|
||||
});
|
||||
|
||||
it('a polygon square answers exactly as the box it stands for, for 900 rays in every direction', () => {
|
||||
const box = shape({ width: 40, height: 30 }, turned(20, 5, -3));
|
||||
const square = shape(
|
||||
{ shape: 'polygon', points: P(-20, -15, 20, -15, 20, 15, -20, 15) },
|
||||
turned(20, 5, -3),
|
||||
);
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) * 2 - 1;
|
||||
let hits = 0;
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const from = { x: random() * 80, y: random() * 80 };
|
||||
const a = random() * Math.PI;
|
||||
const dir = { x: Math.cos(a), y: Math.sin(a) };
|
||||
const p = rayCast(box, from, dir, 200);
|
||||
const q = rayCast(square, from, dir, 200);
|
||||
expect(q === null, `ray ${i}`).toBe(p === null);
|
||||
if (!p || !q) continue;
|
||||
hits++;
|
||||
expect(q.distance, `ray ${i}`).toBeCloseTo(p.distance, 9);
|
||||
expect(q.normal.x, `ray ${i}`).toBeCloseTo(p.normal.x, 9);
|
||||
expect(q.normal.y, `ray ${i}`).toBeCloseTo(p.normal.y, 9);
|
||||
}
|
||||
expect(hits).toBeGreaterThan(80);
|
||||
});
|
||||
|
||||
it('a ray starting inside a shape does not meet it; one on its outline meets it at 0 going in, not going out', () => {
|
||||
const box = shape({ width: 40, height: 20 });
|
||||
expect(rayCast(box, { x: 0, y: 0 }, RIGHT, 1000)).toBeNull();
|
||||
expect(rayCast(shape({ shape: 'circle', radius: 10 }), { x: 2, y: 0 }, RIGHT, 1000)).toBeNull();
|
||||
expect(rayCast(box, { x: -20, y: 0 }, RIGHT, 1000)).toEqual({ distance: 0, normal: { x: -1, y: 0 } });
|
||||
expect(rayCast(box, { x: -20, y: 0 }, { x: -1, y: 0 }, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('inside a shape made of parts the ray does not stop at a seam between them', () => {
|
||||
// Inside the capsule's straight part but outside its end circle: upwards it would enter the circle.
|
||||
const capsule = shape({ shape: 'capsule', width: 20, height: 60 });
|
||||
expect(rayCast(capsule, { x: 9, y: -12 }, { x: 0, y: -1 }, 1000)).toBeNull();
|
||||
// Along the bottom bar of the U, across the seams of its convex pieces.
|
||||
const u = shape({
|
||||
shape: 'polygon',
|
||||
points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50),
|
||||
});
|
||||
expect(rayCast(u, { x: -55, y: 40 }, RIGHT, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('a ray grazing a corner meets it; one passing by or falling short does not', () => {
|
||||
const box = shape({ width: 40, height: 20 });
|
||||
expect(rayCast(box, { x: -100, y: -10 }, RIGHT, 1000)?.distance).toBeCloseTo(80, 12);
|
||||
expect(rayCast(box, { x: -100, y: -10.001 }, RIGHT, 1000)).toBeNull();
|
||||
expect(rayCast(box, { x: -100, y: 0 }, RIGHT, 79.9)).toBeNull();
|
||||
expect(rayCast(box, { x: -100, y: 0 }, RIGHT, 80)?.distance).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
/** A scene of objects with one collider each; `add` returns the object and collider ids. */
|
||||
function scene() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const add = (at: Vec2, collider: Record<string, PropertyValue>, parent?: string) => {
|
||||
const id = place(f, f.spriteId, at, parent);
|
||||
const command = new AddColliderCommand(f.sceneId, id);
|
||||
f.stack.execute(command);
|
||||
for (const [key, value] of Object.entries(collider))
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
|
||||
return { id, collider: command.objectId };
|
||||
};
|
||||
return { ...f, add };
|
||||
}
|
||||
|
||||
function run(f: SceneFixture, physics: boolean) {
|
||||
const started = startWorld(f, { physics, collisions: true, behaviors: true });
|
||||
const entity = (id: string) => started.world.findByObjectId(id)!;
|
||||
const cast = (from: Vec2, dir: Vec2, length = 1000, ignore: string | null = null, triggers = false) =>
|
||||
started.collisions.raycast(started.world, from, dir, length, {
|
||||
ignore: ignore ? entity(ignore) : null,
|
||||
triggers,
|
||||
});
|
||||
return { ...started, entity, cast };
|
||||
}
|
||||
|
||||
describe.each(COLLISION_SYSTEMS)('$system: a ray through the world (#153)', ({ physics }) => {
|
||||
it('meets the nearest collider, and says which one, where and how far', () => {
|
||||
const s = scene();
|
||||
const far = s.add({ x: 300, y: 0 }, { width: 20, height: 100 });
|
||||
const near = s.add({ x: 100, y: 0 }, { shape: 'circle', radius: 10 });
|
||||
const r = run(s, physics);
|
||||
expect(r.cast({ x: 0, y: 0 }, RIGHT)).toEqual({
|
||||
collider: r.entity(near.collider),
|
||||
point: { x: 90, y: 0 },
|
||||
normal: { x: -1, y: 0 },
|
||||
distance: 90,
|
||||
});
|
||||
expect(r.cast({ x: 0, y: 30 }, RIGHT)?.collider).toBe(r.entity(far.collider));
|
||||
expect(r.cast({ x: 0, y: 30 }, RIGHT, 200)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes triggers unless asked, and what it ignores – with everything parented below it', () => {
|
||||
const s = scene();
|
||||
const trigger = s.add({ x: 50, y: 0 }, { width: 10, height: 100, isTrigger: true });
|
||||
const owner = s.add({ x: 100, y: 0 }, { width: 10, height: 100 });
|
||||
const child = s.add({ x: 150, y: 0 }, { width: 10, height: 100 }, owner.id);
|
||||
const wall = s.add({ x: 300, y: 0 }, { width: 10, height: 100 });
|
||||
const r = run(s, physics);
|
||||
expect(r.cast({ x: 0, y: 0 }, RIGHT)?.collider).toBe(r.entity(owner.collider));
|
||||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, null, true)?.collider).toBe(r.entity(trigger.collider));
|
||||
expect(r.cast({ x: 0, y: 0 }, RIGHT, 1000, owner.id)?.collider).toBe(r.entity(wall.collider));
|
||||
expect(r.cast({ x: 200, y: 0 }, { x: -1, y: 0 }, 1000, owner.id)).toBeNull();
|
||||
expect(r.cast({ x: 120, y: 0 }, RIGHT)?.collider).toBe(r.entity(child.collider));
|
||||
});
|
||||
|
||||
it('meets a one-way platform only from the side it blocks from (#155)', () => {
|
||||
const s = scene();
|
||||
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true });
|
||||
const r = run(s, physics);
|
||||
expect(r.cast({ x: 0, y: 0 }, DOWN)).toMatchObject({ distance: 100, normal: { x: 0, y: -1 } });
|
||||
expect(r.cast({ x: 0, y: 200 }, { x: 0, y: -1 })).toBeNull();
|
||||
expect(r.cast({ x: -300, y: 104 }, RIGHT)).toBeNull();
|
||||
});
|
||||
|
||||
it('meets the cells of a tilemap, the map being the collider', () => {
|
||||
const s = scene();
|
||||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
s.stack.execute(wall);
|
||||
s.stack.execute(map);
|
||||
s.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
||||
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
||||
const mapObject = place(s, map.asset.id, { x: 0, y: 0 });
|
||||
const layer = (findAsset(s.project, map.asset.id) as TilemapAsset).layers[0]!.id;
|
||||
// Cells of 48, centred: the bottom row's top face is at 4·48 = 192, a pillar in column 7 from row 5.
|
||||
const cells = [
|
||||
...Array.from({ length: 10 }, (_, x) => ({ x, y: 9, tileId: wall.asset.id })),
|
||||
...[5, 6, 7, 8].map((y) => ({ x: 7, y, tileId: wall.asset.id })),
|
||||
];
|
||||
s.stack.execute(new PaintTilesCommand(map.asset.id, layer, cells));
|
||||
const r = run(s, physics);
|
||||
expect(r.cast({ x: 0, y: 0 }, DOWN)).toEqual({
|
||||
collider: r.entity(mapObject),
|
||||
point: { x: 0, y: 192 },
|
||||
normal: { x: 0, y: -1 },
|
||||
distance: 192,
|
||||
});
|
||||
expect(r.cast({ x: 0, y: 100 }, RIGHT)).toMatchObject({
|
||||
point: { x: 96, y: 100 },
|
||||
normal: { x: -1, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('the Raycast node casts from the graph, skips its owner and hands on what it met', () => {
|
||||
const s = scene();
|
||||
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
|
||||
s.add({ x: 0, y: 150 }, { width: 200, height: 20 }); // top face at 140
|
||||
const behavior = makeBehavior(s, 'Eyes');
|
||||
place(s, behavior.id, { x: 0, y: 0 }, hero.id);
|
||||
const edit = graphEditor(s, behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const ray = edit.addNode('Raycast', { fromX: 0, fromY: 0, dirX: 0, dirY: 1, length: 500 });
|
||||
const logHit = edit.addNode('Log');
|
||||
const logDistance = edit.addNode('Log');
|
||||
edit.wire([
|
||||
[update, 'exec', ray, 'exec'],
|
||||
[ray, 'exec', logHit, 'exec'],
|
||||
[ray, 'hit', logHit, 'message'],
|
||||
[logHit, 'exec', logDistance, 'exec'],
|
||||
[ray, 'distance', logDistance, 'message'],
|
||||
]);
|
||||
const r = run(s, physics);
|
||||
r.frame(1 / 60);
|
||||
// It starts inside its own hero, which it would not meet anyway; the floor is 140 down.
|
||||
expect(r.world.log).toEqual(['true', '140']);
|
||||
expect(r.world.rays).toEqual([{ from: { x: 0, y: 0 }, to: { x: 0, y: 140 }, hit: true, time: 1 / 60 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the rays a run remembers for drawing (#153)', () => {
|
||||
it('keeps those of the last moment only, and a ray that met nothing ends at its length', () => {
|
||||
const s = scene();
|
||||
const r = run(s, false);
|
||||
r.world.noteRay({ x: 0, y: 0 }, { x: 10, y: 0 }, false);
|
||||
r.frame(RAY_SHOWN / 2);
|
||||
r.world.noteRay({ x: 0, y: 0 }, { x: 20, y: 0 }, true);
|
||||
r.frame(RAY_SHOWN);
|
||||
r.world.noteRay({ x: 0, y: 0 }, { x: 30, y: 0 }, false);
|
||||
expect(r.world.rays.map((ray) => ray.to.x)).toEqual([20, 30]);
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,8 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
pointer: { ...NO_POINTER },
|
||||
hitTest: () => false,
|
||||
isTouching: () => false,
|
||||
raycast: () => null,
|
||||
ignoreOneWay: () => {},
|
||||
moveAndSlide: (target, dx, dy) => {
|
||||
target.x += dx;
|
||||
target.y += dy;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/** Kicks its owner in On start, with the scene's gravity switched off first: it glides at 100 per second. */
|
||||
const KICK = 200;
|
||||
@@ -61,24 +61,6 @@ const rollGraph = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
|
||||
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
|
||||
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
|
||||
* whatever is drawn there instead.
|
||||
*/
|
||||
async function inView(t, panel, point) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const rect = await t.json(panel);
|
||||
const at = await t.json(point);
|
||||
if (!rect) return null;
|
||||
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
|
||||
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
|
||||
await t.settle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physics engine (Rapier) loads in the editor, in the background after the first project is
|
||||
* open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* What a game asks the collision about, and platforms one jumps through: one-way colliders (#155),
|
||||
* the Raycast node (#153) and On collision stay with an interval and contact data (#149). What only a
|
||||
* running editor shows: the switch in the inspector, the engine and Move landing on a platform and
|
||||
* passing it from below, the ray drawn in the Scene tab by the editor run, and a stay that fires on
|
||||
* its own clock.
|
||||
*/
|
||||
|
||||
const FALL = 300;
|
||||
/** Falls with Move and "Collide with solids"; 1.5 s after it starts it drops through one-way platforms. */
|
||||
const dropGraph = {
|
||||
nodes: [
|
||||
node('qd-update', 'OnUpdate', 0, 0),
|
||||
node('qd-owner', 'Owner', 0, 160),
|
||||
node('qd-move', 'Move', 300, 0, { dy: 1, scale: FALL, collide: true }),
|
||||
node('qd-timer', 'OnTimer', 0, 320, { interval: 1.5 }),
|
||||
node('qd-drop', 'IgnoreOneWay', 300, 320, { seconds: 0.3 }),
|
||||
],
|
||||
edges: [
|
||||
edge('qd-e1', 'qd-update', 'exec', 'qd-move', 'exec'),
|
||||
edge('qd-e2', 'qd-update', 'dt', 'qd-move', 'dt'),
|
||||
edge('qd-e3', 'qd-owner', 'object', 'qd-move', 'object'),
|
||||
edge('qd-e4', 'qd-timer', 'exec', 'qd-drop', 'exec'),
|
||||
edge('qd-e5', 'qd-owner', 'object', 'qd-drop', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Shoots its owner – a dynamic body below the platform – straight up in On start. */
|
||||
const jumpGraph = {
|
||||
nodes: [
|
||||
node('qj-start', 'OnStart', 0, 0),
|
||||
node('qj-owner', 'Owner', 0, 160),
|
||||
node('qj-shoot', 'SetVelocity', 300, 0, { x: 0, y: -700 }),
|
||||
],
|
||||
edges: [
|
||||
edge('qj-e1', 'qj-start', 'exec', 'qj-shoot', 'exec'),
|
||||
edge('qj-e2', 'qj-owner', 'object', 'qj-shoot', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Casts a ray straight down from its owner every frame. */
|
||||
const eyeGraph = {
|
||||
nodes: [
|
||||
node('qe-update', 'OnUpdate', 0, 0),
|
||||
node('qe-owner', 'Owner', 0, 160),
|
||||
node('qe-where', 'GetPosition', 250, 160),
|
||||
node('qe-ray', 'Raycast', 500, 0, { dirX: 0, dirY: 1, length: 500 }),
|
||||
],
|
||||
edges: [
|
||||
edge('qe-e1', 'qe-update', 'exec', 'qe-ray', 'exec'),
|
||||
edge('qe-e2', 'qe-owner', 'object', 'qe-where', 'object'),
|
||||
edge('qe-e3', 'qe-where', 'worldX', 'qe-ray', 'fromX'),
|
||||
edge('qe-e4', 'qe-where', 'worldY', 'qe-ray', 'fromY'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Logs how its owner touches the ground (normal y, then impulse) each half second it stays on it. */
|
||||
const STAY = 0.5;
|
||||
const feelGraph = {
|
||||
nodes: [
|
||||
node('qf-stay', 'OnCollisionStay', 0, 0, { interval: STAY }),
|
||||
node('qf-log-n', 'Log', 300, 0),
|
||||
node('qf-log-i', 'Log', 550, 0),
|
||||
],
|
||||
edges: [
|
||||
edge('qf-e1', 'qf-stay', 'exec', 'qf-log-n', 'exec'),
|
||||
edge('qf-e2', 'qf-stay', 'normalY', 'qf-log-n', 'message'),
|
||||
edge('qf-e3', 'qf-log-n', 'exec', 'qf-log-i', 'exec'),
|
||||
edge('qf-e4', 'qf-stay', 'impulse', 'qf-log-i', 'message'),
|
||||
],
|
||||
};
|
||||
|
||||
export async function queries(t) {
|
||||
const {
|
||||
check,
|
||||
json,
|
||||
evaluate,
|
||||
mutate,
|
||||
select,
|
||||
click,
|
||||
object,
|
||||
settle,
|
||||
sleep,
|
||||
play,
|
||||
stop,
|
||||
shot,
|
||||
consoleLog,
|
||||
} = t;
|
||||
const Y = LANES.queries;
|
||||
const GROUND_TOP = Y + 280;
|
||||
const PLATFORM_TOP = Y + 95;
|
||||
const setup = (body) =>
|
||||
mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
const assets = __gm.state.project.assets;
|
||||
${MAKE_OBJECT}
|
||||
const collider = (id, props) =>
|
||||
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
|
||||
const behavior = (id, name, graph) => {
|
||||
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
|
||||
assets.push(asset);
|
||||
};
|
||||
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);
|
||||
};
|
||||
${body}
|
||||
__gm.state.select(null)`);
|
||||
const clear = () =>
|
||||
mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('q-'));
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('q-'));
|
||||
__gm.state.select(null)`);
|
||||
const sample = async (seconds, ids) => {
|
||||
await evaluate(`(() => {
|
||||
const out = (window.__querySamples = []);
|
||||
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) / 1000, ...Object.fromEntries(${JSON.stringify(ids)}.map((id) => [id, y(id)])) });
|
||||
if (performance.now() - start < ${seconds * 1000}) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
})()`);
|
||||
await sleep(seconds * 1000 + 300);
|
||||
return json('window.__querySamples');
|
||||
};
|
||||
|
||||
// 43a. A platform one jumps through (#155), switched to one-way in the inspector. A crate falls
|
||||
// onto it with Move and drops through 1.5 s later; a dynamic crate shot up from below
|
||||
// passes it and lands on it.
|
||||
await setup(`
|
||||
collider('q-col-ground', { width: 800, height: 40 });
|
||||
collider('q-col-platform', { width: 300, height: 10 });
|
||||
collider('q-col-box', {});
|
||||
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
|
||||
behavior('q-drop', 'Query drop', ${JSON.stringify(dropGraph)});
|
||||
behavior('q-jump', 'Query jump', ${JSON.stringify(jumpGraph)});
|
||||
const box = { width: 40, height: 40 };
|
||||
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
|
||||
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
|
||||
put('q-platform', 'crate', '', 0, ${Y + 100}, 'Query platform', { width: 300, height: 10 });
|
||||
put('q-platform-col', 'q-col-platform', 'q-platform', 0, 0, 'Platform collider');
|
||||
put('q-dropper', 'crate', '', -100, ${Y}, 'Dropper', box);
|
||||
put('q-dropper-col', 'q-col-box', 'q-dropper', 0, 0);
|
||||
put('q-dropper-go', 'q-drop', 'q-dropper', 0, 0);
|
||||
put('q-jumper', 'crate', '', 100, ${Y + 200}, 'Jumper', box);
|
||||
put('q-jumper-col', 'q-col-box', 'q-jumper', 0, 0);
|
||||
put('q-jumper-rb', 'q-rb', 'q-jumper', 0, 0);
|
||||
put('q-jumper-go', 'q-jump', 'q-jumper', 0, 0);`);
|
||||
await select({
|
||||
kind: 'object',
|
||||
holderId: 'scene1',
|
||||
objectId: 'q-platform-col',
|
||||
objectIds: ['q-platform-col'],
|
||||
});
|
||||
const toggle = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way')");
|
||||
if (toggle) await click(toggle.x, toggle.y);
|
||||
await settle();
|
||||
const blocksFrom = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Blocks from')");
|
||||
check(
|
||||
"a collider's One-way switch in the inspector turns it one-way, blocking from above",
|
||||
Boolean(toggle) &&
|
||||
Boolean(blocksFrom) &&
|
||||
(await object('q-platform-col'))?.overrides?.oneWay === true &&
|
||||
(await t.has('Above')),
|
||||
JSON.stringify({ toggle, blocksFrom, overrides: (await object('q-platform-col'))?.overrides }),
|
||||
);
|
||||
await select(null);
|
||||
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
|
||||
await settle();
|
||||
await shot('43-one-way');
|
||||
|
||||
await play();
|
||||
const run = await sample(2.8, ['q-dropper', 'q-jumper']);
|
||||
await stop();
|
||||
const at = (seconds) =>
|
||||
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
|
||||
const onPlatform = PLATFORM_TOP - 20;
|
||||
const onGround = GROUND_TOP - 20;
|
||||
check(
|
||||
'Move lands on the one-way platform, and Ignore one-way drops it through onto the ground',
|
||||
run.length > 60 &&
|
||||
Math.abs(at(0.7)['q-dropper'] - onPlatform) < 0.01 &&
|
||||
Math.abs(run.at(-1)['q-dropper'] - onGround) < 0.01,
|
||||
JSON.stringify({ frames: run.length, early: at(0.7), last: run.at(-1), onPlatform, onGround }),
|
||||
);
|
||||
const highest = Math.min(...run.map((s) => s['q-jumper']));
|
||||
check(
|
||||
'a dynamic body shot up from below passes through the platform and lands on top of it',
|
||||
highest < PLATFORM_TOP - 60 && Math.abs(run.at(-1)['q-jumper'] - onPlatform) < 1,
|
||||
JSON.stringify({ highest, last: run.at(-1)['q-jumper'], onPlatform }),
|
||||
);
|
||||
await clear();
|
||||
|
||||
// 43b. The Raycast node in the editor run (#153): a crate casting down every frame, the ray drawn
|
||||
// in the Scene tab to where it meets the ground.
|
||||
await setup(`
|
||||
collider('q-col-ground', { width: 800, height: 40 });
|
||||
collider('q-col-box', {});
|
||||
behavior('q-eye', 'Query eye', ${JSON.stringify(eyeGraph)});
|
||||
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
|
||||
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
|
||||
put('q-eyebox', 'crate', '', 200, ${Y}, 'Eye', { width: 40, height: 40 });
|
||||
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
|
||||
put('q-eyebox-go', 'q-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`);
|
||||
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
|
||||
await sleep(500);
|
||||
const rays = await json('__gm.rays()');
|
||||
const ray = rays.at(-1);
|
||||
check(
|
||||
'the editor run casts the ray and the Scene tab draws it, down to the ground it met',
|
||||
(await json('__gm.editorRun()')).running === true &&
|
||||
ray?.hit === true &&
|
||||
ray.from.x === 200 &&
|
||||
ray.from.y === Y &&
|
||||
Math.abs(ray.to.y - GROUND_TOP) < 1e-6,
|
||||
JSON.stringify({ count: rays.length, ray, groundTop: GROUND_TOP }),
|
||||
);
|
||||
await shot('43-ray');
|
||||
await clear();
|
||||
await sleep(300);
|
||||
|
||||
// 43c. On collision stay with an interval (#149): a crate resting on the ground logs how it touches
|
||||
// every half second – the normal from the ground towards it, and the impulse holding it up.
|
||||
const logFrom = consoleLog.length;
|
||||
await setup(`
|
||||
collider('q-col-ground', { width: 800, height: 40 });
|
||||
collider('q-col-box', {});
|
||||
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
|
||||
behavior('q-feel', 'Query feel', ${JSON.stringify(feelGraph)});
|
||||
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
|
||||
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
|
||||
put('q-rest', 'crate', '', -300, ${Y + 220}, 'Rester', { width: 40, height: 40 });
|
||||
put('q-rest-col', 'q-col-box', 'q-rest', 0, 0);
|
||||
put('q-rest-rb', 'q-rb', 'q-rest', 0, 0);
|
||||
put('q-rest-go', 'q-feel', 'q-rest', 0, 0, 'Feel');`);
|
||||
await play();
|
||||
await sleep(2200);
|
||||
await stop();
|
||||
const logged = consoleLog
|
||||
.slice(logFrom)
|
||||
.filter((line) => line.startsWith('[behavior Feel]'))
|
||||
.map((line) => Number(line.split(']').pop()));
|
||||
const pairs = Array.from({ length: Math.floor(logged.length / 2) }, (_, i) =>
|
||||
logged.slice(2 * i, 2 * i + 2),
|
||||
);
|
||||
check(
|
||||
'On collision stay with an interval fires every half second, with the normal and the impulse of the contact',
|
||||
pairs.length >= 2 && pairs.length <= 4 && pairs.every(([ny, impulse]) => ny === -1 && impulse > 0),
|
||||
JSON.stringify({ pairs, interval: STAY }),
|
||||
);
|
||||
await clear();
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { tile } from './39-tile.mjs';
|
||||
import { tilemap } from './40-tilemap.mjs';
|
||||
import { physics } from './41-physics.mjs';
|
||||
import { shapes } from './42-shapes.mjs';
|
||||
import { queries } from './43-queries.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -89,4 +90,5 @@ export const STEPS = [
|
||||
tilemap,
|
||||
physics,
|
||||
shapes,
|
||||
queries,
|
||||
];
|
||||
@@ -23,6 +23,7 @@ export const LANES = {
|
||||
tilemap: -3100,
|
||||
physics: -3500,
|
||||
shapes: -3900,
|
||||
queries: -4300,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -166,3 +167,21 @@ export async function pickAssetType(t, label) {
|
||||
await t.click(p.x, p.y);
|
||||
return { box, p, scrolled };
|
||||
}
|
||||
|
||||
/**
|
||||
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
|
||||
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
|
||||
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
|
||||
* whatever is drawn there instead.
|
||||
*/
|
||||
export async function inView(t, panel, point) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const rect = await t.json(panel);
|
||||
const at = await t.json(point);
|
||||
if (!rect) return null;
|
||||
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
|
||||
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
|
||||
await t.settle();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user