This commit is contained in:
parent
6d38c03110
commit
2d5c23a55f
|
|
@ -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 } : {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue