347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
|
|
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
|
|
import {
|
|
buildIsoMapScene,
|
|
cellAt,
|
|
cellCentre,
|
|
findIsoSpawn as findIsoSpawnFromMap,
|
|
isBlockedAt,
|
|
COLLIDE_BLANK,
|
|
COLLIDE_MASK_INVALID,
|
|
COLLIDE_WALL,
|
|
} from '../src/game/d2map.ts'
|
|
import { findIsoSpawn as findIsoSpawnFromLinks } from '../src/game/level-links.ts'
|
|
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
|
|
|
/**
|
|
* Creates a minimal synthetic DT1 tile with uniform 5x5 sub-tile collision flags.
|
|
*/
|
|
function makeTestTile(type: number, style: number, sequence: number, flagRaw: number): Dt1Tile {
|
|
const flags = Array.from({ length: 25 }, () => ({
|
|
blockWalk: (flagRaw & COLLIDE_WALL) !== 0,
|
|
blockPlayerWalk: (flagRaw & COLLIDE_WALL) !== 0,
|
|
blockLight: false,
|
|
blockLos: (flagRaw & 0x0002) !== 0,
|
|
blockJump: (flagRaw & 0x0004) !== 0,
|
|
raw: flagRaw,
|
|
}))
|
|
|
|
return {
|
|
direction: type,
|
|
roofHeight: 0,
|
|
materialFlags: 0,
|
|
height: -80,
|
|
width: 160,
|
|
type,
|
|
style,
|
|
sequence,
|
|
rarityFrameIndex: 1,
|
|
animated: 0,
|
|
subTileFlags: flags,
|
|
minBlockY: -80,
|
|
bitmapHeight: 80,
|
|
blocks: [
|
|
{
|
|
x: 0,
|
|
y: -80,
|
|
gridX: 0,
|
|
gridY: 0,
|
|
format: 1,
|
|
pixels: new Uint8Array(160 * 80).fill(1),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
describe('Issue #114: Void cell collision masking & player spawn void guard', () => {
|
|
const dt1: Dt1 = {
|
|
versionMajor: 7,
|
|
versionMinor: 6,
|
|
warnings: [],
|
|
tiles: [
|
|
// Type 0, Style 1, Sequence 0: walkable floor (0x00)
|
|
makeTestTile(0, 1, 0, 0x0000),
|
|
// Type 0, Style 1, Sequence 1: blocked floor (COLLIDE_WALL)
|
|
makeTestTile(0, 1, 1, COLLIDE_WALL),
|
|
],
|
|
}
|
|
|
|
it('marks cells outside an isolated 4x4 room in a 16x16 canvas as blocked and COLLIDE_MASK_INVALID', () => {
|
|
const cells: (Ds1Cell | undefined)[][] = []
|
|
|
|
for (let y = 0; y < 16; y += 1) {
|
|
const row: (Ds1Cell | undefined)[] = []
|
|
for (let x = 0; x < 16; x += 1) {
|
|
// 4x4 room placed at cellX: 2..5, cellY: 2..5
|
|
if (x >= 2 && x <= 5 && y >= 2 && y <= 5) {
|
|
row.push({
|
|
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
} else if (x === 0 && y === 0) {
|
|
// Explicit empty cell with no floor array entries
|
|
row.push({
|
|
floors: [],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
} else if (x === 1 && y === 1) {
|
|
// Explicit cell with placeholder / hidden floor slots (prop1 === 0 or hidden === true)
|
|
row.push({
|
|
floors: [
|
|
{ prop1: 0, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 },
|
|
{ prop1: 1, sequence: 0, style: 1, hidden: true, unknown1: 0, unknown2: 0 },
|
|
],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
} else {
|
|
// Unplaced cell: undefined in cells array
|
|
row.push(undefined)
|
|
}
|
|
}
|
|
cells.push(row)
|
|
}
|
|
|
|
const ds1: Ds1 = {
|
|
version: 18,
|
|
width: 16,
|
|
height: 16,
|
|
act: 1,
|
|
substitutionType: 0,
|
|
wallLayers: 1,
|
|
floorLayers: 1,
|
|
objects: [],
|
|
npcPathOffset: null,
|
|
cells: cells as unknown as Ds1['cells'],
|
|
}
|
|
|
|
const scene = buildIsoMapScene(ds1, [dt1], 0)
|
|
expect(scene.cellsX).toBe(16)
|
|
expect(scene.cellsY).toBe(16)
|
|
|
|
// Verify 1: Cells outside the 4x4 room must have all 25 sub-tiles blocked and marked COLLIDE_MASK_INVALID
|
|
const gridWidth = 16 * SUB_TILES_PER_TILE
|
|
for (let cy = 0; cy < 16; cy += 1) {
|
|
for (let cx = 0; cx < 16; cx += 1) {
|
|
const isInsideRoom = cx >= 2 && cx <= 5 && cy >= 2 && cy <= 5
|
|
const cPt = cellCentre(scene, cx, cy)
|
|
|
|
if (isInsideRoom) {
|
|
// Inside room: floors exist, should be walkable
|
|
expect(isBlockedAt(scene, cPt.x, cPt.y)).toBe(false)
|
|
} else {
|
|
// Outside room: must be completely blocked
|
|
expect(isBlockedAt(scene, cPt.x, cPt.y)).toBe(true)
|
|
|
|
// Check every sub-tile of the void cell
|
|
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
|
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
|
const gx = cx * SUB_TILES_PER_TILE + sx
|
|
const gy = cy * SUB_TILES_PER_TILE + sy
|
|
const idx = gy * gridWidth + gx
|
|
expect(scene.blocked[idx]).toBe(1)
|
|
expect(scene.collisionMasks![idx]! & COLLIDE_MASK_INVALID).toBe(COLLIDE_MASK_INVALID)
|
|
expect(scene.collisionMasks![idx]! & COLLIDE_BLANK).toBe(COLLIDE_BLANK)
|
|
expect(scene.collisionMasks![idx]! & COLLIDE_WALL).toBe(COLLIDE_WALL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify 2: Canvas center at (8, 8) is in the void and must be blocked
|
|
const centerPt = cellCentre(scene, 8, 8)
|
|
expect(isBlockedAt(scene, centerPt.x, centerPt.y)).toBe(true)
|
|
|
|
// Verify 3: findIsoSpawn must find a spawn inside the 4x4 room, never in the void
|
|
const spawnFromMap = findIsoSpawnFromMap(scene)
|
|
expect(spawnFromMap).not.toBeNull()
|
|
expect(isBlockedAt(scene, spawnFromMap!.x, spawnFromMap!.y)).toBe(false)
|
|
|
|
const cellOfSpawnMap = cellAt(scene, spawnFromMap!.x, spawnFromMap!.y)
|
|
const floorCellXMap = Math.floor(cellOfSpawnMap.x)
|
|
const floorCellYMap = Math.floor(cellOfSpawnMap.y)
|
|
expect(floorCellXMap).toBeGreaterThanOrEqual(2)
|
|
expect(floorCellXMap).toBeLessThanOrEqual(5)
|
|
expect(floorCellYMap).toBeGreaterThanOrEqual(2)
|
|
expect(floorCellYMap).toBeLessThanOrEqual(5)
|
|
|
|
// Verify 4: Re-exported findIsoSpawn from level-links.ts produces identical result
|
|
const spawnFromLinks = findIsoSpawnFromLinks(scene)
|
|
expect(spawnFromLinks).toEqual(spawnFromMap)
|
|
})
|
|
|
|
it('prioritizes valid entrance warps inside rooms and rejects void warps', () => {
|
|
const cells: (Ds1Cell | undefined)[][] = []
|
|
for (let y = 0; y < 16; y += 1) {
|
|
const row: (Ds1Cell | undefined)[] = []
|
|
for (let x = 0; x < 16; x += 1) {
|
|
if (x >= 10 && x <= 13 && y >= 10 && y <= 13) {
|
|
row.push({
|
|
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
} else {
|
|
row.push(undefined)
|
|
}
|
|
}
|
|
cells.push(row)
|
|
}
|
|
|
|
const ds1: Ds1 = {
|
|
version: 18,
|
|
width: 16,
|
|
height: 16,
|
|
act: 1,
|
|
substitutionType: 0,
|
|
wallLayers: 1,
|
|
floorLayers: 1,
|
|
objects: [],
|
|
npcPathOffset: null,
|
|
cells: cells as unknown as Ds1['cells'],
|
|
}
|
|
|
|
const scene = buildIsoMapScene(ds1, [dt1], 0)
|
|
|
|
// 1. Warp located in the void at cell (0, 0) -> arriveX: 2, arriveY: 2 (in void)
|
|
const voidWarp = {
|
|
toLevelId: 2,
|
|
warpId: 1,
|
|
direction: 'in' as const,
|
|
label: 'Void Warp',
|
|
x: 0,
|
|
y: 0,
|
|
arriveX: 2,
|
|
arriveY: 2,
|
|
selectX: 0,
|
|
selectY: 0,
|
|
selectDX: 0,
|
|
selectDY: 0,
|
|
exitWalkX: 0,
|
|
exitWalkY: 0,
|
|
source: 'tile' as const,
|
|
}
|
|
|
|
// Spawn should reject the void warp and spawn in the 4x4 room (10..13, 10..13)
|
|
const spawnWithVoidWarp = findIsoSpawnFromMap(scene, { warps: [voidWarp] })
|
|
expect(spawnWithVoidWarp).not.toBeNull()
|
|
const cellWithVoidWarp = cellAt(scene, spawnWithVoidWarp!.x, spawnWithVoidWarp!.y)
|
|
expect(Math.floor(cellWithVoidWarp.x)).toBeGreaterThanOrEqual(10)
|
|
expect(Math.floor(cellWithVoidWarp.y)).toBeGreaterThanOrEqual(10)
|
|
|
|
// 2. Warp located inside the room at cell (11, 11) -> sub-tile (56, 56)
|
|
const roomWarp = {
|
|
toLevelId: 3,
|
|
warpId: 2,
|
|
direction: 'in' as const,
|
|
label: 'Room Entrance',
|
|
x: 55,
|
|
y: 55,
|
|
arriveX: 56,
|
|
arriveY: 56,
|
|
selectX: 0,
|
|
selectY: 0,
|
|
selectDX: 0,
|
|
selectDY: 0,
|
|
exitWalkX: 0,
|
|
exitWalkY: 0,
|
|
source: 'tile' as const,
|
|
}
|
|
|
|
const spawnWithRoomWarp = findIsoSpawnFromMap(scene, { warps: [roomWarp] })
|
|
expect(spawnWithRoomWarp).not.toBeNull()
|
|
const cellWithRoomWarp = cellAt(scene, spawnWithRoomWarp!.x, spawnWithRoomWarp!.y)
|
|
expect(Math.floor(cellWithRoomWarp.x)).toBe(11)
|
|
expect(Math.floor(cellWithRoomWarp.y)).toBe(11)
|
|
expect(isBlockedAt(scene, spawnWithRoomWarp!.x, spawnWithRoomWarp!.y)).toBe(false)
|
|
})
|
|
|
|
it('selects spawn within the largest walkable region containing floors when multiple pockets exist', () => {
|
|
const cells: (Ds1Cell | undefined)[][] = []
|
|
for (let y = 0; y < 16; y += 1) {
|
|
const row: (Ds1Cell | undefined)[] = []
|
|
for (let x = 0; x < 16; x += 1) {
|
|
// Tiny 1x1 pocket at (0, 0)
|
|
if (x === 0 && y === 0) {
|
|
row.push({
|
|
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
}
|
|
// Main 4x4 room at (4..7, 4..7)
|
|
else if (x >= 4 && x <= 7 && y >= 4 && y <= 7) {
|
|
row.push({
|
|
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
})
|
|
} else {
|
|
row.push(undefined)
|
|
}
|
|
}
|
|
cells.push(row)
|
|
}
|
|
|
|
const ds1: Ds1 = {
|
|
version: 18,
|
|
width: 16,
|
|
height: 16,
|
|
act: 1,
|
|
substitutionType: 0,
|
|
wallLayers: 1,
|
|
floorLayers: 1,
|
|
objects: [],
|
|
npcPathOffset: null,
|
|
cells: cells as unknown as Ds1['cells'],
|
|
}
|
|
|
|
const scene = buildIsoMapScene(ds1, [dt1], 0)
|
|
const spawn = findIsoSpawnFromMap(scene)
|
|
expect(spawn).not.toBeNull()
|
|
|
|
// Must spawn in the 4x4 room (4..7, 4..7), not the 1x1 pocket at (0, 0)
|
|
const spawnCell = cellAt(scene, spawn!.x, spawn!.y)
|
|
expect(Math.floor(spawnCell.x)).toBeGreaterThanOrEqual(4)
|
|
expect(Math.floor(spawnCell.x)).toBeLessThanOrEqual(7)
|
|
expect(Math.floor(spawnCell.y)).toBeGreaterThanOrEqual(4)
|
|
expect(Math.floor(spawnCell.y)).toBeLessThanOrEqual(7)
|
|
})
|
|
|
|
it('returns null when the entire level has no walkable floors', () => {
|
|
const ds1: Ds1 = {
|
|
version: 18,
|
|
width: 4,
|
|
height: 4,
|
|
act: 1,
|
|
substitutionType: 0,
|
|
wallLayers: 1,
|
|
floorLayers: 1,
|
|
objects: [],
|
|
npcPathOffset: null,
|
|
cells: Array.from({ length: 4 }, () =>
|
|
Array.from({ length: 4 }, () => ({
|
|
floors: [],
|
|
walls: [],
|
|
shadows: [],
|
|
substitutions: [],
|
|
}))
|
|
),
|
|
}
|
|
|
|
const scene = buildIsoMapScene(ds1, [dt1], 0)
|
|
const spawn = findIsoSpawnFromMap(scene)
|
|
expect(spawn).toBeNull()
|
|
})
|
|
})
|