From 4c411c1ff344295eee5786a46692b412d000cc49 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 09:47:03 +0200 Subject: [PATCH] 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) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/model/tile.ts | 6 + src/core/model/tilemapCollision.ts | 128 +++++++++ src/core/properties/PropertySchema.ts | 4 +- src/editor/debugHook.ts | 2 + src/editor/viewport/SceneViewport.ts | 47 +++- src/runtime/systems/CollisionSystem.ts | 138 +++++++--- src/viewport/items.ts | 5 +- tests/core/tilemap-collision.test.ts | 355 +++++++++++++++++++++++++ tools/e2e/40-tilemap.mjs | 71 +++++ 10 files changed, 715 insertions(+), 43 deletions(-) create mode 100644 src/core/model/tilemapCollision.ts create mode 100644 tests/core/tilemap-collision.test.ts diff --git a/README.md b/README.md index 91b3b912..d5c5eab0 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, 1017 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1021 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 diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index 15cca8bf..776e472b 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -18,6 +18,8 @@ export type TileKind = (typeof TILE_KINDS)[number]; export const TILE_KIND_KEY = 'kind'; 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'; /** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; @@ -39,6 +41,10 @@ export function tilePlayback(asset: TileAsset): TilePlayback { return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop'; } +export function isTileSolid(asset: TileAsset): boolean { + return asset.props[TILE_SOLID_KEY] === true; +} + export function tileFps(asset: TileAsset): number { const fps = Number(asset.props[TILE_FPS_KEY]); return Number.isFinite(fps) && fps > 0 ? fps : 0; diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts new file mode 100644 index 00000000..d09e8c00 --- /dev/null +++ b/src/core/model/tilemapCollision.ts @@ -0,0 +1,128 @@ +/** + * Where a placed tilemap blocks (#132). A cell blocks when the tile lying there is solid – on any + * layer, a hidden one too: hiding is how a map is looked at, and an invisible blocking layer is + * a common thing to want. Only cells inside the grid count, the same ones that are drawn. + * + * The blocking cells are merged into rectangles rather than handed on one box each, so a wall is + * one shape and the collision tests stay few – and so an object sliding along a wall does not + * catch on the seam between two of its cells. + */ +import type { TilemapAsset } from './Asset'; +import { angleOf, apply, type Mat, scaleOf } from './matrix'; +import type { PropertyBag } from './Properties'; +import { findAssetOf, type Project } from './Project'; +import { isTileSolid } from './tile'; +import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap'; +import type { ColliderShape } from '../properties/collider'; + +/** A rectangle of cells: top-left cell and size, all in cells. */ +export interface CellRect { + x: number; + y: number; + w: number; + h: number; +} + +/** Every blocking cell of the map, once each however many layers hold a solid tile there. */ +export function solidCells( + project: Project, + props: PropertyBag, + asset: TilemapAsset, +): { x: number; y: number }[] { + const solid = new Map(); + const isSolid = (id: string): boolean => { + const known = solid.get(id); + if (known !== undefined) return known; + const tile = findAssetOf(project, id, 'tile'); + const value = Boolean(tile && isTileSolid(tile)); + solid.set(id, value); + return value; + }; + const seen = new Set(); + const cells: { x: number; y: number }[] = []; + for (const layer of asset.layers) { + for (const [key, tileId] of Object.entries(layer.cells)) { + if (!tileId || seen.has(key) || !isSolid(tileId)) continue; + const at = parseCellKey(key); + if (!at || !inGrid(props, at.x, at.y)) continue; + seen.add(key); + cells.push(at); + } + } + return cells; +} + +/** + * The cells merged into rectangles, row by row: each row is cut into runs, and a run that spans + * exactly the columns of a rectangle ending right above it makes that rectangle one row taller. + * Every cell lands in exactly one rectangle; the result is ordered top-down, then left to right. + */ +export function mergeCells(cells: readonly { x: number; y: number }[]): CellRect[] { + const rows = new Map(); + for (const { x, y } of cells) { + const row = rows.get(y); + if (row) row.push(x); + else rows.set(y, [x]); + } + const done: CellRect[] = []; + // Rectangles still open at the bottom, by the columns they span. + let open = new Map(); + let previous = Number.NaN; + for (const y of [...rows.keys()].sort((a, b) => a - b)) { + if (y !== previous + 1) { + done.push(...open.values()); + open = new Map(); + } + const next = new Map(); + for (const [x0, w] of runsOf(rows.get(y)!)) { + const key = `${x0},${w}`; + const above = open.get(key); + if (above) { + above.h++; + open.delete(key); + next.set(key, above); + } else next.set(key, { x: x0, y, w, h: 1 }); + } + done.push(...open.values()); + open = next; + previous = y; + } + done.push(...open.values()); + return done.sort((a, b) => a.y - b.y || a.x - b.x); +} + +/** Runs of neighbouring columns as `[first column, length]`; doubles count once. */ +function runsOf(columns: number[]): [number, number][] { + const sorted = [...new Set(columns)].sort((a, b) => a - b); + const runs: [number, number][] = []; + for (const x of sorted) { + const last = runs[runs.length - 1]; + if (last && last[0] + last[1] === x) last[1]++; + else runs.push([x, 1]); + } + return runs; +} + +/** The blocking rectangles of a map, in cells. */ +export function solidRects(project: Project, props: PropertyBag, asset: TilemapAsset): CellRect[] { + return mergeCells(solidCells(project, props, asset)); +} + +/** + * One rectangle as it sits in the world: a box turning and growing with the placed map, the same + * shape a collider on it would have (see `properties/collider.ts`). + */ +export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): ColliderShape { + const tile = tilemapTileSize(props); + const corner = cellOrigin(props, rect.x, rect.y); + const centre = apply(m, { x: corner.x + (rect.w * tile) / 2, y: corner.y + (rect.h * tile) / 2 }); + const scale = scaleOf(m); + return { + kind: 'box', + x: centre.x, + y: centre.y, + w: rect.w * tile * scale, + h: rect.h * tile * scale, + angle: angleOf(m), + }; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 38fef5e0..a72f63ff 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,7 +20,7 @@ import { type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; -import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY } from '../model/tile'; +import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY, TILE_SOLID_KEY } from '../model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; import { SLICE_SCALE_KEY } from '../model/texture'; import type { SceneObject } from '../model/SceneObject'; @@ -247,6 +247,8 @@ const ASSET_SCHEMAS: Record = { { value: 'pingpong', label: 'Ping-pong' }, ], }, + // 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 }, ], // The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say // how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f406567d..f02a82f0 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -552,6 +552,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ info: (objectId: string) => viewport.tilemapInfo(objectId), gameInfo: (objectId: string) => gameView.tilemapInfo(objectId), + /** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */ + solids: (objectId: string) => viewport.solidInfo(objectId), /** Where the widgets of the inspector section sit: their texts are on screen elsewhere too. */ toolAt: (tool: string) => { const button = currentTilemapSection()?.tools.get(tool as TilemapTool); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index e4d055e2..6f6dbc26 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -18,6 +18,7 @@ import { invert, linearOf, type Mat, + scaleOf, } from '../../core/model/matrix'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; @@ -34,13 +35,14 @@ import { tilemapRows, tilemapTileSize, } from '../../core/model/tilemap'; +import type { CellRect } from '../../core/model/tilemapCollision'; import { footprintCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { type EditorState, objectSelection, type TilemapTool } from '../state'; import { paintedLayerId } from '../inspector/tilemapSection'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; import { collectHolderItems, type DrawItem } from '../../viewport/items'; -import { dashedLine, drawSourceIcon } from '../../viewport/symbols'; +import { dashedLine, drawSourceIcon, placeNode } from '../../viewport/symbols'; import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { FreeViewport } from './FreeViewport'; import type { Frame } from './WorldViewport'; @@ -115,6 +117,8 @@ export class SceneViewport extends FreeViewport { private readonly gizmos = new Graphics(); private readonly labels = new Map(); private readonly icons = new Map(); + /** Where each placed tilemap blocks, outlined like a collider (#132). */ + private readonly solids = new Map(); private dragging: ObjectDrag | null = null; private stroke: PaintStroke | null = null; private marquee: Marquee | null = null; @@ -500,6 +504,7 @@ export class SceneViewport extends FreeViewport { protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void { this.syncIcon(item, onScreen); + this.syncSolids(item, onScreen, scale); this.syncLabel(item, onScreen, scale, extent); } @@ -508,6 +513,46 @@ export class SceneViewport extends FreeViewport { this.labels.delete(id); this.icons.get(id)?.destroy(); this.icons.delete(id); + this.solids.get(id)?.destroy(); + this.solids.delete(id); + } + + /** The blocking rectangles of a placed tilemap as outlined, in cells (test hook). */ + solidInfo(id: string): { visible: boolean; rects: CellRect[] } | null { + const node = this.solids.get(id); + const item = this.lastItems.find((i) => i.id === id); + if (!node || !item?.tilemap) return null; + return { visible: node.visible, rects: item.tilemap.solids.map((rect) => ({ ...rect })) }; + } + + /** + * The solid cells of a tilemap, drawn the way a collider is: tinted and outlined, above the + * content so a painted wall cannot hide them. Editor-only for the same reason as the icon: the + * game view never syncs decorations. + */ + private syncSolids(item: DrawItem, show: boolean, scale: number): void { + let node = this.solids.get(item.id); + const rects = item.tilemap?.solids ?? []; + if (!show || rects.length === 0) { + if (node) node.visible = false; + return; + } + if (!node) { + node = new Graphics(); + node.eventMode = 'none'; + this.solids.set(item.id, node); + this.overlay.addChild(node); + } + node.visible = true; + placeNode(node, item.m); + 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); + } + 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/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 1e6cab48..17107726 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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(); + /** + * 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(); 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(); - 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)); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 86922bc6..2e3583f7 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -7,6 +7,7 @@ import type { PropertyBag } from '../core/model/Properties'; import type { ParticlesAsset } from '../core/model/Asset'; import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap'; +import { type CellRect, solidRects } from '../core/model/tilemapCollision'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; @@ -65,6 +66,8 @@ export interface TilemapLayerDraw { export interface TilemapDraw { layers: TilemapLayerDraw[]; tiles: Map; + /** Where the map blocks, in cells – the scene view outlines it like a collider (#132). */ + solids: CellRect[]; } /** @@ -92,7 +95,7 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag): }); } } - return { layers, tiles: used }; + return { layers, tiles: used, solids: solidRects(project, props, asset) }; } /** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source diff --git a/tests/core/tilemap-collision.test.ts b/tests/core/tilemap-collision.test.ts new file mode 100644 index 00000000..a8ae1982 --- /dev/null +++ b/tests/core/tilemap-collision.test.ts @@ -0,0 +1,355 @@ +import { 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 { + AddTilemapLayerCommand, + PaintTilesCommand, + SetTilemapLayerHiddenCommand, +} from '../../src/core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../src/core/model/Asset'; +import { transformMatrix, translation } from '../../src/core/model/matrix'; +import { findAsset } from '../../src/core/model/Project'; +import { isTileSolid, TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; +import { + type CellRect, + cellRectShape, + mergeCells, + solidCells, + solidRects, +} from '../../src/core/model/tilemapCollision'; +import { schemaFor } from '../../src/core/properties/PropertySchema'; +import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; + +const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y })); + +/** Every cell of a rectangle list, so a check can see nothing was lost and nothing doubled. */ +function covered(rects: CellRect[]): string[] { + const out: string[] = []; + for (const r of rects) + for (let y = r.y; y < r.y + r.h; y++) for (let x = r.x; x < r.x + r.w; x++) out.push(`${x},${y}`); + return out.sort(); +} + +describe('merging solid cells into rectangles', () => { + it('nothing, one cell, a run and a block', () => { + expect(mergeCells([])).toEqual([]); + expect(mergeCells(cells([3, 4]))).toEqual([{ x: 3, y: 4, w: 1, h: 1 }]); + expect(mergeCells(cells([2, 0], [0, 0], [1, 0]))).toEqual([{ x: 0, y: 0, w: 3, h: 1 }]); + expect(mergeCells(cells([0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1]))).toEqual([ + { x: 0, y: 0, w: 3, h: 2 }, + ]); + }); + + it('a column is one rectangle; a gap in the rows or a different run starts a new one', () => { + expect(mergeCells(cells([5, 0], [5, 1], [5, 2], [5, 3]))).toEqual([{ x: 5, y: 0, w: 1, h: 4 }]); + expect(mergeCells(cells([5, 0], [5, 1], [5, 3]))).toEqual([ + { x: 5, y: 0, w: 1, h: 2 }, + { x: 5, y: 3, w: 1, h: 1 }, + ]); + // An L: the upright and the foot are two rectangles, the foot spanning its whole row. + expect(mergeCells(cells([0, 0], [0, 1], [0, 2], [1, 2], [2, 2]))).toEqual([ + { x: 0, y: 0, w: 1, h: 2 }, + { x: 0, y: 2, w: 3, h: 1 }, + ]); + }); + + it('a ring keeps its hole; negative cells and doubles are fine', () => { + const ring = cells([0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2]); + expect(mergeCells(ring)).toEqual([ + { x: 0, y: 0, w: 3, h: 1 }, + { x: 0, y: 1, w: 1, h: 1 }, + { x: 2, y: 1, w: 1, h: 1 }, + { x: 0, y: 2, w: 3, h: 1 }, + ]); + expect(mergeCells(cells([-2, -1], [-1, -1], [-2, -1]))).toEqual([{ x: -2, y: -1, w: 2, h: 1 }]); + }); + + it('covers every cell exactly once, for any scatter', () => { + let seed = 7; + const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647; + for (let round = 0; round < 20; round++) { + const scatter = Array.from({ length: 150 }, () => ({ + x: Math.floor(random() * 20) - 5, + y: Math.floor(random() * 20) - 5, + })); + const unique = [...new Set(scatter.map((c) => `${c.x},${c.y}`))].sort(); + const rects = mergeCells(scatter); + expect(covered(rects)).toEqual(unique); + // Fewer shapes than cells, or the merge did nothing. + expect(rects.length).toBeLessThan(unique.length); + } + }); +}); + +/** + * A scene with a 10 × 10 map of 48 px cells placed at the origin – cell (x, y) spans + * x·48 − 240 … x·48 − 192 – and a 40 × 40 hero with a collider at the origin. + */ +function setup() { + const f = makeSpriteScene('Hero'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40)); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40)); + const wall = new CreateAssetCommand('tile', 'Wall'); + const grass = new CreateAssetCommand('tile', 'Grass'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + for (const command of [wall, grass, map]) f.stack.execute(command); + f.stack.execute(new SetAssetPropertyCommand(wall.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 hero = place(f, f.spriteId, { x: 0, y: 0 }); + f.stack.execute(new AddColliderCommand(f.sceneId, hero)); + const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; + const paint = (at: [number, number][], tileId = wall.asset.id, layerId = assetOf().layers[0]!.id) => + f.stack.execute( + new PaintTilesCommand( + map.asset.id, + layerId, + cells(...at).map((c) => ({ ...c, tileId })), + ), + ); + return { + ...f, + wallId: wall.asset.id, + grassId: grass.asset.id, + mapId: map.asset.id, + mapObject, + hero, + assetOf, + paint, + }; +} + +/** Cell column 6, all ten rows: its left face lies at x = 48. */ +const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]); + +describe('which cells block', () => { + it('a tile is not solid until switched on, and the switch survives a round trip', () => { + const s = setup(); + expect(schemaFor('tile').find((d) => d.key === TILE_SOLID_KEY)).toMatchObject({ + kind: 'boolean', + default: false, + }); + const grass = findAsset(s.project, s.grassId); + expect(grass?.type === 'tile' && isTileSolid(grass)).toBe(false); + const wall = findAsset(roundTrip(s.project), s.wallId); + expect(wall?.type === 'tile' && isTileSolid(wall)).toBe(true); + }); + + it('solid tiles on every layer, a hidden one too; not grass, not outside the grid, not a deleted tile', () => { + const s = setup(); + s.paint([ + [0, 0], + [1, 0], + [12, 0], + [-1, 3], + ]); // two outside the 10 × 10 grid + s.paint([[2, 0]], s.grassId); + const add = new AddTilemapLayerCommand(s.mapId); + s.stack.execute(add); + s.paint( + [ + [1, 0], + [0, 5], + ], + s.wallId, + add.layerId, + ); // (1, 0) doubles the first layer + s.stack.execute(new SetTilemapLayerHiddenCommand(s.mapId, add.layerId, true)); + s.paint([[4, 4]], 'no-such-tile'); + const props = s.assetOf().props; + const found = solidCells(s.project, props, s.assetOf()) + .map((c) => `${c.x},${c.y}`) + .sort(); + expect(found).toEqual(['0,0', '0,5', '1,0']); + expect(solidRects(s.project, props, s.assetOf())).toEqual([ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 0, y: 5, w: 1, h: 1 }, + ]); + }); + + it('a rectangle sits in the world as the placed map does: moved, grown and turned', () => { + const props = { [TILEMAP_COLUMNS_KEY]: 10, [TILEMAP_ROWS_KEY]: 10 }; + const rect = { x: 6, y: 0, w: 1, h: 10 }; + expect(cellRectShape(props, rect, translation(0, 0))).toEqual({ + kind: 'box', + x: 72, + y: 0, + w: 48, + h: 480, + angle: 0, + }); + expect(cellRectShape(props, rect, translation(100, -50))).toMatchObject({ x: 172, y: -50 }); + const turned = cellRectShape(props, rect, transformMatrix(0, 0, 90, 2, false, false)); + expect(turned).toMatchObject({ kind: 'box', w: 96, h: 960 }); + // Turned a quarter clockwise and doubled, the column's centre (72, 0) lands at (0, 144). + expect(turned.x).toBeCloseTo(0); + expect(turned.y).toBeCloseTo(144); + }); +}); + +describe('a tilemap in the CollisionSystem', () => { + it('stops a move flush at a solid column and lets it leave again', () => { + const s = setup(); + s.paint(COLUMN_SIX); + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + // Right edge at 20, the wall's face at 48: 28 of travel. + expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(hero.x).toBeCloseTo(28, 9); + expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(hero.x).toBeCloseTo(28, 9); + expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false }); + expect(hero.x).toBeCloseTo(-2, 9); + }); + + it('a step larger than a cell does not jump over the wall', () => { + const s = setup(); + s.paint(COLUMN_SIX); + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false }); + expect(hero.x).toBeCloseTo(28, 6); + }); + + it('slides along a wall made of several rectangles without catching on their seams', () => { + const s = setup(); + // The column with a bump on its far side in row 5: three rectangles stacked along the face. + s.paint([...COLUMN_SIX, [7, 5]]); + expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(3); + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + hero.y = -200; + collisions.moveAndSlide(world, hero, 97.3, 0); + for (let i = 0; i < 60; i++) { + // Pressed against the face while walking down past both seams. + const { blockedX, blockedY } = collisions.moveAndSlide(world, hero, 3.1, 6.7); + expect(blockedX).toBe(true); + expect(blockedY).toBe(false); + } + expect(hero.x).toBeCloseTo(28, 6); + expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6); + }); + + it('grass blocks nothing, a switched-off map blocks nothing', () => { + const s = setup(); + s.paint(COLUMN_SIX, s.grassId); + let started = startWorld(s, { collisions: true }); + let hero = started.world.findByObjectId(s.hero)!; + expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false); + s.paint(COLUMN_SIX); + s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'enabled', false)); + started = startWorld(s, { collisions: true }); + hero = started.world.findByObjectId(s.hero)!; + expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false); + }); + + it('follows the placed map: moved, grown and turned', () => { + const s = setup(); + s.paint([[6, 5]]); // one cell: x 48…96, y 0…48 at scale 1 + s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2)); + // Doubled: the cell spans x 96…192, y 0…96. The hero at y = 40 is in its rows. + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + hero.y = 40; + collisions.moveAndSlide(world, hero, 300, 0); + expect(hero.x).toBeCloseTo(76, 6); + // The map moved on by 100 takes its cell along at once: the shapes follow its current place. + const map = world.findByObjectId(s.mapObject)!; + map.x = 100; + collisions.moveAndSlide(world, hero, 300, 0); + expect(hero.x).toBeCloseTo(176, 6); + // Turned a quarter clockwise about its centre, the cell lies at x −96…0, y 96…192: out of the way. + map.x = 0; + map.props.rotation = 90; + hero.x = 0; + expect(collisions.moveAndSlide(world, hero, 300, 0).blockedX).toBe(false); + }); + + it('picks up cells painted while running on the next tick', () => { + const s = setup(); + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + world.tick(0.1); + s.paint(COLUMN_SIX); + world.tick(0.1); + expect(collisions.moveAndSlide(world, hero, 100, 0).blockedX).toBe(true); + }); + + it('reports enter, stay and exit with the tilemap as a body of its own; two maps never meet', () => { + const s = setup(); + s.paint(COLUMN_SIX); + place(s, s.mapId, { x: 0, y: 0 }); // a second placement right on top of the first + const { world, collisions } = startWorld(s, { collisions: true }); + const hero = world.findByObjectId(s.hero)!; + world.tick(0.1); + expect(collisions.enters).toHaveLength(0); + hero.x = 40; + world.tick(0.1); + expect(collisions.enters).toHaveLength(2); // one per placed map, none between the maps + expect(collisions.enters.every((p) => p.a.type === 'tilemap' || p.b.type === 'tilemap')).toBe(true); + world.tick(0.1); + expect(collisions.stays).toHaveLength(2); + const map = world.findByObjectId(s.mapObject)!; + expect(collisions.touching(world, hero, map)).toBe(true); + hero.x = 0; + world.tick(0.1); + expect(collisions.exits).toHaveLength(2); + expect(collisions.touching(world, hero, map)).toBe(false); + }); +}); + +describe('tilemap collisions in behaviors', () => { + it('On collision enter names the tilemap as the other body; Is touching agrees', () => { + const s = setup(); + s.paint(COLUMN_SIX); + const behavior = makeBehavior(s, 'Bump'); + place(s, behavior.id, { x: 0, y: 0 }, s.hero); + const edit = graphEditor(s, behavior); + const enter = edit.addNode('OnCollisionEnter'); + const logEnter = edit.addNode('Log'); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const byName = edit.addNode('ObjectByName', { name: 'Ground' }); + const touching = edit.addNode('IsTouching'); + const logTouch = edit.addNode('Log'); + edit.wire([ + [enter, 'exec', logEnter, 'exec'], + [enter, 'other', logEnter, 'message'], + [update, 'exec', logTouch, 'exec'], + [owner, 'object', touching, 'a'], + [byName, 'object', touching, 'b'], + [touching, 'result', logTouch, 'message'], + ]); + const { world } = startWorld(s, { collisions: true, behaviors: true }); + world.tick(0.1); + expect(world.log).toEqual(['false']); + world.findByObjectId(s.hero)!.x = 40; + world.tick(0.1); + expect(world.log).toEqual(['false', 'Ground', 'true']); + }); + + it('the Move node with "Collide with solids" stops at the painted wall', () => { + const s = setup(); + s.paint(COLUMN_SIX); + const behavior = makeBehavior(s, 'Walk'); + place(s, behavior.id, { x: 0, y: 0 }, s.hero); + const edit = graphEditor(s, behavior); + const update = edit.addNode('OnUpdate'); + const owner = edit.addNode('Owner'); + const move = edit.addNode('Move', { dx: 200, collide: true }); + edit.wire([ + [update, 'exec', move, 'exec'], + [update, 'dt', move, 'dt'], + [owner, 'object', move, 'object'], + ]); + const { world } = startWorld(s, { collisions: true, behaviors: true }); + const hero = world.findByObjectId(s.hero)!; + world.tick(0.1); + expect(hero.x).toBeCloseTo(20, 9); + world.tick(0.1); + expect(hero.x).toBeCloseTo(28, 9); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index bdef5a57..1b717d67 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -320,6 +320,77 @@ export async function tilemap(t) { JSON.stringify({ reloaded, inMemory }), ); + // 40l. Collision (#132). The switch sits on the tile, and the map outlines where it now blocks. + // The map holds a 3 × 3 block and, on the second layer, one cell at (4, 0). + await openTab(t, 'scene'); + await select({ kind: 'asset', assetId: 'at-grass' }); + const solidRow = await json("__gm.inspectorField('Solid')"); + if (solidRow) await click(solidRow.x, solidRow.y); + await settle(); + check( + 'a tile has a Solid switch in the inspector, and clicking it makes the tile solid', + Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true, + JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }), + ); + await mutate(`__gm.lookAt(0, ${LANE})`); + await settle(); + const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); + check( + 'the scene view outlines the blocking cells, merged into rectangles', + outlined?.visible === true && + JSON.stringify(outlined.rects) === + JSON.stringify([ + { x: 0, y: 0, w: 3, h: 3 }, + { x: 4, y: 0, w: 1, h: 1 }, + ]), + JSON.stringify(outlined), + ); + await shot('40-tilemap-solid'); + + // In the game a crate of its own walks into the block: the seeded Crate sprite with a 100 × 60 + // collider and the Mover (ArrowRight moves its owner, colliding with solids) below it. Its own + // rather than the seeded red crate, which an earlier step of a full run may have removed. It + // starts at −200 with 30 units of free travel: the map sits so that the block's face is at −120. + const mapBefore = (await t.object(MAP_OBJECT)).position; + await mutate(` + const scene = __gm.state.activeScene; + const object = (id, assetId, parentId, position, overrides) => ({ id, assetId, name: '', position, + editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} }); + scene.objects.push( + object('tm-crate', 'crate', '', { x: -200, y: ${LANE}, z: 0 }, {}), + object('tm-crate-collider', 'collider', 'tm-crate', { x: 0, y: 0, z: 0 }, + { shape: 'box', width: 100, height: 60, isTrigger: false }), + object('tm-crate-mover', 'mover', 'tm-crate', { x: 0, y: 0, z: 0 }, {})); + scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = { x: 24, y: ${LANE + 24}, z: 0 }; + __gm.state.select(null)`); + await settle(); + const playAgainstMap = async () => { + await t.play(); + await t.holdKey('ArrowRight', 700); + const crate = await t.entity('tm-crate'); + await t.stop(); + await settle(); + return crate; + }; + const stopped = await playAgainstMap(); + check( + 'Move with "Collide with solids" stops the crate flush against the solid cells', + stopped && Math.abs(stopped.x - -170) < 0.01, + `world x=${stopped?.x}`, + ); + await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').props.solid = false"); + const passed = await playAgainstMap(); + check( + 'a tile that is not solid lets the crate walk over it', + passed && passed.x > -170 + 20, + `world x=${passed?.x}`, + ); + await mutate(` + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate')); + scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`); + await settle(); + // Leave the editor as the rest of the suite expects to find it. await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })"); await evaluate('__gm.state.select(null)');