diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 7c2fd8b3..ecaf2099 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -24,7 +24,7 @@ */ import type { TileAsset, TilemapAsset } from './Asset'; import { tileQuadrants } from './autotile'; -import { angleOf, apply, type Mat, scaleOf } from './matrix'; +import { angleOf, apply, invert, type Mat, scaleOf } from './matrix'; import { boundaryOf, type BoundaryLine, clipToRect, convexPieces, signedArea } from './polygon'; import type { PropertyBag, Vec2 } from './Properties'; import { findAssetOf, type Project } from './Project'; @@ -38,8 +38,8 @@ import { tileKind, type TileKind, } from './tile'; -import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap'; -import { type ColliderShape, oneWayAngle } from '../properties/collider'; +import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap'; +import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider'; /** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */ export interface CellRect { @@ -449,6 +449,51 @@ export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): Colli }; } +/** + * Which cells of a placed map can reach into a world rectangle (#184): the rectangle taken back + * into the map's own units, as cell coordinates – a turned map gives the rectangle around its + * turned self, which is wider than needed but never too narrow. + */ +export function cellsWithin(props: PropertyBag, m: Mat, bounds: ShapeBounds): CellBounds { + const back = invert(m); + const tile = tilemapTileSize(props); + const size = tilemapSize(props); + const corners = [ + { x: bounds.left, y: bounds.top }, + { x: bounds.right, y: bounds.top }, + { x: bounds.right, y: bounds.bottom }, + { x: bounds.left, y: bounds.bottom }, + ].map((p) => apply(back, p)); + const cell = (values: number[], half: number) => values.map((v) => (v + half) / tile); + const xs = cell( + corners.map((p) => p.x), + size.w / 2, + ); + const ys = cell( + corners.map((p) => p.y), + size.h / 2, + ); + return { + left: Math.min(...xs), + top: Math.min(...ys), + right: Math.max(...xs), + bottom: Math.max(...ys), + }; +} + +/** A rectangle in cell coordinates, as `cellsWithin` reports it. */ +export interface CellBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +/** Whether a rectangle of cells touches the range at all – touching counts, so nothing near is lost. */ +export function cellsMeet(range: CellBounds, x: number, y: number, w = 1, h = 1): boolean { + return x <= range.right && x + w >= range.left && y <= range.bottom && y + h >= range.top; +} + /** An outlined cell as it sits in the world: a polygon turning and growing with the placed map. */ export function cellPolygonShape(props: PropertyBag, polygon: CellPolygon, m: Mat): ColliderShape { const world = (p: Vec2) => apply(m, cellOrigin(props, p.x, p.y)); diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index 2dc7ffd3..25a98617 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -374,6 +374,64 @@ export function contactBetween( return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } }; } +/** An axis-aligned rectangle in world units, as `shapeBounds` reports it. */ +export interface ShapeBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * The smallest world rectangle the shape fits in – what a broad phase looks at before working out + * an exact contact (#184). A turned box measures its slant, a capsule and a circle their radius, a + * polygon its outline (its pieces never reach beyond it). + */ +export function shapeBounds(shape: ColliderShape): ShapeBounds { + const around = (x: number, y: number, hw: number, hh: number) => ({ + left: x - hw, + top: y - hh, + right: x + hw, + bottom: y + hh, + }); + switch (shape.kind) { + case 'circle': + return around(shape.x, shape.y, shape.r, shape.r); + case 'box': + return around(shape.x, shape.y, reach(shape, { x: 1, y: 0 }), reach(shape, { x: 0, y: 1 })); + case 'capsule': + return { + left: Math.min(shape.a.x, shape.b.x) - shape.r, + top: Math.min(shape.a.y, shape.b.y) - shape.r, + right: Math.max(shape.a.x, shape.b.x) + shape.r, + bottom: Math.max(shape.a.y, shape.b.y) + shape.r, + }; + case 'polygon': + return boundsOfPoints(shape.outline); + } +} + +function boundsOfPoints(points: readonly Vec2[]): ShapeBounds { + const bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }; + for (const p of points) { + bounds.left = Math.min(bounds.left, p.x); + bounds.top = Math.min(bounds.top, p.y); + bounds.right = Math.max(bounds.right, p.x); + bounds.bottom = Math.max(bounds.bottom, p.y); + } + return bounds; +} + +/** The rectangle grown by `pad` on every side. */ +export function grownBounds(b: ShapeBounds, pad: number): ShapeBounds { + return { left: b.left - pad, top: b.top - pad, right: b.right + pad, bottom: b.bottom + pad }; +} + +/** Whether two rectangles share any area or edge – touching counts, so nothing near is dropped. */ +export function boundsMeet(a: ShapeBounds, b: ShapeBounds): boolean { + return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom; +} + /** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */ export function overlapDepth(separation: number): number { return separation < -EPSILON ? -separation : 0; diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index ed768637..ec4d1d4f 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -5,6 +5,7 @@ import type { World } from '../World'; import type { Vec2 } from '../../core/model/Properties'; import { bodyOf, + boundsAround, type Contact, type CollisionPair, type CollisionSource, @@ -67,8 +68,12 @@ export class CollisionSystem implements System, CollisionSource { /** 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.solids.shapesOf(world, b); - return this.solids.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb))); + // A map takes part with a shape per cell, so only the cells within reach of the other side are + // worked out at all (#184); between two maps neither narrows the other down. + if (a.type === 'tilemap' && b.type !== 'tilemap') return this.overlaps(world, b, a); + const mine = this.solids.shapesOf(world, a); + const others = this.solids.shapesOf(world, b, a.type === 'tilemap' ? undefined : boundsAround(mine)); + return mine.some((sa) => others.some((sb) => shapesOverlap(sa, sb))); } /** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */ diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 4e0169c2..c9977155 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -8,6 +8,8 @@ import { cellPolygonShape, type CellRect, cellRectShape, + cellsMeet, + cellsWithin, oneWayAngles, solidPolygons, solidRects, @@ -20,10 +22,12 @@ import { maskHas, } from '../../core/model/collisionLayers'; import { + boundsMeet, type ColliderShape, colliderShape, contactBetween, exitDistance, + grownBounds, isTrigger, landsOn, movedShape, @@ -31,6 +35,8 @@ import { oneWayFacing, type OneWaySide, overlapDepth, + shapeBounds, + type ShapeBounds, shapeExtent, shapesOverlap, } from '../../core/properties/collider'; @@ -47,6 +53,8 @@ const MAX_PASSES = 4; const DEEPER = 1e-9; /** A normal this close to square with an axis does not stand against a move along it. */ const SQUARE = 1e-6; +/** What the broad phase adds to the reach of a move, so rounding never drops a shape (#184). */ +const REACH_SLACK = 1e-6; /** * Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a @@ -217,15 +225,23 @@ export class Solids { * top (#155) within their tile's angle (#159). A tilemap placed before the first tick is worked out * on the spot. */ - solidShapesOf(world: World, body: Entity): SolidShape[] { + solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] { const m = worldMatrixOf(body); if (body.type !== 'tilemap') return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }]; const { rects, polygons, oneWay } = this.blocksOf(world, body); const up = oneWayFacing({ oneWay: true }, m); + // A map is many solids under one entity, so the cells out of reach are dropped before any of + // them is worked out in world units at all (#184). + const range = within ? cellsWithin(body.props, m, within) : null; + const near = (x: number, y: number, w?: number, h?: number) => !range || cellsMeet(range, x, y, w, h); const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: OneWaySide | null) => [ - ...cells.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })), - ...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })), + ...cells + .filter((rect) => near(rect.x, rect.y, rect.w, rect.h)) + .map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })), + ...outlines + .filter((polygon) => near(polygon.x, polygon.y)) + .map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })), ]; return [ ...shapes(rects, polygons, null), @@ -235,8 +251,8 @@ export class Solids { ]; } - shapesOf(world: World, body: Entity): ColliderShape[] { - return this.solidShapesOf(world, body).map(({ shape }) => shape); + shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] { + return this.solidShapesOf(world, body, within).map(({ shape }) => shape); } /** @@ -247,9 +263,10 @@ export class Solids { raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null { const d = unitOf(direction); if ((d.x === 0 && d.y === 0) || !(length > 0)) return null; + const area = rayBounds(from, d, length); let best: RayResult | null = null; for (const body of this.rayBodies(world, filter)) { - for (const { shape, facing } of this.solidShapesOf(world, body)) { + for (const { shape, facing } of this.solidShapesOf(world, body, area)) { const hit = rayCast(shape, from, d, best?.distance ?? length); if (!hit || (facing && !landsOn(hit.normal, facing))) continue; if (best && hit.distance >= best.distance) continue; @@ -275,13 +292,14 @@ export class Solids { ): RayResult[] { const d = unitOf(direction); if ((d.x === 0 && d.y === 0) || !(length > 0)) return []; + const area = rayBounds(from, d, length); const hits: RayResult[] = []; for (const body of this.rayBodies(world, filter)) { // One solid shape is one hit, except for a collider: its pieces are one shape to the world, // so only the nearest of them counts (a polygon in three parts is not three enemies). const perShape = body.type === 'tilemap'; let nearest: RayResult | null = null; - for (const { shape, facing } of this.solidShapesOf(world, body)) { + for (const { shape, facing } of this.solidShapesOf(world, body, area)) { const hit = rayCast(shape, from, d, length); if (!hit || (facing && !landsOn(hit.normal, facing))) continue; const result = this.rayResult(body, from, d, hit); @@ -341,12 +359,21 @@ export class Solids { entity.y += dy; return NOT_BLOCKED; } - // Nothing that blocks moves while this one does, so its shapes are taken once for the whole move. + // Nothing that blocks moves while this one does, so its shapes are taken once for the whole + // move. Only what the move can reach is kept: sliding never lengthens what is left, so the + // mover never gets farther from where it starts than the whole move is long (#184). + const reach = own.map((a) => + grownBounds(shapeBounds(shapeOf(a.collider)), Math.hypot(wanted.x, wanted.y) + REACH_SLACK), + ); + const area = reach.reduce(unionBounds); const obstacles: Obstacle[] = world.entities .filter((c) => blocksMover(c, entity)) .flatMap((body) => { const filter = collisionFilterOf(body.props); - return this.solidShapesOf(world, body).map((solid) => ({ ...solid, body, filter })); + if (!own.some((a) => layersMeet(a.filter, filter))) return []; + return this.solidShapesOf(world, body, area) + .filter((solid) => reach.some((near) => boundsMeet(near, shapeBounds(solid.shape)))) + .map((solid) => ({ ...solid, body, filter })); }); // A one-way solid the mover is inside already, or told to fall through, lets it pass for the // whole move: it is on its way through, from below or from the side. @@ -491,6 +518,35 @@ function frictionBetween(own: Entity, obstacle: Entity): number { return (of(own) + of(obstacle)) / 2; } +/** The rectangle around every shape, or null without one – nothing to narrow anything down with. */ +export function boundsAround(shapes: readonly ColliderShape[]): ShapeBounds | undefined { + return shapes.length > 0 ? shapes.map(shapeBounds).reduce(unionBounds) : undefined; +} + +/** The rectangle around both rectangles. */ +function unionBounds(a: ShapeBounds, b: ShapeBounds): ShapeBounds { + return { + left: Math.min(a.left, b.left), + top: Math.min(a.top, b.top), + right: Math.max(a.right, b.right), + bottom: Math.max(a.bottom, b.bottom), + }; +} + +/** The rectangle around a ray, so a map only works out the cells it runs through (#184). */ +function rayBounds(from: Vec2, d: Vec2, length: number): ShapeBounds { + const to = { x: from.x + d.x * length, y: from.y + d.y * length }; + return grownBounds( + { + left: Math.min(from.x, to.x), + top: Math.min(from.y, to.y), + right: Math.max(from.x, to.x), + bottom: Math.max(from.y, to.y), + }, + REACH_SLACK, + ); +} + function unitOf(v: Vec2): Vec2 { const length = Math.hypot(v.x, v.y); return length > 0 ? { x: v.x / length, y: v.y / length } : v; diff --git a/tests/core/broadphase.test.ts b/tests/core/broadphase.test.ts new file mode 100644 index 00000000..5a215619 --- /dev/null +++ b/tests/core/broadphase.test.ts @@ -0,0 +1,216 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; +import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; +import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; +import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset'; +import { IDENTITY, transformMatrix } from '../../src/core/model/matrix'; +import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; +import { findAsset } from '../../src/core/model/Project'; +import { TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; +import { cellsMeet, cellsWithin } from '../../src/core/model/tilemapCollision'; +import { boundsMeet, grownBounds, shapeBounds } from '../../src/core/properties/collider'; +import type { Entity } from '../../src/runtime/Entity'; +import { loadPhysics } from '../../src/runtime/physics/engine'; +import { Solids } from '../../src/runtime/systems/solids'; +import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers'; + +beforeAll(async () => { + await loadPhysics(); +}); + +/** A jagged ring inside a 32 px cell, like the lake outlines of #184. */ +function jagged(corners: number, r: number): Vec2[] { + return Array.from({ length: corners }, (_, i) => { + const a = (i / corners) * Math.PI * 2; + const rad = r * (i % 2 === 0 ? 1 : 0.7); + return { x: 16 + Math.cos(a) * rad, y: 16 + Math.sin(a) * rad }; + }); +} + +function scene() { + const f = makeSpriteScene('Thing'); + const add = (at: Vec2, collider: Record) => { + const id = place(f, f.spriteId, at); + const command = new AddColliderCommand(f.sceneId, id); + f.stack.execute(command); + for (const [key, value] of Object.entries(collider)) + f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value)); + return { id, collider: command.objectId }; + }; + return { ...f, add }; +} + +/** + * A map of `cells` × `cells` cells of 32 px, every one painted with a tile whose picture carries an + * eight-cornered outline – the shape of #184. The map sits at the origin, so it spans + * −16·`cells` … 16·`cells` on both axes. + */ +function outlinedMap(cells: number, angle = 0, only?: Vec2) { + const f = scene(); + const texture = new CreateAssetCommand('texture', 'Sheet'); + const tile = new CreateAssetCommand('tile', 'Lake'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + for (const command of [texture, tile, map]) f.stack.execute(command); + const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; + sheet.file = `${'a'.repeat(64)}.png`; + sheet.width = 32; + sheet.height = 32; + const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); + f.stack.execute(frame); + f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId)); + f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true)); + f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [jagged(8, 15)])); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, cells)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, cells)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32)); + const mapId = place(f, map.asset.id, { x: 0, y: 0 }); + if (angle !== 0) f.stack.execute(new SetOverrideCommand(f.sceneId, mapId, 'rotation', angle)); + const asset = findAsset(f.project, map.asset.id) as TilemapAsset; + const painted: { x: number; y: number; tileId: string }[] = only + ? [{ x: only.x, y: only.y, tileId: tile.asset.id }] + : []; + if (!only) + for (let x = 0; x < cells; x++) + for (let y = 0; y < cells; y++) painted.push({ x, y, tileId: tile.asset.id }); + f.stack.execute(new PaintTilesCommand(map.asset.id, asset.layers[0]!.id, painted)); + return { ...f, mapId }; +} + +describe('the broad phase of a move and a ray (#184)', () => { + it('a rectangle around a shape holds every corner of it, turned as it is', () => { + const square = shapeBounds({ kind: 'box', x: 0, y: 0, w: 10, h: 10, angle: Math.PI / 4 }); + expect(square.right).toBeCloseTo(Math.hypot(5, 5), 9); + expect(square.left).toBeCloseTo(-Math.hypot(5, 5), 9); + expect(shapeBounds({ kind: 'circle', x: 3, y: -2, r: 4 })).toEqual({ + left: -1, + top: -6, + right: 7, + bottom: 2, + }); + const capsule = shapeBounds({ + kind: 'capsule', + x: 0, + y: 0, + angle: 0, + a: { x: 0, y: -5 }, + b: { x: 0, y: 5 }, + r: 2, + }); + expect(capsule).toEqual({ left: -2, top: -7, right: 2, bottom: 7 }); + const polygon = shapeBounds({ + kind: 'polygon', + x: 0, + y: 0, + angle: 0, + outline: [ + { x: -3, y: 0 }, + { x: 4, y: 1 }, + { x: 0, y: 9 }, + ], + pieces: [], + }); + expect(polygon).toEqual({ left: -3, top: 0, right: 4, bottom: 9 }); + }); + + it('two rectangles that share an edge still count as meeting, so nothing near is dropped', () => { + const a = { left: 0, top: 0, right: 10, bottom: 10 }; + expect(boundsMeet(a, { left: 10, top: 10, right: 20, bottom: 20 })).toBe(true); + expect(boundsMeet(a, { left: 10.001, top: 0, right: 20, bottom: 10 })).toBe(false); + expect(boundsMeet(a, grownBounds({ left: 12, top: 0, right: 20, bottom: 10 }, 2))).toBe(true); + }); + + it('the cells of a map within a rectangle are read in cells, a turned map through its own axes', () => { + // A 4 × 4 map of 32 px cells at the origin spans −64 … 64; cell (0,0) is its top left. + const props = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 4, [TILEMAP_TILE_KEY]: 32 }; + const straight = cellsWithin(props, IDENTITY, { left: -64, top: -64, right: -32, bottom: -32 }); + expect(straight).toEqual({ left: 0, top: 0, right: 1, bottom: 1 }); + expect(cellsMeet(straight, 0, 0)).toBe(true); + expect(cellsMeet(straight, 2, 2)).toBe(false); + // Turned by 90°, the world rectangle of the top left corner covers the map's top right one. + const turned = cellsWithin(props, transformMatrix(0, 0, 90, 1, false, false), { + left: -64, + top: -64, + right: -32, + bottom: -32, + }); + expect(turned.left).toBeCloseTo(0, 9); + expect(turned.right).toBeCloseTo(1, 9); + expect(turned.top).toBeCloseTo(3, 9); + expect(turned.bottom).toBeCloseTo(4, 9); + }); + + it('a map hands out only the cells of the rectangle it is asked about, and all of them without one', () => { + const f = outlinedMap(20); + const { world } = startWorld(f); + const map = world.findByObjectId(f.mapId)!; + const solids = new Solids(); + expect(solids.solidShapesOf(world, map)).toHaveLength(400); + // One cell of the map, grown by a hair: its own shape and those of the cells it touches. + const near = solids.solidShapesOf(world, map, { left: -320, top: -320, right: -300, bottom: -300 }); + expect(near.length).toBeGreaterThan(0); + expect(near.length).toBeLessThan(10); + expect(solids.solidShapesOf(world, map, { left: 1e6, top: 1e6, right: 2e6, bottom: 2e6 })).toEqual([]); + }); +}); + +describe.each(COLLISION_SYSTEMS)('$system: a move sees everything it can reach (#184)', ({ physics }) => { + const mover = (f: ReturnType, at: Vec2) => + f.add(at, { shape: 'box', width: 16, height: 16 }); + + it('a long move into a map far away still stops at its first cell, however far it starts', () => { + const f = outlinedMap(20); + const m = mover(f, { x: -1000, y: 0 }); + const started = startWorld(f, { physics, collisions: true }); + const e: Entity = started.world.findByObjectId(m.id)!; + // The map spans −320 … 320, so the move of 800 units has to be stopped well before its end. + const result = started.collisions.moveAndSlide(started.world, e, 800, 0); + expect(result.blockedX).toBe(true); + expect(e.x).toBeGreaterThan(-400); + expect(e.x).toBeLessThan(-300); + }); + + it('a turned map blocks where its one painted cell has turned to, not where it lies in the asset', () => { + // Turned by 90°, a point of the map lands at (−y, x): the cell at the left middle of the asset + // (own units −304, −16) sits at the top middle of the world, so a move down runs into it. + const f = outlinedMap(20, 90, { x: 0, y: 9 }); + const m = mover(f, { x: 16, y: -1000 }); + const started = startWorld(f, { physics, collisions: true }); + const e: Entity = started.world.findByObjectId(m.id)!; + const result = started.collisions.moveAndSlide(started.world, e, 0, 800); + expect(result.blockedY).toBe(true); + expect(e.y).toBeLessThan(-300); + expect(e.y).toBeGreaterThan(-340); + }); + + it('a ray across the whole map still meets its first cell', () => { + const f = outlinedMap(20); + const started = startWorld(f, { physics, collisions: true }); + const hit = started.collisions.raycast(started.world, { x: -1000, y: -16 }, { x: 1, y: 0 }, 2000, { + ignore: null, + triggers: false, + layers: 0xffff, + }); + expect(hit).not.toBeNull(); + expect(hit!.distance).toBeGreaterThan(600); + expect(hit!.distance).toBeLessThan(700); + }); +}); + +describe('the editor run over a big map (#184)', () => { + it('a collider on a far cell of a big map still counts as touching it', () => { + // The only painted cell is the bottom right one: its middle sits at (304, 304). + const f = outlinedMap(20, 0, { x: 19, y: 19 }); + const on = f.add({ x: 304, y: 304 }, { shape: 'box', width: 16, height: 16 }); + const off = f.add({ x: 304, y: 100 }, { shape: 'box', width: 16, height: 16 }); + const started = startWorld(f, { collisions: true }); + const map = started.world.findByObjectId(f.mapId)!; + expect(started.collisions.touching(started.world, started.world.findByObjectId(on.id)!, map)).toBe(true); + expect(started.collisions.touching(started.world, started.world.findByObjectId(off.id)!, map)).toBe( + false, + ); + }); +});