/** * Isometric collision counting. * * The point of {@link isoOverlap} is that it returns a *count*, not a boolean. * `combat.ts` escapes geometry with `if (terrain.overlap(next) <= current)`, so * a boolean predicate reads `1` everywhere inside a wall, `1 <= 1` always holds, * and a body that spawns inside geometry random-walks out. A count gives that * rule a gradient to descend. These tests therefore assert the *shape* of the * returned numbers, not just "blocked or not". * * The grid is synthetic rather than a decoded DS1 so the expected values can be * reasoned about by hand; the DS1 path is already covered by the act tests. */ import { describe, expect, it } from 'vitest' import { isoOverlap } from '../src/game/iso-terrain.ts' import { createIsoTerrain } from '../src/game/iso-terrain.ts' import { isBlockedAt } from '../src/game/d2map.ts' import type { CollisionGrid } from '../src/game/d2map.ts' /** Sub-tiles per tile edge, matching `d2map.ts`. */ const SUB = 5 /** Grid size in cells. Large enough to hold a wall block and clear space. */ const CELLS = 10 /** * Origin large enough that every cell of the grid maps to a positive scene x. * * `cx - cy` ranges over ±9, and a cell is 80 wide in x, so 900 clears it. */ const ORIGIN_X = 900 /** The player's feet box in `act-scene.ts`. Deliberately 2:1, like the tiles. */ const FEET_WIDTH = 20 const FEET_HEIGHT = 10 /** * An empty collision grid. * * @returns a grid with nothing blocked. */ function makeGrid(): CollisionGrid { return { originX: ORIGIN_X, originY: 0, cellsX: CELLS, cellsY: CELLS, blocked: new Uint8Array(CELLS * SUB * CELLS * SUB), gridWidth: CELLS * SUB, } } /** * Mark every sub-tile of one cell blocked. * * @param grid - the grid to mutate. * @param cellX - cell column. * @param cellY - cell row. */ function blockCell(grid: CollisionGrid, cellX: number, cellY: number): void { for (let sy = 0; sy < SUB; sy += 1) { for (let sx = 0; sx < SUB; sx += 1) { grid.blocked[(cellY * SUB + sy) * grid.gridWidth + cellX * SUB + sx] = 1 } } } /** * Fractional cell coordinates as a scene-space point. * * Forward of `cellAt`'s inversion: `x = (cx - cy) * 80`, `y = (cx + cy) * 40`. * * @param cellX - fractional cell column. * @param cellY - fractional cell row. * @returns the scene point. */ function pointAt(cellX: number, cellY: number): { x: number; y: number } { return { x: (cellX - cellY) * 80 + ORIGIN_X, y: (cellX + cellY) * 40 } } /** * A grid with cells 2..5 in both axes blocked — a wall four cells thick. * * Thick enough that a body at its centre is fully enclosed, so the count * saturates and the walk outwards has somewhere to descend from. * * @returns the grid. */ function gridWithWallBlock(): CollisionGrid { const grid = makeGrid() for (let cy = 2; cy <= 5; cy += 1) { for (let cx = 2; cx <= 5; cx += 1) blockCell(grid, cx, cy) } return grid } /** Samples taken for the feet box: 4 columns × 4 rows. */ const FEET_SAMPLES = 16 describe('isoOverlap', () => { it('counts nothing on clear ground', () => { const grid = gridWithWallBlock() const clear = pointAt(8.5, 8.5) expect(isoOverlap(grid, clear.x, clear.y, FEET_WIDTH, FEET_HEIGHT)).toBe(0) }) it('saturates when the whole body is inside geometry', () => { const grid = gridWithWallBlock() const deep = pointAt(4, 4) expect(isoOverlap(grid, deep.x, deep.y, FEET_WIDTH, FEET_HEIGHT)).toBe(FEET_SAMPLES) }) it('treats off-grid as fully blocked, so the level edge needs no special case', () => { const grid = gridWithWallBlock() expect(isoOverlap(grid, -99_999, -99_999, FEET_WIDTH, FEET_HEIGHT)).toBe(FEET_SAMPLES) }) it('falls monotonically as the body leaves a wall, and passes through partial counts', () => { const grid = gridWithWallBlock() const deep = pointAt(4, 4) const sweep: number[] = [] for (let dx = 0; dx <= 400; dx += 20) { sweep.push(isoOverlap(grid, deep.x + dx, deep.y, FEET_WIDTH, FEET_HEIGHT)) } // Never worse as we walk away: this is exactly the invariant combat.ts's // `overlap(next) <= current` rule needs in order to make progress. for (let i = 1; i < sweep.length; i += 1) expect(sweep[i]).toBeLessThanOrEqual(sweep[i - 1]!) expect(sweep[0]).toBe(FEET_SAMPLES) expect(sweep.at(-1)).toBe(0) // A boolean predicate could only ever produce 0 and 1 here. Partial counts // are the whole reason this module exists. expect(sweep.some(count => count > 0 && count < FEET_SAMPLES)).toBe(true) }) it('counts an overlap before the body centre enters the wall', () => { const grid = gridWithWallBlock() const deep = pointAt(4, 4) // Found by sweeping: the feet box still clips the block here while its // centre is already on clear ground. const x = deep.x + 320 expect(isBlockedAt(grid, x, deep.y)).toBe(false) expect(isoOverlap(grid, x, deep.y, FEET_WIDTH, FEET_HEIGHT)).toBeGreaterThan(0) }) it('samples y twice as densely as x, because sub-tiles are 16 by 8', () => { const grid = gridWithWallBlock() const deep = pointAt(4, 4) // A 2:1 box matches the projection, so it costs the same on both axes. expect(isoOverlap(grid, deep.x, deep.y, 20, 10)).toBe(16) // A square box is twice as tall in sub-tiles as it is wide, so it grows // rows, not columns. A grid-index scan over a rectangle — what map.ts does // for the orthogonal fixture — cannot express this. expect(isoOverlap(grid, deep.x, deep.y, 20, 20)).toBe(24) // And doubling the width grows columns by the same ratio. expect(isoOverlap(grid, deep.x, deep.y, 40, 10)).toBe(24) }) }) describe('createIsoTerrain', () => { it('exposes the level size and binds the body once', () => { const grid = gridWithWallBlock() const terrain = createIsoTerrain(grid, 1234, 567, { width: FEET_WIDTH, height: FEET_HEIGHT }) expect(terrain.widthPx).toBe(1234) expect(terrain.heightPx).toBe(567) const deep = pointAt(4, 4) const clear = pointAt(8.5, 8.5) expect(terrain.overlap(deep.x, deep.y)).toBe(FEET_SAMPLES) expect(terrain.overlap(clear.x, clear.y)).toBe(0) }) it('is a pure function of position, so saves and lockstep stay reproducible', () => { const grid = gridWithWallBlock() const terrain = createIsoTerrain(grid, 100, 100, { width: FEET_WIDTH, height: FEET_HEIGHT }) const p = pointAt(5.9, 4.1) const first = terrain.overlap(p.x, p.y) for (let i = 0; i < 5; i += 1) expect(terrain.overlap(p.x, p.y)).toBe(first) }) })