From fd67de7b1bafb3c019b92351890ec8cbe7dec5a8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 14:30:43 +0000 Subject: [PATCH] =?UTF-8?q?fix(d2map):=20=E4=BF=AE=E6=AD=A3=E7=A9=BA?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=A0=BC=E9=98=BB=E6=8C=A1=E6=8E=A9=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E7=8E=A9=E5=AE=B6=E5=87=BA=E7=94=9F=E7=82=B9=E8=99=9A?= =?UTF-8?q?=E7=A9=BA=E6=82=AC=E7=A9=BA=E9=97=AE=E9=A2=98=20(Issue=20#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 在 buildIsoMapScene 中为所有无有效非隐藏地面的单元格(纯黑虚空)标记全部 25 个子瓦片为 blocked = 1 及 COLLIDE_MASK_INVALID (COLLIDE_BLANK | COLLIDE_WALL)。 2. 在 level-links.ts 中实现增强型 findIsoSpawn 并从 d2map.ts 导出,确保出生点单元格必须拥有有效地面且 blocked === 0,优先选取有效传送点/入口位置,或在包含地面的最大可行走连通区域中定位。 3. 添加单元测试 tests/iso-spawn-void-guard.test.ts,验证 16x16 画布中 4x4 房间外的虚空单元格全阻挡,且出生点必定落在房间内。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/d2map.ts | 41 ++-- src/game/level-links.ts | 330 +++++++++++++++++++++++++++ tests/iso-spawn-void-guard.test.ts | 346 +++++++++++++++++++++++++++++ 3 files changed, 694 insertions(+), 23 deletions(-) create mode 100644 tests/iso-spawn-void-guard.test.ts diff --git a/src/game/d2map.ts b/src/game/d2map.ts index c0504e9..e189200 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -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' + diff --git a/src/game/level-links.ts b/src/game/level-links.ts index e83c909..a9144c9 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -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() + 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 +} + diff --git a/tests/iso-spawn-void-guard.test.ts b/tests/iso-spawn-void-guard.test.ts new file mode 100644 index 0000000..c3091ab --- /dev/null +++ b/tests/iso-spawn-void-guard.test.ts @@ -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() + }) +})