diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 161d52d..0448c47 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -39,7 +39,7 @@ import type { Dt1 } from '../src/formats/dt1.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import type { IsoMapScene } from '../src/game/d2map.ts' -import { loadObjectsTable, resolveDs1Object } from '../src/game/objects.ts' +import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' @@ -65,7 +65,6 @@ const HOT_RADIUS_CELLS = 8 /** Where object art lives. */ const OBJECT_PREFIX = 'data\\global\\objects\\' /** Where monster and town-NPC art lives. */ -const MONSTER_PREFIX = 'data\\global\\monsters\\' /** * Mode directories tried in order when picking an object's art. * @@ -349,12 +348,12 @@ const objectMembers = new Map>() const monsterMembers = new Map>() for (const name of allNames) { const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase()) - const isMon = name.toLowerCase().startsWith(MONSTER_PREFIX.toLowerCase()) + const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase()) if (!isObj && !isMon) continue // Derive the length; a hand-counted magic number here was off by one (22 vs // the real 21), which sliced the first letter off every monster token and // silently broke all NPC art lookups. - const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_PREFIX.length + const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length const rest = name.slice(prefixLen).split('\\') if (rest.length < 3) continue const token = (rest[0] ?? '').toUpperCase() @@ -584,6 +583,7 @@ for (let act = 1; act <= 5; act += 1) { let totalPngBytes = 0 let totalLevels = 0 +let skippedArtlessSpawns = 0 async function bakeDs1Variant( entry: LevelJob, @@ -660,12 +660,19 @@ async function bakeDs1Variant( for (const object of level.objects) { let resolved try { - resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id) + resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, { + preset: tables.monpreset, + stats: tables.monstats + }) } catch (err) { missingObjects.push((err as Error).message) continue } if (resolved.kind === 'monster') continue + if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) { + skippedArtlessSpawns++ + continue + } const row = resolved.row const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX @@ -739,7 +746,7 @@ async function bakeDs1Variant( list.push({ id: object.id, type: object.type, - name: row?.name ?? resolved.token, + name: resolved.name ?? row?.name ?? resolved.token, token: resolved.token, mode: resolved.mode === '' ? 'NU' : resolved.mode, // `objectsTxtId` is the Objects.txt row the table points at; -1 means the @@ -980,3 +987,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}`) diff --git a/src/game/acts.ts b/src/game/acts.ts index ac99eb3..c766050 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -95,6 +95,8 @@ export interface ActTables { readonly levels: D2Table readonly lvltypes: D2Table readonly lvlprest: D2Table + readonly monstats: D2Table + readonly monpreset: D2Table } /** Everything needed to place and render one level. */ @@ -135,7 +137,7 @@ export function tileMemberPath(relative: string): string { } /** - * Load the three tables from a mounted stack. + * Load the tables from a mounted stack. * * @param archives - the mounted archives. * @returns the parsed tables. @@ -146,6 +148,8 @@ export async function loadActTables(archives: MountedArchives): Promise = { 'XR': 'Larzuk', 'XS': 'Drehya', '0J': 'Nihlathak', + 'RG': 'Rogue', + 'CK': 'Chicken', + 'CW': 'Cow', } export function resolveNpcName(token: string, fallbackName: string): string { diff --git a/src/game/objects.ts b/src/game/objects.ts index 8c653d5..c7e2589 100644 --- a/src/game/objects.ts +++ b/src/game/objects.ts @@ -73,7 +73,9 @@ import type { ObjectLookupEntry } from './object-lookup.ts' /** `Objects.txt` inside the archives. */ const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt' /** Prefix every object composition and sprite lives under (`szPathPrefix`). */ -const OBJECT_ROOT = 'data\\global\\objects\\' +export const OBJECT_ROOT = 'data\\global\\objects\\' +/** Prefix every monster composition and sprite lives under. */ +export const MONSTER_ROOT = 'data\\global\\monsters\\' /** Weapon-class token the engine hard-codes for objects (`COMPOSIT_GetWeaponClassCode`). */ const OBJECT_WEAPON = 'hth' /** Armor-class token objects always use; characters vary it (`lit`/`med`/`hvy`). */ @@ -458,16 +460,44 @@ 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) + const token = statRow ? cell(monsters.stats, statRow, 'Code') : '' + const nameStr = statRow ? cell(monsters.stats, statRow, 'NameStr') : '' + + return { + entry: null, + row: null, + token, + mode: 'NU', + artless: token.trim() === '', + kind: 'npc', + name: nameStr || place + } + } + } return { entry: null, row: null, token: '', mode: '', artless: true, kind: 'monster' } } const entry = lookupObject(act, objectType, objectId) @@ -513,6 +543,8 @@ export interface ResolvedDs1Object { readonly artless: boolean /** `object` for a real object, `monster` for a DS1 monster spawn point, `npc` for named NPC. */ readonly kind: 'object' | 'monster' | 'npc' + /** The name resolved for this object (like the name of the NPC), if any. */ + readonly name?: string } /** What {@link resolveObjectArt} is asked to resolve. */ @@ -568,7 +600,7 @@ export interface ObjectArt { export function objectCofMember(token: string, modeIndex: number, baseIsMonsters = false): string { const name = token.trim().toLowerCase() const mode = modeToken(modeIndex).toLowerCase() - const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT + const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT return `${root}${name}\\cof\\${name}${mode}${OBJECT_WEAPON}.cof` } @@ -590,7 +622,7 @@ export function objectSpriteMember(token: string, component: string, modeIndex: const name = token.trim().toLowerCase() const part = component.toLowerCase() const mode = modeToken(modeIndex).toLowerCase() - const root = baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT + const root = baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT return `${root}${name}\\${part}\\${name}${part}${OBJECT_ARMOR_CLASS}${mode}${OBJECT_WEAPON}.dcc` } @@ -721,7 +753,7 @@ export function resolveObjectArt(request: ObjectArtRequest): ObjectArt { if (member === null) { for (const index of order) { const mode = OBJECT_MODE_TOKENS[index]?.toLowerCase() ?? 'nu' - const root = request.baseIsMonsters ? 'data\\global\\monsters\\' : OBJECT_ROOT + const root = request.baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT const prefix = `${root}${token.toLowerCase()}\\` const suffix = `${mode}${OBJECT_WEAPON}.dcc` const hits = members