From 11439256bf3d0e01aa08e922897ec4b68dffda57 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 13:07:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(art):=20=E5=B0=86=20NPC=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=96=87=E4=BB=B6=E7=9A=84=E5=9F=BA=E7=A1=80=E5=AF=BB?= =?UTF-8?q?=E5=9D=80=E4=BB=8E=20objects=20=E4=BF=AE=E6=AD=A3=E4=B8=BA=20mo?= =?UTF-8?q?nsters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 Deckard Cain、Jerhyn 等城镇 NPC 的基础美术路径。通过从 objectEntry 下传的 baseIsMonsters 布尔标识,使特定 Object 的 DCC 及 COF 查表过程自动从默认的 `data\global\objects` 切换到 `data\global\monsters`, 彻底解决打包器因跨域失效而丢弃图集的情况。 --- src/game/objects.ts | 33 ++++++++++++++++++++------------- tests/npc-paths.test.ts | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 tests/npc-paths.test.ts diff --git a/src/game/objects.ts b/src/game/objects.ts index 98b15ab..8c653d5 100644 --- a/src/game/objects.ts +++ b/src/game/objects.ts @@ -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 { - 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 } diff --git a/tests/npc-paths.test.ts b/tests/npc-paths.test.ts new file mode 100644 index 0000000..0300387 --- /dev/null +++ b/tests/npc-paths.test.ts @@ -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') +})