fix(skills): reconstruct skill tooltips to 1.13c formula and next level preview (Fixes #152)
This commit is contained in:
parent
1f1996f65b
commit
9fac33a1e2
|
|
@ -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 type { CharacterClassCode } from './classes.ts'
|
||||||
|
import { compute3BandDuration } from './engine/calc-ast.ts'
|
||||||
import {
|
import {
|
||||||
SKILLS_CATALOG,
|
SKILLS_CATALOG,
|
||||||
SKILLS_BY_ID,
|
SKILLS_BY_ID,
|
||||||
|
|
@ -480,7 +481,13 @@ export function calculateDetailedDamage(
|
||||||
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
||||||
|
|
||||||
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
|
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))) {
|
} else if (skill.minDam > 0 || skill.maxDam > 0 || (skill.minLevDam && skill.minLevDam.some(x => x > 0))) {
|
||||||
type = 'physical'
|
type = 'physical'
|
||||||
|
|
@ -658,22 +665,42 @@ export function evaluateSkillEffects(
|
||||||
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
|
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
|
||||||
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
|
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
|
||||||
|
|
||||||
// Determine duration len
|
// Determine duration len using authentic 1.13c 3-band duration scaling
|
||||||
let durationFrames = skill.eLen + (lvl - 1) * (skill.eLevLen?.[0] ?? 0)
|
let durationFrames = compute3BandDuration(
|
||||||
if (durationFrames === 0 && (skill.params.par1 ?? 0) > 0 && (skill.params.par2 ?? 0) > 0) {
|
lvl,
|
||||||
if (skill.name === 'Battle Orders' || skill.name === 'Shout' || skill.name === 'Battle Command') {
|
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 synShout = state.hardPoints[138] ?? 0
|
||||||
const synBC = state.hardPoints[155] ?? 0
|
const synBC = state.hardPoints[155] ?? 0
|
||||||
const synBO = state.hardPoints[149] ?? 0
|
const synBO = state.hardPoints[149] ?? 0
|
||||||
let synBonus = 0
|
let synBonus = 0
|
||||||
if (skill.id === 149) synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
if (skill.id === 149 || skill.name === 'Battle Orders') {
|
||||||
else if (skill.id === 138) synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
synBonus = (synShout + 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
|
||||||
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,
|
lvl,
|
||||||
params: skill.params,
|
params: skill.params,
|
||||||
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
|
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
|
||||||
|
|
@ -684,15 +711,24 @@ export function evaluateSkillEffects(
|
||||||
dm34,
|
dm34,
|
||||||
dm56,
|
dm56,
|
||||||
dm78,
|
dm78,
|
||||||
toht: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
|
toht: 0,
|
||||||
len: durationFrames,
|
len: durationFrames,
|
||||||
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
|
edmn: Math.round(evaluateLevelBand(skill.eMin, skill.eMinLev, lvl) * shiftMultiplier),
|
||||||
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
|
edmx: Math.round(evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl) * shiftMultiplier),
|
||||||
mana: calculateManaCost(skill, lvl),
|
mana: calculateManaCost(skill, lvl),
|
||||||
hardPoints: state.hardPoints,
|
hardPoints: state.hardPoints,
|
||||||
effLevels,
|
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 results: FormattedDescLine[] = []
|
||||||
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
|
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -861,7 +897,7 @@ export function buildTooltipViewModel(
|
||||||
|
|
||||||
const hard = state.hardPoints[skillId] ?? 0
|
const hard = state.hardPoints[skillId] ?? 0
|
||||||
const eff = getEffectiveLevel(state, skillId)
|
const eff = getEffectiveLevel(state, skillId)
|
||||||
const isLearned = hard > 0
|
const isLearned = eff > 0
|
||||||
const levelMet = playerLevel >= skill.reqlevel
|
const levelMet = playerLevel >= skill.reqlevel
|
||||||
const canAlloc = canAllocate(state, skillId)
|
const canAlloc = canAllocate(state, skillId)
|
||||||
|
|
||||||
|
|
@ -883,10 +919,10 @@ export function buildTooltipViewModel(
|
||||||
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
|
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
|
||||||
const currentEffects = evaluateSkillEffects(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
|
let nextLevel: TooltipViewModel['nextLevel'] = null
|
||||||
if (hard < 20) {
|
if (isLearned && hard < 20) {
|
||||||
const nextLvl = isLearned ? eff + 1 : 1
|
const nextLvl = eff + 1
|
||||||
nextLevel = {
|
nextLevel = {
|
||||||
level: nextLvl,
|
level: nextLvl,
|
||||||
manaCost: calculateManaCost(skill, nextLvl),
|
manaCost: calculateManaCost(skill, nextLvl),
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,20 @@
|
||||||
* point allocation, prerequisite checks, and rich hover tooltips.
|
* 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 {
|
export interface SkillNodeDef {
|
||||||
readonly skillId: number
|
readonly skillId: number
|
||||||
|
|
@ -74,6 +87,8 @@ export class SkillTreePanel {
|
||||||
activeTab: 0 | 1 | 2 = 2
|
activeTab: 0 | 1 | 2 = 2
|
||||||
unspentSkillPoints = 12
|
unspentSkillPoints = 12
|
||||||
bonusAllSkills = 8
|
bonusAllSkills = 8
|
||||||
|
characterClass: CharacterClassCode = 'sor'
|
||||||
|
characterLevel = 85
|
||||||
readonly hardPoints = new Map<number, number>([
|
readonly hardPoints = new Map<number, number>([
|
||||||
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
|
[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],
|
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
|
||||||
|
|
@ -107,6 +122,24 @@ export class SkillTreePanel {
|
||||||
return hard > 0 ? hard + this.bonusAllSkills : 0
|
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 {
|
canAllocate(skillId: number, charLevel = 85): boolean {
|
||||||
if (this.unspentSkillPoints <= 0) return false
|
if (this.unspentSkillPoints <= 0) return false
|
||||||
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
|
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
|
||||||
|
|
@ -317,51 +350,259 @@ export class SkillTreePanel {
|
||||||
|
|
||||||
// 6. Hover Tooltip for Skill Node
|
// 6. Hover Tooltip for Skill Node
|
||||||
if (this.hoveredSkill) {
|
if (this.hoveredSkill) {
|
||||||
const s = this.hoveredSkill
|
this.drawSkillTooltip(ctx, font)
|
||||||
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)
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(6, 6, 6, 0.94)'
|
static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
|
||||||
ctx.fillRect(tx, ty, tw, th)
|
const lines: FormattedTooltipLine[] = []
|
||||||
ctx.strokeStyle = '#c8a15a'
|
|
||||||
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1)
|
|
||||||
|
|
||||||
font.drawText(ctx, `${s.nameZh} (${s.nameEn})`, tx + tw / 2, ty + 20, {
|
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
|
||||||
font: 'fontexocet10',
|
lines.push({
|
||||||
color: 'gold',
|
text: `${vm.skill.nameZh} (${vm.skill.name})`,
|
||||||
align: 'center',
|
color: 'gold',
|
||||||
})
|
font: 'fontexocet10',
|
||||||
font.drawText(ctx, s.descZh, tx + tw / 2, ty + 38, {
|
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',
|
font: 'font8',
|
||||||
color: 'white',
|
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, `当前等级: ${eff} (基础 ${hard} + 装备 ${this.bonusAllSkills})`, tx + tw / 2, ty + 58, {
|
}
|
||||||
font: 'font8',
|
for (const prereq of vm.prereqs) {
|
||||||
color: 'blue',
|
if (!prereq.met) {
|
||||||
align: 'center',
|
lines.push({
|
||||||
})
|
text: `需要前置技能: ${prereq.nameZh}`,
|
||||||
if (s.baseDmgMax > 0) {
|
color: 'red',
|
||||||
font.drawText(ctx, `法术效果/伤害: ${minDmg} - ${maxDmg}`, tx + tw / 2, ty + 76, {
|
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'white',
|
|
||||||
align: 'center',
|
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].name).toBe('Fire Bolt')
|
||||||
expect(vm?.prereqs[0].met).toBe(false)
|
expect(vm?.prereqs[0].met).toBe(false)
|
||||||
expect(vm?.current.damage.hasDamage).toBe(true)
|
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).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_ZH,
|
||||||
LEVEL_NAMES_EN,
|
LEVEL_NAMES_EN,
|
||||||
} from '../src/game/level-names-zh.ts'
|
} 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)', () => {
|
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
|
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(sceneNameZh(null as any)).toBe('Unknown Area')
|
||||||
expect(sceneNameEn(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