fix(art): 将 NPC 渲染文件的基础寻址从 objects 修正为 monsters

修正 Deckard Cain、Jerhyn 等城镇 NPC 的基础美术路径。通过从 objectEntry 下传的 baseIsMonsters 布尔标识,使特定 Object 的 DCC 及 COF 查表过程自动从默认的 `data\global\objects` 切换到 `data\global\monsters`, 彻底解决打包器因跨域失效而丢弃图集的情况。
This commit is contained in:
troytt 2026-09-14 13:07:31 +00:00
parent 11e0688930
commit 11439256bf
2 changed files with 36 additions and 13 deletions

View File

@ -483,7 +483,7 @@ export function resolveDs1Object(
token,
mode: entry.mode,
artless: token.trim() === '',
kind: 'object',
kind: entry.baseIsMonsters ? 'npc' : 'object',
}
}
@ -511,8 +511,8 @@ export interface ResolvedDs1Object {
readonly mode: string
/** True when no token could be found: drawing nothing is then correct. */
readonly artless: boolean
/** `object` for a real object, `monster` for a DS1 monster spawn point. */
readonly kind: 'object' | 'monster'
/** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */
readonly kind: 'object' | 'monster' | 'npc'
}
/** What {@link resolveObjectArt} is asked to resolve. */
@ -536,6 +536,8 @@ export interface ObjectArtRequest {
readonly row: { name: string; token: string; subClass: number; mode: number; hp: number } | null
/** Every member name in the mounted archives, for name-only resolution. */
readonly members: readonly string[]
/** True when the object is effectively an NPC (`baseIsMonsters`), resolving in `monsters/` not `objects/`. */
readonly baseIsMonsters?: boolean
}
/** The art the engine would use for one DS1 object. */
@ -563,10 +565,11 @@ export interface ObjectArt {
* @param modeIndex - animation mode index, 0..7.
* @returns the lower-cased member name.
*/
export function objectCofMember(token: string, modeIndex: number): string {
export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string {
const name = token.trim().toLowerCase()
const mode = modeToken(modeIndex).toLowerCase()
return `${OBJECT_ROOT}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof`
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof`
}
/**
@ -580,13 +583,15 @@ export function objectCofMember(token: string, modeIndex: number): string {
* @param token - `Objects.txt` `Token`, any case.
* @param component - component directory, e.g. `tr`.
* @param modeIndex - animation mode index, 0..7.
* @param baseIsMonsters - true when the base directory is `monsters/` not `objects/`.
* @returns the lower-cased `.dcc` member name.
*/
export function objectSpriteMember(token: string, component: string, modeIndex: number): string {
export function objectSpriteMember(token: string, component: string, modeIndex: number, baseIsMonsters = false): string {
const name = token.trim().toLowerCase()
const part = component.toLowerCase()
const mode = modeToken(modeIndex).toLowerCase()
return `${OBJECT_ROOT}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc`
const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc`
}
/**
@ -688,11 +693,11 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
let modeIndex = requested
let cof: string | undefined
for (const candidate of modeOrder(requested)) {
const found = findMember(members, objectCofMember(token, candidate))
const found = findMember(members, objectCofMember(token, candidate, request.baseIsMonsters))
if (found !== undefined) { cof = found; modeIndex = candidate; break }
}
if (cof === undefined) {
notes.push(`token ${token} ships no COF for any mode (looked for ${objectCofMember(token, requested)})`)
notes.push(`token ${token} ships no COF for any mode (looked for ${objectCofMember(token, requested, request.baseIsMonsters)})`)
} else if (modeIndex !== requested) {
notes.push(`mode ${String(requested)} has no COF; fell back to ${OBJECT_MODE_TOKENS[modeIndex] ?? 'NU'}`)
}
@ -704,7 +709,7 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
const order = cof === undefined ? modeOrder(requested) : [modeIndex]
outer: for (const index of order) {
for (const component of COMPONENT_PREFERENCE) {
const found = findMember(members, objectSpriteMember(token, component, index))
const found = findMember(members, objectSpriteMember(token, component, index, request.baseIsMonsters))
if (found === undefined) continue
layers.push(found)
if (member === null) { member = found; chosenMode = index }
@ -716,7 +721,8 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt {
if (member === null) {
for (const index of order) {
const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu'
const prefix = `${OBJECT_ROOT}${token.toLowerCase()}\\`
const root = request.baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT
const prefix = `${root}${token.toLowerCase()}\\`
const suffix = `${mode}${OBJECT_WEAPON}.dcc`
const hits = members
.filter(name => name.toLowerCase().startsWith(prefix) && name.toLowerCase().endsWith(suffix))
@ -785,8 +791,9 @@ export async function loadObjectSheet(
token: string,
modeIndex: number,
row?: ObjectsRow,
baseIsMonsters = false,
): Promise<ObjectSheet> {
const cofMember = objectCofMember(token, modeIndex)
const cofMember = objectCofMember(token, modeIndex, baseIsMonsters)
let cof: CofFile
try {
cof = decodeCof(await archives.read(cofMember))
@ -800,7 +807,7 @@ export async function loadObjectSheet(
for (const layer of cof.layers) {
const component = OBJECT_COMPONENTS[layer.type]
if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue }
const wanted = objectSpriteMember(token, component, modeIndex)
const wanted = objectSpriteMember(token, component, modeIndex, baseIsMonsters)
let found = findMember(names, wanted)
if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6'))
if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue }

16
tests/npc-paths.test.ts Normal file
View File

@ -0,0 +1,16 @@
import { test, expect } from 'vitest'
import { objectCofMember, objectSpriteMember } from '../src/game/objects.ts'
test('ordinary object art paths are unchanged (non-:m)', () => {
expect(objectCofMember('D1', 0, false)).toBe('data\\global\\objects\\d1\\cof\\d1nuhth.cof')
expect(objectSpriteMember('D1', 'TR', 0, false)).toBe('data\\global\\objects\\d1\\tr\\d1trlitnuhth.dcc')
// Also verify that omission (default) preserves the old path
expect(objectCofMember('D1', 0)).toBe('data\\global\\objects\\d1\\cof\\d1nuhth.cof')
expect(objectSpriteMember('D1', 'TR', 0)).toBe('data\\global\\objects\\d1\\tr\\d1trlitnuhth.dcc')
})
test('NPC art paths resolve to monsters/ directory (:m)', () => {
expect(objectCofMember('DC', 0, true)).toBe('data\\global\\monsters\\dc\\cof\\dcnuhth.cof')
expect(objectSpriteMember('DC', 'TR', 0, true)).toBe('data\\global\\monsters\\dc\\tr\\dctrlitnuhth.dcc')
})