fix: 修复非基底对象 NPC 丢失问题

Diablo 2 城镇中的部分 NPC(如 Gheed, Charsi, Akara, Kashya, Warriv)没有定义在
Objects.txt 作为 Type 2 对象,而是通过 MonPreset.txt 定义为 Type 1(怪兽生成点)。
以前此类对象被一律视为 kind: 'monster' 并且放弃生成(silently dropped)。

此修复:
1. `src/game/acts.ts`: 在 `loadActTables` 中增加了 `MonPreset.txt` 和 `monstats.txt` 解析。
2. `src/game/objects.ts`: `resolveDs1Object` 现在接受 `monsters` 表以处理 `type === 1` 对象查询:
   `MonPreset` 通过 Act + `id` => `Place`;
   `monstats.txt` 的第一列 (Id, 恰好对应 Place) => `Code` (对应资源 token) 和 `NameStr` (角色名)。
3. `scripts/pack-act-assets.ts`: 将解析后的新表传递给 resolver。对真实存在艺术资源的 `kind: 'npc'` 进行打包;对于确实无资源的占位对象(如 place_nothing, trap-*)现在会干净地跳过并做数量统计 (Skipped artless spawns),不再将它们报告为艺术丢失或引发异常。
4. 抽象统一 `MONSTER_ROOT` 并移除所有写死的 'data\\global\\monsters\\' 字面量。
5. `src/game/npc.ts`: 扩展 `NPC_NAME_FALLBACKS` 清单,解决部分名称未定义的显示为短码的问题(例如 RG 替换为 Rogue, CW 为 Cow 等)。

在保证现有 Type 2 行为完全不变和所有测试继续通过的前提下,总共正确重新召回了 67 个原先丢失的 Type 1 镇民、卫兵遗漏和活物 (Cow, Chicken等) 实例。

