196 lines
7.5 KiB
TypeScript
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)
|
|
})
|