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 tables: ActTables = await loadActTables(archives)
|
||||||
const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt'))
|
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 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()
|
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 {
|
function pickObjectMember(token: string, modeToken: string, baseIsMonsters = false): { member: string; candidates: number } | null {
|
||||||
const map = baseIsMonsters ? monsterMembers : objectMembers
|
const map = baseIsMonsters ? monsterMembers : objectMembers
|
||||||
const dirs = map.get(token)
|
const dirs = map.get(token.toUpperCase())
|
||||||
if (dirs === undefined) return null
|
if (dirs === undefined) return null
|
||||||
const all = [...dirs.values()].flat().filter(name => {
|
const all = [...dirs.values()].flat().filter(name => {
|
||||||
const lower = name.toLowerCase()
|
const lower = name.toLowerCase()
|
||||||
|
|
@ -584,6 +610,7 @@ for (let act = 1; act <= 5; act += 1) {
|
||||||
let totalPngBytes = 0
|
let totalPngBytes = 0
|
||||||
let totalLevels = 0
|
let totalLevels = 0
|
||||||
let skippedArtlessSpawns = 0
|
let skippedArtlessSpawns = 0
|
||||||
|
let skippedMissingArtSpawns = 0
|
||||||
|
|
||||||
async function bakeDs1Variant(
|
async function bakeDs1Variant(
|
||||||
entry: LevelJob,
|
entry: LevelJob,
|
||||||
|
|
@ -660,21 +687,22 @@ async function bakeDs1Variant(
|
||||||
for (const object of level.objects) {
|
for (const object of level.objects) {
|
||||||
let resolved
|
let resolved
|
||||||
try {
|
try {
|
||||||
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, {
|
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
|
||||||
preset: tables.monpreset,
|
|
||||||
stats: tables.monstats
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
missingObjects.push((err as Error).message)
|
missingObjects.push((err as Error).message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (resolved.kind === 'monster') continue
|
if (resolved.kind === 'monster') continue
|
||||||
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
|
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
|
||||||
skippedArtlessSpawns++
|
skippedArtlessSpawns += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const row = resolved.row
|
const row = resolved.row
|
||||||
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
|
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 orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
|
||||||
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
|
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 mkdir(outDir, { recursive: true })
|
||||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
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(`\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
|
tally.objects += 1
|
||||||
let resolved
|
let resolved
|
||||||
try {
|
try {
|
||||||
|
// Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour.
|
||||||
resolved = resolveDs1Object(tables, info.act, object.type, object.id)
|
resolved = resolveDs1Object(tables, info.act, object.type, object.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tally.undefinedRows += 1
|
tally.undefinedRows += 1
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ for (const entry of index.levels) {
|
||||||
const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = []
|
const expected: { object: (typeof level.objects)[number]; token: string; mode: string }[] = []
|
||||||
for (const object of level.objects) {
|
for (const object of level.objects) {
|
||||||
try {
|
try {
|
||||||
|
// Omitting the monsters tables intentionally restores the legacy drop-everything-non-object behaviour.
|
||||||
const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id)
|
const resolved = resolveDs1Object(objectsTable, packed.act, object.type, object.id)
|
||||||
if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode })
|
if (resolved.kind === 'object') expected.push({ object, token: resolved.token, mode: resolved.mode })
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ import { decodeDcc } from '../formats/dcc.ts'
|
||||||
import type { DccFile } from '../formats/dcc.ts'
|
import type { DccFile } from '../formats/dcc.ts'
|
||||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||||
import type { MountedArchives } from '../mpq/mount.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'
|
import type { ObjectLookupEntry } from './object-lookup.ts'
|
||||||
|
|
||||||
/** `Objects.txt` inside the archives. */
|
/** `Objects.txt` inside the archives. */
|
||||||
|
|
@ -438,6 +438,17 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
|
||||||
return { table, rows, byId }
|
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.
|
* 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 act - the level's act, 1..5.
|
||||||
* @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn).
|
* @param objectType - the DS1 object's `type` (2 = object, 1 = monster spawn).
|
||||||
* @param objectId - the DS1 object's `id`.
|
* @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.
|
* @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
|
* @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.
|
* 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(
|
export function resolveDs1Object(
|
||||||
tables: ObjectsTable,
|
tables: ObjectsTable,
|
||||||
act: number,
|
act: number,
|
||||||
objectType: number,
|
objectType: number,
|
||||||
objectId: number,
|
objectId: number,
|
||||||
monsters?: MonstersTable
|
monsters?: MonstersTable,
|
||||||
): ResolvedDs1Object {
|
): ResolvedDs1Object {
|
||||||
if (!Number.isFinite(objectId)) {
|
if (!Number.isFinite(objectId)) {
|
||||||
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
|
throw new Error(`resolveDs1Object: object id ${String(objectId)} is not a number`)
|
||||||
}
|
}
|
||||||
if (objectType !== OBJECT_TYPE_OBJECT) {
|
if (objectType !== OBJECT_TYPE_OBJECT) {
|
||||||
if (objectType === 1 && monsters) {
|
if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) {
|
||||||
const actStr = String(act)
|
const presets = monsters.presetPlaceByAct.get(String(act))
|
||||||
const presets = monsters.preset.rows.filter(row => cell(monsters.preset, row, 'Act') === actStr)
|
if (presets !== undefined && objectId >= 0 && objectId < presets.length) {
|
||||||
if (objectId >= 0 && objectId < presets.length) {
|
const place = presets[objectId]!
|
||||||
const place = cell(monsters.preset, presets[objectId]!, 'Place')
|
const statRow = monsters.statsById.get(place)
|
||||||
const statRow = monsters.stats.rows.find(row => cell(monsters.stats, row, 'Id') === place)
|
|
||||||
const token = statRow ? cell(monsters.stats, statRow, 'Code') : ''
|
const token = statRow ? cell(monsters.stats, statRow, 'Code') : ''
|
||||||
const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : ''
|
const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entry: null,
|
entry: null,
|
||||||
row: null,
|
row: null,
|
||||||
|
|
@ -494,7 +498,7 @@ export function resolveDs1Object(
|
||||||
mode: 'NU',
|
mode: 'NU',
|
||||||
artless: token.trim() === '',
|
artless: token.trim() === '',
|
||||||
kind: 'npc',
|
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