/** * Offline Skill Asset & Data Extraction Script. * * Extracts all 210 skill icons and 21 skill tree backgrounds from Diablo II MPQ archives * into public/skills/ and generates the comprehensive skill catalog. */ import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { decodeTbl } from '../src/formats/tbl.ts' import { loadActTables } from '../src/game/acts.ts' import { encodeIndexedPng } from './png.ts' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' const CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const const CLASS_ICON_DC6: Record = { ama: 'data\\global\\ui\\SPELLS\\AmSkillicon.DC6', sor: 'data\\global\\ui\\SPELLS\\SoSkillicon.DC6', nec: 'data\\global\\ui\\SPELLS\\NeSkillicon.DC6', pal: 'data\\global\\ui\\SPELLS\\PaSkillicon.DC6', bar: 'data\\global\\ui\\SPELLS\\BaSkillicon.DC6', dru: 'data\\global\\ui\\SPELLS\\DrSkillicon.dc6', ass: 'data\\global\\ui\\SPELLS\\AsSkillicon.dc6', } const CLASS_BACK_DC6: Record = { ama: 'data\\global\\ui\\SPELLS\\skltree_a_back.DC6', sor: 'data\\global\\ui\\SPELLS\\skltree_s_back.DC6', nec: 'data\\global\\ui\\SPELLS\\skltree_n_back.DC6', pal: 'data\\global\\ui\\SPELLS\\skltree_p_back.DC6', bar: 'data\\global\\ui\\SPELLS\\skltree_b_back.DC6', dru: 'data\\global\\ui\\SPELLS\\skltree_d_back.dc6', ass: 'data\\global\\ui\\SPELLS\\skltree_i_back.dc6', } const CLASS_NAMES_ZH: Record = { ama: '亚马逊', sor: '法师', nec: '死灵法师', pal: '圣骑士', bar: '野蛮人', dru: '德鲁伊', ass: '刺客', } const TAB_NAMES_ZH: Record = { ama: ['标枪与长矛技能', '被动与魔法技能', '弓与弩技能'], sor: ['火焰法术', '闪电法术', '冰冷法术'], nec: ['诅咒', '毒素与白骨法术', '召唤法术'], pal: ['作战技能', '攻击灵气', '防御灵气'], bar: ['战斗技能', '战斗专家', '呐喊'], dru: ['召唤技能', '变形技能', '元素技能'], ass: ['陷阱', '暗影武艺', '武学艺术'], } const EXPANSION_SKILL_NAMES_ZH: Record = { // Druid 221: '乌鸦', 222: '毒藤', 223: '狼人变化', 224: '变形术', 225: '火风暴', 226: '橡木智者', 227: '召唤恶狼', 228: '熊人变化', 229: '熔岩巨石', 230: '极地风暴', 231: '食尸藤', 232: '狂犬病', 233: '太阳藤', 234: '裂地', 235: '旋风甲', 236: '狼獾之心', 237: '召唤野猪', 238: '狂怒', 239: '撞击', 240: '龙卷风', 241: '枯萎藤', 242: '火爪', 243: '震波', 244: '火山', 245: '暴风', 246: '灵之守护', 247: '召唤灰熊', 248: '狂怒', 249: '末日浩劫', 250: '毁天灭地', // Assassin 251: '火爆', 252: '爪支配', 253: '心灵震爆', 254: '虎击', 255: '龙爪', 256: '电能网', 257: '刃之小刀', 258: '加速', 259: '双手挥舞', 260: '眼镜蛇打击', 261: '闪电手雷', 262: '火之复生', 263: '武器格挡', 264: '魔影斗篷', 265: '飞龙在天', 266: '雷电爪', 267: '消解', 268: '狂怒刃', 269: '复仇之火', 270: '影分身', 271: '寒冰刃', 272: '龙尾', 273: '雷光守卫', 274: '火焰复苏', 275: '刃之护盾', 276: '毒牙', 277: '暗影大师', 278: '飞龙升天', 279: '死亡守卫', 280: '凤凰攻击', } async function main() { console.log('Mounting MPQ archives...') const archives = new MountedArchives() for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`))) } const tables = await loadActTables(archives) const skillsTable = tables.skills! const skillDescTable = tables.skilldesc! const pl2Data = await archives.read('data\\global\\palette\\ACT1\\pal.pl2') const pl2 = decodePl2(pl2Data) const engTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\string.tbl')) const engExpTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\expansionstring.tbl')) const engPatchTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\patchstring.tbl')) const chiTbl = decodeTbl(await archives.read('data\\local\\LNG\\CHI\\string.tbl'), 'utf-8') const chiPatchTbl = decodeTbl(await archives.read('data\\local\\LNG\\CHI\\patchstring.tbl'), 'utf-8') function getStrings(key: string): { en: string; zh: string } { if (!key) return { en: '', zh: '' } const en = engPatchTbl.dict.get(key) || engExpTbl.dict.get(key) || engTbl.dict.get(key) || key const zh = chiPatchTbl.dict.get(key) || chiTbl.dict.get(key) || en return { en: en.trim(), zh: zh.trim() } } const outDir = join(process.cwd(), 'public', 'skills') mkdirSync(outDir, { recursive: true }) console.log('Extracting skill tab backgrounds...') for (const cls of CLASS_CODES) { const backDc6 = decodeDc6(await archives.read(CLASS_BACK_DC6[cls]!)) const W = 256, H = 432 const baseTop = backDc6.groups[0]!.frames[0]! const baseBot = backDc6.groups[0]!.frames[2]! for (let tab = 0; tab < 3; tab++) { const topFrameIndex = 4 + tab * 4 const botFrameIndex = 6 + tab * 4 const topFrame = backDc6.groups[0]!.frames[topFrameIndex]! const botFrame = backDc6.groups[0]!.frames[botFrameIndex]! const pixels = new Uint8Array(W * H) for (let i = 0; i < 256 * 256; i++) { const p = topFrame.indices[i]! pixels[i] = p !== 0 ? p : baseTop.indices[i]! } for (let i = 0; i < 256 * 176; i++) { const p = botFrame.indices[i]! pixels[256 * 256 + i] = p !== 0 ? p : baseBot.indices[i]! } const png = encodeIndexedPng({ width: W, height: H, pixels, palette: pl2.rgb, }) writeFileSync(join(outDir, `bg_${cls}_${tab}.png`), png) } } console.log('All 21 skill tab backgrounds extracted!') console.log('Extracting skill icons...') const iconSheets: Record = {} for (const cls of CLASS_CODES) { iconSheets[cls] = decodeDc6(await archives.read(CLASS_ICON_DC6[cls]!)) } let totalIcons = 0 for (const cls of CLASS_CODES) { const classSkills = skillsTable.rows.filter(r => r[2]?.toLowerCase() === cls) const sheet = iconSheets[cls]! for (const r of classSkills) { const skillId = Number(r[1]) const descName = r[3] const descRow = skillDescTable.rows.find(d => d[0]?.toLowerCase() === descName?.toLowerCase()) if (!descRow) continue const iconCel = Number(descRow[6]) // Normal icon const frameNormal = sheet.groups[0].frames[iconCel] if (frameNormal) { const png = encodeIndexedPng({ width: frameNormal.width, height: frameNormal.height, pixels: frameNormal.indices, palette: pl2.rgb, transparentIndex: 0, }) writeFileSync(join(outDir, `icon_${skillId}.png`), png) } // Active / highlighted icon (iconCel + 1) const frameActive = sheet.groups[0].frames[iconCel + 1] if (frameActive) { const png = encodeIndexedPng({ width: frameActive.width, height: frameActive.height, pixels: frameActive.indices, palette: pl2.rgb, transparentIndex: 0, }) writeFileSync(join(outDir, `icon_${skillId}_active.png`), png) } totalIcons++ } } console.log(`Extracted ${totalIcons} skill icons!`) } main().catch(err => { console.error(err) process.exit(1) })