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 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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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