Fix map tile atlas fallbacks and level view grounded spawn protection (Closes #633, #634)

This commit is contained in:
troytt 2026-10-02 16:39:06 +00:00
parent 6d38c03110
commit 2d5c23a55f
4 changed files with 233 additions and 65 deletions

View File

@ -41,8 +41,9 @@ import {
type DrlgWarpInput,
} from '../../common/drlg/drlg-map.ts'
import type { Ds1Object } from '../../common/formats/ds1.ts'
import type { Dt1 } from '../../common/formats/dt1.ts'
import { isAnimatedTile, type Dt1 } from '../../common/formats/dt1.ts'
import type { SpriteFrame } from '../../common/formats/sprite.ts'
import { reportMissingArt } from '../scene/world-renderer.ts'
import type { PackedTilePlacement, TileAtlas } from './tile-atlas.ts'
export interface LevelDrawItem extends IsoDraw {
@ -160,6 +161,10 @@ export interface LevelView {
readonly animSpeed?: number
/** Optional 4-layer automap data. */
readonly automap?: AutomapLayerData
/** Number of missing tile references during isometric scene construction. */
readonly missingTiles?: number
/** Descriptions of missing tile references during isometric scene construction. */
readonly missingRefs?: readonly string[]
}
export interface BuildLevelViewOptions {
@ -222,18 +227,37 @@ function enrichDraws(
tileAtlas?: TileAtlas,
): LevelDrawItem[] {
return draws.map(draw => {
const dt1Path = dt1Names[draw.library] ?? ''
const dt1Path = dt1Names[draw.library]
if (!dt1Path) {
reportMissingArt('tile', `library-${String(draw.library)}`, `Invalid draw library index ${String(draw.library)}`)
throw new Error(
`[LevelView] Invalid draw library index ${String(draw.library)} (dt1Names length: ${String(dt1Names.length)})`,
)
}
const atlasEntry = tileAtlas?.getFrameByLibraryTile(dt1Path, draw.tile)
if (tileAtlas && (!atlasEntry || !atlasEntry.placement)) {
reportMissingArt('tile', dt1Path, `Missing tile placement for tileIndex=${String(draw.tile)}`)
}
const placement = atlasEntry?.placement ?? null
let animatedPlacements: (PackedTilePlacement | null)[] | undefined
if (draw.animatedFrames && draw.animatedFrames.length > 1 && tileAtlas) {
const tile = libraries[draw.library]?.tiles[draw.tile]
if (tile) {
if (tile && isAnimatedTile(tile)) {
const candidates = tileAtlas
.getFramesByStyleSeqType(dt1Path, tile.style, tile.sequence, tile.type)
.filter(c => isAnimatedTile(c))
.slice()
.sort((a, b) => a.rarityFrameIndex - b.rarityFrameIndex || a.index - b.index)
if (candidates.length > 1) {
for (const c of candidates) {
if (!c.placement) {
reportMissingArt(
'tile',
dt1Path,
`Missing animated tile placement for candidate tile ${String(c.index)}`,
)
}
}
animatedPlacements = candidates.map(c => c.placement)
}
}
@ -247,9 +271,72 @@ function enrichDraws(
})
}
function findLargestConnectedComponent(
subWidth: number,
subHeight: number,
walkable: Uint8Array,
): { readonly label: Int32Array; readonly mainComponentId: number; readonly mainComponentSize: number } {
const total = subWidth * subHeight
const label = new Int32Array(total).fill(-1)
const queue = new Int32Array(total)
let maxCompId = -1
let maxCompSize = 0
let nextId = 0
for (let start = 0; start < total; start++) {
if (walkable[start] !== 1 || label[start]! >= 0) continue
const compId = nextId++
let size = 0
let head = 0
let tail = 0
label[start] = compId
queue[tail++] = start
while (head < tail) {
const idx = queue[head++]!
size++
const x = idx % subWidth
const y = Math.floor(idx / subWidth)
if (x + 1 < subWidth) {
const r = idx + 1
if (walkable[r] === 1 && label[r]! < 0) {
label[r] = compId
queue[tail++] = r
}
}
if (x > 0) {
const l = idx - 1
if (walkable[l] === 1 && label[l]! < 0) {
label[l] = compId
queue[tail++] = l
}
}
if (y + 1 < subHeight) {
const d = idx + subWidth
if (walkable[d] === 1 && label[d]! < 0) {
label[d] = compId
queue[tail++] = d
}
}
if (y > 0) {
const u = idx - subWidth
if (walkable[u] === 1 && label[u]! < 0) {
label[u] = compId
queue[tail++] = u
}
}
}
if (size > maxCompSize) {
maxCompSize = size
maxCompId = compId
}
}
return { label, mainComponentId: maxCompId, mainComponentSize: maxCompSize }
}
/**
* Find a grounded walkable spawn tile `(spawnX, spawnY)` in level-local tile coordinates.
* Never returns an unwalkable geometric center if any walkable sub-tile exists.
* Ground player spawn strictly on valid DRLG entrance coordinates and walkable connected floor tiles.
* Eliminates sub-tile centroid and geometric center fallbacks.
*/
function resolveGroundedSpawnTile(
cellsX: number,
@ -259,6 +346,25 @@ function resolveGroundedSpawnTile(
walkable: Uint8Array,
entrances: readonly DrlgEntrance[],
): { readonly spawnX: number; readonly spawnY: number } {
const { label, mainComponentId } = findLargestConnectedComponent(subWidth, subHeight, walkable)
const isTileOnConnectedFloor = (tx: number, ty: number): boolean => {
if (tx < 0 || ty < 0 || tx >= cellsX || ty >= cellsY) return false
for (let dy = 0; dy < 5; dy++) {
const sy = ty * 5 + dy
if (sy >= subHeight) continue
for (let dx = 0; dx < 5; dx++) {
const sx = tx * 5 + dx
if (sx >= subWidth) continue
const idx = sy * subWidth + sx
if (walkable[idx] === 1 && (mainComponentId < 0 || label[idx] === mainComponentId)) {
return true
}
}
}
return false
}
const isTileWalkable = (tx: number, ty: number): boolean => {
if (tx < 0 || ty < 0 || tx >= cellsX || ty >= cellsY) return false
for (let dy = 0; dy < 5; dy++) {
@ -273,6 +379,17 @@ function resolveGroundedSpawnTile(
return false
}
// 1. Prioritize DRLG entrances landing on the main connected walkable floor
for (const ent of entrances) {
if (isTileOnConnectedFloor(ent.interiorX, ent.interiorY)) {
return { spawnX: ent.interiorX, spawnY: ent.interiorY }
}
if (isTileOnConnectedFloor(ent.x, ent.y)) {
return { spawnX: ent.x, spawnY: ent.y }
}
}
// 2. Fall back to any entrance with a walkable sub-tile
for (const ent of entrances) {
if (isTileWalkable(ent.interiorX, ent.interiorY)) {
return { spawnX: ent.interiorX, spawnY: ent.interiorY }
@ -282,51 +399,33 @@ function resolveGroundedSpawnTile(
}
}
// Find walkable sub-tile closest to the centroid of all walkable sub-tiles
let sumX = 0
let sumY = 0
let count = 0
for (let sy = 0; sy < subHeight; sy++) {
const rowOff = sy * subWidth
for (let sx = 0; sx < subWidth; sx++) {
if (walkable[rowOff + sx] === 1) {
sumX += sx
sumY += sy
count++
}
}
}
if (count > 0) {
const meanX = sumX / count
const meanY = sumY / count
let bestSx = 0
let bestSy = 0
let bestDistSq = Infinity
for (let sy = 0; sy < subHeight; sy++) {
const rowOff = sy * subWidth
for (let sx = 0; sx < subWidth; sx++) {
if (walkable[rowOff + sx] !== 1) continue
const dx = sx - meanX
const dy = sy - meanY
const distSq = dx * dx + dy * dy
if (distSq < bestDistSq) {
bestDistSq = distSq
bestSx = sx
bestSy = sy
// 3. Ground strictly on the first tile belonging to the main connected walkable floor
if (mainComponentId >= 0) {
for (let ty = 0; ty < cellsY; ty++) {
for (let tx = 0; tx < cellsX; tx++) {
if (isTileOnConnectedFloor(tx, ty)) {
return { spawnX: tx, spawnY: ty }
}
}
}
return {
spawnX: Math.min(cellsX - 1, Math.floor(bestSx / 5)),
spawnY: Math.min(cellsY - 1, Math.floor(bestSy / 5)),
}
}
return {
spawnX: Math.max(0, Math.floor(cellsX / 2)),
spawnY: Math.max(0, Math.floor(cellsY / 2)),
// 4. In unrevealed views (walkableCellCount === 0), fall back to primary DRLG entrance coordinates
let walkableCount = 0
for (let i = 0; i < walkable.length; i++) {
if (walkable[i] === 1) walkableCount++
}
if (walkableCount === 0) {
if (entrances.length > 0 && entrances[0]) {
return { spawnX: entrances[0].interiorX, spawnY: entrances[0].interiorY }
}
return { spawnX: 0, spawnY: 0 }
}
// Fail-fast: no connected walkable floor exists
throw new Error(
`[LevelView] Failed to resolve grounded spawn: no valid walkable connected floor tiles in ${String(cellsX)}x${String(cellsY)} level grid`,
)
}
/**
@ -373,6 +472,19 @@ export function buildLevelView(options: BuildLevelViewOptions): LevelView {
: undefined,
)
if (scene.missingTiles > 0) {
for (const ref of scene.missingRefs) {
reportMissingArt('tile', `level-${String(map.levelId)}`, ref)
}
if (scene.missingRefs.length === 0) {
reportMissingArt(
'tile',
`level-${String(map.levelId)}`,
`Missing ${String(scene.missingTiles)} tiles in level ${String(map.levelId)}`,
)
}
}
const collision = new Uint16Array(scene.collisionMasks)
const blocked = new Uint8Array(scene.blocked)
@ -484,6 +596,8 @@ export function buildLevelView(options: BuildLevelViewOptions): LevelView {
warps: activeInput.warps,
landmarks: map.landmarks,
rooms: roomViews,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
...(map.animSpeed !== undefined ? { animSpeed: map.animSpeed } : {}),
...(scene.automap !== undefined ? { automap: scene.automap } : {}),
}

View File

@ -118,11 +118,13 @@ export class MapService {
}
>()
private constructor(options: {
readonly engine?: MapWorkerEngine
readonly worker?: WorkerLike
readonly tileAtlas?: TileAtlas | null
}) {
constructor(
options: {
readonly engine?: MapWorkerEngine | null
readonly worker?: WorkerLike | null
readonly tileAtlas?: TileAtlas | null
} = {},
) {
this.engine = options.engine ?? null
this.worker = options.worker ?? null
this.tileAtlas = options.tileAtlas ?? this.engine?.getTileAtlas() ?? null
@ -440,7 +442,9 @@ export class MapService {
* Query the 16-bit collision mask at world sub-tile `(worldSubX, worldSubY)` in the active `LevelView`.
*/
getCollisionWorldSubTile(worldSubX: number, worldSubY: number): number {
if (!this.activeView) return 0x1c09
if (!this.activeView) {
throw new Error('[MapService] getCollisionWorldSubTile: active LevelView is not set')
}
return getCollisionWorldSubTile(this.activeView, worldSubX, worldSubY)
}

View File

@ -151,7 +151,16 @@ export class TileAtlas {
for (const [rawPath, dt1] of dt1ByPath) {
const key = normalizeDrlgPath(rawPath)
const match = /[\\/]act([1-5])[\\/]/i.exec(key)
const ownerAct = match ? Number(match[1]) : 1
let ownerAct: number
if (match) {
ownerAct = Number(match[1])
} else if (/[\\/]expansion[\\/]/i.test(key)) {
ownerAct = 5
} else {
throw new Error(
`[TileAtlas.fromDt1Map] Failed to determine act (1..5) for DT1 library path: "${rawPath}"`,
)
}
const tiles: PackedDt1TileEntry[] = dt1.tiles.map((t, index) => {
const subTileFlags = t.subTileFlags.map(f => f.raw)
const rawSubTileFlags = new Array<number>(25).fill(0)
@ -336,8 +345,14 @@ export class TileAtlas {
if (pinnedLibrary !== undefined && pinnedTile !== undefined) {
const libPath = dt1Names[pinnedLibrary]
if (libPath !== undefined) {
const entry = this.getFrameByLibraryTile(libPath, pinnedTile)
if (entry !== undefined) {
const lib = this.getLibrary(libPath)
if (lib !== null) {
const entry = this.getFrameByLibraryTile(libPath, pinnedTile)
if (!entry) {
throw new Error(
`[TileAtlas.resolveCellTile] Pinned tile index ${String(pinnedTile)} not found in library "${libPath}"`,
)
}
const normPath = normalizeDrlgPath(libPath)
const isAnim = isAnimatedTile(entry)
if (!isAnim) {
@ -391,11 +406,11 @@ export class TileAtlas {
}
if (allCandidates.length === 0) return null
const masked =
const active =
dt1Mask !== undefined
? allCandidates.filter(c => dt1MaskAllows(dt1Mask, c.poolTile.library))
: allCandidates
const active = masked.length > 0 ? masked : allCandidates
if (active.length === 0) return null
if (active.some(c => c.poolTile.animated)) {
const sorted = [...active].sort(

View File

@ -127,7 +127,7 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
})
})
it('resolves DRLG-pinned tiles by (dt1Path, tileIndex) and falls back deterministically via SplitMix64', () => {
it('resolves DRLG-pinned tiles by (dt1Path, tileIndex) and fails fast on missing pin, resolving variants deterministically via SplitMix64', () => {
const invisWalPath = 'data\\global\\tiles\\act1\\barracks\\inviswal.dt1'
const lib = tileAtlas.getLibrary(invisWalPath)
expect(lib).not.toBeNull()
@ -147,29 +147,64 @@ describe('Milestone M3 — TileAtlas, Runtime DRLG (136 Levels), Client Motion &
expect(pinned!.matchKind).toBe('pinned')
expect(pinned!.tileIndex).toBe(0)
// Fallback lookup with out-of-bounds tileIndex on InvisWal floor (49, 7, 0)
const fallbackA = tileAtlas.resolveCellTile({
// Out-of-bounds pinned tile index on InvisWal throws explicit fail-fast Error
expect(() =>
tileAtlas.resolveCellTile({
dt1Path: invisWalPath,
tileIndex: 99999,
style: 49,
sequence: 7,
tileType: 0,
x: 17,
y: 33,
}),
).toThrow(/Pinned tile index 99999 not found/)
// Unpinned variant lookup resolves deterministically via SplitMix64
const variantA = tileAtlas.resolveCellTile({
dt1Path: invisWalPath,
tileIndex: 99999,
style: 49,
sequence: 7,
tileType: 0,
x: 17,
y: 33,
})
const fallbackB = tileAtlas.resolveCellTile({
const variantB = tileAtlas.resolveCellTile({
dt1Path: invisWalPath,
tileIndex: 99999,
style: 49,
sequence: 7,
tileType: 0,
x: 17,
y: 33,
})
expect(fallbackA).not.toBeNull()
expect(fallbackA!.matchKind).toBe('fallback')
expect(fallbackA!.dt1Path).toBe(invisWalPath)
expect(fallbackA!.tileIndex).toBe(fallbackB!.tileIndex)
expect(variantA).not.toBeNull()
expect(variantA!.matchKind).toBe('fallback')
expect(variantA!.dt1Path).toBe(invisWalPath)
expect(variantA!.tileIndex).toBe(variantB!.tileIndex)
// dt1Mask filtering: when dt1Mask excludes candidate libraries, returns null instead of falling back
const maskedOut = tileAtlas.resolveCellTile({
dt1Names: [invisWalPath],
dt1Mask: 0, // library 0 not allowed
style: 49,
sequence: 7,
tileType: 0,
x: 17,
y: 33,
})
expect(maskedOut).toBeNull()
})
it('throws Error in getCollisionWorldSubTile when active LevelView is not set', () => {
const freshMap = new MapService()
expect(() => freshMap.getCollisionWorldSubTile(100, 100)).toThrow(/active LevelView is not set/)
})
it('throws Error in TileAtlas.fromDt1Map when DT1 library path has no recognizable act', () => {
const fakeMap = new Map([
['some/custom/path/without/act/tile.dt1', { versionMajor: 1, versionMinor: 1, tiles: [], warnings: [] }],
])
expect(() => TileAtlas.fromDt1Map(fakeMap as any)).toThrow(/Failed to determine act/)
})
})