Tilemaps can block movement: a tile gains a Solid switch, the solid cells of all layers are merged into rectangles and the placed tilemap takes part in the collision system as one body, reported as other in collision events (#132)
game-maker / gate (push) Successful in 1m41s
game-maker / suite (push) Successful in 6m3s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 09:47:03 +02:00
1 parent 10b49fac46
commit 4c411c1ff3
10 files changed
+715 -43

No files matched your search

+99 -39
View File
@@ -1,8 +1,11 @@
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, IDENTITY } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import {
type ColliderShape,
colliderShape,
exitDistance,
isTrigger,
@@ -19,7 +22,10 @@ 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. */
/**
* 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).
*/
export interface CollisionPair {
a: Entity;
b: Entity;
@@ -28,23 +34,35 @@ export interface CollisionPair {
/**
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
* collide. Runs before the BehaviorSystem so graphs see this frame's events.
*
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
* are ground, not things that walk into each other.
*/
export class CollisionSystem implements System {
enters: CollisionPair[] = [];
stays: CollisionPair[] = [];
exits: CollisionPair[] = [];
private previous = new Map<string, CollisionPair>();
/**
* The blocking rectangles of each tilemap, in cells. Worked out once per tick – 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 rects = new Map<Entity, CellRect[]>();
update(world: World): void {
// Switched-off colliders (or ones below something switched off) take no part at all.
const colliders = world.entities.filter((e) => e.type === 'collider' && isHandleEnabled(e));
this.rects = new Map();
// Switched-off bodies (or ones below something switched off) take no part at all.
const bodies = world.entities.filter((e) => isCollisionBody(e) && isHandleEnabled(e));
const current = new Map<string, CollisionPair>();
for (let i = 0; i < colliders.length; i++) {
for (let j = i + 1; j < colliders.length; j++) {
const a = colliders[i]!;
const b = colliders[j]!;
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const a = bodies[i]!;
const b = bodies[j]!;
if (bodyOf(a) === bodyOf(b)) continue;
if (this.overlapping(a, b)) current.set(`${a.id}|${b.id}`, { a, b });
if (a.type === 'tilemap' && b.type === 'tilemap') continue;
if (this.overlaps(world, a, b)) current.set(`${a.id}|${b.id}`, { a, b });
}
}
this.enters = [];
@@ -57,26 +75,44 @@ export class CollisionSystem implements System {
stop(): void {
this.previous = new Map();
this.rects = new Map();
this.enters = [];
this.stays = [];
this.exits = [];
}
/** Live test of two collider entities (world positions applied). */
overlapping(a: Entity, b: Entity): boolean {
return shapesOverlap(shapeOf(a), shapeOf(b));
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
private overlaps(world: World, a: Entity, b: Entity): boolean {
const others = this.shapesOf(world, b);
return this.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
}
/** Whether any collider of `a` overlaps any collider of `b` (see `collidersOf`). */
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
touching(world: World, a: Entity, b: Entity): boolean {
for (const ca of collidersOf(world, a)) {
for (const cb of collidersOf(world, b)) {
if (ca !== cb && this.overlapping(ca, cb)) return true;
for (const ca of partsOf(world, a)) {
for (const cb of partsOf(world, b)) {
if (ca !== cb && this.overlaps(world, ca, cb)) return true;
}
}
return false;
}
/**
* The shapes a body occupies in the world: one for a collider, one box per merged rectangle
* for a tilemap. 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)];
let rects = this.rects.get(body);
if (!rects) {
const asset = findAssetOf(world.project, body.assetId, 'tilemap');
rects = asset ? solidRects(world.project, body.props, asset) : [];
this.rects.set(body, rects);
}
const m = worldMatrixOf(body);
return rects.map((rect) => cellRectShape(body.props, rect, m));
}
/**
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
@@ -89,13 +125,19 @@ export class CollisionSystem implements System {
dy: number,
): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
const obstacles =
own.length === 0
? []
: world.entities.filter(
(c) =>
c.type === 'collider' && !isTrigger(c.props) && isHandleEnabled(c) && !travelsWith(c, entity),
);
: world.entities
.filter(
(c) =>
isCollisionBody(c) &&
!(c.type === 'collider' && isTrigger(c.props)) &&
isHandleEnabled(c) &&
!travelsWith(c, entity),
)
.flatMap((c) => this.shapesOf(world, c));
return {
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
@@ -105,7 +147,7 @@ export class CollisionSystem implements System {
private slideAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
): boolean {
@@ -124,7 +166,7 @@ export class CollisionSystem implements System {
private stepAxis(
entity: Entity,
own: Entity[],
obstacles: Entity[],
obstacles: ColliderShape[],
axis: PlaneAxis,
delta: number,
travel: AxisTravel,
@@ -135,19 +177,22 @@ export class CollisionSystem implements System {
// 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 = penetration(a, b, forward);
before.push([a, b, p > SKIN ? p : 0]);
}
}
const before = own.map((a) => {
const mover = shapeOf(a);
return obstacles.map((b) => {
const p = penetration(mover, b, forward);
return p > SKIN ? p : 0;
});
});
entity[axis] += delta;
let correction = 0;
for (const [a, b, was] of before) {
const t = penetration(a, b, forward) - was;
if (t > correction) correction = t;
}
own.forEach((a, i) => {
const mover = shapeOf(a);
obstacles.forEach((b, j) => {
const t = penetration(mover, b, forward) - before[i]![j]!;
if (t > correction) correction = t;
});
});
if (correction <= 0) return false;
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
return true;
@@ -178,9 +223,7 @@ function shapeOf(collider: Entity) {
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
* so nothing blocks – that keeps an object that ended up inside something able to leave.
*/
function penetration(a: Entity, b: Entity, forward: Vec2): number {
const mover = shapeOf(a);
const obstacle = shapeOf(b);
function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec2): number {
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
}
@@ -195,19 +238,36 @@ function travelsWith(collider: Entity, entity: Entity): boolean {
* Longest safe step in world units: half of the thinnest collider involved, measured along the
* travel as the colliders sit in the world (never below 1).
*/
function stepLimit(own: Entity[], obstacles: Entity[], along: Vec2): number {
function stepLimit(own: Entity[], obstacles: ColliderShape[], along: Vec2): number {
let min = Infinity;
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const e of obstacles) min = Math.min(min, shapeExtent(shapeOf(e), along));
for (const shape of obstacles) min = Math.min(min, shapeExtent(shape, along));
return Math.max(1, min / 2);
}
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
function isCollisionBody(entity: Entity): boolean {
return entity.type === 'collider' || entity.type === 'tilemap';
}
/**
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
*/
export function bodyOf(collider: Entity): Entity {
if (collider.type === 'tilemap') return collider;
return collider.parent ?? collider;
}
/** A collider stands for itself; any other entity stands for its direct collider children. */
/** What a body touches with: a tilemap its cells, anything else its colliders (see `collidersOf`). */
function partsOf(world: World, entity: Entity): Entity[] {
return entity.type === 'tilemap' ? [entity] : collidersOf(world, entity);
}
/**
* A collider stands for itself; any other entity stands for its direct collider children – a
* tilemap as well when it is the one being moved: its cells only ever take part as an obstacle.
*/
function collidersOf(world: World, entity: Entity): Entity[] {
if (entity.type === 'collider') return [entity];
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));