diablo2-web/scripts/pack-skill-assets.ts

196 lines
7.5 KiB
TypeScript

/**
* 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<string, string> = {
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<string, string> = {
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<string, string> = {
ama: '亚马逊',
sor: '法师',
nec: '死灵法师',
pal: '圣骑士',
bar: '野蛮人',
dru: '德鲁伊',
ass: '刺客',
}
const TAB_NAMES_ZH: Record<string, readonly string[]> = {
ama: ['标枪与长矛技能', '被动与魔法技能', '弓与弩技能'],
sor: ['火焰法术', '闪电法术', '冰冷法术'],
nec: ['诅咒', '毒素与白骨法术', '召唤法术'],
pal: ['作战技能', '攻击灵气', '防御灵气'],
bar: ['战斗技能', '战斗专家', '呐喊'],
dru: ['召唤技能', '变形技能', '元素技能'],
ass: ['陷阱', '暗影武艺', '武学艺术'],
}
const EXPANSION_SKILL_NAMES_ZH: Record<number, string> = {
// 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<string, any> = {}
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)
})