diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index fcb52df..e428852 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -30,9 +30,10 @@ import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' -import { cell, loadActTables, parseTable, resolveLevel } from '../src/game/acts.ts' -import type { ActTables, LevelInfo } from '../src/game/acts.ts' +import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts' +import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' +import type { Ds1 } from '../src/formats/ds1.ts' import { decodeDt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts' import { decodePl2 } from '../src/formats/pl2.ts' @@ -42,6 +43,10 @@ import { loadObjectsTable, resolveDs1Object } 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' +import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' +import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' +import { generateWilderness } from '../src/game/wilderness.ts' +import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' import { encodeIndexedPng } from './png.ts' /** @@ -62,7 +67,7 @@ const OBJECT_PREFIX = 'data\\global\\objects\\' /** * Mode directories tried in order when picking an object's art. * - * Note what the object art actually *is*: under `data\global\objects\` the + * Note what the object art actually *is*: under `data\global\objects` the * archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II * draws objects with the same composite pipeline as characters — a COF that * lists layers and frames plus the DCC files behind them — so this packer @@ -417,6 +422,141 @@ function pickObjectMember(token: string, modeToken: string): { member: string; c /** Cache decoded member frames across levels so each unique member is decoded once. */ const decodedMemberCache = new Map() +const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) +const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) + +/** The level type row for a level row. */ +function levelType(levelRow: readonly string[]): { id: string; name: string } { + const id = cell(tables.levels, levelRow, 'LevelType') + const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id) + return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') } +} + +/** The `LvlMaze` row for a level, by id then by name. */ +function mazeRow(levelId: number, levelName: string): readonly string[] | undefined { + return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId) + ?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName) +} + +const ds1Cache = new Map() + +async function loadDs1(member: string): Promise { + const cached = ds1Cache.get(member) + if (cached !== undefined) return cached + const decoded = decodeDs1(await archives.read(member)) + ds1Cache.set(member, decoded) + return decoded +} + +async function rowDs1s(table: D2Table, row: readonly string[]): Promise { + const levels: Ds1[] = [] + for (let slot = 1; slot <= 6; slot += 1) { + const value = cell(table, row, `File${String(slot)}`) + if (value === '' || value === '0') continue + levels.push(await loadDs1(tileMemberPath(value))) + } + return levels +} + +function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { + const values: number[] = [] + for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) + return values +} + +const WILDERNESS_PIECE_FAMILIES: Readonly> = { + 'Act 1 - Wilderness': ['Act 1 - Wild'], + 'Act 2 - Desert': ['Act 2 - Desert'], + 'Act 3 - Jungle': ['Act 3 - Jungle'], + 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], + 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], + 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], + 'Act 5 - Siege': ['Act 5 - Siege'], + 'Act 5 - Barricade': ['Act 5 - Barricade'], +} + +const mazePiecesCache = new Map>() +function getMazePieces(levelTypeName: string, levelTypeId: string): Promise { + void levelTypeId + const cached = mazePiecesCache.get(levelTypeName) + if (cached !== undefined) return cached + const promise = (async () => { + const pieces: MazePiece[] = [] + for (const row of tables.lvlprest.rows) { + if (cell(tables.lvlprest, row, 'LevelId') !== '0' && cell(tables.lvlprest, row, 'LevelId') !== '') continue + const name = cell(tables.lvlprest, row, 'Name') + const classified = classifyMazePieceName(name, levelTypeName) + if (classified === null) continue + const levels = await rowDs1s(tables.lvlprest, row) + if (levels.length === 0) continue + pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels }) + } + return pieces + })() + mazePiecesCache.set(levelTypeName, promise) + return promise +} + +const wildernessPiecesCache = new Map>() +function getWildernessPieces(levelTypeName: string): Promise { + const cached = wildernessPiecesCache.get(levelTypeName) + if (cached !== undefined) return cached + const promise = (async () => { + const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] + const pieces: WildernessPiece[] = [] + for (const row of tables.lvlprest.rows) { + const name = cell(tables.lvlprest, row, 'Name') + if (!families.some(family => name.startsWith(family))) continue + if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue + const levels = await rowDs1s(tables.lvlprest, row) + if (levels.length === 0) continue + pieces.push({ name, levels, border: /border|cliff/i.test(name) }) + } + return pieces + })() + wildernessPiecesCache.set(levelTypeName, promise) + return promise +} + +const substitutionsCache = new Map>() +function getSubstitutions(type: number): Promise { + if (type < 0) return Promise.resolve([]) + const cached = substitutionsCache.get(type) + if (cached !== undefined) return cached + const promise = (async () => { + const rows: WildernessSubstitution[] = [] + for (const row of lvlsub.rows) { + if (Number(cell(lvlsub, row, 'Type')) !== type) continue + const file = cell(lvlsub, row, 'File') + if (file === '' || file === '0') continue + const levels = [await loadDs1(tileMemberPath(file))] + rows.push({ + name: cell(lvlsub, row, 'Name'), + type, + gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, + bordType: Number(cell(lvlsub, row, 'BordType')), + dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, + prob: themeValues(lvlsub, row, 'Prob'), + trials: themeValues(lvlsub, row, 'Trials'), + max: themeValues(lvlsub, row, 'Max'), + levels, + }) + } + return rows + })() + substitutionsCache.set(type, promise) + return promise +} + +const paletteCache = new Map() +async function getPalette(paletteName: string): Promise { + const cached = paletteCache.get(paletteName) + if (cached !== undefined) return cached + const pl2 = decodePl2(await archives.read(paletteName)) + paletteCache.set(paletteName, pl2.rgb) + return pl2.rgb +} + const index: Record = { version: 1, generated: new Date().toISOString(), @@ -426,318 +566,401 @@ const index: Record = { levels: [] as unknown[], } +for (let act = 1; act <= 5; act += 1) { + const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`) + ;(index.palettes as Record)[`act${String(act)}`] = [...pal] +} + let totalPngBytes = 0 let totalLevels = 0 -const pending: string[] = [] -for (const entry of LEVELS) { - if (entry.kind !== 'preset') { - // Maze and wilderness levels come from the generators; until those land they - // are reported rather than silently missing from the pack. - pending.push(`${entry.kind} ${String(entry.levelId)} ${entry.name}`) - continue +async function bakeDs1Variant( + entry: LevelJob, + levelName: string, + palette: Uint8Array, + paletteName: string, + dt1Names: readonly string[], + libraries: Dt1[], + level: Ds1, + ds1Name: string, + label: string, + seed: number, +): Promise { + const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) + const spawn = findIsoSpawn(scene) + const dir = join(outDir, `act${String(entry.act)}`, label) + + // Frames used near the spawn load first; everything else follows. + const hot = new Set() + if (spawn !== null) { + const spawnCell = cellAt(scene, spawn.x, spawn.y) + for (const draw of scene.floors) { + const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) + if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) + } + for (const draw of scene.walls) { + const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) + if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) + } } - const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) - const pl2 = decodePl2(await archives.read(info.paletteName)) - const palette = pl2.rgb - ;(index.palettes as Record)[`act${String(entry.act)}`] = [...palette] - const libraries = [] - for (const name of info.dt1Names) libraries.push(await libraryOf(name)) + const firstDrawAt = new Map() + for (const draw of [...scene.floors, ...scene.walls]) { + if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y }) + } + const bandOf = (frameIndex: number): number => { + const at = firstDrawAt.get(frameIndex) + if (at === undefined) return Number.MAX_SAFE_INTEGER + return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768) + } + const order = scene.frames.map((_, at) => at).sort((a, b) => { + const ha = hot.has(a) ? 0 : 1 + const hb = hot.has(b) ? 0 : 1 + if (ha !== hb) return ha - hb + const ba = bandOf(a) + const bb = bandOf(b) + if (ba !== bb) return ba - bb + const fa = scene.frames[a]! + const fb = scene.frames[b]! + return fb.width * fb.height - fa.width * fa.height + }) + const pages = new PageBuilder(palette) + const placementOf = new Map() + for (const frameIndex of order) { + const frame = scene.frames[frameIndex]! + placementOf.set(frameIndex, pages.add(frame)) + } + const pageFiles = pages.encode(0) + await mkdir(dir, { recursive: true }) + const sha = createHash('sha256') + for (const page of pageFiles) { + await writeFile(join(dir, page.name), page.png) + sha.update(page.png) + totalPngBytes += page.png.byteLength + } - for (const ds1Name of info.ds1Names) { - const level = decodeDs1(await archives.read(ds1Name)) - const scene: IsoMapScene = buildIsoMapScene(level, libraries, levelSeed(ds1Name)) - const spawn = findIsoSpawn(scene) - const label = `${entry.slug}-${slugOf(ds1Name)}` - const dir = join(outDir, `act${String(entry.act)}`, label) + const objectPages = new PageBuilder(palette) + const objects: unknown[] = [] + const missingObjects: string[] = [] + let objectsWithArt = 0 + const placementByMember = new Map() - // Frames used near the spawn load first; everything else follows. - const hot = new Set() - if (spawn !== null) { - const spawnCell = cellAt(scene, spawn.x, spawn.y) - for (const draw of scene.floors) { - const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) - if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) - } - for (const draw of scene.walls) { - const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) - if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) - } - } - // Page order decides how much a first paint costs. Hot frames (the spawn - // area) come first, then frames are ordered by *where on the map they are - // first drawn*, in coarse screen bands (roughly a viewport each): a viewport then needs one band's page - // instead of an arbitrary handful. Sorting by size instead — the obvious - // choice for packing efficiency — scatters a viewport's frames across every - // page, which is exactly what lazy loading cannot afford. - const firstDrawAt = new Map() - for (const draw of [...scene.floors, ...scene.walls]) { - if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y }) - } - const bandOf = (frameIndex: number): number => { - const at = firstDrawAt.get(frameIndex) - if (at === undefined) return Number.MAX_SAFE_INTEGER - return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768) - } - const order = scene.frames.map((_, at) => at).sort((a, b) => { - const ha = hot.has(a) ? 0 : 1 - const hb = hot.has(b) ? 0 : 1 - if (ha !== hb) return ha - hb - const ba = bandOf(a) - const bb = bandOf(b) - if (ba !== bb) return ba - bb - const fa = scene.frames[a]! - const fb = scene.frames[b]! - return fb.width * fb.height - fa.width * fa.height - }) - const pages = new PageBuilder(palette) - const placementOf = new Map() - for (const frameIndex of order) { - const frame = scene.frames[frameIndex]! - placementOf.set(frameIndex, pages.add(frame)) - } - const pageFiles = pages.encode(0) - await mkdir(dir, { recursive: true }) - const sha = createHash('sha256') - for (const page of pageFiles) { - await writeFile(join(dir, page.name), page.png) - sha.update(page.png) - totalPngBytes += page.png.byteLength + for (const object of level.objects) { + let resolved + try { + resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id) + } catch (err) { + missingObjects.push((err as Error).message) + continue } + if (resolved.kind === 'monster') continue + const row = resolved.row + const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode) + const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX + const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY - // Object placements and their art members. The art itself is decoded from DCC/DC6 - // and shelf-packed into objectPages so the client can draw objects at depth. - const objectPages = new PageBuilder(palette) - const objects: unknown[] = [] - const missingObjects: string[] = [] - let objectsWithArt = 0 - const placementByMember = new Map() + let objectFrame: { + page: number + x: number + y: number + width: number + height: number + offsetX: number + offsetY: number + } | null = null - for (const object of level.objects) { - // The DS1 `id` is an index into the hardcoded per-act object table, not an - // `Objects.txt` row: act 1 id 0 is the rogue fountain (`Objects.txt` 12), not - // row 0 ("Expansion"). `resolveDs1Object` walks that table and returns the - // token/mode the engine actually uses. - let resolved - try { - resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id) - } catch (err) { - missingObjects.push((err as Error).message) - continue - } - if (resolved.kind === 'monster') continue - const row = resolved.row - const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode) - const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX - const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY - - let objectFrame: { - page: number - x: number - y: number - width: number - height: number - offsetX: number - offsetY: number - } | null = null - - if (pick !== null) { - let placed = placementByMember.get(pick.member) - if (placed === undefined) { - let art = decodedMemberCache.get(pick.member) - if (art === undefined) { - try { - const bytes = await archives.read(pick.member) - if (pick.member.toLowerCase().endsWith('.dc6')) { - const sheet = decodeDc6(bytes) - const group = sheet.groups[0] - const f = group?.frames[0] - if (f) { - art = { - frame: f, - offsetX: -16 + f.offsetX, - offsetY: 16 + (f.offsetY - f.height + 1), - } - } - } else { - const dcc = decodeDcc(bytes) - const dir = dcc.directions[0] - const f = dir?.frames[0] - if (f && dir) { - art = { - frame: f.frame, - offsetX: -16 + dir.box.left, - offsetY: 16 + dir.box.top, - } + if (pick !== null) { + let placed = placementByMember.get(pick.member) + if (placed === undefined) { + let art = decodedMemberCache.get(pick.member) + if (art === undefined) { + try { + const bytes = await archives.read(pick.member) + if (pick.member.toLowerCase().endsWith('.dc6')) { + const sheet = decodeDc6(bytes) + const group = sheet.groups[0] + const f = group?.frames[0] + if (f) { + art = { + frame: f, + offsetX: -16 + f.offsetX, + offsetY: 16 + (f.offsetY - f.height + 1), + } + } + } else { + const dcc = decodeDcc(bytes) + const dir = dcc.directions[0] + const f = dir?.frames[0] + if (f && dir) { + art = { + frame: f.frame, + offsetX: -16 + dir.box.left, + offsetY: 16 + dir.box.top, } } - if (art) decodedMemberCache.set(pick.member, art) - } catch (err) { - console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`) } - } - if (art) { - const placement = objectPages.add(art.frame) - placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY } - placementByMember.set(pick.member, placed) + if (art) decodedMemberCache.set(pick.member, art) + } catch (err) { + console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`) } } - if (placed) { - objectFrame = { - page: placed.placement.page, - x: placed.placement.x, - y: placed.placement.y, - width: placed.placement.width, - height: placed.placement.height, - offsetX: placed.offsetX, - offsetY: placed.offsetY, - } + if (art) { + const placement = objectPages.add(art.frame) + placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY } + placementByMember.set(pick.member, placed) + } + } + if (placed) { + objectFrame = { + page: placed.placement.page, + x: placed.placement.x, + y: placed.placement.y, + width: placed.placement.width, + height: placed.placement.height, + offsetX: placed.offsetX, + offsetY: placed.offsetY, } } - - objects.push({ - id: object.id, - type: object.type, - 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 - // engine picks the art straight from the table's token, with no row. - objectsTxtId: resolved.entry?.objectsTxtId ?? -1, - hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0), - member: pick === null ? null : pick.member, - alternatives: pick === null ? 0 : pick.candidates, - // Screen point of the object's sub-tile; the sprite anchor is applied - // when a frame exists. - x: Math.round(orthoX), - y: Math.round(orthoY), - depth: (object.x + object.y) / 5, - frame: objectFrame, - }) - if (pick !== null) objectsWithArt += 1 - else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`) - } - const objectFiles = objectPages.encode(0) - const shaObjects = createHash('sha256') - for (const page of objectFiles) { - await writeFile(join(dir, page.name.replace('tiles-', 'objects-')), page.png) - shaObjects.update(page.png) - totalPngBytes += page.png.byteLength } - // Collision grid as runs, so the JSON stays small without a second format. - const runs: number[][] = [] - let current = scene.blocked[0] ?? 0 - let count = 0 - for (const value of scene.blocked) { - if (value === current) { count += 1; continue } - runs.push([current, count]) - current = value - count = 1 - } - runs.push([current, count]) - - const sceneJson = { - version: 1, - fidelity: 'exact', - act: entry.act, - levelId: entry.levelId, - levelName: info.levelName, - ds1: ds1Name, - cellsX: scene.cellsX, - cellsY: scene.cellsY, - originX: scene.originX, - originY: scene.originY, - widthPx: scene.widthPx, - heightPx: scene.heightPx, - pageSize: PAGE_SIZE, - pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })), - objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })), - frames: scene.frames.map(frame => [frame.width, frame.height]), - frameHash: scene.frames.map(frame => frameHash(frame.indices)), - framePlacement: scene.frames.map((_, at) => { - const place = placementOf.get(at)! - return [place.page, place.x, place.y, place.width, place.height] - }), - floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), - walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), - // Roofs stay in their own list because the engine paints them last of all. - roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), - objects, - collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, - spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], - stats: { - floors: scene.floors.length, - walls: scene.walls.length, - roofs: scene.roofs.length, - frames: scene.frames.length, - missingTiles: scene.missingTiles, - missingRefs: scene.missingRefs, - walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length, - objects: objects.length, - objectsWithArt, - objectsUnresolved: missingObjects, - objectsArtPending: objects.filter((o: any) => o.member !== null && o.frame === null).length, - dt1Libraries: info.dt1Names.length, - }, - } - const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson)) - await writeFile(join(dir, 'scene.json'), sceneBytes) - const manifest = { - level: info.levelName, - act: entry.act, - levelId: entry.levelId, - ds1: ds1Name, - dt1: info.dt1Names, - palette: info.paletteName, - sceneBytes: sceneBytes.byteLength, - sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'), - tilePagesSha256: sha.digest('hex'), - objectPagesSha256: shaObjects.digest('hex'), - pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0) - + objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0), - sourceArchive: MOUNTS, - } - await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1)) - ;(index.levels as unknown[]).push({ - act: entry.act, - levelId: entry.levelId, - kind: entry.kind, - slug: entry.slug, - label, - levelName: info.levelName, - ds1: ds1Name, - path: `act${String(entry.act)}/${label}`, - cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`, - frames: scene.frames.length, - objects: objects.length, - pages: pageFiles.length, - objectPages: objectFiles.length, - missingTiles: scene.missingTiles, - bytes: manifest.pngBytes + sceneBytes.byteLength, + objects.push({ + id: object.id, + type: object.type, + 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 + // engine picks the art straight from the table's token, with no row. + objectsTxtId: resolved.entry?.objectsTxtId ?? -1, + hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0), + member: pick === null ? null : pick.member, + alternatives: pick === null ? 0 : pick.candidates, + // Screen point of the object's sub-tile; the sprite anchor is applied + // when a frame exists. + x: Math.round(orthoX), + y: Math.round(orthoY), + depth: (object.x + object.y) / 5, + frame: objectFrame, }) - totalLevels += 1 - const bakedFramesCount = objects.filter((o: any) => o.frame !== null).length - const pendingCount = objects.filter((o: any) => o.member !== null && o.frame === null).length - console.log( - `act${String(entry.act)}/${label.padEnd(22)} ${String(scene.cellsX)}x${String(scene.cellsY)} ` - + `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG ` - + `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB ` - + `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})` - + ` · 缺失瓦片 ${String(scene.missingTiles)}`, - ) + if (pick !== null) objectsWithArt += 1 + else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`) + } + + const objectFiles = objectPages.encode(0) + const shaObjects = createHash('sha256') + for (const page of objectFiles) { + const name = page.name.replace('tiles-', 'objects-') + await writeFile(join(dir, name), page.png) + shaObjects.update(page.png) + totalPngBytes += page.png.byteLength + } + + // Collision grid as runs, so the JSON stays small without a second format. + const runs: number[][] = [] + let current = scene.blocked[0] ?? 0 + let count = 0 + for (const value of scene.blocked) { + if (value === current) { count += 1; continue } + runs.push([current, count]) + current = value + count = 1 + } + runs.push([current, count]) + + const sceneJson = { + version: 1, + fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', + act: entry.act, + levelId: entry.levelId, + levelName, + ds1: ds1Name, + cellsX: scene.cellsX, + cellsY: scene.cellsY, + originX: scene.originX, + originY: scene.originY, + widthPx: scene.widthPx, + heightPx: scene.heightPx, + pageSize: PAGE_SIZE, + pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })), + objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })), + frames: scene.frames.map(frame => [frame.width, frame.height]), + frameHash: scene.frames.map(frame => frameHash(frame.indices)), + framePlacement: scene.frames.map((_, at) => { + const place = placementOf.get(at)! + return [place.page, place.x, place.y, place.width, place.height] + }), + floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), + walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), + roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), + objects, + collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, + spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], + stats: { + floors: scene.floors.length, + walls: scene.walls.length, + roofs: scene.roofs.length, + frames: scene.frames.length, + missingTiles: scene.missingTiles, + missingRefs: scene.missingRefs, + walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length, + objects: objects.length, + objectsWithArt, + objectsUnresolved: missingObjects, + objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, + dt1Libraries: dt1Names.length, + }, + } + const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson)) + await writeFile(join(dir, 'scene.json'), sceneBytes) + const manifest = { + level: levelName, + act: entry.act, + levelId: entry.levelId, + ds1: ds1Name, + dt1: dt1Names, + palette: paletteName, + sceneBytes: sceneBytes.byteLength, + sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'), + tilePagesSha256: sha.digest('hex'), + objectPagesSha256: shaObjects.digest('hex'), + pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0) + + objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0), + sourceArchive: MOUNTS, + } + await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1)) + ;(index.levels as unknown[]).push({ + act: entry.act, + levelId: entry.levelId, + kind: entry.kind, + slug: entry.slug, + label, + levelName, + ds1: ds1Name, + path: `act${String(entry.act)}/${label}`, + cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`, + frames: scene.frames.length, + objects: objects.length, + pages: pageFiles.length, + objectPages: objectFiles.length, + missingTiles: scene.missingTiles, + bytes: manifest.pngBytes + sceneBytes.byteLength, + }) + totalLevels += 1 + const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length + const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length + console.log( + `[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} ` + + `${String(scene.cellsX)}x${String(scene.cellsY)} ` + + `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG ` + + `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB ` + + `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})` + + ` · 缺失瓦片 ${String(scene.missingTiles)}`, + ) +} + +const filterLevel = process.env.FILTER_LEVEL + +for (const entry of LEVELS) { + if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) continue + + const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)! + const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) + const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2` + const palette = await getPalette(paletteName) + + if (entry.kind === 'preset') { + const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) + const libraries: Dt1[] = [] + for (const name of info.dt1Names) libraries.push(await libraryOf(name)) + + for (const ds1Name of info.ds1Names) { + const level = await loadDs1(ds1Name) + const label = `${entry.slug}-${slugOf(ds1Name)}` + await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name)) + } + } else if (entry.kind === 'maze') { + const type = levelType(levelRow) + const row = mazeRow(entry.levelId, entry.name) + if (row === undefined) { + console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`) + continue + } + const sectionX = Number(cell(lvlmaze, row, 'SizeX')) + const sectionY = Number(cell(lvlmaze, row, 'SizeY')) + const minRooms = Number(cell(lvlmaze, row, 'Rooms')) + const merge = Number(cell(lvlmaze, row, 'Merge')) + const pieces = await getMazePieces(type.name, type.id) + if (pieces.length === 0) { + console.warn(`skipping maze ${entry.name}: no maze pieces`) + continue + } + const libInfo = resolveLevelLibraries(tables, entry.levelId) + const libraries: Dt1[] = [] + for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) + + for (let v = 1; v <= 3; v += 1) { + const seed = 0x5eed_0000 + entry.levelId * 10 + v + const label = `${entry.slug}-var${v}` + try { + const result = generateMaze({ + levelId: entry.levelId, + levelName: entry.name, + levelTypeName: type.name, + sectionSize: sectionX, + sectionHeight: sectionY, + minRooms, + merge, + seed, + pieces, + }) + await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed) + } catch (err) { + console.error(`failed to bake maze ${label}: ${(err as Error).message}`) + } + } + } else if (entry.kind === 'wilderness') { + const type = levelType(levelRow) + const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) + const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) + const subType = Number(cell(tables.levels, levelRow, 'SubType')) + const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) + const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) + const pieces = await getWildernessPieces(type.name) + if (pieces.length === 0) { + console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`) + continue + } + const rows = await getSubstitutions(subType) + const shrineRows = await getSubstitutions(subShrine) + const libInfo = resolveLevelLibraries(tables, entry.levelId) + const libraries: Dt1[] = [] + for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) + + for (let v = 1; v <= 3; v += 1) { + const seed = 0x5eed_1000 + entry.levelId * 10 + v + const label = `${entry.slug}-var${v}` + try { + const result = generateWilderness({ + levelId: entry.levelId, + levelName: entry.name, + levelTypeName: type.name, + sizeX, + sizeY, + subType, + subTheme: Math.max(0, subTheme), + seed, + pieces, + substitutions: rows, + shrineSubstitutions: shrineRows, + }) + await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed) + } catch (err) { + console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) + } + } } } await mkdir(outDir, { recursive: true }) await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1)) -if (pending.length > 0) { - const byKind = new Map() - for (const item of pending) { - const kind = item.split(' ')[0] ?? '?' - byKind.set(kind, (byKind.get(kind) ?? 0) + 1) - } - console.log(`尚未生成(需要生成器):${[...byKind].map(([k, v]) => `${k} ${String(v)} 个`).join(',')}`) -} console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index 084364e..ef71e59 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -97,7 +97,7 @@ for (const name of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { } const tables = await loadActTables(archives) const index = JSON.parse(await readFile(join(packDir, 'index.json'), 'utf8')) as { - levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string }[] + levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[] } let checks = 0 @@ -122,6 +122,7 @@ let unknownObjectIds = 0 const objectsTable = await loadObjectsTable(archives) for (const entry of index.levels) { + if (entry.kind !== undefined && entry.kind !== 'preset') continue const packed = JSON.parse(await readFile(join(packDir, entry.path, 'scene.json'), 'utf8')) as PackedScene const info = resolveLevel(tables, packed.levelId, packed.act) const libraries = [] diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index faa52cd..9459040 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -21,25 +21,103 @@ export const ACT_NAMES_ZH: Readonly> = { /** `Levels.txt` 的 `Id` → 中文场景名。 */ export const LEVEL_NAMES_ZH: Readonly> = { + // Act 1 1: '罗格营地', + 2: '鲜血荒地', + 3: '冰冷之原', + 4: '石块旷野', + 5: '黑暗森林', + 6: '黑色荒地', + 7: '泰摩高地', + 8: '邪恶洞窟', + 9: '洞穴一层', + 10: '地下通道一层', + 11: '窟窿一层', + 12: '深坑一层', 13: '洞穴二层', 14: '地下通道二层', 15: '窟窿二层', 16: '深坑二层', + 17: '埋骨之地', + 18: '大陵寝', + 19: '灵庙', 20: '遗忘之塔', + 21: '塔地窖一层', + 22: '塔地窖二层', + 23: '塔地窖三层', + 24: '塔地窖四层', 25: '塔地窖五层', 26: '修道院大门', 27: '外回廊', + 28: '军营', + 29: '监牢一层', + 30: '监牢二层', + 31: '监牢三层', 32: '内回廊', 33: '大教堂', + 34: '地下墓穴一层', + 35: '地下墓穴二层', + 36: '地下墓穴三层', 37: '地下墓穴四层', 38: '崔斯特瑞姆', + 39: '奶牛关', + + // Act 2 40: '鲁高因', + 41: '碎石荒地', + 42: '干燥高地', + 43: '遥远绿洲', + 44: '遗失的城市', + 45: '群蛇峡谷', + 46: '术士峡谷', + 47: '下水道一层', + 48: '下水道二层', + 49: '下水道三层', 50: '后宫一层', + 51: '后宫二层', + 52: '皇宫地牢一层', + 53: '皇宫地牢二层', + 54: '皇宫地牢三层', + 55: '石制古墓一层', + 56: '死亡之殿一层', + 57: '死亡之殿二层', + 58: '利爪蝮蛇神殿一层', + 59: '石制古墓二层', + 60: '死亡之殿三层', + 61: '利爪蝮蛇神殿二层', + 62: '蛆虫巢穴一层', + 63: '蛆虫巢穴二层', + 64: '蛆虫巢穴三层', + 65: '古代通道', + 66: '塔·拉夏古墓 (1)', + 67: '塔·拉夏古墓 (2)', + 68: '塔·拉夏古墓 (3)', + 69: '塔·拉夏古墓 (4)', + 70: '塔·拉夏古墓 (5)', + 71: '塔·拉夏古墓 (6)', + 72: '塔·拉夏古墓 (7)', 73: '都瑞尔的巢穴', + 74: '秘法圣所', + + // Act 3 75: '库拉斯特码头', + 76: '蜘蛛森林', + 77: '庞大湿地', + 78: '剥皮丛林', + 79: '库拉斯特下层', + 80: '库拉斯特商场', + 81: '库拉斯特上层', + 82: '库拉斯特堤道', + 83: '崔凡克', + 84: '蜘蛛巢穴', + 85: '蜘蛛洞窟', + 86: '沼泽地坑一层', + 87: '沼泽地坑二层', + 88: '剥皮地牢一层', + 89: '剥皮地牢二层', 90: '沼泽地坑三层', 91: '剥皮地牢三层', + 92: '下水道一层', 93: '下水道二层', 94: '废弃神殿', 95: '荒废神庙', @@ -47,15 +125,47 @@ export const LEVEL_NAMES_ZH: Readonly> = { 97: '遗忘神殿', 98: '废弃神庙', 99: '荒废圣物室', + 100: '憎恨囚牢一层', + 101: '憎恨囚牢二层', 102: '憎恨囚牢三层', - 103: '火焰之河要塞', + + // Act 4 + 103: '群魔堡垒', + 104: '郊外大草原', + 105: '绝望平原', + 106: '神罚之城', + 107: '火焰之河', + 108: '混沌避难所', + + // Act 5 109: '哈洛加斯', + 110: '血腥丘陵', + 111: '冰冻高地', + 112: '亚瑞特高原', + 113: '水晶通道', + 114: '冰河', + 115: '冰河路径', + 116: '漂泊者洞穴', + 117: '冰冻苔原', + 118: '冰窖', + 119: '痛苦之厅', 120: '岩石之巅', 121: '尼拉塞克神殿', + 122: '悲痛之厅', + 123: '死者呼唤之厅', 124: '沃特厅', + 125: '艾巴当', + 126: '阿克隆地道', + 127: '炼狱地坑', + 128: '世界之石要塞一层', + 129: '世界之石要塞二层', + 130: '世界之石要塞三层', 131: '毁灭王座', 132: '世界之石大殿', - 136: '崔斯特瑞姆', + 133: '遗忘之沙', + 134: '巨窟', + 135: '苦痛熔炉', + 136: '混沌崔斯特瑞姆', } /** @@ -80,10 +190,12 @@ const DIRECTION_SUFFIXES: Readonly> = { /** * 把一个变体后缀变成显示用的中文(拿不准就原样返回 DS1 名)。 * - * @param suffix - the part of the pack label after the level slug, e.g. `towns1`. + * @param suffix - the part of the pack label after the level slug, e.g. `towns1` or `var1`. * @returns the label to show in the third selector. */ export function variantLabelZh(suffix: string): string { + const match = /^var(\d+)$/i.exec(suffix) + if (match) return `变体 ${match[1]}` return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix }