diablo2-web/tests/iso-terrain.test.ts

181 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
})
})