(refs #6)
This commit is contained in:
troytt 2026-09-14 15:14:15 +00:00
parent ad7ced6760
commit 451816d627
4 changed files with 58 additions and 11 deletions

View File

@ -39,7 +39,7 @@ import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts' import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts' import type { IsoMapScene } from '../src/game/d2map.ts'
import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts' import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts' import type { SpriteFrame } from '../src/formats/sprite.ts'
@ -65,7 +65,6 @@ const HOT_RADIUS_CELLS = 8
/** Where object art lives. */ /** Where object art lives. */
const OBJECT_PREFIX = 'data\\global\\objects\\' const OBJECT_PREFIX = 'data\\global\\objects\\'
/** Where monster and town-NPC art lives. */ /** Where monster and town-NPC art lives. */
const MONSTER_PREFIX = 'data\\global\\monsters\\'
/** /**
* Mode directories tried in order when picking an object's art. * Mode directories tried in order when picking an object's art.
* *
@ -349,12 +348,12 @@ const objectMembers = new Map<string, Map<string, string[]>>()
const monsterMembers = new Map<string, Map<string, string[]>>() const monsterMembers = new Map<string, Map<string, string[]>>()
for (const name of allNames) { for (const name of allNames) {
const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase()) const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())
const isMon = name.toLowerCase().startsWith(MONSTER_PREFIX.toLowerCase()) const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase())
if (!isObj && !isMon) continue if (!isObj && !isMon) continue
// Derive the length; a hand-counted magic number here was off by one (22 vs // Derive the length; a hand-counted magic number here was off by one (22 vs
// the real 21), which sliced the first letter off every monster token and // the real 21), which sliced the first letter off every monster token and
// silently broke all NPC art lookups. // silently broke all NPC art lookups.
const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_PREFIX.length const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length
const rest = name.slice(prefixLen).split('\\') const rest = name.slice(prefixLen).split('\\')
if (rest.length < 3) continue if (rest.length < 3) continue
const token = (rest[0] ?? '').toUpperCase() const token = (rest[0] ?? '').toUpperCase()
@ -584,6 +583,7 @@ for (let act = 1; act <= 5; act += 1) {
let totalPngBytes = 0 let totalPngBytes = 0
let totalLevels = 0 let totalLevels = 0
let skippedArtlessSpawns = 0
async function bakeDs1Variant( async function bakeDs1Variant(
entry: LevelJob, entry: LevelJob,
@ -660,12 +660,19 @@ async function bakeDs1Variant(
for (const object of level.objects) { for (const object of level.objects) {
let resolved let resolved
try { try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id) resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, {
preset: tables.monpreset,
stats: tables.monstats
})
} catch (err) { } catch (err) {
missingObjects.push((err as Error).message) missingObjects.push((err as Error).message)
continue continue
} }
if (resolved.kind === 'monster') continue if (resolved.kind === 'monster') continue
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
skippedArtlessSpawns++
continue
}
const row = resolved.row const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
@ -739,7 +746,7 @@ async function bakeDs1Variant(
list.push({ list.push({
id: object.id, id: object.id,
type: object.type, type: object.type,
name: row?.name ?? resolved.token, name: resolved.name ?? row?.name ?? resolved.token,
token: resolved.token, token: resolved.token,
mode: resolved.mode === '' ? 'NU' : resolved.mode, mode: resolved.mode === '' ? 'NU' : resolved.mode,
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the // `objectsTxtId` is the Objects.txt row the table points at; -1 means the
@ -980,3 +987,4 @@ for (const entry of LEVELS) {
await mkdir(outDir, { recursive: true }) await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1)) await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}`)

View File

@ -95,6 +95,8 @@ export interface ActTables {
readonly levels: D2Table readonly levels: D2Table
readonly lvltypes: D2Table readonly lvltypes: D2Table
readonly lvlprest: D2Table readonly lvlprest: D2Table
readonly monstats: D2Table
readonly monpreset: D2Table
} }
/** Everything needed to place and render one level. */ /** Everything needed to place and render one level. */
@ -135,7 +137,7 @@ export function tileMemberPath(relative: string): string {
} }
/** /**
* Load the three tables from a mounted stack. * Load the tables from a mounted stack.
* *
* @param archives - the mounted archives. * @param archives - the mounted archives.
* @returns the parsed tables. * @returns the parsed tables.
@ -146,6 +148,8 @@ export async function loadActTables(archives: MountedArchives): Promise<ActTable
levels: await read('levels.txt'), levels: await read('levels.txt'),
lvltypes: await read('lvltypes.txt'), lvltypes: await read('lvltypes.txt'),
lvlprest: await read('lvlprest.txt'), lvlprest: await read('lvlprest.txt'),
monstats: await read('monstats.txt'),
monpreset: await read('MonPreset.txt'),
} }
} }

View File

@ -7,6 +7,9 @@ const NPC_NAME_FALLBACKS: Record<string, string> = {
'XR': 'Larzuk', 'XR': 'Larzuk',
'XS': 'Drehya', 'XS': 'Drehya',
'0J': 'Nihlathak', '0J': 'Nihlathak',
'RG': 'Rogue',
'CK': 'Chicken',
'CW': 'Cow',
} }
export function resolveNpcName(token: string, fallbackName: string): string { export function resolveNpcName(token: string, fallbackName: string): string {

View File

@ -73,7 +73,9 @@ import type { ObjectLookupEntry } from './object-lookup.ts'
/** `Objects.txt` inside the archives. */ /** `Objects.txt` inside the archives. */
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt' const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
/** Prefix every object composition and sprite lives under (`szPathPrefix`). */ /** Prefix every object composition and sprite lives under (`szPathPrefix`). */
const OBJECT_ROOT = 'data\\global\\objects\\' export const OBJECT_ROOT = 'data\\global\\objects\\'
/** Prefix every monster composition and sprite lives under. */
export const MONSTER_ROOT = 'data\\global\\monsters\\'
/** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */ /** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */
const OBJECT_WEAPON = 'hth' const OBJECT_WEAPON = 'hth'
/** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */ /** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */
@ -458,16 +460,44 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
* @throws when the object type is an object but the table has no such id, because a * @throws when the object type is an object but the table has no such id, because a
* silent fallback would bake the wrong token into the pack under the right id. * silent fallback would bake the wrong token into the pack under the right id.
*/ */
export interface MonstersTable {
/** Parsed monstats.txt. */
readonly stats: D2Table
/** Parsed MonPreset.txt. */
readonly preset: D2Table
}
export function resolveDs1Object( export function resolveDs1Object(
tables: ObjectsTable, tables: ObjectsTable,
act: number, act: number,
objectType: number, objectType: number,
objectId: number, objectId: number,
monsters?: MonstersTable
): ResolvedDs1Object { ): ResolvedDs1Object {
if (!Number.isFinite(objectId)) { if (!Number.isFinite(objectId)) {
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`) throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
} }
if (objectType !== OBJECT_TYPE_OBJECT) { if (objectType !== OBJECT_TYPE_OBJECT) {
if (objectType === 1 && monsters) {
const actStr = String(act)
const presets = monsters.preset.rows.filter(row => cell(monsters.preset, row, 'Act') === actStr)
if (objectId >= 0 && objectId < presets.length) {
const place = cell(monsters.preset, presets[objectId]!, 'Place')
const statRow = monsters.stats.rows.find(row => cell(monsters.stats, row, 'Id') === place)
const token = statRow ? cell(monsters.stats, statRow, 'Code') : ''
const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : ''
return {
entry: null,
row: null,
token,
mode: 'NU',
artless: token.trim() === '',
kind: 'npc',
name: nameStr || place
}
}
}
return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' } return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' }
} }
const entry = lookupObject(act, objectType, objectId) const entry = lookupObject(act, objectType, objectId)
@ -513,6 +543,8 @@ export interface ResolvedDs1Object {
readonly artless: boolean readonly artless: boolean
/** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */ /** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */
readonly kind: 'object' | 'monster' | 'npc' readonly kind: 'object' | 'monster' | 'npc'
/** The name resolved for this object (like the name of the NPC), if any. */
readonly name?: string
} }
/** What {@link resolveObjectArt} is asked to resolve. */ /** What {@link resolveObjectArt} is asked to resolve. */
@ -568,7 +600,7 @@ export interface ObjectArt {
export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string { export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string {
const name = token.trim().toLowerCase() const name = token.trim().toLowerCase()
const mode = modeToken(modeIndex).toLowerCase() const mode = modeToken(modeIndex).toLowerCase()
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof` return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof`
} }
@ -590,7 +622,7 @@ export function objectSpriteMember(token: string, component: string, modeIndex:
const name = token.trim().toLowerCase() const name = token.trim().toLowerCase()
const part = component.toLowerCase() const part = component.toLowerCase()
const mode = modeToken(modeIndex).toLowerCase() const mode = modeToken(modeIndex).toLowerCase()
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc` return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc`
} }
@ -721,7 +753,7 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
if (member === null) { if (member === null) {
for (const index of order) { for (const index of order) {
const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu' const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu'
const root = request.baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT const root = request.baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
const prefix = `${root}${token.toLowerCase()}\\` const prefix = `${root}${token.toLowerCase()}\\`
const suffix = `${mode}${OBJECT_WEAPON}.dcc` const suffix = `${mode}${OBJECT_WEAPON}.dcc`
const hits = members const hits = members