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:
parent
ad7ced6760
commit
451816d627
|
|
@ -39,7 +39,7 @@ import type { Dt1 } from '../src/formats/dt1.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 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 { decodeDc6 } from '../src/formats/dc6.ts'
|
||||
import type { SpriteFrame } from '../src/formats/sprite.ts'
|
||||
|
|
@ -65,7 +65,6 @@ const HOT_RADIUS_CELLS = 8
|
|||
/** Where object art lives. */
|
||||
const OBJECT_PREFIX = 'data\\global\\objects\\'
|
||||
/** Where monster and town-NPC art lives. */
|
||||
const MONSTER_PREFIX = 'data\\global\\monsters\\'
|
||||
/**
|
||||
* 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[]>>()
|
||||
for (const name of allNames) {
|
||||
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
|
||||
// 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
|
||||
// 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('\\')
|
||||
if (rest.length < 3) continue
|
||||
const token = (rest[0] ?? '').toUpperCase()
|
||||
|
|
@ -584,6 +583,7 @@ for (let act = 1; act <= 5; act += 1) {
|
|||
|
||||
let totalPngBytes = 0
|
||||
let totalLevels = 0
|
||||
let skippedArtlessSpawns = 0
|
||||
|
||||
async function bakeDs1Variant(
|
||||
entry: LevelJob,
|
||||
|
|
@ -660,12 +660,19 @@ async function bakeDs1Variant(
|
|||
for (const object of level.objects) {
|
||||
let resolved
|
||||
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) {
|
||||
missingObjects.push((err as Error).message)
|
||||
continue
|
||||
}
|
||||
if (resolved.kind === 'monster') continue
|
||||
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
|
||||
skippedArtlessSpawns++
|
||||
continue
|
||||
}
|
||||
const row = resolved.row
|
||||
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
|
||||
|
|
@ -739,7 +746,7 @@ async function bakeDs1Variant(
|
|||
list.push({
|
||||
id: object.id,
|
||||
type: object.type,
|
||||
name: row?.name ?? resolved.token,
|
||||
name: resolved.name ?? row?.name ?? resolved.token,
|
||||
token: resolved.token,
|
||||
mode: resolved.mode === '' ? 'NU' : resolved.mode,
|
||||
// `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 writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
||||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||||
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}`)
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ export interface ActTables {
|
|||
readonly levels: D2Table
|
||||
readonly lvltypes: D2Table
|
||||
readonly lvlprest: D2Table
|
||||
readonly monstats: D2Table
|
||||
readonly monpreset: D2Table
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* @returns the parsed tables.
|
||||
|
|
@ -146,6 +148,8 @@ export async function loadActTables(archives: MountedArchives): Promise<ActTable
|
|||
levels: await read('levels.txt'),
|
||||
lvltypes: await read('lvltypes.txt'),
|
||||
lvlprest: await read('lvlprest.txt'),
|
||||
monstats: await read('monstats.txt'),
|
||||
monpreset: await read('MonPreset.txt'),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ const NPC_NAME_FALLBACKS: Record<string, string> = {
|
|||
'XR': 'Larzuk',
|
||||
'XS': 'Drehya',
|
||||
'0J': 'Nihlathak',
|
||||
'RG': 'Rogue',
|
||||
'CK': 'Chicken',
|
||||
'CW': 'Cow',
|
||||
}
|
||||
|
||||
export function resolveNpcName(token: string, fallbackName: string): string {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ import type { ObjectLookupEntry } from './object-lookup.ts'
|
|||
/** `Objects.txt` inside the archives. */
|
||||
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
|
||||
/** 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`). */
|
||||
const OBJECT_WEAPON = 'hth'
|
||||
/** 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
|
||||
* 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(
|
||||
tables: ObjectsTable,
|
||||
act: number,
|
||||
objectType: number,
|
||||
objectId: number,
|
||||
monsters?: MonstersTable
|
||||
): ResolvedDs1Object {
|
||||
if (!Number.isFinite(objectId)) {
|
||||
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
|
||||
}
|
||||
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' }
|
||||
}
|
||||
const entry = lookupObject(act, objectType, objectId)
|
||||
|
|
@ -513,6 +543,8 @@ export interface ResolvedDs1Object {
|
|||
readonly artless: boolean
|
||||
/** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named 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. */
|
||||
|
|
@ -568,7 +600,7 @@ export interface ObjectArt {
|
|||
export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string {
|
||||
const name = token.trim().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`
|
||||
}
|
||||
|
||||
|
|
@ -590,7 +622,7 @@ export function objectSpriteMember(token: string, component: string, modeIndex:
|
|||
const name = token.trim().toLowerCase()
|
||||
const part = component.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`
|
||||
}
|
||||
|
||||
|
|
@ -721,7 +753,7 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
|
|||
if (member === null) {
|
||||
for (const index of order) {
|
||||
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 suffix = `${mode}${OBJECT_WEAPON}.dcc`
|
||||
const hits = members
|
||||
|
|
|
|||
Loading…
Reference in New Issue