From 3cd4549332371d259ae459c0ba01ac16151c3a54 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 15:37:32 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=9D=9E=E5=9F=BA?= =?UTF-8?q?=E5=BA=95=E5=AF=B9=E8=B1=A1=20NPC=20=E4=B8=A2=E5=A4=B1=E5=8F=8A?= =?UTF-8?q?=E5=85=B6=E8=B5=84=E6=BA=90=E6=A3=80=E7=B4=A2=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在前一次提交的基础上修复并强化了 Type 1 对象(怪兽及 NPC 原型)解析与打包的正确性。 1. 修正大写回退遗漏问题(解决失联的 6 个特定角色如 Act 4 Jamella 等):`pickObjectMember` 中需要使用大写查找 token,而原来直接代入了小写 token 导致失败,修复后所有角色均成功合图。 2. 添加安全跳过统计 (`skippedMissingArtSpawns`),使得未匹配上图片的隐藏对象能干净跳过。 3. 增加 `tests/resolve-type1.test.ts` 提供核心覆盖率。 4. 应用性能重构与规范修复,将二次查询提速为内置的 `Map` 遍历,同时补齐了 `OBJECT_TYPE_MONSTER`、Typescript interface、尾随空格与逗号修复。 5. 后向保留了省略 tables 时旧版逻辑的注解。 (refs #6) --- scripts/pack-act-assets.ts | 44 +++++++++++++--- scripts/verify-objects.ts | 1 + scripts/verify-packs.ts | 1 + src/game/objects.ts | 38 ++++++++------ tests/resolve-type1.test.ts | 101 ++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 tests/resolve-type1.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 0448c47..72b049e 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -271,8 +271,34 @@ for (const name of MOUNTS) { } const tables: ActTables = await loadActTables(archives) const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt')) -/** Same table, through the typed loader the object resolution expects. */ const objectsTableTyped = await loadObjectsTable(archives) + +const presetPlaceByAct = new Map() +for (const row of tables.monpreset.rows) { + const act = cell(tables.monpreset, row, 'Act') + const place = cell(tables.monpreset, row, 'Place') + if (!place) continue + let list = presetPlaceByAct.get(act) + if (list === undefined) { + list = [] + presetPlaceByAct.set(act, list) + } + list.push(place) +} + +const statsById = new Map() +for (const row of tables.monstats.rows) { + const id = cell(tables.monstats, row, 'Id') + if (id) statsById.set(id, row) +} + +const monstersTable = { + preset: tables.monpreset, + stats: tables.monstats, + presetPlaceByAct, + statsById, +} + const allNames = await archives.listFiles() { @@ -389,7 +415,7 @@ function compRank(path: string): number { */ function pickObjectMember(token: string, modeToken: string, baseIsMonsters = false): { member: string; candidates: number } | null { const map = baseIsMonsters ? monsterMembers : objectMembers - const dirs = map.get(token) + const dirs = map.get(token.toUpperCase()) if (dirs === undefined) return null const all = [...dirs.values()].flat().filter(name => { const lower = name.toLowerCase() @@ -584,6 +610,7 @@ for (let act = 1; act <= 5; act += 1) { let totalPngBytes = 0 let totalLevels = 0 let skippedArtlessSpawns = 0 +let skippedMissingArtSpawns = 0 async function bakeDs1Variant( entry: LevelJob, @@ -660,21 +687,22 @@ async function bakeDs1Variant( for (const object of level.objects) { let resolved try { - resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, { - preset: tables.monpreset, - stats: tables.monstats - }) + resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable) } catch (err) { missingObjects.push((err as Error).message) continue } if (resolved.kind === 'monster') continue if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) { - skippedArtlessSpawns++ + skippedArtlessSpawns += 1 continue } const row = resolved.row const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') + if (pick === null && resolved.kind === 'npc' && object.type === 1) { + skippedMissingArtSpawns += 1 + continue + } const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY @@ -987,4 +1015,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}`) +console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`) diff --git a/scripts/verify-objects.ts b/scripts/verify-objects.ts index c099f2d..d47b251 100644 --- a/scripts/verify-objects.ts +++ b/scripts/verify-objects.ts @@ -334,6 +334,7 @@ async function walkPresetObjects( tally.objects += 1 let resolved try { + // Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour. resolved = resolveDs1Object(tables, info.act, object.type, object.id) } catch (err) { tally.undefinedRows += 1 diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index ef71e59..d473f59 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -219,6 +219,7 @@ for (const entry of index.levels) { const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = [] for (const object of level.objects) { try { + // Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour. const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id) if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode }) } catch { diff --git a/src/game/objects.ts b/src/game/objects.ts index c7e2589..1086f10 100644 --- a/src/game/objects.ts +++ b/src/game/objects.ts @@ -67,7 +67,7 @@ import { decodeDcc } from '../formats/dcc.ts' import type { DccFile } from '../formats/dcc.ts' import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts' import type { MountedArchives } from '../mpq/mount.ts' -import { OBJECT_TYPE_OBJECT, lookupObject } from './object-lookup.ts' +import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts' import type { ObjectLookupEntry } from './object-lookup.ts' /** `Objects.txt` inside the archives. */ @@ -438,6 +438,17 @@ export async function loadObjectsTable(archives: MountedArchives): Promise + /** Pre-indexed monstats.txt rows by Id (column 0). */ + readonly statsById: ReadonlyMap +} + /** * Resolve a DS1 object entry to the art the engine would use for it. * @@ -456,37 +467,30 @@ export async function loadObjectsTable(archives: MountedArchives): Promise 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) + if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) { + const presets = monsters.presetPlaceByAct.get(String(act)) + if (presets !== undefined && objectId >= 0 && objectId < presets.length) { + const place = presets[objectId]! + const statRow = monsters.statsById.get(place) const token = statRow ? cell(monsters.stats, statRow, 'Code') : '' const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : '' - + return { entry: null, row: null, @@ -494,7 +498,7 @@ export function resolveDs1Object( mode: 'NU', artless: token.trim() === '', kind: 'npc', - name: nameStr || place + name: nameStr || place, } } } diff --git a/tests/resolve-type1.test.ts b/tests/resolve-type1.test.ts new file mode 100644 index 0000000..47a677b --- /dev/null +++ b/tests/resolve-type1.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import { resolveDs1Object, OBJECT_ROOT } from '../src/game/objects'; +import type { ObjectsTable, MonstersTable } from '../src/game/objects'; +import { OBJECT_TYPE_MONSTER, OBJECT_TYPE_OBJECT } from '../src/game/object-lookup'; + +describe('resolveDs1Object (type 1 rules)', () => { + + const emptyObjectsTable: ObjectsTable = { + table: { header: [], rows: [] }, + rows: [], + byId: new Map() + }; + + const mockMonsters = (): MonstersTable => { + // Let's create a preset map and stats map. + // We need Act 1, Id 0 -> 'dummyplace' + // Act 1, Id 1 -> 'warriv1' -> has code WA + // Act 2, Id 0 -> 'warriv1' -> has code NO_WA + // Act 1, Id 2 -> 'place_nothing' -> has no row + + // We only need to populate `presetPlaceByAct` for Act 1 and 2. + const presetPlaceByAct = new Map(); + presetPlaceByAct.set('1', ['dummyplace', 'warriv1', 'place_nothing']); + presetPlaceByAct.set('2', ['warriv1']); // same id but diff act + + const statsById = new Map(); + // Assume columns are 0: Id, 8: Code, 5: NameStr? (Whatever cell reading does, wait, cell reads by column header offset!) + // So we need proper D2Table for `monsters.stats`. + + const statsHeader = ['Id', 'NameStr', 'Code']; + const statsRows = [ + ['warriv1', 'Warriv', 'WA'], + ['dummyplace', '', 'DM'], // has place and art + // place_nothing is absent + ]; + + statsById.set('warriv1', statsRows[0]); + statsById.set('dummyplace', statsRows[1]); + + const statsTable = { header: statsHeader, rows: statsRows }; + const presetTable = { header: ['Act', 'Place'], rows: [] }; // The array wasn't used in code if presetPlaceByAct is there, but cell needs headers! + + return { + stats: statsTable, + preset: presetTable, + presetPlaceByAct, + statsById + }; + }; + + test('resolves named NPC with code and name', () => { + const monsters = mockMonsters(); + const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, monsters); + + expect(res.kind).toBe('npc'); + expect(res.token).toBe('WA'); + expect(res.name).toBe('Warriv'); + expect(res.artless).toBe(false); + }); + + test('resolves place with no stats row as artless monster/npc', () => { + const monsters = mockMonsters(); + const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 2, monsters); + + // place_nothing is not in statsById -> code is '' -> artless is true + expect(res.kind).toBe('npc'); + expect(res.token).toBe(''); + expect(res.name).toBe('place_nothing'); + expect(res.artless).toBe(true); + }); + + test('returns default monster if objectId out of range', () => { + const monsters = mockMonsters(); + const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 999, monsters); + + expect(res.kind).toBe('monster'); + expect(res.token).toBe(''); + expect(res.artless).toBe(true); + }); + + test('act filtering produces different results for same id', () => { + const monsters = mockMonsters(); + // In Act 1, Id 0 -> dummyplace (token DM) + const res1 = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 0, monsters); + expect(res1.token).toBe('DM'); + + // In Act 2, Id 0 -> warriv1 (token WA) + const res2 = resolveDs1Object(emptyObjectsTable, 2, OBJECT_TYPE_MONSTER, 0, monsters); + expect(res2.token).toBe('WA'); + }); + + test('omitted monsters parameter restores legacy behavior (dropping type 1)', () => { + // If monsters is undefined, type 1 falls back to artless monster + const res = resolveDs1Object(emptyObjectsTable, 1, OBJECT_TYPE_MONSTER, 1, undefined); + + expect(res.kind).toBe('monster'); + expect(res.token).toBe(''); + expect(res.artless).toBe(true); + }); + +});