Merge branch 'issue-114'

This commit is contained in:
troytt 2026-09-18 14:32:19 +00:00
commit ad822bfe31
3 changed files with 694 additions and 23 deletions

View File

@ -599,6 +599,18 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
for (let cellY = 0; cellY < cellsY; cellY += 1) {
for (let cellX = 0; cellX < cellsX; cellX += 1) {
const cell = level.cells[cellY]?.[cellX]
const hasNoValidFloor = cell === undefined || cell.floors.length === 0 || cell.floors.every(f => f.hidden || f.prop1 === 0)
if (hasNoValidFloor) {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
for (let subX = 0; subX < SUB_TILES_PER_TILE; subX += 1) {
const gx = cellX * SUB_TILES_PER_TILE + subX
const gy = cellY * SUB_TILES_PER_TILE + subY
const idx = gy * gridWidth + gx
blocked[idx] = 1
collisionMasks[idx] = (collisionMasks[idx] ?? 0) | COLLIDE_MASK_INVALID
}
}
}
if (cell === undefined) continue
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
@ -1089,26 +1101,9 @@ export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number):
}
}
/**
* Find a walkable spawn near the middle of the map.
*
* @param scene - the scene.
* @returns a scene-space point, or null when nothing is walkable.
*/
export function findIsoSpawn(scene: CollisionGrid & { cellsX: number; cellsY: number }): { x: number; y: number } | null {
const centreX = Math.floor(scene.cellsX / 2)
const centreY = Math.floor(scene.cellsY / 2)
for (let radius = 0; radius < Math.max(scene.cellsX, scene.cellsY); radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const cellX = centreX + dx
const cellY = centreY + dy
if (cellX < 0 || cellY < 0 || cellX >= scene.cellsX || cellY >= scene.cellsY) continue
const point = cellCentre(scene, cellX, cellY)
if (!isBlockedAt(scene, point.x, point.y)) return point
}
}
}
return null
}
export {
findIsoSpawn,
type IsoSpawnOptions,
type IsoSpawnTarget,
} from './level-links.ts'

View File

