diff --git a/README.md b/README.md index 5d78b16e..8009c1f5 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 3201b3cb..04a47319 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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; diff --git a/src/core/graph/nodes/action/IgnoreOneWay.ts b/src/core/graph/nodes/action/IgnoreOneWay.ts new file mode 100644 index 00000000..e8ba2ad1 --- /dev/null +++ b/src/core/graph/nodes/action/IgnoreOneWay.ts @@ -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' }; + }, +}; diff --git a/src/core/graph/nodes/action/Raycast.ts b/src/core/graph/nodes/action/Raycast.ts new file mode 100644 index 00000000..e089f327 --- /dev/null +++ b/src/core/graph/nodes/action/Raycast.ts @@ -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, + }, + }; + }, +}; diff --git a/src/core/graph/nodes/events/OnCollisionEnter.ts b/src/core/graph/nodes/events/OnCollisionEnter.ts index d690cc03..090a28ff 100644 --- a/src/core/graph/nodes/events/OnCollisionEnter.ts +++ b/src/core/graph/nodes/events/OnCollisionEnter.ts @@ -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: [], diff --git a/src/core/graph/nodes/events/OnCollisionStay.ts b/src/core/graph/nodes/events/OnCollisionStay.ts index 0a2eb3cf..d5512954 100644 --- a/src/core/graph/nodes/events/OnCollisionStay.ts +++ b/src/core/graph/nodes/events/OnCollisionStay.ts @@ -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, + }, + ], }; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 973587cb..eb961bdb 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 0e532275..a3675b74 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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' }; diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index 290c76fe..3f7076a4 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -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); diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 73073edc..520e589f 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -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(); 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[] { - return blockingCells(project, props, asset) +export function solidCells( + project: Project, + props: PropertyBag, + asset: TilemapAsset, + oneWay = false, +): Required[] { + 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)); } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 0a32057e..30390ed3 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { }, // 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 = { { 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: [ { diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 7f24a588..a4f88f24 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -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 = { + 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. diff --git a/src/core/properties/ray.ts b/src/core/properties/ray.ts new file mode 100644 index 00000000..44a5d3f8 --- /dev/null +++ b/src/core/properties/ray.ts @@ -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 } }; +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 7bd9bba3..f02ef711 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 7ec4c89c..193f13bb 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 66ea494b..ac336e17 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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(); /** Where each placed tilemap blocks, outlined like a collider (#132). */ private readonly solids = new Map(); + 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). */ diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 1afb658b..73a6bfe4 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); 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; diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index a9336c67..1592060e 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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(); private current = new Map(); + /** The engine colliders each current pair was found with, `a`'s first – where its contact is read. */ + private pairColliders = new Map(); + /** + * 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(); + 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(); 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(); + 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() }; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 13dc325f..bb16cfb2 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 = { 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 | null) => { + const outputs: Record = { 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(); + 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 = {}; + 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, diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index d6510d11..6830eca1 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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; + } } diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 72206804..10123165 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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(); + private blocks = new Map(); /** 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, + 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)); } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 9aa38c80..7621d1e3 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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), + }, }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 4065edd9..3eb10270 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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) { diff --git a/tests/core/collision-stay.test.ts b/tests/core/collision-stay.test.ts new file mode 100644 index 00000000..973fda51 --- /dev/null +++ b/tests/core/collision-stay.test.ts @@ -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, + 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']); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 69c522a1..740e9cff 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -944,6 +944,72 @@ const CASES: Record = { }, { 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', diff --git a/tests/core/one-way.test.ts b/tests/core/one-way.test.ts new file mode 100644 index 00000000..cd8f4075 --- /dev/null +++ b/tests/core/one-way.test.ts @@ -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, body?: Record) => { + 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) { + 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); + }); +}); diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index eddee394..1c8b3c04 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -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); + }); +}); diff --git a/tests/core/raycast.test.ts b/tests/core/raycast.test.ts new file mode 100644 index 00000000..90e73997 --- /dev/null +++ b/tests/core/raycast.test.ts @@ -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, 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, 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]); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index c2732d30..16201e67 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -224,6 +224,8 @@ export function fakeContext(overrides: Partial = {}): FakeContext { pointer: { ...NO_POINTER }, hitTest: () => false, isTouching: () => false, + raycast: () => null, + ignoreOneWay: () => {}, moveAndSlide: (target, dx, dy) => { target.x += dx; target.y += dy; diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index dc1259ed..ad4d5246 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -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 diff --git a/tools/e2e/43-queries.mjs b/tools/e2e/43-queries.mjs new file mode 100644 index 00000000..59256f7f --- /dev/null +++ b/tools/e2e/43-queries.mjs @@ -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(); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 46c0bf11..18884150 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index f7c1b175..4eef4a49 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -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; +}