fix(d2map): 修正空单元格阻挡掩码与玩家出生点虚空悬空问题 (Issue #114)
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
This commit is contained in:
parent
370dcc05b1
commit
fd67de7b1b
|
|
@ -599,6 +599,18 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
|
||||||
for (let cellY = 0; cellY < cellsY; cellY += 1) {
|
for (let cellY = 0; cellY < cellsY; cellY += 1) {
|
||||||
for (let cellX = 0; cellX < cellsX; cellX += 1) {
|
for (let cellX = 0; cellX < cellsX; cellX += 1) {
|
||||||
const cell = level.cells[cellY]?.[cellX]
|
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
|
if (cell === undefined) continue
|
||||||
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
|
const orthoX = (cellX - cellY) * ORTHO_CELL_WIDTH
|
||||||
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
|
const orthoY = (cellX + cellY) * ORTHO_CELL_HEIGHT
|
||||||
|
|
@ -1089,26 +1101,9 @@ export function cellCentre(scene: CollisionGrid, cellX: number, cellY: number):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export {
|
||||||
* Find a walkable spawn near the middle of the map.
|
findIsoSpawn,
|
||||||
*
|
type IsoSpawnOptions,
|
||||||
* @param scene - the scene.
|
type IsoSpawnTarget,
|
||||||
* @returns a scene-space point, or null when nothing is walkable.
|
} from './level-links.ts'
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -630,3 +630,333 @@ export function findWaypointSpot(
|
||||||
|
|
||||||
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue