fix: 修复非基底对象 NPC 丢失及其资源检索区分大小写的问题
在前一次提交的基础上修复并强化了 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)
This commit is contained in:
parent
451816d627
commit
3cd4549332
|
|
@ -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<string, string[]>()
|
||||
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<string, readonly string[]>()
|
||||
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}`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Objec
|
|||
return { table, rows, byId }
|
||||
}
|
||||
|
||||
export interface MonstersTable {
|
||||
/** Parsed monstats.txt. */
|
||||
readonly stats: D2Table
|
||||
/** Parsed MonPreset.txt. */
|
||||
readonly preset: D2Table
|
||||
/** Pre-filtered MonPreset Place column values, grouped by String(act). */
|
||||
readonly presetPlaceByAct: ReadonlyMap<string, readonly string[]>
|
||||
/** Pre-indexed monstats.txt rows by Id (column 0). */
|
||||
readonly statsById: ReadonlyMap<string, readonly string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Objec
|
|||
* @param act - the level's act, 1..5.
|
||||
* @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn).
|
||||
* @param objectId - the DS1 object's `id`.
|
||||
* @param monsters - precomputed monster lookup tables. Omitting this parameter intentionally restores the legacy behavior of dropping all non-object (type !== 2) DS1 entries.
|
||||
* @returns the entry, the optional metadata row, and the token/mode to draw with.
|
||||
* @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
|
||||
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)
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>();
|
||||
presetPlaceByAct.set('1', ['dummyplace', 'warriv1', 'place_nothing']);
|
||||
presetPlaceByAct.set('2', ['warriv1']); // same id but diff act
|
||||
|
||||
const statsById = new Map<string, string[]>();
|
||||
// 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);
|
||||
});
|
||||
|
||||
});
|
||||
Loading…
Reference in New Issue