@ -630,3 +630,333 @@ export function findWaypointSpot(
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
}
/** Cell width in screen pixels (the isometric diamond's width). */
const ORTHO_CELL_WIDTH = 80
/** Cell height in screen pixels (half the diamond's width: a 2:1 projection). */
const ORTHO_CELL_HEIGHT = 40
export interface IsoSpawnOptions {
/** Optional warps to prioritize for spawn placement (e.g. entrance warps). */
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
/** Optional entrances to prioritize. */
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
/** Optional list of floor placements if not present on scene. */
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
}
export interface IsoSpawnTarget {
readonly cellsX: number
readonly cellsY: number
readonly blocked: Uint8Array
readonly gridWidth?: number
readonly gridHeight?: number
readonly originX?: number
readonly originY?: number
readonly collisionMasks?: Uint16Array | undefined
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
}
function subTileAtCoords(
originX: number,
originY: number,
cellsX: number,
cellsY: number,
x: number,
y: number,
): { subX: number; subY: number; inBounds: boolean } {
const px = x - originX
const py = y - originY
const cx = (px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2
const cy = (py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2
const cellX = Math.floor(cx)
const cellY = Math.floor(cy)
const inBounds = cellX >= 0 && cellY >= 0 && cellX < cellsX && cellY < cellsY
const localSubX = Math.min(Math.max(0, Math.floor((cx - cellX) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const localSubY = Math.min(Math.max(0, Math.floor((cy - cellY) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
const subX = cellX * SUB_TILES_PER_TILE + localSubX
const subY = cellY * SUB_TILES_PER_TILE + localSubY
return { subX, subY, inBounds }
}
function cellCentreCoords(originX: number, originY: number, cellX: number, cellY: number): { x: number; y: number } {
return {
x: (cellX - cellY) * ORTHO_CELL_WIDTH + originX,
y: (cellX + cellY) * ORTHO_CELL_HEIGHT + originY + ORTHO_CELL_HEIGHT / 2,
}
}
function subTileCentreCoords(
originX: number,
originY: number,
subX: number,
subY: number,
): { x: number; y: number } {
const cx = (subX + 0.5) / SUB_TILES_PER_TILE
const cy = (subY + 0.5) / SUB_TILES_PER_TILE
return {
x: (cx - cy) * ORTHO_CELL_WIDTH + originX,
y: (cx + cy) * ORTHO_CELL_HEIGHT + originY,
}
}
/**
* Find the largest connected walkable region that contains at least one floor tile.
*
* @param grid - the collision map.
* @param hasFloor - whether a cell coordinate contains a valid floor.
* @returns one byte per sub-tile, 1 inside the largest floor-containing region.
*/
export function largestWalkableRegionWithFloors(
grid: LinkGrid,
hasFloor?: (cellX: number, cellY: number) => boolean,
): WalkableRegion {
const width = grid.gridWidth
const height = grid.gridHeight
const component = new Int32Array(width * height).fill(-1)
const sizes: number[] = []
const containsFloor: boolean[] = []
const stack: number[] = []
for (let seed = 0; seed < component.length; seed += 1) {
if (component[seed] !== -1 || grid.blocked[seed] !== 0) continue
const label = sizes.length
let size = 0
let floorFound = false
component[seed] = label
stack.push(seed)
while (stack.length > 0) {
const here = stack.pop()!
size += 1
const x = here % width
const y = Math.floor(here / width)
const cx = Math.floor(x / SUB_TILES_PER_TILE)
const cy = Math.floor(y / SUB_TILES_PER_TILE)
if (hasFloor === undefined || hasFloor(cx, cy)) {
floorFound = true
}
if (x > 0) { const n = here - 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (x < width - 1) { const n = here + 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (y > 0) { const n = here - width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
if (y < height - 1) { const n = here + width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
}
sizes.push(size)
containsFloor.push(floorFound)
}
let best = -1
let bestSize = 0
for (let label = 0; label < sizes.length; label += 1) {
if (containsFloor[label] && sizes[label]! > bestSize) {
bestSize = sizes[label]!
best = label
}
}
if (best < 0) {
for (let label = 0; label < sizes.length; label += 1) {
if (sizes[label]! > bestSize) {
bestSize = sizes[label]!
best = label
}
}
}
const region = new Uint8Array(component.length)
if (best < 0) return region
for (let at = 0; at < component.length; at += 1) {
if (component[at] === best) region[at] = 1
}
return region
}
/**
* Find a valid, walkable spawn point on a floor tile.
*
* Ensures:
* 1. The spawn is located on a valid floor tile and blocked === 0.
* 2. If available, prioritizes entrance warps / entrances.
* 3. Falls back to the largest walkable region that contains floors,
* avoiding pure void cells even when canvas center is void.
*
* @param scene - the scene or collision grid.
* @param options - optional warps, entrances, or floor placements.
* @returns a scene-space point in pixels, or null when nothing is walkable.
*/
export function findIsoSpawn(
scene: IsoSpawnTarget,
options?: IsoSpawnOptions,
): { x: number; y: number } | null {
const cellsX = scene.cellsX
const cellsY = scene.cellsY
const gridWidth = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
const gridHeight = scene.gridHeight
?? (scene.blocked.length ? Math.floor(scene.blocked.length / gridWidth) : cellsY * SUB_TILES_PER_TILE)
const originX = scene.originX ?? 0
const originY = scene.originY ?? 0
const floors = options?.floors ?? scene.floors
let hasFloorCell: (cx: number, cy: number) => boolean
if (floors !== undefined && floors.length > 0) {
const floorSet = new Set<string>()
for (const f of floors) {
floorSet.add(`${f.cellX},${f.cellY}`)
}
hasFloorCell = (cx, cy) => floorSet.has(`${cx},${cy}`)
} else {
hasFloorCell = (cx, cy) => {
if (cx < 0 || cy < 0 || cx >= cellsX || cy >= cellsY) return false
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
if (scene.blocked[idx] === 0) {
if (scene.collisionMasks && (scene.collisionMasks[idx]! & 0x0020) !== 0) {
continue
}
return true
}
}
}
return false
}
}
const linkGrid: LinkGrid = {
cellsX,
cellsY,
gridWidth,
gridHeight,
blocked: scene.blocked,
}
const isSubTileWalkable = (sx: number, sy: number): boolean => {
if (sx < 0 || sy < 0 || sx >= gridWidth || sy >= gridHeight) return false
return scene.blocked[sy * gridWidth + sx] === 0
}
// 1. Prioritize entrance warps or entrances if available.
const warps = options?.warps ?? scene.warps
if (warps !== undefined && warps.length > 0) {
const sortedWarps = [...warps].sort((a, b) => {
const dirScore = (w: any) =>
w.direction === 'in' ? 4 : w.direction === 'up' ? 3 : w.direction === 'down' ? 2 : 1
return dirScore(b) - dirScore(a)
})
for (const warp of sortedWarps) {
const ax = ('arriveX' in warp && typeof warp.arriveX === 'number') ? warp.arriveX : warp.x
const ay = ('arriveY' in warp && typeof warp.arriveY === 'number') ? warp.arriveY : warp.y
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
if (hasFloorCell(cx, cy)) {
const pt = subTileCentreCoords(originX, originY, ax, ay)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
return pt
}
const near = nearestWalkable(linkGrid, ax, ay, 5)
if (near !== null) {
const ncx = Math.floor(near.x / SUB_TILES_PER_TILE)
const ncy = Math.floor(near.y / SUB_TILES_PER_TILE)
if (hasFloorCell(ncx, ncy)) {
const npt = subTileCentreCoords(originX, originY, near.x, near.y)
const nsub = subTileAtCoords(originX, originY, cellsX, cellsY, npt.x, npt.y)
if (nsub.inBounds && isSubTileWalkable(nsub.subX, nsub.subY)) {
return npt
}
}
}
}
}
}
const entrances = options?.entrances ?? scene.entrances
if (entrances !== undefined && entrances.length > 0) {
for (const ent of entrances) {
const ax = ('arriveX' in ent && typeof ent.arriveX === 'number') ? ent.arriveX : ent.x
const ay = ('arriveY' in ent && typeof ent.arriveY === 'number') ? ent.arriveY : ent.y
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
if (hasFloorCell(cx, cy)) {
const pt = subTileCentreCoords(originX, originY, ax, ay)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
return pt
}
}
}
}
// 2. Locate within the largest walkable region containing floors.
const region = largestWalkableRegionWithFloors(linkGrid, hasFloorCell)
let regionCount = 0
let sumX = 0
let sumY = 0
for (let sy = 0; sy < gridHeight; sy += 1) {
for (let sx = 0; sx < gridWidth; sx += 1) {
if (region[sy * gridWidth + sx] === 1) {
regionCount += 1
sumX += sx
sumY += sy
}
}
}
const canvasMidX = Math.floor(cellsX / 2)
const canvasMidY = Math.floor(cellsY / 2)
const canvasMidPt = cellCentreCoords(originX, originY, canvasMidX, canvasMidY)
const canvasMidSub = subTileAtCoords(originX, originY, cellsX, cellsY, canvasMidPt.x, canvasMidPt.y)
let centreX = canvasMidX
let centreY = canvasMidY
const canvasMidValid = hasFloorCell(canvasMidX, canvasMidY)
&& canvasMidSub.inBounds
&& isSubTileWalkable(canvasMidSub.subX, canvasMidSub.subY)
&& (regionCount === 0 || region[canvasMidSub.subY * gridWidth + canvasMidSub.subX] === 1)
if (!canvasMidValid && regionCount > 0) {
centreX = Math.round(sumX / regionCount / SUB_TILES_PER_TILE)
centreY = Math.round(sumY / regionCount / SUB_TILES_PER_TILE)
}
const maxRadius = Math.max(cellsX, cellsY)
for (let radius = 0; radius < maxRadius; radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const cellX = centreX + dx
const cellY = centreY + dy
if (cellX < 0 || cellY < 0 || cellX >= cellsX || cellY >= cellsY) continue
if (!hasFloorCell(cellX, cellY)) continue
// Check centre of the cell
const pt = cellCentreCoords(originX, originY, cellX, cellY)
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY) && (regionCount === 0 || region[sub.subY * gridWidth + sub.subX] === 1)) {
return pt
}
// If centre sub-tile was blocked, check other sub-tiles in this cell
for (let lsy = 0; lsy < SUB_TILES_PER_TILE; lsy += 1) {
for (let lsx = 0; lsx < SUB_TILES_PER_TILE; lsx += 1) {
const sx = cellX * SUB_TILES_PER_TILE + lsx
const sy = cellY * SUB_TILES_PER_TILE + lsy
if (isSubTileWalkable(sx, sy) && (regionCount === 0 || region[sy * gridWidth + sx] === 1)) {
const spt = subTileCentreCoords(originX, originY, sx, sy)
const ssub = subTileAtCoords(originX, originY, cellsX, cellsY, spt.x, spt.y)
if (ssub.inBounds && isSubTileWalkable(ssub.subX, ssub.subY)) {
return spt
}
}
}
}
}
}
}
return null
}

View File

@ -0,0 +1,346 @@
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()
})
})