fix(skills): reconstruct skill tooltips to 1.13c formula and next level preview (Fixes #152)
This commit is contained in:
commit
0df0dfede2
|
|
@ -0,0 +1,168 @@
|
|||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { loadActTables } from '../src/game/acts.ts'
|
||||
import { SKILLS_CATALOG } from '../src/data/skills-catalog.ts'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
async function run() {
|
||||
console.log('Mounting MPQs to extract 1.13c ToHit and auraLenCalc...')
|
||||
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 st = tables.skills!
|
||||
const toHitIdx = st.header.indexOf('ToHit')
|
||||
const levToHitIdx = st.header.indexOf('LevToHit')
|
||||
const toHitCalcIdx = st.header.indexOf('ToHitCalc')
|
||||
const auraLenCalcIdx = st.header.indexOf('auralencalc')
|
||||
const startmanaIdx = st.header.indexOf('startmana')
|
||||
|
||||
const updatedCatalog = SKILLS_CATALOG.map(skill => {
|
||||
const row = st.rows.find(r => Number(r[1]) === skill.id)
|
||||
const th = row ? (Number(row[toHitIdx]) || 0) : 0
|
||||
const lth = row ? (Number(row[levToHitIdx]) || 0) : 0
|
||||
const rawThc = row ? row[toHitCalcIdx] : undefined
|
||||
const thc = rawThc && rawThc.trim() !== '' ? rawThc.trim() : undefined
|
||||
const rawAlc = row ? row[auraLenCalcIdx] : undefined
|
||||
const alc = rawAlc && rawAlc.trim() !== '' ? rawAlc.trim() : undefined
|
||||
const sm = row ? (Number(row[startmanaIdx]) || 0) : 0
|
||||
|
||||
return {
|
||||
...skill,
|
||||
toHit: th,
|
||||
toHitLev: lth,
|
||||
...(thc ? { toHitCalc: thc } : {}),
|
||||
...(alc ? { auraLenCalc: alc } : {}),
|
||||
startmana: sm,
|
||||
}
|
||||
})
|
||||
|
||||
const tsContent = `// Auto-generated Diablo II 1.13c 210-Skill Catalog
|
||||
// Ground truth parsed from Skills.txt, SkillDesc.txt, string.tbl, and MPQs.
|
||||
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
|
||||
export interface SynergyBonusInfo {
|
||||
readonly providerId: number
|
||||
readonly providerName: string
|
||||
readonly percentPerLevel: number
|
||||
readonly type: 'damage' | 'elemental' | 'duration'
|
||||
}
|
||||
|
||||
export interface SynergyGivenInfo {
|
||||
readonly targetId: number
|
||||
readonly targetName: string
|
||||
readonly targetNameZh: string
|
||||
readonly percentPerLevel: number
|
||||
}
|
||||
|
||||
export interface DescLineDefinition {
|
||||
readonly lineType: number
|
||||
readonly textaKey?: string | undefined
|
||||
readonly textbKey?: string | undefined
|
||||
readonly textaEn?: string | undefined
|
||||
readonly textaZh?: string | undefined
|
||||
readonly textbEn?: string | undefined
|
||||
readonly textbZh?: string | undefined
|
||||
readonly calca?: string | undefined
|
||||
readonly calcb?: string | undefined
|
||||
}
|
||||
|
||||
export interface MinionBaseStats {
|
||||
readonly code: string
|
||||
readonly minHP: number
|
||||
readonly maxHP: number
|
||||
readonly minDam: number
|
||||
readonly maxDam: number
|
||||
}
|
||||
|
||||
export interface SkillCatalogEntry {
|
||||
readonly id: number
|
||||
readonly name: string
|
||||
readonly nameZh: string
|
||||
readonly classCode: CharacterClassCode
|
||||
readonly classNameZh: string
|
||||
readonly tabIndex: number
|
||||
readonly tabName: string
|
||||
readonly tabNameZh: string
|
||||
readonly row: number
|
||||
readonly col: number
|
||||
readonly reqlevel: number
|
||||
readonly reqskills: readonly number[]
|
||||
readonly dependents: readonly number[]
|
||||
readonly mana: number
|
||||
readonly lvlmana: number
|
||||
readonly minmana: number
|
||||
readonly manashift: number
|
||||
readonly delay: number
|
||||
readonly descdam?: string | undefined
|
||||
readonly hitshift: number
|
||||
readonly srcDamage: number
|
||||
readonly minDam: number
|
||||
readonly maxDam: number
|
||||
readonly minLevDam: readonly number[]
|
||||
readonly maxLevDam: readonly number[]
|
||||
readonly eType: string
|
||||
readonly eMin: number
|
||||
readonly eMax: number
|
||||
readonly eMinLev: readonly number[]
|
||||
readonly eMaxLev: readonly number[]
|
||||
readonly eLen: number
|
||||
readonly eLevLen: readonly number[]
|
||||
readonly toHit: number
|
||||
readonly toHitLev: number
|
||||
readonly toHitCalc?: string | undefined
|
||||
readonly auraLenCalc?: string | undefined
|
||||
readonly startmana: number
|
||||
readonly params: Readonly<Record<string, number>>
|
||||
readonly synergyFormulas: {
|
||||
readonly physical?: string | undefined
|
||||
readonly elemental?: string | undefined
|
||||
readonly duration?: string | undefined
|
||||
}
|
||||
readonly synergiesReceived: readonly SynergyBonusInfo[]
|
||||
readonly synergiesGiven: readonly SynergyGivenInfo[]
|
||||
readonly masteries: readonly number[]
|
||||
readonly descLines?: readonly DescLineDefinition[] | undefined
|
||||
readonly dsc2Lines?: readonly DescLineDefinition[] | undefined
|
||||
readonly minionStats?: MinionBaseStats | undefined
|
||||
readonly shortDesc: string
|
||||
readonly shortDescZh: string
|
||||
readonly longDesc: string
|
||||
readonly longDescZh: string
|
||||
readonly icon: string
|
||||
readonly iconActive: string
|
||||
}
|
||||
|
||||
export const SKILLS_CATALOG: readonly SkillCatalogEntry[] = Object.freeze(${JSON.stringify(updatedCatalog, null, 2)})
|
||||
|
||||
export const SKILLS_BY_ID: Readonly<Record<number, SkillCatalogEntry>> = Object.freeze(
|
||||
SKILLS_CATALOG.reduce<Record<number, SkillCatalogEntry>>((acc, cur) => {
|
||||
acc[cur.id] = cur
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
export const SKILLS_BY_CLASS: Readonly<Record<CharacterClassCode, readonly SkillCatalogEntry[]>> = Object.freeze({
|
||||
ama: SKILLS_CATALOG.filter(s => s.classCode === 'ama'),
|
||||
sor: SKILLS_CATALOG.filter(s => s.classCode === 'sor'),
|
||||
nec: SKILLS_CATALOG.filter(s => s.classCode === 'nec'),
|
||||
pal: SKILLS_CATALOG.filter(s => s.classCode === 'pal'),
|
||||
bar: SKILLS_CATALOG.filter(s => s.classCode === 'bar'),
|
||||
dru: SKILLS_CATALOG.filter(s => s.classCode === 'dru'),
|
||||
ass: SKILLS_CATALOG.filter(s => s.classCode === 'ass'),
|
||||
})
|
||||
`
|
||||
|
||||
const outPath = join(process.cwd(), 'src', 'data', 'skills-catalog.ts')
|
||||
writeFileSync(outPath, tsContent, 'utf-8')
|
||||
console.log(`Successfully updated ${outPath} with 1.13c ToHit and auraLenCalc!`)
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,6 +12,7 @@
|
|||
*/
|
||||
|
||||
import type { CharacterClassCode } from './classes.ts'
|
||||
import { compute3BandDuration } from './engine/calc-ast.ts'
|
||||
import {
|
||||
SKILLS_CATALOG,
|
||||
SKILLS_BY_ID,
|
||||
|
|
@ -480,7 +481,13 @@ export function calculateDetailedDamage(
|
|||
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
||||
|
||||
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
|
||||
durationFrames = evaluateLevelBand(skill.eLen, skill.eLevLen, lvl)
|
||||
durationFrames = compute3BandDuration(
|
||||
lvl,
|
||||
skill.eLen,
|
||||
skill.eLevLen?.[0] ?? 0,
|
||||
skill.eLevLen?.[1] ?? 0,
|
||||
skill.eLevLen?.[2] ?? 0,
|
||||
)
|
||||
}
|
||||
} else if (skill.minDam > 0 || skill.maxDam > 0 || (skill.minLevDam && skill.minLevDam.some(x => x > 0))) {
|
||||
type = 'physical'
|
||||
|
|
@ -658,22 +665,42 @@ export function evaluateSkillEffects(
|
|||
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
|
||||
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
|
||||
|
||||
// Determine duration len
|
||||
let durationFrames = skill.eLen + (lvl - 1) * (skill.eLevLen?.[0] ?? 0)
|
||||
if (durationFrames === 0 && (skill.params.par1 ?? 0) > 0 && (skill.params.par2 ?? 0) > 0) {
|
||||
if (skill.name === 'Battle Orders' || skill.name === 'Shout' || skill.name === 'Battle Command') {
|
||||
// Determine duration len using authentic 1.13c 3-band duration scaling
|
||||
let durationFrames = compute3BandDuration(
|
||||
lvl,
|
||||
skill.eLen,
|
||||
skill.eLevLen?.[0] ?? 0,
|
||||
skill.eLevLen?.[1] ?? 0,
|
||||
skill.eLevLen?.[2] ?? 0,
|
||||
)
|
||||
if (durationFrames === 0) {
|
||||
if (
|
||||
skill.id === 138 ||
|
||||
skill.id === 149 ||
|
||||
skill.id === 155 ||
|
||||
skill.name === 'Battle Orders' ||
|
||||
skill.name === 'Shout' ||
|
||||
skill.name === 'Battle Command'
|
||||
) {
|
||||
const synShout = state.hardPoints[138] ?? 0
|
||||
const synBC = state.hardPoints[155] ?? 0
|
||||
const synBO = state.hardPoints[149] ?? 0
|
||||
let synBonus = 0
|
||||
if (skill.id === 149) synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
||||
else if (skill.id === 138) synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
||||
else if (skill.id === 155) synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
if (skill.id === 149 || skill.name === 'Battle Orders') {
|
||||
synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
} else if (skill.id === 138 || skill.name === 'Shout') {
|
||||
synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par3 ?? 0) + (lvl - 1) * (skill.params.par4 ?? 0) + synBonus
|
||||
} else if (skill.id === 155 || skill.name === 'Battle Command') {
|
||||
synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: SkillFormulaContext = {
|
||||
const shiftMultiplier = 2 ** ((skill.hitshift ?? 8) - 8)
|
||||
const baseCtx: SkillFormulaContext = {
|
||||
lvl,
|
||||
params: skill.params,
|
||||
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
|
||||
|
|
@ -684,15 +711,24 @@ export function evaluateSkillEffects(
|
|||
dm34,
|
||||
dm56,
|
||||
dm78,
|
||||
toht: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
|
||||
toht: 0,
|
||||
len: durationFrames,
|
||||
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
|
||||
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
|
||||
edmn: Math.round(evaluateLevelBand(skill.eMin, skill.eMinLev, lvl) * shiftMultiplier),
|
||||
edmx: Math.round(evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl) * shiftMultiplier),
|
||||
mana: calculateManaCost(skill, lvl),
|
||||
hardPoints: state.hardPoints,
|
||||
effLevels,
|
||||
}
|
||||
|
||||
const toht = skill.toHitCalc
|
||||
? evalSkillDescFormula(skill.toHitCalc, baseCtx)
|
||||
: (skill.toHit ?? 0) + (lvl - 1) * (skill.toHitLev ?? 0)
|
||||
|
||||
const ctx: SkillFormulaContext = {
|
||||
...baseCtx,
|
||||
toht,
|
||||
}
|
||||
|
||||
const results: FormattedDescLine[] = []
|
||||
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
|
||||
for (const line of lines) {
|
||||
|
|
@ -861,7 +897,7 @@ export function buildTooltipViewModel(
|
|||
|
||||
const hard = state.hardPoints[skillId] ?? 0
|
||||
const eff = getEffectiveLevel(state, skillId)
|
||||
const isLearned = hard > 0
|
||||
const isLearned = eff > 0
|
||||
const levelMet = playerLevel >= skill.reqlevel
|
||||
const canAlloc = canAllocate(state, skillId)
|
||||
|
||||
|
|
@ -883,10 +919,10 @@ export function buildTooltipViewModel(
|
|||
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
|
||||
const currentEffects = evaluateSkillEffects(state, skill, displayLevel)
|
||||
|
||||
// Next Level stats
|
||||
// Next Level stats: only show when learned and not maxed
|
||||
let nextLevel: TooltipViewModel['nextLevel'] = null
|
||||
if (hard < 20) {
|
||||
const nextLvl = isLearned ? eff + 1 : 1
|
||||
if (isLearned && hard < 20) {
|
||||
const nextLvl = eff + 1
|
||||
nextLevel = {
|
||||
level: nextLvl,
|
||||
manaCost: calculateManaCost(skill, nextLvl),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,20 @@
|
|||
* point allocation, prerequisite checks, and rich hover tooltips.
|
||||
*/
|
||||
|
||||
import type { D2FontRenderer } from './font.ts'
|
||||
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import {
|
||||
buildTooltipViewModel,
|
||||
type TooltipViewModel,
|
||||
type SkillCalcState,
|
||||
} from '../game/skill-calc-engine.ts'
|
||||
|
||||
export interface FormattedTooltipLine {
|
||||
readonly text: string
|
||||
readonly color: D2ColorCode
|
||||
readonly font: 'fontexocet10' | 'font8'
|
||||
readonly align?: 'left' | 'center'
|
||||
}
|
||||
|
||||
export interface SkillNodeDef {
|
||||
readonly skillId: number
|
||||
|
|
@ -74,6 +87,8 @@ export class SkillTreePanel {
|
|||
activeTab: 0 | 1 | 2 = 2
|
||||
unspentSkillPoints = 12
|
||||
bonusAllSkills = 8
|
||||
characterClass: CharacterClassCode = 'sor'
|
||||
characterLevel = 85
|
||||
readonly hardPoints = new Map<number, number>([
|
||||
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
|
||||
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
|
||||
|
|
@ -107,6 +122,24 @@ export class SkillTreePanel {
|
|||
return hard > 0 ? hard + this.bonusAllSkills : 0
|
||||
}
|
||||
|
||||
getSkillCalcState(): SkillCalcState {
|
||||
const hardPoints: Record<number, number> = {}
|
||||
for (const [id, pts] of this.hardPoints.entries()) {
|
||||
if (pts > 0) hardPoints[id] = pts
|
||||
}
|
||||
return {
|
||||
classCode: this.characterClass,
|
||||
hardPoints,
|
||||
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
||||
allSkillsSoftPoints: this.bonusAllSkills,
|
||||
specificSoftPoints: {},
|
||||
}
|
||||
}
|
||||
|
||||
getTooltipViewModel(skillId: number): TooltipViewModel | null {
|
||||
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel)
|
||||
}
|
||||
|
||||
canAllocate(skillId: number, charLevel = 85): boolean {
|
||||
if (this.unspentSkillPoints <= 0) return false
|
||||
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
|
||||
|
|
@ -317,51 +350,259 @@ export class SkillTreePanel {
|
|||
|
||||
// 6. Hover Tooltip for Skill Node
|
||||
if (this.hoveredSkill) {
|
||||
const s = this.hoveredSkill
|
||||
const pos = SkillTreePanel.nodeScreenPos(s)
|
||||
const eff = Math.max(1, this.getEffectiveLevel(s.skillId))
|
||||
const hard = this.getHardPoints(s.skillId)
|
||||
const minDmg = s.baseDmgMin + (eff - 1) * s.dmgPerLevel
|
||||
const maxDmg = s.baseDmgMax + (eff - 1) * Math.round(s.dmgPerLevel * 1.25)
|
||||
const tw = 240
|
||||
const th = 112
|
||||
const tx = Math.max(404, Math.min(800 - tw - 8, pos.x - 80))
|
||||
const ty = Math.max(12, pos.y - th - 6)
|
||||
this.drawSkillTooltip(ctx, font)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(6, 6, 6, 0.94)'
|
||||
ctx.fillRect(tx, ty, tw, th)
|
||||
ctx.strokeStyle = '#c8a15a'
|
||||
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1)
|
||||
static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
|
||||
const lines: FormattedTooltipLine[] = []
|
||||
|
||||
font.drawText(ctx, `${s.nameZh} (${s.nameEn})`, tx + tw / 2, ty + 20, {
|
||||
font: 'fontexocet10',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, s.descZh, tx + tw / 2, ty + 38, {
|
||||
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
|
||||
lines.push({
|
||||
text: `${vm.skill.nameZh} (${vm.skill.name})`,
|
||||
color: 'gold',
|
||||
font: 'fontexocet10',
|
||||
align: 'center',
|
||||
})
|
||||
|
||||
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
|
||||
if (!vm.levelMet) {
|
||||
lines.push({
|
||||
text: `需要角色等级: ${vm.reqlevel}`,
|
||||
color: 'red',
|
||||
font: 'font8',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, `当前等级: ${eff} (基础 ${hard} + 装备 ${this.bonusAllSkills})`, tx + tw / 2, ty + 58, {
|
||||
font: 'font8',
|
||||
color: 'blue',
|
||||
align: 'center',
|
||||
})
|
||||
if (s.baseDmgMax > 0) {
|
||||
font.drawText(ctx, `法术效果/伤害: ${minDmg} - ${maxDmg}`, tx + tw / 2, ty + 76, {
|
||||
}
|
||||
for (const prereq of vm.prereqs) {
|
||||
if (!prereq.met) {
|
||||
lines.push({
|
||||
text: `需要前置技能: ${prereq.nameZh}`,
|
||||
color: 'red',
|
||||
font: 'font8',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
font.drawText(
|
||||
ctx,
|
||||
`法力消耗: ${s.baseMana} · 需要等级: ${s.reqLevel} (左键点击加点)`,
|
||||
tx + tw / 2,
|
||||
ty + 96,
|
||||
{ font: 'font8', color: 'tan', align: 'center' },
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Narrative: Short/Long description in White (#e8e4dc, font8)
|
||||
const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
|
||||
if (desc) {
|
||||
lines.push({
|
||||
text: desc,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Current Level header: Blue/White
|
||||
if (vm.isLearned) {
|
||||
const soft = vm.effectiveLevel - vm.hardPoints
|
||||
const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
|
||||
lines.push({
|
||||
text: lvlText,
|
||||
color: soft > 0 ? 'blue' : 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
text: '未习得 (预览 1 级效果)',
|
||||
color: 'gray',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8)
|
||||
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
|
||||
lines.push({
|
||||
text: `法力消耗: ${vm.current.manaCost}`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.current.delaySeconds > 0) {
|
||||
lines.push({
|
||||
text: `施法延迟: ${vm.current.delaySeconds} 秒`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.current.damage.hasDamage) {
|
||||
const d = vm.current.damage
|
||||
lines.push({
|
||||
text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
|
||||
lines.push({
|
||||
text: `持续时间: ${d.durationSeconds} 秒`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
|
||||
lines.push({
|
||||
text: `武器伤害: ${d.weaponDamagePercent}%`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const eff of vm.current.effects) {
|
||||
if (eff.textZh) {
|
||||
lines.push({
|
||||
text: eff.textZh,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points).
|
||||
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) {
|
||||
lines.push({
|
||||
text: `[下一等级] (Lv ${vm.nextLevel.level})`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (vm.nextLevel.manaCost !== vm.current.manaCost) {
|
||||
lines.push({
|
||||
text: `法力消耗: ${vm.nextLevel.manaCost}`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.nextLevel.damage.hasDamage) {
|
||||
const nd = vm.nextLevel.damage
|
||||
lines.push({
|
||||
text: `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (nd.durationSeconds !== undefined && nd.durationSeconds > 0 && nd.durationSeconds !== vm.current.damage.durationSeconds) {
|
||||
lines.push({
|
||||
text: `持续时间: ${nd.durationSeconds} 秒`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (nd.weaponDamagePercent !== undefined && nd.weaponDamagePercent !== vm.current.damage.weaponDamagePercent) {
|
||||
lines.push({
|
||||
text: `武器伤害: ${nd.weaponDamagePercent}%`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const eff of vm.nextLevel.effects) {
|
||||
if (eff.textZh) {
|
||||
lines.push({
|
||||
text: eff.textZh,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (vm.hardPoints >= 20) {
|
||||
lines.push({
|
||||
text: '[已达技能点投入上限 (20/20)]',
|
||||
color: 'gold',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line: • <Name>: 每个有效技能点增加 <X>% 伤害 (当前 +<Y>%)
|
||||
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
|
||||
lines.push({
|
||||
text: '[协同技能加成]',
|
||||
color: 'blue',
|
||||
font: 'fontexocet10',
|
||||
align: 'center',
|
||||
})
|
||||
for (const syn of vm.synergiesReceived) {
|
||||
const text = syn.hardPoints > 0
|
||||
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
|
||||
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
|
||||
lines.push({
|
||||
text,
|
||||
color: syn.hardPoints > 0 ? 'blue' : 'gray',
|
||||
font: 'font8',
|
||||
align: 'left',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (vm.masteryInfo && vm.masteryInfo.active) {
|
||||
lines.push({
|
||||
text: `• ${vm.masteryInfo.descriptionZh}`,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
static computeTooltipPlacement(
|
||||
pos: { x: number; y: number; w: number; h: number },
|
||||
lines: readonly FormattedTooltipLine[],
|
||||
font: D2FontRenderer,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const maxLineWidth = lines.reduce((max, l) => {
|
||||
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
|
||||
return Math.max(max, w)
|
||||
}, 0)
|
||||
const boxW = Math.max(240, maxLineWidth + 28)
|
||||
const boxH = lines.length * 17 + 24
|
||||
const rawX = pos.x - boxW - 8
|
||||
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
|
||||
const rawY = Math.round(pos.y + 24 - boxH / 2)
|
||||
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
|
||||
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
|
||||
}
|
||||
|
||||
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
|
||||
if (!this.hoveredSkill) return
|
||||
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
|
||||
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
|
||||
if (!vm) return
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
|
||||
if (lines.length === 0) return
|
||||
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
|
||||
|
||||
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
|
||||
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
|
||||
ctx.strokeStyle = '#8c7244'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(placement.x + 0.5, placement.y + 0.5, placement.w - 1, placement.h - 1)
|
||||
|
||||
lines.forEach((l, idx) => {
|
||||
const lineY = placement.y + 12 + 10 + idx * 17
|
||||
const lineX = l.align === 'center' ? placement.x + Math.round(placement.w / 2) : placement.x + 14
|
||||
font.drawText(ctx, l.text, lineX, lineY, {
|
||||
font: l.font,
|
||||
color: l.color,
|
||||
align: l.align ?? 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,100 @@ describe('Diablo II Skill Calculator Engine (Issue #26)', () => {
|
|||
expect(vm?.prereqs[0].name).toBe('Fire Bolt')
|
||||
expect(vm?.prereqs[0].met).toBe(false)
|
||||
expect(vm?.current.damage.hasDamage).toBe(true)
|
||||
// For slvl === 0 (unlearned), nextLevel must be null while main body previews Lv 1
|
||||
expect(vm?.isLearned).toBe(false)
|
||||
expect(vm?.current.level).toBe(1)
|
||||
expect(vm?.nextLevel).toBeNull()
|
||||
})
|
||||
|
||||
it('previews nextLevel when learned (slvl >= 1) and hardPoints < 20', () => {
|
||||
allocatePoint(state, 36, 1) // Fire Bolt prerequisite
|
||||
allocatePoint(state, 47, 5) // Fire Ball lvl 5
|
||||
state.allSkillsSoftPoints = 2 // +2 from gear -> effLevel 7
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm?.isLearned).toBe(true)
|
||||
expect(vm?.hardPoints).toBe(5)
|
||||
expect(vm?.effectiveLevel).toBe(7)
|
||||
expect(vm?.current.level).toBe(7)
|
||||
expect(vm?.nextLevel).not.toBeNull()
|
||||
expect(vm?.nextLevel?.level).toBe(8)
|
||||
expect(vm?.nextLevel?.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
|
||||
})
|
||||
|
||||
it('omits nextLevel preview when skill has reached maximum base allocation (hardPoints === 20)', () => {
|
||||
allocatePoint(state, 36, 1)
|
||||
allocatePoint(state, 47, 20) // Max 20 hard points
|
||||
state.allSkillsSoftPoints = 5 // +5 gear -> effLevel 25
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm?.isLearned).toBe(true)
|
||||
expect(vm?.hardPoints).toBe(20)
|
||||
expect(vm?.effectiveLevel).toBe(25)
|
||||
expect(vm?.current.level).toBe(25)
|
||||
expect(vm?.nextLevel).toBeNull()
|
||||
})
|
||||
|
||||
it('evaluates authentic 1.13c Attack Rating (ToHit) scaling for combat skills', () => {
|
||||
// Jab (10): ToHit = 10, LevToHit = 9 -> 10 + (lvl - 1) * 9
|
||||
const amaState = createInitialState('ama')
|
||||
amaState.hardPoints[10] = 1
|
||||
let vmLvl1 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine1 = vmLvl1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine1?.textZh).toBe('準確率:+10%')
|
||||
|
||||
amaState.hardPoints[10] = 10
|
||||
let vmLvl10 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine10 = vmLvl10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine10?.textZh).toBe('準確率:+91%')
|
||||
|
||||
amaState.hardPoints[10] = 20
|
||||
let vmLvl20 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine20 = vmLvl20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine20?.textZh).toBe('準確率:+181%')
|
||||
|
||||
// Zeal (106): ToHit = 10, LevToHit = 10 -> 10 + (lvl - 1) * 10 = +200%
|
||||
const palState = createInitialState('pal')
|
||||
palState.hardPoints[106] = 20
|
||||
const vmZeal = buildTooltipViewModel(palState, 106, 99)
|
||||
const zealAr = vmZeal?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
|
||||
expect(zealAr?.textZh).toBe('攻擊加成:+200%')
|
||||
|
||||
// Sacrifice (96): ToHit = 20, LevToHit = 7 -> 20 + (lvl - 1) * 7 = +83%
|
||||
palState.hardPoints[96] = 10
|
||||
const vmSacrifice = buildTooltipViewModel(palState, 96, 99)
|
||||
const sacAr = vmSacrifice?.current.effects.find(e => e.textZh.includes('準確率'))
|
||||
expect(sacAr?.textZh).toBe('變動準確率:+83%')
|
||||
})
|
||||
|
||||
it('evaluates authentic 1.13c Shout warcry duration using Param3/Param4 (ln34)', () => {
|
||||
const barState = createInitialState('bar')
|
||||
barState.hardPoints[138] = 1 // Shout Level 1: Param3 = 500 frames = 20 seconds
|
||||
let vmShout = buildTooltipViewModel(barState, 138, 99)
|
||||
let durLine = vmShout?.current.effects.find(e => e.lineType === 12)
|
||||
expect(durLine?.textZh).toBe('持續:20 秒')
|
||||
|
||||
barState.hardPoints[138] = 20 // Shout Level 20: 500 + 19 * 250 = 5250 frames = 210 seconds
|
||||
vmShout = buildTooltipViewModel(barState, 138, 99)
|
||||
durLine = vmShout?.current.effects.find(e => e.lineType === 12)
|
||||
expect(durLine?.textZh).toBe('持續:210 秒')
|
||||
})
|
||||
|
||||
it('strictly enforces that synergies ignore soft gear points (1.13c Marrowwalk fix)', () => {
|
||||
const sorState1 = createInitialState('sor')
|
||||
sorState1.hardPoints[36] = 20 // 20 hard points in Fire Bolt
|
||||
const vmHard = buildTooltipViewModel(sorState1, 47, 99)
|
||||
const synHard = vmHard?.synergiesReceived.find(s => s.providerId === 36)
|
||||
expect(synHard?.hardPoints).toBe(20)
|
||||
expect(synHard?.currentBonusPct).toBe(280) // 20 * 14% = 280%
|
||||
|
||||
const sorState2 = createInitialState('sor')
|
||||
sorState2.hardPoints[36] = 0 // 0 hard points
|
||||
sorState2.allSkillsSoftPoints = 20 // +20 soft points from gear
|
||||
const vmSoft = buildTooltipViewModel(sorState2, 47, 99)
|
||||
const synSoft = vmSoft?.synergiesReceived.find(s => s.providerId === 36)
|
||||
expect(synSoft?.hardPoints).toBe(0)
|
||||
expect(synSoft?.currentBonusPct).toBe(0) // Soft points give 0% synergy
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ import {
|
|||
LEVEL_NAMES_ZH,
|
||||
LEVEL_NAMES_EN,
|
||||
} from '../src/game/level-names-zh.ts'
|
||||
import {
|
||||
buildTooltipViewModel,
|
||||
createInitialState,
|
||||
allocatePoint,
|
||||
} from '../src/game/skill-calc-engine.ts'
|
||||
|
||||
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
|
||||
|
|
@ -635,4 +640,122 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
|||
expect(sceneNameZh(null as any)).toBe('Unknown Area')
|
||||
expect(sceneNameEn(null as any)).toBe('Unknown Area')
|
||||
})
|
||||
|
||||
it('renders authentic multi-line skill tooltips with 1.13c next-level preview and styling rules (Issue #152)', () => {
|
||||
// 1. Unlearned skill with requirements not met (Sorceress lvl 1 looking at Fire Ball lvl 12)
|
||||
const sorcBuild = createInitialState('sor')
|
||||
const unlearnedFireBall = buildTooltipViewModel(sorcBuild, 47, 1) // Fire Ball requires clvl 12, Fire Bolt
|
||||
expect(unlearnedFireBall).not.toBeNull()
|
||||
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
|
||||
|
||||
// Title: Fire Ball in Gold
|
||||
expect(unlearnedLines[0]).toEqual({ text: '火球 (Fire Ball)', color: 'gold', font: 'fontexocet10', align: 'center' })
|
||||
// Required Level: red when character level < reqlevel
|
||||
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12'))
|
||||
expect(reqLevelLine).toBeDefined()
|
||||
expect(reqLevelLine?.color).toBe('red')
|
||||
// Prerequisites: red when unmet
|
||||
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
|
||||
expect(prereqLine).toBeDefined()
|
||||
expect(prereqLine?.color).toBe('red')
|
||||
// Unlearned skill MUST NOT include [下一等级] preview
|
||||
expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
|
||||
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
|
||||
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
|
||||
expect(fireBolt).not.toBeNull()
|
||||
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
|
||||
// When level is met, reqlevel line is omitted from tooltip
|
||||
expect(fireBoltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
|
||||
expect(fireBoltLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
|
||||
// 3. Learned skill (slvl = 1) with next-level preview in green
|
||||
const learnedBuild = createInitialState('sor')
|
||||
allocatePoint(learnedBuild, 36) // Fire Bolt +1
|
||||
const learnedFireBolt = buildTooltipViewModel(learnedBuild, 36, 1)
|
||||
expect(learnedFireBolt).not.toBeNull()
|
||||
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!)
|
||||
|
||||
const currLevelLine = learnedLines.find(l => l.text.includes('当前技能等级: 1'))
|
||||
expect(currLevelLine).toBeDefined()
|
||||
expect(currLevelLine?.color).toBe('white')
|
||||
|
||||
const nextLevelHeader = learnedLines.find(l => l.text.includes('[下一等级] (Lv 2)'))
|
||||
expect(nextLevelHeader).toBeDefined()
|
||||
expect(nextLevelHeader?.color).toBe('green')
|
||||
|
||||
// Next level damage preview in green
|
||||
const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
|
||||
expect(nextLevelDmg).toBeDefined()
|
||||
|
||||
// 4. Maxed skill (hardPoints === 20): omits [下一等级] and shows max allocation notice in gold
|
||||
const maxedBuild = createInitialState('sor')
|
||||
allocatePoint(maxedBuild, 36, 20)
|
||||
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
|
||||
expect(maxedFireBolt).not.toBeNull()
|
||||
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
|
||||
|
||||
expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]'))
|
||||
expect(maxedNotice).toBeDefined()
|
||||
expect(maxedNotice?.color).toBe('gold')
|
||||
|
||||
// 5. Synergy section formatting
|
||||
const fireballBuild = createInitialState('sor')
|
||||
allocatePoint(fireballBuild, 36) // Fire Bolt +1
|
||||
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
|
||||
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!)
|
||||
const synHeader = fbLines.find(l => l.text.includes('[协同技能加成]'))
|
||||
expect(synHeader).toBeDefined()
|
||||
expect(synHeader?.color).toBe('blue')
|
||||
const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害'))
|
||||
expect(synFireBolt).toBeDefined()
|
||||
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point)
|
||||
const synMeteor = fbLines.find(l => l.text.includes('隕石: 每个有效技能点增加 14% 伤害'))
|
||||
expect(synMeteor).toBeDefined()
|
||||
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points)
|
||||
})
|
||||
|
||||
it('clamps tooltip placement within canvas viewport and strictly above the control bar (Issue #152)', () => {
|
||||
const dummyLines = [
|
||||
{ text: '火球', color: 'white' as const, font: 'fontexocet10' as const, align: 'center' as const },
|
||||
{ text: '当前等级: 1', color: 'white' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '火焰伤害: 6 - 14', color: 'blue' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '[下一等级] (Lv 2)', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '火焰伤害: 10 - 20', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '[协同技能加成]', color: 'blue' as const, font: 'fontexocet10' as const, align: 'center' as const },
|
||||
{ text: '• 火弹: 每个有效技能点增加 14% 伤害 (当前 +14%)', color: 'blue' as const, font: 'font8' as const, align: 'left' as const },
|
||||
{ text: '• 陨石: 每个有效技能点增加 14% 伤害', color: 'gray' as const, font: 'font8' as const, align: 'left' as const },
|
||||
]
|
||||
|
||||
const mockFont = {
|
||||
measureText: (txt: string) => txt.length * 8,
|
||||
} as any
|
||||
|
||||
// Test extreme positions across the 800x600 screen
|
||||
const testPositions = [
|
||||
{ x: 420, y: 80, w: 48, h: 48 }, // Top-left node in right-dock panel
|
||||
{ x: 700, y: 120, w: 48, h: 48 }, // Far-right node
|
||||
{ x: 450, y: 400, w: 48, h: 48 }, // Bottom node
|
||||
{ x: 500, y: 500, w: 48, h: 48 }, // Extreme bottom node near control bar
|
||||
{ x: 50, y: 200, w: 48, h: 48 }, // Far left node
|
||||
]
|
||||
|
||||
for (const pos of testPositions) {
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
|
||||
// Viewport bounds: x >= 8, x + w <= 792 (800 - 8)
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
|
||||
// Vertical bounds: y >= 8, y + h <= 552 (strictly above control bar y=552)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
|
||||
// Minimum width is 240
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
// Height matches line count * 17 + 24
|
||||
expect(placement.h).toBe(dummyLines.length * 17 + 24)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue