One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
947a6b1977
commit
6a31f5e0ad
33 files changed
+2064
-125
No files matched your search
+129
-23
@@ -17,9 +17,14 @@ import {
|
||||
contactBetween,
|
||||
exitDistance,
|
||||
isTrigger,
|
||||
landsOn,
|
||||
movedShape,
|
||||
oneWayFacing,
|
||||
overlapDepth,
|
||||
shapeExtent,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import { rayCast } from '../../core/properties/ray';
|
||||
import { colliderMaterial } from '../../core/properties/rigidbody';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -36,14 +41,15 @@ const SQUARE = 1e-6;
|
||||
/**
|
||||
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
|
||||
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
|
||||
* `normal` points from `b` towards `a` and `impulse` is how hard they met; both are only known
|
||||
* where the physics engine saw the contact begin (#146), and zero elsewhere.
|
||||
* `normal` points from `b` towards `a`, `impulse` is how hard they met and `point` where; they are
|
||||
* only known where the physics engine saw the contact begin (#146), and zero elsewhere.
|
||||
*/
|
||||
export interface CollisionPair {
|
||||
a: Entity;
|
||||
b: Entity;
|
||||
normal?: Vec2;
|
||||
impulse?: number;
|
||||
point?: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,14 +81,36 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({
|
||||
normal: Object.freeze({ x: 0, y: 0 }),
|
||||
});
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle {
|
||||
/**
|
||||
* A shape of a collider or tilemap, and for a one-way one the world direction it blocks from (#155):
|
||||
* only a contact whose normal lies within 45° of it stops anything.
|
||||
*/
|
||||
export interface SolidShape {
|
||||
shape: ColliderShape;
|
||||
facing: Vec2 | null;
|
||||
}
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle extends SolidShape {
|
||||
body: Entity;
|
||||
}
|
||||
|
||||
/** What a ray met (#153): the collider or tilemap, where, the face's normal and how far along the ray. */
|
||||
export interface RayResult {
|
||||
collider: Entity;
|
||||
point: Vec2;
|
||||
normal: Vec2;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked. */
|
||||
export interface RayFilter {
|
||||
ignore: Entity | null;
|
||||
triggers: boolean;
|
||||
}
|
||||
|
||||
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
|
||||
interface Contact {
|
||||
interface SweepContact {
|
||||
by: Entity;
|
||||
own: Entity;
|
||||
normal: Vec2;
|
||||
@@ -96,6 +124,20 @@ export interface CollisionSource {
|
||||
readonly exits: CollisionPair[];
|
||||
touching(world: World, a: Entity, b: Entity): boolean;
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null;
|
||||
/**
|
||||
* How a pair that stays in contact touches right now (#149): the normal from `b` towards `a`, the
|
||||
* impulse of the last physics step and the middle of the contact points; null where nothing
|
||||
* simulates the contact (the editor run).
|
||||
*/
|
||||
contactOf(pair: CollisionPair): Contact | null;
|
||||
}
|
||||
|
||||
/** A contact as the physics engine knows it (see `CollisionSource.contactOf`). */
|
||||
export interface Contact {
|
||||
normal: Vec2;
|
||||
impulse: number;
|
||||
point: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,23 +150,27 @@ export class Solids {
|
||||
* painting, or a behavior changing the map, is picked up by the next one – and turned into world
|
||||
* shapes only when asked, since the map may move in between.
|
||||
*/
|
||||
private blocks = new Map<Entity, { rects: CellRect[]; polygons: CellPolygon[] }>();
|
||||
private blocks = new Map<Entity, TilemapBlocks>();
|
||||
|
||||
/** Forgets the cells, so the next tick works them out afresh. */
|
||||
reset(): void {
|
||||
this.blocks = new Map();
|
||||
}
|
||||
|
||||
private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } {
|
||||
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
|
||||
let blocks = this.blocks.get(tilemap);
|
||||
if (!blocks) {
|
||||
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
|
||||
const { project } = world;
|
||||
const { props } = tilemap;
|
||||
blocks = asset
|
||||
? {
|
||||
rects: solidRects(world.project, tilemap.props, asset),
|
||||
polygons: solidPolygons(world.project, tilemap.props, asset),
|
||||
rects: solidRects(project, props, asset),
|
||||
polygons: solidPolygons(project, props, asset),
|
||||
oneWayRects: solidRects(project, props, asset, true),
|
||||
oneWayPolygons: solidPolygons(project, props, asset, true),
|
||||
}
|
||||
: { rects: [], polygons: [] };
|
||||
: { rects: [], polygons: [], oneWayRects: [], oneWayPolygons: [] };
|
||||
this.blocks.set(tilemap, blocks);
|
||||
}
|
||||
return blocks;
|
||||
@@ -132,17 +178,48 @@ export class Solids {
|
||||
|
||||
/**
|
||||
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
|
||||
* rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is
|
||||
* worked out on the spot.
|
||||
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
|
||||
* top (#155). A tilemap placed before the first tick is worked out on the spot.
|
||||
*/
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
if (body.type !== 'tilemap') return [shapeOf(body)];
|
||||
solidShapesOf(world: World, body: Entity): SolidShape[] {
|
||||
const m = worldMatrixOf(body);
|
||||
const { rects, polygons } = this.blocksOf(world, body);
|
||||
return [
|
||||
...rects.map((rect) => cellRectShape(body.props, rect, m)),
|
||||
...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)),
|
||||
if (body.type !== 'tilemap')
|
||||
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }];
|
||||
const { rects, polygons, oneWayRects, oneWayPolygons } = this.blocksOf(world, body);
|
||||
const up = oneWayFacing({ oneWay: true }, m);
|
||||
const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: Vec2 | null) => [
|
||||
...cells.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
|
||||
...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
|
||||
];
|
||||
return [...shapes(rects, polygons, null), ...shapes(oneWayRects, oneWayPolygons, up)];
|
||||
}
|
||||
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
return this.solidShapesOf(world, body).map(({ shape }) => shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first solid the ray from `from` along `direction` meets within `length` (world units): any
|
||||
* blocking collider or tilemap cell, triggers when asked, a one-way one only on the side it blocks
|
||||
* from. What the ray starts inside is not met (see `rayCast`).
|
||||
*/
|
||||
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
|
||||
const d = unitOf(direction);
|
||||
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
|
||||
let best: RayResult | null = null;
|
||||
for (const body of world.entities) {
|
||||
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
|
||||
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
|
||||
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
|
||||
for (const { shape, facing } of this.solidShapesOf(world, body)) {
|
||||
const hit = rayCast(shape, from, d, best?.distance ?? length);
|
||||
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
|
||||
if (best && hit.distance >= best.distance) continue;
|
||||
const point = { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance };
|
||||
best = { collider: body, point, normal: hit.normal, distance: hit.distance };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,13 +247,19 @@ export class Solids {
|
||||
return NOT_BLOCKED;
|
||||
}
|
||||
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
|
||||
const obstacles = world.entities
|
||||
const obstacles: Obstacle[] = world.entities
|
||||
.filter((c) => blocksMover(c, entity))
|
||||
.flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body })));
|
||||
const contacts: Contact[] = [];
|
||||
.flatMap((body) => this.solidShapesOf(world, body).map((solid) => ({ ...solid, body })));
|
||||
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
|
||||
// whole move: it is on its way through, from below or from the side.
|
||||
const ignoring = world.ignoresOneWay(entity);
|
||||
const passing = new Set(
|
||||
obstacles.filter((o) => o.facing && (ignoring || own.some((a) => shapesOverlap(shapeOf(a), o.shape)))),
|
||||
);
|
||||
const contacts: SweepContact[] = [];
|
||||
let rest = wanted;
|
||||
for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) {
|
||||
const contact = sweep(entity, frame, own, obstacles, rest);
|
||||
const contact = sweep(entity, frame, own, obstacles, passing, rest);
|
||||
if (!contact) break;
|
||||
contacts.push(contact);
|
||||
rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by));
|
||||
@@ -213,8 +296,16 @@ function parentFrame(entity: Entity): Mat {
|
||||
/**
|
||||
* Moves the entity along `move` (world) in steps no solid is thinner than, until a step runs into
|
||||
* one: then it is set back flush against it and the contact is reported with what is left of the move.
|
||||
* A one-way solid met anywhere but on its blocking side joins `passing` instead.
|
||||
*/
|
||||
function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[], move: Vec2): Contact | null {
|
||||
function sweep(
|
||||
entity: Entity,
|
||||
frame: Mat,
|
||||
own: Entity[],
|
||||
obstacles: Obstacle[],
|
||||
passing: Set<Obstacle>,
|
||||
move: Vec2,
|
||||
): SweepContact | null {
|
||||
const distance = Math.hypot(move.x, move.y);
|
||||
const forward = { x: move.x / distance, y: move.y / distance };
|
||||
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, forward))));
|
||||
@@ -238,6 +329,7 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
|
||||
for (const [k, a] of own.entries()) {
|
||||
const mover = shapeOf(a);
|
||||
for (const [j, obstacle] of obstacles.entries()) {
|
||||
if (passing.has(obstacle)) continue;
|
||||
const was = before[k]![j]!;
|
||||
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
|
||||
// Coming from outside, the step goes back exactly to where the two met; from inside, all
|
||||
@@ -246,6 +338,13 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
|
||||
was === 0
|
||||
? Math.min(step, exitDistance(mover, obstacle.shape, { x: -forward.x, y: -forward.y }))
|
||||
: step;
|
||||
if (obstacle.facing) {
|
||||
const met = movedShape(mover, (p) => ({ x: p.x - forward.x * back, y: p.y - forward.y * back }), 0);
|
||||
if (!landsOn(contactBetween(met, obstacle.shape, forward).normal, obstacle.facing)) {
|
||||
passing.add(obstacle);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (back > correction || !found) {
|
||||
correction = back;
|
||||
found = { own: a, obstacle };
|
||||
@@ -299,6 +398,13 @@ function unitOf(v: Vec2): Vec2 {
|
||||
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
||||
}
|
||||
|
||||
interface TilemapBlocks {
|
||||
rects: CellRect[];
|
||||
polygons: CellPolygon[];
|
||||
oneWayRects: CellRect[];
|
||||
oneWayPolygons: CellPolygon[];
|
||||
}
|
||||
|
||||
export function shapeOf(collider: Entity): ColliderShape {
|
||||
return colliderShape(collider.props, worldMatrixOf(collider));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user