Fix collider tunneling, seed the container demo, split the e2e fixture
Move with "Collide with solids" now steps in sub-steps of at most half the thinnest collider, so a fast step can no longer jump across a collider (the crates are only 60 high, which is why it showed up mostly on the y axis). A pre-existing overlap is still never resolved, but it can no longer be deepened either, so a small overlap cannot open a wall permanently. seed-demo.mjs now writes the saved demo scene: a "Player" container (green sprite with collider and the Mover behavior) against two "Enemy" container instances, camera at zoom 0.5, Spawner dropping another enemy on Space. The e2e checks assert against the old two-crate layout and locate widgets by text, which the demo can no longer provide (three identically named sprite instances, both crates nested in containers). Its fixture therefore moves to tools/seed-e2e.mjs so the shipped demo can evolve without rewriting 100+ checks: seed-e2e -> e2e -> seed-demo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3379232e64
commit
4900d7bbf6
6 files changed
+269
-39
No files matched your search
@@ -1,8 +1,13 @@
|
||||
import { type Axis, axisCorrection, colliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
|
||||
import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import type { System } from './System';
|
||||
|
||||
/** Overlaps up to this depth (world units) count as contact, not as "already inside". */
|
||||
const SKIN = 0.05;
|
||||
/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */
|
||||
const MAX_STEPS = 256;
|
||||
|
||||
/** Two collider entities that overlap (or overlapped) – always the same pair order per key. */
|
||||
export interface CollisionPair {
|
||||
a: Entity;
|
||||
@@ -66,9 +71,10 @@ export class CollisionSystem implements System {
|
||||
* Moves `entity` by (dx, dy) in its local units, one axis at a time (x, then y): after
|
||||
* each axis step its solid colliders are pushed back to just touch whatever solid collider
|
||||
* they ran into, so the remaining axis still moves – the object slides along walls.
|
||||
* Pairs that already overlapped before the step are ignored (an object that starts inside
|
||||
* a wall may leave it, and stacked objects never get jolted apart). Triggers, the
|
||||
* entity's own colliders and colliders that travel with it (descendants) never block.
|
||||
* An overlap that already existed is never resolved (no jolt) but also never deepened, so
|
||||
* an object inside a wall can leave it without being able to burrow further in. Long moves
|
||||
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
|
||||
* colliders and colliders that travel with it (descendants) never block.
|
||||
*/
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
|
||||
@@ -77,22 +83,50 @@ export class CollisionSystem implements System {
|
||||
}
|
||||
|
||||
private slideAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
|
||||
if (delta === 0) return false;
|
||||
if (delta === 0 || own.length === 0) return false;
|
||||
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
|
||||
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
|
||||
for (let i = 0; i < steps; i++) if (this.stepAxis(world, entity, own, obstacles, axis, delta / steps)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
|
||||
private stepAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
|
||||
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
||||
// Which pairs are free to collide: only those that were apart before this step.
|
||||
const candidates: [Entity, Entity][] = [];
|
||||
for (const a of own) for (const b of obstacles) if (!this.overlapping(world, a, b)) candidates.push([a, b]);
|
||||
// How deep each pair already sits in the other before the step. A step may never deepen
|
||||
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
|
||||
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
|
||||
// previous correction land, and taking those for "already inside" would open the wall.
|
||||
const before: [Entity, Entity, number][] = [];
|
||||
for (const a of own) {
|
||||
for (const b of obstacles) {
|
||||
const p = this.penetration(world, a, b, axis, dir);
|
||||
before.push([a, b, p > SKIN ? p : 0]);
|
||||
}
|
||||
}
|
||||
entity[axis] += delta;
|
||||
let correction = 0;
|
||||
for (const [a, b] of candidates) {
|
||||
const t = axisCorrection(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b)), axis, dir);
|
||||
for (const [a, b, was] of before) {
|
||||
const t = this.penetration(world, a, b, axis, dir) - was;
|
||||
if (t > correction) correction = t;
|
||||
}
|
||||
if (correction === 0) return false;
|
||||
if (correction <= 0) return false;
|
||||
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are
|
||||
* apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards,
|
||||
* so nothing blocks – that keeps an object that ended up inside something able to leave.
|
||||
*/
|
||||
private penetration(world: World, a: Entity, b: Entity, axis: Axis, dir: 1 | -1): number {
|
||||
const mover = colliderShape(a.props, world.worldPosition(a));
|
||||
const obstacle = colliderShape(b.props, world.worldPosition(b));
|
||||
if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0;
|
||||
return axisCorrection(mover, obstacle, axis, dir);
|
||||
}
|
||||
|
||||
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
|
||||
private travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true;
|
||||
@@ -100,6 +134,19 @@ export class CollisionSystem implements System {
|
||||
}
|
||||
}
|
||||
|
||||
/** Longest safe step along `axis`: half of the thinnest collider involved (never below 1). */
|
||||
function stepLimit(own: Entity[], obstacles: Entity[], axis: Axis): number {
|
||||
let min = Infinity;
|
||||
for (const e of own) min = Math.min(min, extentOf(e, axis));
|
||||
for (const e of obstacles) min = Math.min(min, extentOf(e, axis));
|
||||
return Math.max(1, min / 2);
|
||||
}
|
||||
|
||||
function extentOf(collider: Entity, axis: Axis): number {
|
||||
const size = colliderSize(collider.props);
|
||||
return axis === 'x' ? size.w : size.h;
|
||||
}
|
||||
|
||||
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
|
||||
export function bodyOf(collider: Entity): Entity {
|
||||
return collider.parent ?? collider;
|
||||
|
||||
Reference in new issue
Block a user