diff --git a/src/client/map/level-view.ts b/src/client/map/level-view.ts index 6498783..e1ef595 100644 --- a/src/client/map/level-view.ts +++ b/src/client/map/level-view.ts @@ -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 } : {}), } diff --git a/src/client/map/map-service.ts b/src/client/map/map-service.ts index abe66d7..8c42220 100644 --- a/src/client/map/map-service.ts +++ b/src/client/map/map-service.ts @@ -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) } diff --git a/src/client/map/tile-atlas.ts b/src/client/map/tile-atlas.ts index c783fa6..d51da02 100644 --- a/src/client/map/tile-atlas.ts +++ b/src/client/map/tile-atlas.ts @@ -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(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( diff --git a/tests/client/drlg-map-motion.test.ts b/tests/client/drlg-map-motion.test.ts index 0178668..cec915f 100644 --- a/tests/client/drlg-map-motion.test.ts +++ b/tests/client/drlg-map-motion.test.ts @@ -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/) }) })