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)');