Fix flippy DC6 offsets, missile data-driven sprites, automap cel extraction, and art loader fail-fast (Closes #609, #612, #619, #622, #623)
This commit is contained in:
parent
56f4f7f4fe
commit
6af87acc13
|
|
@ -505,13 +505,7 @@ export class AutomapView {
|
|||
const originX = view.isoOriginX ?? (minCellX - minCellY) * 80
|
||||
const originY = view.isoOriginY ?? (minCellX + minCellY) * 40
|
||||
|
||||
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
|
||||
cellX: wm.cellX - minCellX,
|
||||
cellY: wm.cellY - minCellY,
|
||||
kind: wm.toAreaId === this.currentAreaId ? 'waypoint' : 'exit',
|
||||
toLevelId: wm.toAreaId,
|
||||
targetName: wm.label,
|
||||
}))
|
||||
const markers: MinimapMarker[] = []
|
||||
|
||||
if (Array.isArray(view.entrances)) {
|
||||
for (const ent of view.entrances) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monste
|
|||
import { resolveMonsterArtSpec } from '../../common/units/monster-mapping.ts'
|
||||
import type { AtlasFrame } from '../render/atlas.ts'
|
||||
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||
import { reportMissingArt } from './world-renderer.ts'
|
||||
import { normalizeHeroToken } from '../../common/world/scene-population.ts'
|
||||
|
||||
export { ADDITIVE_MONSTER_TOKENS }
|
||||
|
|
@ -305,8 +306,13 @@ export async function loadCharacterArt(
|
|||
renderer: SpriteRenderer,
|
||||
packBase?: string,
|
||||
act?: number,
|
||||
token = 'so',
|
||||
token?: string,
|
||||
): Promise<LoadedCharacter | null> {
|
||||
if (!token || !token.trim()) {
|
||||
throw new Error(
|
||||
'[entity-art-loader] Missing character token in loadCharacterArt (fail-fast, no silent "so" fallback)',
|
||||
)
|
||||
}
|
||||
const heroToken = normalizeHeroToken(token)
|
||||
if (packBase !== undefined) {
|
||||
const r8CandidatePrefixes = [
|
||||
|
|
@ -391,6 +397,7 @@ export async function loadCharacterArt(
|
|||
}
|
||||
|
||||
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
|
||||
reportMissingArt('player', heroToken, `character art unavailable for ${heroToken} in pre-baked packs`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +452,13 @@ export async function loadObjectArt(
|
|||
handle,
|
||||
groups,
|
||||
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
|
||||
blendMode:
|
||||
meta.blendMode === 'additive' ||
|
||||
indexEntry?.blendMode === 'additive' ||
|
||||
indexEntry?.trans === 1 ||
|
||||
indexEntry?.trans === 2
|
||||
? 'additive'
|
||||
: 'normal',
|
||||
directions: Math.max(1, meta.directions || 1),
|
||||
standOffset: meta.standOffset ?? 0,
|
||||
standFrames: Math.max(1, meta.standFrames || 1),
|
||||
|
|
@ -489,7 +502,13 @@ export async function loadObjectArt(
|
|||
handle,
|
||||
groups,
|
||||
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
|
||||
blendMode:
|
||||
meta.blendMode === 'additive' ||
|
||||
indexEntry?.blendMode === 'additive' ||
|
||||
indexEntry?.trans === 1 ||
|
||||
indexEntry?.trans === 2
|
||||
? 'additive'
|
||||
: 'normal',
|
||||
directions: Math.max(1, meta.directions || 1),
|
||||
standOffset: meta.standOffset ?? 0,
|
||||
standFrames: Math.max(1, meta.standFrames || 1),
|
||||
|
|
@ -506,6 +525,7 @@ export async function loadObjectArt(
|
|||
}
|
||||
}
|
||||
|
||||
reportMissingArt('object', cleanSlug, `Failed to load object art for slug=${cleanSlug}`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -660,6 +680,7 @@ export class EntityArtCache {
|
|||
}
|
||||
}
|
||||
this.indexState = 'failed'
|
||||
reportMissingArt('entity-index', 'entity-index.json', 'Failed to load entity catalog index')
|
||||
this.onArtLoaded?.()
|
||||
return null
|
||||
})()
|
||||
|
|
@ -740,12 +761,14 @@ export class EntityArtCache {
|
|||
this.monsterByClassIdCache.set(classId, withScale)
|
||||
} else {
|
||||
this.failedMonsters.add(classId)
|
||||
reportMissingArt('monster', specKey, `Failed to load monster art for specKey=${specKey}`)
|
||||
}
|
||||
this.onArtLoaded?.()
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(err => {
|
||||
this.inflightMonsters.delete(classId)
|
||||
this.failedMonsters.add(classId)
|
||||
reportMissingArt('monster', specKey, String(err))
|
||||
this.onArtLoaded?.()
|
||||
})
|
||||
|
||||
|
|
@ -812,12 +835,14 @@ export class EntityArtCache {
|
|||
this.objectByClassIdCache.set(cacheKey, merged)
|
||||
} else {
|
||||
this.failedObjects.add(cacheKey)
|
||||
reportMissingArt('object', slug, `Failed to load object art for slug=${slug}`)
|
||||
}
|
||||
this.onArtLoaded?.()
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(err => {
|
||||
this.inflightObjects.delete(cacheKey)
|
||||
this.failedObjects.add(cacheKey)
|
||||
reportMissingArt('object', slug, String(err))
|
||||
this.onArtLoaded?.()
|
||||
})
|
||||
|
||||
|
|
@ -828,8 +853,14 @@ export class EntityArtCache {
|
|||
art: LoadedObjectArt,
|
||||
entry: ObjectIndexEntry,
|
||||
): LoadedObjectArt {
|
||||
const isAdditive =
|
||||
entry.blendMode === 'additive' ||
|
||||
entry.trans === 1 ||
|
||||
entry.trans === 2 ||
|
||||
art.blendMode === 'additive'
|
||||
return {
|
||||
...art,
|
||||
blendMode: isAdditive ? 'additive' : art.blendMode ?? 'normal',
|
||||
...(entry.frameCnt !== undefined ? { frameCnt: entry.frameCnt } : {}),
|
||||
...(entry.frameDelta !== undefined ? { frameDelta: entry.frameDelta } : {}),
|
||||
...(entry.cycleAnim !== undefined ? { cycleAnim: entry.cycleAnim } : {}),
|
||||
|
|
|
|||
|
|
@ -532,9 +532,6 @@ export class Minimap {
|
|||
/** Loaded WebGL atlas handles keyed by sheet name (`MaxiMap`, `MaxiMapS`, etc.). */
|
||||
private readonly atlases = new Map<AutomapSpriteKey, AtlasHandle>()
|
||||
|
||||
/** Cached synthesized `AutomapLayerData` for minimal test `MinimapLevel` objects lacking `level.automap`. */
|
||||
private readonly synthesizedLayers = new Map<string, AutomapLayerData>()
|
||||
|
||||
/** level key -> one byte per cell, non-zero meaning explored. */
|
||||
private readonly explored = new Map<string, Uint8Array>()
|
||||
|
||||
|
|
@ -988,7 +985,14 @@ export class Minimap {
|
|||
this.panY,
|
||||
)
|
||||
|
||||
const automapLayer = level.automap ?? this.getOrSynthesizeAutomapLayer(level)
|
||||
const automapLayer = level.automap
|
||||
if (!automapLayer) {
|
||||
reportMissingArt(
|
||||
'automap',
|
||||
String(level.levelId ?? level.key),
|
||||
'Missing authentic automap layer data (no synthetic fallback allowed)',
|
||||
)
|
||||
}
|
||||
this.lastRenderStats.mode = this.mode
|
||||
this.lastRenderStats.bMinimap = this.bMinimap
|
||||
this.lastRenderStats.divisor = vp.divisor
|
||||
|
|
@ -999,15 +1003,16 @@ export class Minimap {
|
|||
this.lastRenderStats.panY = this.panY
|
||||
this.lastRenderStats.viewport = vp
|
||||
this.lastRenderStats.cellCounts = {
|
||||
floors: automapLayer.floors.length,
|
||||
walls: automapLayer.walls.length,
|
||||
objects: automapLayer.objects.length,
|
||||
extras: automapLayer.extras.length,
|
||||
total:
|
||||
automapLayer.floors.length +
|
||||
automapLayer.walls.length +
|
||||
automapLayer.objects.length +
|
||||
automapLayer.extras.length,
|
||||
floors: automapLayer?.floors.length ?? 0,
|
||||
walls: automapLayer?.walls.length ?? 0,
|
||||
objects: automapLayer?.objects.length ?? 0,
|
||||
extras: automapLayer?.extras.length ?? 0,
|
||||
total: automapLayer
|
||||
? automapLayer.floors.length +
|
||||
automapLayer.walls.length +
|
||||
automapLayer.objects.length +
|
||||
automapLayer.extras.length
|
||||
: 0,
|
||||
}
|
||||
this.lastRenderStats.spritesDrawn = {
|
||||
floors: 0,
|
||||
|
|
@ -1133,14 +1138,16 @@ export class Minimap {
|
|||
}
|
||||
|
||||
// Strict 1.13c 4-layer draw order (`0x6fb10489`):
|
||||
// 1. Layer 0 (`floors`)
|
||||
drawLayerCells(automapLayer.floors, 'floors', false)
|
||||
// 2. Layer 1 (`walls`)
|
||||
drawLayerCells(automapLayer.walls, 'walls', false)
|
||||
// 3. Layer 2 (`objects`)
|
||||
drawLayerCells(automapLayer.objects, 'objects', false)
|
||||
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
|
||||
drawLayerCells(automapLayer.extras, 'extras', true)
|
||||
if (automapLayer) {
|
||||
// 1. Layer 0 (`floors`)
|
||||
drawLayerCells(automapLayer.floors, 'floors', false)
|
||||
// 2. Layer 1 (`walls`)
|
||||
drawLayerCells(automapLayer.walls, 'walls', false)
|
||||
// 3. Layer 2 (`objects`)
|
||||
drawLayerCells(automapLayer.objects, 'objects', false)
|
||||
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
|
||||
drawLayerCells(automapLayer.extras, 'extras', true)
|
||||
}
|
||||
|
||||
// 5. Dynamic active Town Portals (`0x6fb11490`: `MaxiMap` / `MaxiMapS` frame 300 in Level 4, else 306/307)
|
||||
const maxiSheetName = resolveAutomapSheetName(0, this.bMinimap)
|
||||
|
|
@ -1496,95 +1503,6 @@ export class Minimap {
|
|||
return this.atlases.get(sheetName) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesizes a 4-layer `AutomapLayerData` from a minimal `MinimapLevel` (when `level.automap`
|
||||
* is not explicitly passed, e.g. in standalone unit tests).
|
||||
*/
|
||||
private getOrSynthesizeAutomapLayer(level: MinimapLevel): AutomapLayerData {
|
||||
const cached = this.synthesizedLayers.get(level.key)
|
||||
if (cached) return cached
|
||||
|
||||
const floors: AutomapCell[] = []
|
||||
const walls: AutomapCell[] = []
|
||||
const objects: AutomapCell[] = []
|
||||
const extras: AutomapCell[] = []
|
||||
|
||||
for (let y = 0; y < level.cellsY; y += 1) {
|
||||
for (let x = 0; x < level.cellsX; x += 1) {
|
||||
const coords = computeAutomapTilePixelCoords(x, y, 0)
|
||||
if (this.cellBlocked(level, x, y)) {
|
||||
walls.push({
|
||||
cellNo: 9,
|
||||
xPixel: coords.xPixel,
|
||||
yPixel: coords.yPixel,
|
||||
cellX: x,
|
||||
cellY: y,
|
||||
fSaved: 0,
|
||||
weight: 0,
|
||||
})
|
||||
} else {
|
||||
floors.push({
|
||||
cellNo: 0,
|
||||
xPixel: coords.xPixel,
|
||||
yPixel: coords.yPixel,
|
||||
cellX: x,
|
||||
cellY: y,
|
||||
fSaved: 0,
|
||||
weight: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const marker of level.markers) {
|
||||
const subTileX = marker.cellX * 5 + 2
|
||||
const subTileY = marker.cellY * 5 + 2
|
||||
const coords = computeAutomapObjectPixelCoords(subTileX, subTileY)
|
||||
if (marker.kind === 'waypoint') {
|
||||
extras.push({
|
||||
cellNo: 309,
|
||||
xPixel: coords.xPixel,
|
||||
yPixel: coords.yPixel,
|
||||
cellX: marker.cellX,
|
||||
cellY: marker.cellY,
|
||||
fSaved: 0,
|
||||
weight: 0,
|
||||
})
|
||||
} else {
|
||||
objects.push({
|
||||
cellNo: 306,
|
||||
xPixel: coords.xPixel,
|
||||
yPixel: coords.yPixel,
|
||||
cellX: marker.cellX,
|
||||
cellY: marker.cellY,
|
||||
fSaved: 0,
|
||||
weight: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const layer: AutomapLayerData = {
|
||||
layerId: level.levelId ?? 0,
|
||||
levelTypeId: 1,
|
||||
floors,
|
||||
walls,
|
||||
objects,
|
||||
extras,
|
||||
}
|
||||
this.synthesizedLayers.set(level.key, layer)
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cell is blocked in the sub-tile walkability grid.
|
||||
*/
|
||||
private cellBlocked(level: MinimapLevel, cellX: number, cellY: number): boolean {
|
||||
const sx = cellX * 5 + 2
|
||||
const sy = cellY * 5 + 2
|
||||
const idx = sy * level.gridWidth + sx
|
||||
return idx >= 0 && idx < level.blocked.length && level.blocked[idx] !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The explored bytes for a level, created on first use.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -941,7 +941,56 @@ export function itemToUiInventoryItem(
|
|||
: (rawItem as any).id ?? `item-${code}-${++itemBridgeSeqCounter}`,
|
||||
)
|
||||
|
||||
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
|
||||
let flippyFile: string | undefined = (rawItem as any).flippyFile ?? (rawItem as any).flippyfile
|
||||
if (!flippyFile && quality === 'unique') {
|
||||
flippyFile = rawItem.uniqueItemDef?.flippyfile
|
||||
if (!flippyFile && dropTables?.uniques) {
|
||||
const targetName = (rawItem.name ?? '').trim()
|
||||
let match =
|
||||
(targetName ? dropTables.uniques.get?.(targetName) : undefined) ??
|
||||
(targetName
|
||||
? dropTables.uniques.all?.find(
|
||||
u => u.enabled && u.index.trim().toLowerCase() === targetName.toLowerCase(),
|
||||
)
|
||||
: undefined)
|
||||
if (!match && code) {
|
||||
const byCode = dropTables.uniques.getEnabledByCode?.(code.trim())
|
||||
if (byCode && byCode.length === 1) {
|
||||
match = byCode[0]
|
||||
}
|
||||
}
|
||||
if (match?.flippyfile) {
|
||||
flippyFile = match.flippyfile
|
||||
}
|
||||
}
|
||||
} else if (!flippyFile && quality === 'set') {
|
||||
flippyFile = rawItem.setItemDef?.flippyfile
|
||||
if (!flippyFile && dropTables?.sets?.items) {
|
||||
const targetName = (rawItem.name ?? '').trim()
|
||||
let match =
|
||||
(targetName ? dropTables.sets.items.getByIndex?.(targetName) : undefined) ??
|
||||
(targetName
|
||||
? dropTables.sets.items.entries?.find(
|
||||
s => s.index.trim().toLowerCase() === targetName.toLowerCase(),
|
||||
)
|
||||
: undefined)
|
||||
if (!match && code) {
|
||||
const lowerCode = code.trim().toLowerCase()
|
||||
const byCode = dropTables.sets.items.entries?.filter(
|
||||
s => s.item?.trim().toLowerCase() === lowerCode,
|
||||
)
|
||||
if (byCode && byCode.length === 1) {
|
||||
match = byCode[0]
|
||||
}
|
||||
}
|
||||
if (match?.flippyfile) {
|
||||
flippyFile = match.flippyfile
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!flippyFile) {
|
||||
flippyFile = (base as any).flippyfile
|
||||
}
|
||||
const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound
|
||||
const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
|
||||
const permStore = Boolean(rawItem.permStore ?? base.permStoreItem)
|
||||
|
|
@ -1451,7 +1500,10 @@ export function onlineItemDataToUiInventoryItem(
|
|||
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
|
||||
} {
|
||||
const tables = dropTables ?? getEmbeddedDropTables()
|
||||
const code = String(item.code ?? '').trim().toLowerCase() || 'rin'
|
||||
const code = String(item.code ?? '').trim().toLowerCase()
|
||||
if (!code) {
|
||||
throw new Error('[item-bridge] Missing item code in onlineItemDataToUiInventoryItem (fail-fast, no silent fallback)')
|
||||
}
|
||||
|
||||
const weaponRow = tables.weapons?.get(code)
|
||||
const armorRow = !weaponRow ? tables.armor?.get(code) : undefined
|
||||
|
|
@ -1759,7 +1811,7 @@ export function onlineItemDataToUiInventoryItem(
|
|||
invfile: baseRow?.invfile ?? baseRow?.invFile,
|
||||
uniqueinvfile: baseRow?.uniqueinvfile,
|
||||
setinvfile: baseRow?.setinvfile,
|
||||
flippyfile: baseRow?.flippyfile,
|
||||
flippyfile: uniqueItemDef?.flippyfile ?? setItemDef?.flippyfile ?? baseRow?.flippyfile,
|
||||
dropsound: baseRow?.dropsound,
|
||||
dropsfxframe: baseRow?.dropsfxframe,
|
||||
twoHanded: Boolean(baseRow?.twoHanded || baseRow?.twohanded === '1' || baseRow?.['2handed'] === 1),
|
||||
|
|
|
|||
|
|
@ -594,7 +594,7 @@ describe('Tier 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)'
|
|||
expect(minimap.spritesDrawnLastFrame.extras).toBe(1)
|
||||
})
|
||||
|
||||
it('F8.B3: synthesizes a fallback 4-layer AutomapLayerData when level.automap is omitted', () => {
|
||||
it('F8.B3: does not synthesize fake fallback layers when level.automap is omitted', () => {
|
||||
const minimap = new Minimap()
|
||||
registerAtlases(minimap)
|
||||
minimap.mode = 'full'
|
||||
|
|
@ -613,8 +613,8 @@ describe('Tier 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)'
|
|||
}
|
||||
minimap.reveal(level, 2, 2, 0, 160, true)
|
||||
minimap.draw(createNoopRenderer(), level, 2, 2, { x: 0, y: 160, zoom: 1 }, { width: 800, height: 600 })
|
||||
expect(minimap.lastRenderStats.cellCounts.total).toBe(100 + 2)
|
||||
expect(minimap.spritesDrawnLastFrame.total).toBeGreaterThan(0)
|
||||
expect(minimap.lastRenderStats.cellCounts.total).toBe(0)
|
||||
expect(minimap.spritesDrawnLastFrame.total).toBe(0)
|
||||
})
|
||||
|
||||
it('F8.B4: skips all rendering when minimap.visible === false while resetting frame draw stats to 0', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue