Fix speedbar generic skills, skilldesc.txt tooltips, character sheet dual AR/damage and stat colors, skills.txt metadata, and Attack icon (Closes #679, #686, #687, #688, #689)

This commit is contained in:
troytt 2026-10-03 01:32:48 +00:00
parent 04bfa1a157
commit 2733a720ae
10 changed files with 1816 additions and 735 deletions

View File

@ -10,7 +10,7 @@
*/ */
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../../common/units/classes.ts'
import { import {
CANONICAL_ELITE_MODIFIERS, CANONICAL_ELITE_MODIFIERS,
FIXED_UNIQUE_BOSS_HCIDX_SET, FIXED_UNIQUE_BOSS_HCIDX_SET,
@ -21,7 +21,16 @@ import {
rollUniqueMonsterNameKeys, rollUniqueMonsterNameKeys,
type Difficulty, type Difficulty,
} from '../../common/units/monsters-data.ts' } from '../../common/units/monsters-data.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' import {
calculateDetailedDamage,
calculateManaCost,
createInitialState,
} from '../../common/skills/skill-calc-engine.ts'
import {
canUseSkillWithWeapon,
getSkillName,
getSkillTableMeta,
} from '../../common/skills/skills-meta.ts'
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts' import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
import { ClickButtonId } from '../../netproto/index.ts' import { ClickButtonId } from '../../netproto/index.ts'
import type { import type {
@ -32,7 +41,12 @@ import type {
} from '../../netproto/index.ts' } from '../../netproto/index.ts'
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts' import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
import type { BaseStatKey } from '../ui/character-sheet.ts' import type { BaseStatKey } from '../ui/character-sheet.ts'
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts' import {
createCanonicalGenericSkillEntry,
isAuraSkill,
isLeftUsableSkill,
isPassiveSkill,
} from '../ui/hotkeys.ts'
import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts' import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
import { import {
type EquipSlotId, type EquipSlotId,
@ -129,7 +143,15 @@ export function itemDataToUiInventoryItem(
item: ItemData, item: ItemData,
socketChildren?: readonly ItemData[], socketChildren?: readonly ItemData[],
): UiInventoryItem { ): UiInventoryItem {
return onlineItemDataToUiInventoryItem(item, socketChildren) const ui = onlineItemDataToUiInventoryItem(item, socketChildren)
const variant =
(item as { graphicIndex?: number; variant?: number }).graphicIndex ??
(item as { variant?: number }).variant ??
(ui as { variant?: number }).variant
if (variant !== undefined) {
;(ui as { variant?: number }).variant = variant
}
return ui
} }
export interface HudModelOptions { export interface HudModelOptions {
@ -633,68 +655,51 @@ export class HudModel implements HudCommandSink {
attrs.xp = self.experience attrs.xp = self.experience
attrs.nextLevelXp = nextLevelExperience(self.level) attrs.nextLevelXp = nextLevelExperience(self.level)
attrs.str = stats.get(D2StatId.Strength) attrs.str = stats.get(D2StatId.Strength)
attrs.baseStr = stats.getBase(D2StatId.Strength)
attrs.dex = stats.get(D2StatId.Dexterity) attrs.dex = stats.get(D2StatId.Dexterity)
attrs.baseDex = stats.getBase(D2StatId.Dexterity)
attrs.vit = stats.get(D2StatId.Vitality) attrs.vit = stats.get(D2StatId.Vitality)
attrs.baseVit = stats.getBase(D2StatId.Vitality)
attrs.ene = stats.get(D2StatId.Energy) attrs.ene = stats.get(D2StatId.Energy)
attrs.baseEne = stats.getBase(D2StatId.Energy)
attrs.statPoints = stats.get(D2StatId.StatPts) attrs.statPoints = stats.get(D2StatId.StatPts)
attrs.baseMaxHp = self.maxLife const rawBaseMaxHp = stats.getBase(D2StatId.MaxHp)
attrs.baseMaxMana = self.maxMana attrs.baseMaxHp = rawBaseMaxHp > 0 ? Math.floor(rawBaseMaxHp / 256) : self.maxLife
attrs.baseMaxStamina = self.maxStamina const rawBaseMaxMana = stats.getBase(D2StatId.MaxMana)
attrs.baseMaxMana = rawBaseMaxMana > 0 ? Math.floor(rawBaseMaxMana / 256) : self.maxMana
const rawBaseMaxStamina = stats.getBase(D2StatId.MaxStamina)
attrs.baseMaxStamina = rawBaseMaxStamina > 0 ? Math.floor(rawBaseMaxStamina / 256) : self.maxStamina
// Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known. // Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known.
const derived = self.charClass === null const classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
const derived = classCode === null
? null ? null
: computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty, this.isRunMode) : computeCharacterSheetDerived(classCode, stats, this.world.difficulty, this.isRunMode)
const charStats = classCode === null ? null : CANONICAL_CHARSTATS[classCode]
attrs.defense = derived?.defense ?? 0 attrs.defense = derived?.defense ?? 0
attrs.baseDefense = classCode === null
? 0
: Math.max(0, Math.floor(attrs.baseDex / 4) + stats.getBase(D2StatId.ArmorClass))
attrs.attackRating = derived?.attackRating ?? 0 attrs.attackRating = derived?.attackRating ?? 0
attrs.baseAttackRating = classCode === null
? 0
: (attrs.baseDex - 7) * 5 + (charStats?.toHitFactor ?? 0) + stats.getBase(D2StatId.ToHit)
attrs.rightAttackRating = attrs.attackRating
attrs.baseRightAttackRating = attrs.baseAttackRating
attrs.fireRes = derived?.fireRes ?? 0 attrs.fireRes = derived?.fireRes ?? 0
attrs.coldRes = derived?.coldRes ?? 0 attrs.coldRes = derived?.coldRes ?? 0
attrs.lightRes = derived?.lightRes ?? 0 attrs.lightRes = derived?.lightRes ?? 0
attrs.poisRes = derived?.poisRes ?? 0 attrs.poisRes = derived?.poisRes ?? 0
attrs.maxFireRes = Math.min(95, 75 + stats.get(40))
attrs.maxLightRes = Math.min(95, 75 + stats.get(42))
attrs.maxColdRes = Math.min(95, 75 + stats.get(44))
attrs.maxPoisonRes = Math.min(95, 75 + stats.get(46))
// 3. Gold (stat 14 inventory gold, stat 15 stash gold). // 3. Gold (stat 14 inventory gold, stat 15 stash gold).
hud.inventory.gold = stats.get(D2StatId.Gold) hud.inventory.gold = stats.get(D2StatId.Gold)
hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank) hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
// 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync. // 4. Containers (equipment & inventory) synced before speedbar weapon/scroll checks.
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
hud.skillTree.characterLevel = self.level
hud.skillTree.hardPoints.clear()
hud.skillTree.bonusLevels.clear()
hud.hotkeys.setAvailableSkills([])
for (const [skillId, sl] of self.skillEntries.entries()) {
const hard = Math.max(0, sl.baseLevel ?? sl.level)
const bonus = Math.max(0, sl.bonus ?? 0)
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
if (isPassiveSkill(skillId)) continue
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) continue
const effSkillLevel = hard + bonus
const charges = self.skillCharges?.get(skillId)
hud.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
nameZh: catalogEntry.nameZh,
level: effSkillLevel,
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
leftUsable: isLeftUsableSkill(skillId),
rightUsable: true,
isAura: isAuraSkill(skillId),
...(charges !== undefined ? { charges } : {}),
})
if (charges !== undefined) {
hud.hotkeys.setSkillCharges(skillId, charges)
}
}
for (const [slot, binding] of this.world.skillHotkeys.entries()) {
const fKey = `F${slot + 1}`
hud.hotkeys.bindHotkey(fKey, binding.hand, binding.skill)
}
hud.hotkeys.leftSkillId = self.leftSkill
hud.hotkeys.rightSkillId = self.rightSkill
hud.inventory.weaponSwapSet = self.weaponSet === 1 ? 1 : 0 hud.inventory.weaponSwapSet = self.weaponSet === 1 ? 1 : 0
// 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) { for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
delete hud.inventory.equipped[key] delete hud.inventory.equipped[key]
} }
@ -723,6 +728,123 @@ export class HudModel implements HudCommandSink {
})) }))
hud.inventory.gridItems = placements hud.inventory.gridItems = placements
// 5. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) + generic/item skills on every sync.
const eqW1 = hud.inventory.equipped.weapon1
const eqW2 = hud.inventory.equipped.weapon2
const weaponState = {
rightHandCode: eqW1?.code ?? null,
rightHandType: ((eqW1 as any)?.rawItem?.base?.type ?? (eqW1 as any)?.subType ?? null) as string | null,
leftHandCode: eqW2?.code ?? null,
leftHandType: ((eqW2 as any)?.rawItem?.base?.type ?? (eqW2 as any)?.subType ?? null) as string | null,
}
const scrollCounts = new Map<number, number>()
for (const item of this.world.containers.inventory.values()) {
const code = (item.code ?? '').trim().toLowerCase()
const scrollSkillId =
code === 'isc' ? 217 : code === 'ibk' ? 218 : code === 'tsc' ? 219 : code === 'tbk' ? 220 : null
if (scrollSkillId === null) continue
const statQty = item.stats?.find((s) => (s.statId ?? (s as any).id) === 70)?.value
const qty =
typeof (item as any).quantity === 'number'
? (item as any).quantity
: typeof (item as any).stack === 'number'
? (item as any).stack
: statQty !== undefined
? statQty
: 1
if (qty > 0) {
scrollCounts.set(scrollSkillId, (scrollCounts.get(scrollSkillId) ?? 0) + qty)
}
}
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
hud.skillTree.characterLevel = self.level
hud.skillTree.hardPoints.clear()
hud.skillTree.bonusLevels.clear()
hud.hotkeys.setAvailableSkills([])
for (const [skillId, sl] of self.skillEntries.entries()) {
const tableMeta = getSkillTableMeta(skillId)
if (!tableMeta || !tableMeta.skilldesc) {
throw new Error(`[hud-model] Missing canonical skills.txt/skilldesc.txt metadata for skillId=${skillId}`)
}
const hard = Math.max(0, sl.baseLevel ?? sl.level)
const bonus = Math.max(0, sl.bonus ?? 0)
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
if (isPassiveSkill(skillId) || tableMeta.listRow < 0) continue
if (!canUseSkillWithWeapon(skillId, weaponState)) continue
const effSkillLevel = hard + bonus
const charges = self.skillCharges?.get(skillId) ?? scrollCounts.get(skillId)
const genericEntry = createCanonicalGenericSkillEntry(skillId, Math.max(1, effSkillLevel), charges)
if (genericEntry) {
hud.hotkeys.addOrUpdateSkill(genericEntry)
if (charges !== undefined) {
hud.hotkeys.setSkillCharges(skillId, charges)
}
continue
}
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) continue
hud.hotkeys.addOrUpdateSkill({
skillId,
name: getSkillName(skillId, 'ENG'),
nameZh: getSkillName(skillId, 'CHI'),
level: effSkillLevel,
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
leftUsable: isLeftUsableSkill(skillId),
rightUsable: true,
isAura: isAuraSkill(skillId),
...(charges !== undefined ? { charges } : {}),
})
if (charges !== undefined) {
hud.hotkeys.setSkillCharges(skillId, charges)
}
}
const hasEquippedThrowing =
Boolean(weaponState.rightHandCode || weaponState.leftHandCode) &&
canUseSkillWithWeapon(2, weaponState)
if (self.skillEntries.size > 0 || scrollCounts.size > 0 || hasEquippedThrowing) {
if (!hud.hotkeys.availableSkills.some((s) => s.skillId === 0)) {
const atkEntry = createCanonicalGenericSkillEntry(0, 1)
if (atkEntry) hud.hotkeys.addOrUpdateSkill(atkEntry)
}
if (hasEquippedThrowing && !hud.hotkeys.availableSkills.some((s) => s.skillId === 2)) {
const throwEntry = createCanonicalGenericSkillEntry(2, 1)
if (throwEntry) hud.hotkeys.addOrUpdateSkill(throwEntry)
}
if (
self.charClass === 4 &&
Boolean(weaponState.leftHandCode) &&
canUseSkillWithWeapon(4, weaponState) &&
!hud.hotkeys.availableSkills.some((s) => s.skillId === 4)
) {
const lhtEntry = createCanonicalGenericSkillEntry(4, 1)
if (lhtEntry) hud.hotkeys.addOrUpdateSkill(lhtEntry)
}
for (const [scrollSkillId, qty] of scrollCounts.entries()) {
const scrollEntry = createCanonicalGenericSkillEntry(scrollSkillId, 1, qty)
if (scrollEntry) {
hud.hotkeys.addOrUpdateSkill(scrollEntry)
hud.hotkeys.setSkillCharges(scrollSkillId, qty)
}
}
}
for (const [slot, binding] of this.world.skillHotkeys.entries()) {
const fKey = `F${slot + 1}`
hud.hotkeys.bindHotkey(fKey, binding.hand, binding.skill)
}
hud.hotkeys.leftSkillId = self.leftSkill
hud.hotkeys.rightSkillId = self.rightSkill
const tblLang = (hud.charSheet.lang as string) === 'en' || hud.charSheet.lang === 'ENG' ? 'ENG' : 'CHI'
attrs.leftSkillName = getSkillName(self.leftSkill, tblLang)
attrs.rightSkillName = getSkillName(self.rightSkill, tblLang)
attrs.leftSkillDmg = this.computeSkillDamageString(self.leftSkill, hud)
attrs.rightSkillDmg = this.computeSkillDamageString(self.rightSkill, hud)
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({ hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
item: this.toUiItem(item), item: this.toUiItem(item),
col: item.gridX ?? item.x ?? 0, col: item.gridX ?? item.x ?? 0,
@ -1068,6 +1190,55 @@ export class HudModel implements HudCommandSink {
hud.syncPublishedState() hud.syncPublishedState()
} }
private computeSkillDamageString(skillId: number, hud: HudManager): string {
const strBonus = 1 + (hud.charSheet.attrs.str || 0) / 100
if (skillId === 0 || skillId === 2 || skillId === 4) {
const w = skillId === 4 ? hud.inventory.equipped.weapon2 : hud.inventory.equipped.weapon1
if (w) {
const base = (w as any)?.rawItem?.base
if ((skillId === 2 || skillId === 4) && base?.minMisDam && base?.maxMisDam) {
const minDmg = Math.max(1, Math.floor(Number(base.minMisDam) * strBonus))
const maxDmg = Math.max(minDmg + 1, Math.floor(Number(base.maxMisDam) * strBonus))
return `${minDmg}-${maxDmg}`
}
const dmgText = w.damageEn ?? w.damage ?? ''
const m = /(\d+)\s*(?:-|to)\s*(\d+)/.exec(dmgText)
if (m) {
const minDmg = Math.max(1, Math.floor(Number(m[1]) * strBonus))
const maxDmg = Math.max(minDmg + 1, Math.floor(Number(m[2]) * strBonus))
return `${minDmg}-${maxDmg}`
}
}
if (skillId === 0) {
const minDmg = Math.max(1, Math.floor(1 * strBonus))
const maxDmg = Math.max(2, Math.floor(2 * strBonus))
return `${minDmg}-${maxDmg}`
}
return '--'
}
const def = SKILLS_BY_ID[skillId]
if (!def) return '--'
const sl = this.world.self.skillEntries.get(skillId)
const effLvl = sl
? Math.max(0, (sl.baseLevel ?? sl.level) + (sl.bonus ?? 0))
: hud.skillTree.getEffectiveLevel(skillId)
if (effLvl <= 0) return '--'
const calcState = createInitialState((hud.currentClass ?? def.classCode ?? 'sor') as any)
for (const [id, pts] of hud.skillTree.hardPoints.entries()) {
calcState.hardPoints[id] = pts
}
for (const [id, bon] of hud.skillTree.bonusLevels.entries()) {
calcState.specificSoftPoints[id] = bon
}
const dmg = calculateDetailedDamage(calcState, def, effLvl)
if (dmg.hasDamage && dmg.finalMax > 0) {
return `${Math.floor(dmg.finalMin)}-${Math.floor(dmg.finalMax)}`
}
return '--'
}
private syncNpcInteraction(hud: HudManager): void { private syncNpcInteraction(hud: HudManager): void {
const npc = this.world.npcInteraction const npc = this.world.npcInteraction
const prev = this.prevNpc const prev = this.prevNpc
@ -1093,7 +1264,7 @@ export class HudModel implements HudCommandSink {
(this.world.merc as any)?.name ?? (this.world.merc as any)?.name ??
((this.world.merc as any)?.nameId ? `Merc #${(this.world.merc as any).nameId}` : '') ((this.world.merc as any)?.nameId ? `Merc #${(this.world.merc as any).nameId}` : '')
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1) const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, { const menuCtx = {
unidentifiedItemCount, unidentifiedItemCount,
hasUnidentifiedItems: unidentifiedItemCount > 0, hasUnidentifiedItems: unidentifiedItemCount > 0,
hasDeadMercenary, hasDeadMercenary,
@ -1101,7 +1272,33 @@ export class HudModel implements HudCommandSink {
deadMercenaryName, deadMercenaryName,
playerLevel, playerLevel,
questFlags: this.world.quests, questFlags: this.world.quests,
}) }
if (wp.npcMenu && wp.npcMenu.npcId === npc.npcId && npc.npcId === prev.npcId && !wp.npcMenu.talking) {
if (typeof (wp as any).buildNpcMenuOptions === 'function') {
const nextOptions = (wp as any).buildNpcMenuOptions(desc, menuCtx)
wp.npcMenu.options = nextOptions
if ((wp.npcMenu.hoveredOptionIdx ?? -1) >= nextOptions.length) {
wp.npcMenu.hoveredOptionIdx = Math.max(-1, nextOptions.length - 1)
}
}
} else {
const npcUnit = this.world.getUnit('monster', npc.npcId)
let anchorX = 400
let anchorY = 150
if (
npcUnit &&
(npcUnit.x !== 0 || npcUnit.y !== 0) &&
(this.world.self.x !== 0 || this.world.self.y !== 0)
) {
const dx = npcUnit.x - this.world.self.x
const dy = npcUnit.y - this.world.self.y
const npcScreenX = Math.round(400 + (dx - dy) * 16)
const npcScreenY = Math.round(300 + (dx + dy) * 8)
anchorX = Math.max(40, Math.min(760, npcScreenX))
anchorY = Math.max(20, npcScreenY - 150)
}
;(wp as any).openNpcMenu(desc, anchorX, anchorY, npc.npcId, menuCtx)
}
} }
} }
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) { if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {

View File

@ -8,13 +8,48 @@
* and Fire / Cold / Lightning / Poison resistances. * and Fire / Cold / Lightning / Poison resistances.
*/ */
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer, D2ColorCode } from './font.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts' import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts'
import type { CharacterClassCode } from '../../common/units/classes.ts' import type { CharacterClassCode } from '../../common/units/classes.ts'
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts' import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
import { getSkillName } from '../../common/skills/skills-meta.ts'
export type { BaseStatKey, CharacterAttributes } export type { BaseStatKey, CharacterAttributes }
export interface CharacterSheetAttributes extends CharacterAttributes {
baseStr?: number | undefined
baseDex?: number | undefined
baseVit?: number | undefined
baseEne?: number | undefined
baseDefense?: number | undefined
baseAttackRating?: number | undefined
rightAttackRating?: number | undefined
baseRightAttackRating?: number | undefined
leftSkillName?: string | undefined
rightSkillName?: string | undefined
leftSkillDmg?: string | undefined
rightSkillDmg?: string | undefined
leftSkillDmgColor?: D2ColorCode | undefined
rightSkillDmgColor?: D2ColorCode | undefined
maxFireRes?: number | undefined
maxColdRes?: number | undefined
maxLightRes?: number | undefined
maxPoisonRes?: number | undefined
}
/**
* Resolves canonical D2 1.13c stat value color (`D2Client.dll`):
* - `current > base` -> `'blue'`
* - `current < base` -> `'red'`
* - `current === base` (or `base === undefined`) -> `'white'`
*/
export function resolveStatValueColor(current: number, base?: number): D2ColorCode {
if (base === undefined) return 'white'
if (current > base) return 'blue'
if (current < base) return 'red'
return 'white'
}
export const CHAR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432 } as const export const CHAR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432 } as const
export const CHAR_CLOSE_BTN_BOUNDS = { x: 80 + 128, y: 60 + 388, w: 32, h: 32 } as const export const CHAR_CLOSE_BTN_BOUNDS = { x: 80 + 128, y: 60 + 388, w: 32, h: 32 } as const
@ -29,7 +64,7 @@ export const STAT_ALLOC_BUTTONS: Readonly<Record<BaseStatKey, { x: number; y: nu
* Neutral, not-ready character attributes. Every field is filled from server packets * Neutral, not-ready character attributes. Every field is filled from server packets
* (`ClientWorld.self`) by `HudModel.syncFromWorld()`; nothing is pre-seeded with demo values. * (`ClientWorld.self`) by `HudModel.syncFromWorld()`; nothing is pre-seeded with demo values.
*/ */
export function createEmptyCharacterAttributes(): CharacterAttributes { export function createEmptyCharacterAttributes(): CharacterSheetAttributes {
return { return {
name: '', name: '',
classNameZh: '', classNameZh: '',
@ -59,7 +94,7 @@ export class CharacterSheetPanel {
lang: TblLang = getTblLang() lang: TblLang = getTblLang()
/** `null` until the server assigns the character class. */ /** `null` until the server assigns the character class. */
classCode: CharacterClassCode | null = null classCode: CharacterClassCode | null = null
attrs: CharacterAttributes = createEmptyCharacterAttributes() attrs: CharacterSheetAttributes = createEmptyCharacterAttributes()
handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean { handleClick(logicalX: number, logicalY: number, onStatAllocated?: (key: BaseStatKey) => void): boolean {
if (!this.visible) return false if (!this.visible) return false
@ -140,27 +175,57 @@ export class CharacterSheetPanel {
}) })
// 4. Level (x=12..53, mid=33), Experience (x=66..181, mid=124), Next Level (x=194..309, mid=252) // 4. Level (x=12..53, mid=33), Experience (x=66..181, mid=124), Next Level (x=194..309, mid=252)
font.drawText(ctx, isEn ? 'Level' : '等级', ox + 33, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' }) font.drawText(ctx, requireTbl('strchrlvl', this.lang), ox + 33, oy + 46, {
font.drawText(ctx, String(this.attrs.level), ox + 33, oy + 62, { font: 'fontexocet10', color: 'white', align: 'center' }) font: isEn ? 'font6' : 'font8',
color: 'tan',
align: 'center',
})
font.drawText(ctx, String(this.attrs.level), ox + 33, oy + 62, {
font: 'fontexocet10',
color: 'white',
align: 'center',
})
font.drawText(ctx, isEn ? 'Experience' : '当前经验值', ox + 124, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' }) font.drawText(ctx, requireTbl('strchrexp', this.lang), ox + 124, oy + 46, {
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, { font: 'font6', color: 'white', align: 'center' }) font: isEn ? 'font6' : 'font8',
color: 'tan',
align: 'center',
})
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, {
font: 'font6',
color: 'white',
align: 'center',
})
font.drawText(ctx, isEn ? 'Next Level' : '升级所需经验', ox + 252, oy + 46, { font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center' }) font.drawText(ctx, requireTbl('strchrnxtlvl', this.lang), ox + 252, oy + 46, {
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { font: 'font6', color: 'white', align: 'center' }) font: isEn ? 'font6' : 'font8',
color: 'tan',
align: 'center',
})
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, {
font: 'font6',
color: 'white',
align: 'center',
})
// 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`) // 5. Four Primary Attributes (`Str`, `Dex`, `Vit`, `Ene`)
const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [ const statRows: readonly {
{ key: 'str', label: isEn ? 'Strength' : '力量', y: 94, val: this.attrs.str }, key: BaseStatKey
{ key: 'dex', label: isEn ? 'Dexterity' : '敏捷', y: 156, val: this.attrs.dex }, label: string
{ key: 'vit', label: isEn ? 'Vitality' : '体力', y: 242, val: this.attrs.vit }, y: number
{ key: 'ene', label: isEn ? 'Energy' : '精力', y: 304, val: this.attrs.ene }, val: number
base: number | undefined
}[] = [
{ key: 'str', label: requireTbl('strchrstr', this.lang), y: 94, val: this.attrs.str, base: this.attrs.baseStr },
{ key: 'dex', label: requireTbl('strchrdex', this.lang), y: 156, val: this.attrs.dex, base: this.attrs.baseDex },
{ key: 'vit', label: requireTbl('strchrvit', this.lang), y: 242, val: this.attrs.vit, base: this.attrs.baseVit },
{ key: 'ene', label: requireTbl('strchreng', this.lang), y: 304, val: this.attrs.ene, base: this.attrs.baseEne },
] ]
for (const r of statRows) { for (const r of statRows) {
font.drawText(ctx, r.label, ox + 18, oy + r.y, { font: 'font8', color: 'tan' }) font.drawText(ctx, r.label, ox + 18, oy + r.y, { font: 'font8', color: 'tan' })
font.drawText(ctx, String(r.val), ox + 95, oy + r.y, { font.drawText(ctx, String(r.val), ox + 95, oy + r.y, {
font: 'fontexocet10', font: 'fontexocet10',
color: 'blue', color: resolveStatValueColor(r.val, r.base),
align: 'center', align: 'center',
}) })
if (this.attrs.statPoints > 0) { if (this.attrs.statPoints > 0) {
@ -175,26 +240,20 @@ export class CharacterSheetPanel {
} }
} }
// Remaining Stat Points // Remaining Stat Points (`strchrstat` + `strchrrema`)
if (this.attrs.statPoints > 0) { if (this.attrs.statPoints > 0) {
if (isEn) { const statPtsLabel = requireTbl('strchrstat', this.lang)
font.drawText(ctx, 'Stat Points', ox + 56, oy + 363, { const remLabel = requireTbl('strchrrema', this.lang)
font: 'font6', font.drawText(ctx, statPtsLabel, ox + 56, oy + 363, {
color: 'red', font: isEn ? 'font6' : 'font8',
align: 'center', color: 'red',
}) align: 'center',
font.drawText(ctx, 'Remaining', ox + 56, oy + 371, { })
font: 'font6', font.drawText(ctx, remLabel, ox + 56, oy + 373, {
color: 'red', font: isEn ? 'font6' : 'font8',
align: 'center', color: 'red',
}) align: 'center',
} else { })
font.drawText(ctx, '剩余属性点', ox + 56, oy + 368, {
font: 'font8',
color: 'red',
align: 'center',
})
}
font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 368, { font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 368, {
font: 'fontexocet10', font: 'fontexocet10',
color: 'red', color: 'red',
@ -202,140 +261,165 @@ export class CharacterSheetPanel {
}) })
} }
// 6. Combat Ratings, Stamina, Life, Mana // 6. Combat Ratings (Left/Right Skill Damage, Left/Right Attack Rating, Defense), Stamina, Life, Mana
if (isEn) { const dmgLabel = requireTbl('strchrskm', this.lang)
font.drawText(ctx, 'Left Attack', ox + 214, oy + 90, { const defaultAtkName = getSkillName(0, this.lang)
font: 'font6', const leftSkillLabel = this.attrs.leftSkillName || defaultAtkName
color: 'tan', const rightSkillLabel = this.attrs.rightSkillName || defaultAtkName
align: 'center', const leftDmgStr = liveRuntime.leftSkillDmg || this.attrs.leftSkillDmg || '1-2'
}) const rightDmgStr = liveRuntime.rightSkillDmg || this.attrs.rightSkillDmg || '1-2'
font.drawText(ctx, 'Damage', ox + 214, oy + 98, {
font: 'font6', font.drawText(ctx, leftSkillLabel, ox + 214, oy + 89, {
color: 'tan', font: isEn ? 'font6' : 'font8',
align: 'center', color: 'tan',
}) align: 'center',
} else { })
font.drawText(ctx, '左键技能伤害', ox + 164, oy + 94, { font.drawText(ctx, dmgLabel, ox + 214, oy + 98, {
font: 'font8', font: isEn ? 'font6' : 'font8',
color: 'tan', color: 'tan',
}) align: 'center',
} })
font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 94, { font.drawText(ctx, leftDmgStr, ox + 288, oy + 94, {
font: 'font6', font: 'font6',
color: 'blue', color: this.attrs.leftSkillDmgColor ?? 'white',
align: 'center', align: 'center',
}) })
if (isEn) { font.drawText(ctx, rightSkillLabel, ox + 214, oy + 113, {
font.drawText(ctx, 'Right Attack', ox + 214, oy + 114, { font: isEn ? 'font6' : 'font8',
font: 'font6', color: 'tan',
color: 'tan', align: 'center',
align: 'center', })
}) font.drawText(ctx, dmgLabel, ox + 214, oy + 122, {
font.drawText(ctx, 'Damage', ox + 214, oy + 122, { font: isEn ? 'font6' : 'font8',
font: 'font6', color: 'tan',
color: 'tan', align: 'center',
align: 'center', })
}) font.drawText(ctx, rightDmgStr, ox + 288, oy + 118, {
} else {
font.drawText(ctx, '右键技能伤害', ox + 164, oy + 118, {
font: 'font8',
color: 'tan',
})
}
font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 118, {
font: 'font6', font: 'font6',
color: 'blue', color: this.attrs.rightSkillDmgColor ?? 'white',
align: 'center', align: 'center',
}) })
font.drawText(ctx, isEn ? 'Attack Rating' : '命中率', ox + 166, oy + 156, { font: 'font8', color: 'tan' }) // Three boxes next to Dexterity (y=138..159 Left AR, y=164..185 Right AR, y=192..213 Defense)
font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 156, { const arTemplate = requireTbl('strchratr', this.lang)
const arParts = arTemplate.replace('%s', '').trim().split('\n').filter(Boolean)
const arLabelText = arParts[arParts.length - 1] ?? requireTbl('strchratr', this.lang)
font.drawText(ctx, arLabelText, ox + 166, oy + 153, {
font: isEn ? 'font6' : 'font8',
color: 'tan',
})
// Box 1: Left-hand Attack Rating (y=138..159, baseline oy + 153)
font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 153, {
font: 'font8', font: 'font8',
color: 'white', color: resolveStatValueColor(this.attrs.attackRating, this.attrs.baseAttackRating),
align: 'center', align: 'center',
}) })
// Box 2: Right-hand Attack Rating (y=164..185, baseline oy + 178)
font.drawText(ctx, isEn ? 'Defense' : '防御力', ox + 166, oy + 204, { font: 'font8', color: 'tan' }) const rightAr = this.attrs.rightAttackRating ?? this.attrs.attackRating
const baseRightAr = this.attrs.baseRightAttackRating ?? this.attrs.baseAttackRating
font.drawText(ctx, String(rightAr), ox + 290, oy + 178, {
font: 'font8',
color: resolveStatValueColor(rightAr, baseRightAr),
align: 'center',
})
// Box 3: Defense (y=192..213, baseline oy + 204)
font.drawText(ctx, requireTbl('strchrdef', this.lang), ox + 166, oy + 204, {
font: 'font8',
color: 'tan',
})
font.drawText(ctx, String(this.attrs.defense), ox + 290, oy + 204, { font.drawText(ctx, String(this.attrs.defense), ox + 290, oy + 204, {
font: 'font8', font: 'font8',
color: 'blue', color: resolveStatValueColor(this.attrs.defense, this.attrs.baseDefense),
align: 'center', align: 'center',
}) })
font.drawText(ctx, isEn ? 'Stamina' : '耐力', ox + 166, oy + 242, { font: 'font8', color: 'tan' }) // Stamina, Life, Mana
font.drawText(ctx, `${Math.round(liveRuntime.maxStamina)}`, ox + 251, oy + 242, { const maxStam = Math.round(liveRuntime.maxStamina)
const curStam = Math.round(liveRuntime.stamina)
const baseMaxStam = this.attrs.baseMaxStamina > 0 ? Math.round(this.attrs.baseMaxStamina) : undefined
font.drawText(ctx, requireTbl('strchrstm', this.lang), ox + 166, oy + 242, {
font: 'font8', font: 'font8',
color: 'white', color: 'tan',
})
font.drawText(ctx, `${maxStam}`, ox + 251, oy + 242, {
font: 'font8',
color: resolveStatValueColor(maxStam, baseMaxStam),
align: 'center', align: 'center',
}) })
font.drawText(ctx, `${Math.round(liveRuntime.stamina)}`, ox + 291, oy + 242, { font.drawText(ctx, `${curStam}`, ox + 291, oy + 242, {
font: 'font8', font: 'font8',
color: 'white', color: 'white',
align: 'center', align: 'center',
}) })
font.drawText(ctx, isEn ? 'Life' : '生命', ox + 166, oy + 266, { font: 'font8', color: 'tan' }) const maxHp = Math.round(liveRuntime.maxHp)
font.drawText(ctx, `${Math.round(liveRuntime.maxHp)}`, ox + 251, oy + 266, { const curHp = Math.round(liveRuntime.hp)
const baseMaxHp = this.attrs.baseMaxHp > 0 ? Math.round(this.attrs.baseMaxHp) : undefined
font.drawText(ctx, requireTbl('strchrlif', this.lang), ox + 166, oy + 266, {
font: 'font8', font: 'font8',
color: 'blue', color: 'tan',
})
font.drawText(ctx, `${maxHp}`, ox + 251, oy + 266, {
font: 'font8',
color: resolveStatValueColor(maxHp, baseMaxHp),
align: 'center', align: 'center',
}) })
font.drawText(ctx, `${Math.round(liveRuntime.hp)}`, ox + 291, oy + 266, { font.drawText(ctx, `${curHp}`, ox + 291, oy + 266, {
font: 'font8', font: 'font8',
color: 'white', color: 'white',
align: 'center', align: 'center',
}) })
font.drawText(ctx, isEn ? 'Mana' : '法力', ox + 166, oy + 304, { font: 'font8', color: 'tan' }) const maxMana = Math.round(liveRuntime.maxMana)
font.drawText(ctx, `${Math.round(liveRuntime.maxMana)}`, ox + 251, oy + 304, { const curMana = Math.round(liveRuntime.mana)
const baseMaxMana = this.attrs.baseMaxMana > 0 ? Math.round(this.attrs.baseMaxMana) : undefined
font.drawText(ctx, requireTbl('strchrman', this.lang), ox + 166, oy + 304, {
font: 'font8', font: 'font8',
color: 'blue', color: 'tan',
})
font.drawText(ctx, `${maxMana}`, ox + 251, oy + 304, {
font: 'font8',
color: resolveStatValueColor(maxMana, baseMaxMana),
align: 'center', align: 'center',
}) })
font.drawText(ctx, `${Math.round(liveRuntime.mana)}`, ox + 291, oy + 304, { font.drawText(ctx, `${curMana}`, ox + 291, oy + 304, {
font: 'font8', font: 'font8',
color: 'white', color: 'white',
align: 'center', align: 'center',
}) })
// 7. Four Elemental Resistances // 7. Four Elemental Resistances (`strchrfir`, `strchrcol`, `strchrlit`, `strchrpos`)
const resRows = [ const resRows = [
{ labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 344, val: this.attrs.fireRes }, { tblKey: 'strchrfir', y: 344, val: this.attrs.fireRes, maxCap: this.attrs.maxFireRes ?? 75 },
{ labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 368, val: this.attrs.coldRes }, { tblKey: 'strchrcol', y: 368, val: this.attrs.coldRes, maxCap: this.attrs.maxColdRes ?? 75 },
{ labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 392, val: this.attrs.lightRes }, { tblKey: 'strchrlit', y: 392, val: this.attrs.lightRes, maxCap: this.attrs.maxLightRes ?? 75 },
{ labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 416, val: this.attrs.poisRes }, { tblKey: 'strchrpos', y: 416, val: this.attrs.poisRes, maxCap: this.attrs.maxPoisonRes ?? 75 },
] as const ] as const
for (const res of resRows) { for (const res of resRows) {
if (isEn) { const rawLabel = requireTbl(res.tblKey, this.lang)
if (res.labelEn.length === 2) { const labelLines = rawLabel.split('\n').map(s => s.trim()).filter(Boolean)
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y - 4, { if (labelLines.length >= 2) {
font: 'font6', font.drawText(ctx, labelLines[0]!, ox + 222, oy + res.y - 4, {
color: 'tan', font: isEn ? 'font6' : 'font8',
align: 'center', color: 'tan',
}) align: 'center',
font.drawText(ctx, res.labelEn[1], ox + 222, oy + res.y + 4, { })
font: 'font6', font.drawText(ctx, labelLines[1]!, ox + 222, oy + res.y + 4, {
color: 'tan', font: isEn ? 'font6' : 'font8',
align: 'center', color: 'tan',
}) align: 'center',
} else { })
font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y, {
font: 'font6',
color: 'tan',
align: 'center',
})
}
} else { } else {
font.drawText(ctx, res.labelZh, ox + 222, oy + res.y, { font.drawText(ctx, labelLines[0] ?? rawLabel, ox + 222, oy + res.y, {
font: 'font8', font: isEn ? 'font6' : 'font8',
color: 'tan', color: 'tan',
align: 'center', align: 'center',
}) })
} }
font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, { font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, {
font: 'fontexocet10', font: 'fontexocet10',
color: res.val >= 75 ? 'gold' : res.val < 0 ? 'red' : 'white', color: res.val >= res.maxCap ? 'gold' : res.val < 0 ? 'red' : 'white',
align: 'center', align: 'center',
}) })
} }

View File

@ -11,7 +11,7 @@
import type { D2FontRenderer, D2ColorCode } from './font.ts' import type { D2FontRenderer, D2ColorCode } from './font.ts'
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import type { TooltipViewModel } from '../../common/skills/skill-calc-engine.ts' import { buildTooltipViewModel, createInitialState, type TooltipViewModel } from '../../common/skills/skill-calc-engine.ts'
import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts' import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts'
import type { TblLang } from '../i18n/lang.ts' import type { TblLang } from '../i18n/lang.ts'
@ -22,6 +22,15 @@ import {
isLeftUsableSkill, isLeftUsableSkill,
PALADIN_AURA_SKILL_IDS, PALADIN_AURA_SKILL_IDS,
isAuraSkill, isAuraSkill,
SKILLS_TABLE_META,
SKILL_TBL_STRINGS,
GENERIC_SKILL_TBL_STRINGS,
getSkillName,
getGenericSkillLongDesc,
getCanonicalSkillListRow,
getCanonicalSkillIconCel,
canUseSkillWithWeapon,
type WeaponEquipCheckState,
type HotkeySkillEntry, type HotkeySkillEntry,
} from '../../common/skills/skills-meta.ts' } from '../../common/skills/skills-meta.ts'
@ -32,6 +41,8 @@ export {
isLeftUsableSkill, isLeftUsableSkill,
PALADIN_AURA_SKILL_IDS, PALADIN_AURA_SKILL_IDS,
isAuraSkill, isAuraSkill,
canUseSkillWithWeapon,
type WeaponEquipCheckState,
type HotkeySkillEntry, type HotkeySkillEntry,
} }
@ -47,129 +58,80 @@ export interface SkillIconMapping {
} }
/** /**
* Resolves authentic icon mapping for skillId (Parity with 1.13c Skillicon.DC6 frame 2 sword / frame 0 fist, frame 6 throw, frame 4 unsummon). * Resolves authentic icon mapping for skillId from `skilldesc.txt` `IconCel`
* (Skillicon.DC6 frame 2 for Attack #0 regardless of weapon state, frame 6 for Throw #2,
* frame 4 for Unsummon #3, frame 12 for Left Hand Throw #4, frame 14 for Identify #217/#218,
* frame 18 for Townportal #219/#220).
*/ */
export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMapping { export function resolveSkillIcon(skillId: number, _hasWeapon = true): SkillIconMapping {
if (skillId === 0) { if (skillId in GENERIC_SKILL_TBL_STRINGS) {
return { const iconCel = getCanonicalSkillIconCel(skillId)
skillId: 0, const name = getSkillName(skillId, 'ENG')
name: 'Attack',
iconPath: hasWeapon ? '/skills/icon_0.png' : '/skills/icon_0_fist.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: hasWeapon ? 2 : 0,
atlasRect: { x: hasWeapon ? 96 : 0, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 2) {
return {
skillId: 2,
name: 'Throw',
iconPath: '/skills/icon_2.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 6,
atlasRect: { x: 288, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 3) {
return {
skillId: 3,
name: 'Unsummon',
iconPath: '/skills/icon_3.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 4,
atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 4) {
return {
skillId: 4,
name: 'Left Hand Throw',
iconPath: '/skills/icon_4.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 12,
atlasRect: { x: 576, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 217 || skillId === 218) {
return { return {
skillId, skillId,
name: skillId === 217 ? 'Scroll of Identify' : 'Book of Identify', name,
iconPath: `/skills/icon_${skillId}.png`, iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true, isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 14, frameIndex: iconCel,
atlasRect: { x: 672, y: 0, width: 48, height: 48, w: 48, h: 48 }, atlasRect: { x: iconCel * 48, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 219 || skillId === 220) {
return {
skillId,
name: skillId === 219 ? 'Scroll of Townportal' : 'Book of Townportal',
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 18,
atlasRect: { x: 864, y: 0, width: 48, height: 48, w: 48, h: 48 },
} }
} }
const meta = SKILLS_TABLE_META[skillId]
return { return {
skillId, skillId,
name: `Skill ${skillId}`, name: getSkillName(skillId, 'ENG'),
iconPath: `/skills/icon_${skillId}.png`, iconPath: `/skills/icon_${skillId}.png`,
isGeneric: false, isGeneric: false,
dc6Path: '', dc6Path: '',
dc6Source: '', dc6Source: '',
frameIndex: skillId, frameIndex: meta ? meta.iconCel : skillId,
atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 }, atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 },
} }
} }
/** /**
* Resolves authentic 1.13c ListRow for Speedbar popup (0..4). * Resolves authentic 1.13c `ListRow` for Speedbar popup (`-1..4`) from `skilldesc.txt`.
* Row 0 (bottom): Basic universal actions (Attack 0, Throw 2, Unsummon 3, etc.) * - Row -1: Excluded from speedbar (Kick #1, Left Hand Swing #5)
* Rows 1..3: Character skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) * - Row 0 (bottom): Basic universal actions (Attack #0, Throw #2, Unsummon #3, Left Hand Throw #4)
* Row 4 (top): Item skills (Scrolls/Books of TP & ID, or items marked as item skills) * - Rows 1..3: Character skill tree tabs
* - Row 4 (top): Item skills (Scrolls/Books of TP & ID #217..#220, or items with charges)
* Fails fast if an unknown skill has no `skilldesc` entry and is not an item skill.
*/ */
export function getSkillListRow(skill: HotkeySkillEntry): number { export function getSkillListRow(skill: HotkeySkillEntry): number {
if (typeof (skill as any).listRow === 'number') { if (typeof (skill as { listRow?: unknown }).listRow === 'number') {
return (skill as any).listRow return (skill as { listRow: number }).listRow
} }
const id = skill.skillId const id = skill.skillId
// 1. Item skills (Scrolls/Books of TP & ID, charged items, or marked as item skills) if (
if (id === 217 || id === 218 || id === 219 || id === 220 || (skill as any).isItemSkill || skill.charges !== undefined) { id === 217 ||
id === 218 ||
id === 219 ||
id === 220 ||
Boolean((skill as { isItemSkill?: boolean }).isItemSkill) ||
skill.charges !== undefined
) {
return 4 return 4
} }
// 2. Universal / basic actions if (id in SKILLS_TABLE_META) {
if (id === 0 || id === 1 || id === 2 || id === 3 || id === 4 || id === 5 || id === 350 || (skill as any).isGeneric) { return getCanonicalSkillListRow(id, false)
}
if (typeof (skill as { tabIndex?: unknown }).tabIndex === 'number') {
return (skill as { tabIndex: number }).tabIndex + 1
}
if ((skill as { isGeneric?: boolean }).isGeneric) {
return 0 return 0
} }
// 3. Class skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) return getCanonicalSkillListRow(id, false)
const catalogEntry = SKILLS_BY_ID[id]
if (catalogEntry) {
return catalogEntry.tabIndex + 1
}
if (typeof (skill as any).tabIndex === 'number') {
return (skill as any).tabIndex + 1
}
return 0
} }
/** /**
* Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup. * Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup.
*/ */
export function getSkillReqLevel(skill: HotkeySkillEntry): number { export function getSkillReqLevel(skill: HotkeySkillEntry): number {
if (typeof (skill as any).reqlevel === 'number') { if (typeof (skill as { reqlevel?: unknown }).reqlevel === 'number') {
return (skill as any).reqlevel return (skill as { reqlevel: number }).reqlevel
} }
const catalogEntry = SKILLS_BY_ID[skill.skillId] const catalogEntry = SKILLS_BY_ID[skill.skillId]
if (catalogEntry && typeof catalogEntry.reqlevel === 'number') { if (catalogEntry && typeof catalogEntry.reqlevel === 'number') {
@ -178,12 +140,53 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number {
return 1 return 1
} }
/**
* Builds a canonical `HotkeySkillEntry` for a generic or item skill (`#0..#5`, `#217..#220`).
*/
export function createCanonicalGenericSkillEntry(
skillId: number,
charges?: number,
maxCharges?: number,
): HotkeySkillEntry {
const meta = getCanonicalSkillListRow(skillId, charges !== undefined && skillId >= 217)
const entry: HotkeySkillEntry = {
skillId,
name: getSkillName(skillId, 'ENG'),
nameZh: getSkillName(skillId, 'CHI'),
level: 1,
manaCost: 0,
leftUsable: isLeftUsableSkill(skillId),
rightUsable: !isPassiveSkill(skillId),
listRow: meta,
reqlevel: 1,
...(charges !== undefined ? { charges } : {}),
...(maxCharges !== undefined ? { maxCharges } : {}),
...(skillId >= 217 && skillId <= 220 ? { isItemSkill: true } : {}),
}
return entry
}
/**
* Ensures Default Attack (`#0`) is always present in a player's skill list so the player can
* always switch back to normal Attack from the speedbar.
*/
export function ensureDefaultAttackSkill(skills: readonly HotkeySkillEntry[]): HotkeySkillEntry[] {
if (skills.some(s => s.skillId === 0)) {
return [...skills]
}
return [createCanonicalGenericSkillEntry(0), ...skills]
}
export function getAvailableSkillsForSide( export function getAvailableSkillsForSide(
side: 'left' | 'right', side: 'left' | 'right',
skills: readonly HotkeySkillEntry[], skills: readonly HotkeySkillEntry[],
weaponState?: WeaponEquipCheckState,
): readonly HotkeySkillEntry[] { ): readonly HotkeySkillEntry[] {
return skills.filter(s => { return ensureDefaultAttackSkill(skills).filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false if (isPassiveSkill(s.skillId) || s.isPassive) return false
const row = getSkillListRow(s)
if (row < 0) return false
if (!canUseSkillWithWeapon(s.skillId, weaponState)) return false
if (side === 'left') { if (side === 'left') {
if (isAuraSkill(s.skillId) || s.isAura) return false if (isAuraSkill(s.skillId) || s.isAura) return false
if (!isLeftUsableSkill(s.skillId)) return false if (!isLeftUsableSkill(s.skillId)) return false
@ -323,13 +326,18 @@ export class SkillHotkeysHud {
} }
} }
weaponEquipState?: WeaponEquipCheckState | undefined
setAvailableSkills( setAvailableSkills(
skills: readonly HotkeySkillEntry[], skills: readonly HotkeySkillEntry[],
defaultLeft?: number, defaultLeft?: number,
defaultRight?: number, defaultRight?: number,
): void { ): void {
// Passive skills strictly cannot enter the quickbar // Passive skills and ListRow < 0 skills (Kick #1, Left Hand Swing #5) strictly cannot enter the quickbar
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive) this.availableSkills = skills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false
return getSkillListRow(s) >= 0
})
if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) { if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) {
this.leftSkillId = defaultLeft this.leftSkillId = defaultLeft
} }
@ -341,6 +349,7 @@ export class SkillHotkeysHud {
addOrUpdateSkill(entry: HotkeySkillEntry): void { addOrUpdateSkill(entry: HotkeySkillEntry): void {
if (isPassiveSkill(entry.skillId) || entry.isPassive) return if (isPassiveSkill(entry.skillId) || entry.isPassive) return
if (getSkillListRow(entry) < 0) return
const idx = this.availableSkills.findIndex(s => s.skillId === entry.skillId) const idx = this.availableSkills.findIndex(s => s.skillId === entry.skillId)
if (idx >= 0) { if (idx >= 0) {
this.availableSkills[idx] = entry this.availableSkills[idx] = entry
@ -422,7 +431,7 @@ export class SkillHotkeysHud {
} }
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] { getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
return getAvailableSkillsForSide(side, this.availableSkills) return getAvailableSkillsForSide(side, this.availableSkills, this.weaponEquipState)
} }
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null { triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
@ -653,77 +662,15 @@ export class SkillHotkeysHud {
ctx.fillStyle = '#14110d' ctx.fillStyle = '#14110d'
ctx.fillRect(x, y, 48, 48) ctx.fillRect(x, y, 48, 48)
if (skillId === 0) { if (skillId in GENERIC_SKILL_TBL_STRINGS) {
if (this.hasEquippedWeapon) { const iconCel = getCanonicalSkillIconCel(skillId)
if (this.swordImage && this.swordImage.complete && this.swordImage.naturalWidth > 0) {
ctx.drawImage(this.swordImage, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 2 * 48, 0, 48, 48, x, y, 48, 48)
} else {
const icon = this.iconImages.get(0)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
}
} else {
if (this.fistImage && this.fistImage.complete && this.fistImage.naturalWidth > 0) {
ctx.drawImage(this.fistImage, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 0, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
}
} else if (skillId === 2) {
const icon = this.iconImages.get(2)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 6 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 3) {
const icon = this.iconImages.get(3)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 4 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 4) {
const icon = this.iconImages.get(4)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 12 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 217 || skillId === 218) {
const icon = this.iconImages.get(skillId) const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) { if (skillId === 0 && this.swordImage && this.swordImage.complete && this.swordImage.naturalWidth > 0) {
ctx.drawImage(this.swordImage, x, y, 48, 48)
} else if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48) ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 14 * 48, 0, 48, 48, x, y, 48, 48) ctx.drawImage(genericSkillsImg, iconCel * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 219 || skillId === 220) {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 18 * 48, 0, 48, 48, x, y, 48, 48)
} else { } else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44) ctx.fillRect(x + 2, y + 2, 44, 44)
@ -785,7 +732,8 @@ export class SkillHotkeysHud {
} }
/** /**
* Formats authentic Diablo II 1.13c multi-line tooltip lines for quickbar and speedbar popups. * Formats authentic Diablo II 1.13c multi-line tooltip lines for quickbar and speedbar popups
* driven by `skilldesc.txt` (`descline*`) and `.tbl` strings.
*/ */
formatTooltipLines( formatTooltipLines(
skillId: number, skillId: number,
@ -795,6 +743,7 @@ export class SkillHotkeysHud {
): FormattedTooltipLine[] { ): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = [] const lines: FormattedTooltipLine[] = []
const isEn = lang === 'ENG' const isEn = lang === 'ENG'
const tbl = SKILL_TBL_STRINGS[lang]
const pushHotkeyLine = () => { const pushHotkeyLine = () => {
const boundKey = this.getHotkeyLabelForSkill(side, skillId) const boundKey = this.getHotkeyLabelForSkill(side, skillId)
@ -817,167 +766,57 @@ export class SkillHotkeysHud {
} }
} }
// 1. Check if special default skill (Attack 0, Throw 2, Unsummon 3) // 1. Generic & Item Skills (#0..#5, #217..#220, #350) from skilldesc.txt + .tbl
if (skillId === 0) { if (skillId in GENERIC_SKILL_TBL_STRINGS) {
lines.push({ lines.push({
text: isEn ? 'Attack' : '普通攻击', text: getSkillName(skillId, lang),
color: 'gold', color: 'gold',
font: 'fontexocet10', font: 'fontexocet10',
align: 'center', align: 'center',
}) })
pushHotkeyLine() pushHotkeyLine()
lines.push({ const longDesc = getGenericSkillLongDesc(skillId, lang)
text: isEn if (longDesc) {
? 'Normal physical attack with equipped weapon or bare hands.' for (const descLine of longDesc.split('\n')) {
: '使用当前装备的武器或赤手空拳进行基础物理攻击。', const trimmed = descLine.trim()
color: 'white', if (trimmed) {
font: 'font8', lines.push({
align: 'center', text: trimmed,
}) color: 'white',
lines.push({ font: 'font8',
text: isEn ? 'Mana Cost: 0' : '法力消耗: 0', align: 'center',
color: 'white', })
font: 'font8', }
align: 'center', }
})
return lines
}
if (skillId === 2) {
lines.push({
text: isEn ? 'Throw' : '投掷',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
pushHotkeyLine()
lines.push({
text: isEn ? 'Throw equipped throwing weapon.' : '投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 3) {
lines.push({
text: isEn ? 'Unsummon' : '取消召唤',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
pushHotkeyLine()
lines.push({
text: isEn ? 'Click a summoned minion or pet to dismiss it.' : '点击你所召唤的仆从或宠物以将其解散。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 4) {
lines.push({
text: isEn ? 'Left Hand Throw' : '左手投掷',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
pushHotkeyLine()
lines.push({
text: isEn ? 'Throw off-hand throwing weapon.' : '使用副手投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: isEn ? 'Mana Cost: 0' : '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 217 || skillId === 218) {
const isBook = skillId === 218
lines.push({
text: isEn
? (isBook ? 'Tome of Identify' : 'Scroll of Identify')
: (isBook ? '辨识之书' : '辨识卷轴'),
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
pushHotkeyLine()
lines.push({
text: isEn
? 'Identifies an unidentified magic, rare, set, or unique item.'
: '使用后辨识未鉴定的魔法、稀有或暗金物品。',
color: 'white',
font: 'font8',
align: 'center',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: isEn ? `Quantity: ${charges}` : `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
})
} }
return lines const meta = SKILLS_TABLE_META[skillId]
} if (meta?.descline1 && meta.descline1 >= 53 && meta.descline1 <= 56) {
const charges = this.getSkillCharges(skillId)
if (skillId === 219 || skillId === 220) { if (charges !== undefined) {
const isBook = skillId === 220 lines.push({
lines.push({ text: `${tbl.skilldesc4}${charges}`,
text: isEn color: charges > 0 ? 'white' : 'red',
? (isBook ? 'Tome of Town Portal' : 'Scroll of Town Portal') font: 'font8',
: (isBook ? '城镇传送之书' : '城镇传送卷轴'), align: 'center',
color: 'gold', })
font: 'fontexocet10', }
align: 'center',
})
pushHotkeyLine()
lines.push({
text: isEn
? 'Opens a magical blue portal to the nearest town.'
: '打开一个通往最近城镇的安全蓝色传送门。',
color: 'white',
font: 'font8',
align: 'center',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: isEn ? `Quantity: ${charges}` : `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
})
} }
return lines return lines
} }
// 2. Class Skills // 2. Class Skills
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
const entry = this.availableSkills.find(s => s.skillId === skillId) const entry = this.availableSkills.find(s => s.skillId === skillId)
const catalogEntry = SKILLS_BY_ID[skillId] const catalogEntry = SKILLS_BY_ID[skillId]
const fallbackLevel = Math.max(1, entry?.level ?? 1)
const vm = this.tooltipResolver
? this.tooltipResolver(skillId)
: catalogEntry
? (() => {
const st = createInitialState(catalogEntry.classCode)
st.hardPoints[skillId] = fallbackLevel
return buildTooltipViewModel(st, skillId)
})()
: null
const nameZh = vm?.skill.nameZh ?? entry?.nameZh ?? catalogEntry?.nameZh ?? `技能 ${skillId}` const nameZh = vm?.skill.nameZh ?? entry?.nameZh ?? catalogEntry?.nameZh ?? `技能 ${skillId}`
const nameEn = vm?.skill.name ?? entry?.name ?? catalogEntry?.name ?? `Skill ${skillId}` const nameEn = vm?.skill.name ?? entry?.name ?? catalogEntry?.name ?? `Skill ${skillId}`
@ -993,29 +832,7 @@ export class SkillHotkeysHud {
// Hotkey Info // Hotkey Info
pushHotkeyLine() pushHotkeyLine()
// Aura Tag // Charges Tag (for item-granted charged skills)
const isAura = isAuraSkill(skillId) || entry?.isAura
if (isAura) {
lines.push({
text: isEn ? 'Aura Skill (Right-Click Only)' : '灵气技能 (仅右键生效)',
color: 'yellow',
font: 'font8',
align: 'center',
})
}
// Cooldown Tag
if (this.isSkillOnCooldown(skillId)) {
const rem = this.getSkillCooldownRemaining(skillId)
lines.push({
text: isEn ? `[Cooldown: ${(rem / 1000).toFixed(1)} sec]` : `[冷却中: ${(rem / 1000).toFixed(1)} 秒]`,
color: 'red',
font: 'font8',
align: 'center',
})
}
// Charges Tag
const charges = this.getSkillCharges(skillId) const charges = this.getSkillCharges(skillId)
const maxCharges = this.getSkillMaxCharges(skillId) const maxCharges = this.getSkillMaxCharges(skillId)
if (charges !== undefined) { if (charges !== undefined) {
@ -1030,32 +847,19 @@ export class SkillHotkeysHud {
} }
if (vm) { if (vm) {
// Current level // Current level (StrSkill2: "Current Skill Level: " / "目前技能等級:")
const soft = vm.effectiveLevel - vm.hardPoints const soft = vm.effectiveLevel - vm.hardPoints
const lvlText = isEn
? `Current Skill Level: ${vm.effectiveLevel}${soft > 0 ? ` (Base ${vm.hardPoints} + Gear +${soft})` : ''}`
: `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({ lines.push({
text: lvlText, text: `${tbl.StrSkill2}${vm.effectiveLevel}`,
color: soft > 0 ? 'blue' : 'white', color: soft > 0 ? 'blue' : 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
// Mana cost // Mana cost (StrSkill3: "Mana Cost: " / "法力消耗:")
if (vm.current.manaCost > 0 || vm.skill.mana > 0) { if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({ lines.push({
text: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${vm.current.manaCost}`, text: `${tbl.StrSkill3}${vm.current.manaCost}`,
color: 'white',
font: 'font8',
align: 'center',
})
}
// Cast delay
if (vm.current.delaySeconds > 0) {
lines.push({
text: isEn ? `Casting Delay: ${vm.current.delaySeconds} sec` : `施法延迟: ${vm.current.delaySeconds} 秒`,
color: 'white', color: 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
@ -1117,69 +921,44 @@ export class SkillHotkeysHud {
} }
} }
// Synergies received // Synergies received (canonical .tbl Sksyn)
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) { if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
const skillTitle = isEn ? nameEn : nameZh
lines.push({ lines.push({
text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]', text: tbl.Sksyn.replace('%s', skillTitle),
color: 'blue', color: 'white',
font: 'fontexocet10', font: 'font8',
align: 'center', align: 'center',
}) })
for (const syn of vm.synergiesReceived) { for (const syn of vm.synergiesReceived) {
const text = isEn const text = isEn
? (syn.hardPoints > 0 ? `${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`
? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)` : `${syn.providerNameZh}: +${syn.percentPerLevel}% 每等級傷害`
: `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`)
: (syn.hardPoints > 0
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`)
lines.push({ lines.push({
text, text,
color: syn.hardPoints > 0 ? 'blue' : 'gray', color: 'white',
font: 'font8', font: 'font8',
align: 'left', align: 'center',
}) })
} }
} }
} else { } else {
// Fallback without ViewModel
const level = entry?.level ?? 1 const level = entry?.level ?? 1
const mana = entry?.manaCost ?? catalogEntry?.mana ?? 0 const mana = entry?.manaCost ?? 0
lines.push({ lines.push({
text: isEn ? `Current Skill Level: ${level}` : `当前技能等级: ${level}`, text: `${tbl.StrSkill2}${level}`,
color: 'white', color: 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
if (mana > 0) { if (mana > 0) {
lines.push({ lines.push({
text: isEn ? `Mana Cost: ${mana}` : `法力消耗: ${mana}`, text: `${tbl.StrSkill3}${mana}`,
color: 'white', color: 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
} }
const desc = isEn
? (catalogEntry?.shortDesc || catalogEntry?.longDesc)
: (catalogEntry?.shortDescZh || catalogEntry?.longDescZh)
if (desc) {
lines.push({
text: desc,
color: 'white',
font: 'font8',
align: 'center',
})
}
if (catalogEntry && catalogEntry.minDam > 0) {
lines.push({
text: isEn
? `Base Damage: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`
: `基础伤害: ${catalogEntry.minDam} - ${catalogEntry.maxDam}`,
color: 'blue',
font: 'font8',
align: 'center',
})
}
} }
return lines return lines

View File

@ -20,7 +20,12 @@ import { BeltHud } from './belt.ts'
import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts' import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts'
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts' import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' import {
calculateDetailedDamage,
calculateManaCost,
createInitialState,
} from '../../common/skills/skill-calc-engine.ts'
import { getSkillName } from '../../common/skills/skills-meta.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import { import {
resolveMercPortraitIcon, resolveMercPortraitIcon,
@ -2081,6 +2086,20 @@ export class HudManager {
return return
} }
if (!this._commandSink && this.onWeaponSwitch) {
const swapTabs = [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right]
for (const tab of swapTabs) {
if (rightPt.x >= tab.x && rightPt.x <= tab.x + tab.w && rightPt.y >= tab.y && rightPt.y <= tab.y + tab.h) {
const targetSet: 0 | 1 = rightPt.x < tab.x + tab.w / 2 ? 0 : 1
if (targetSet !== this.inventory.weaponSwapSet) {
this.onWeaponSwitch()
}
this.syncPublishedState()
return
}
}
}
if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) { if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) {
this.syncPublishedState() this.syncPublishedState()
return return
@ -2160,15 +2179,16 @@ export class HudManager {
private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean { private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean {
const sink = this._commandSink const sink = this._commandSink
if (!sink) return false if (!sink) return false
const swapTabs = [ const swapTabs = [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right]
{ ...WEAPON_SWAP_TABS_BOUNDS.left, set: 0 as const },
{ ...WEAPON_SWAP_TABS_BOUNDS.right, set: 1 as const },
]
for (const tab of swapTabs) { for (const tab of swapTabs) {
if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) { if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) {
if (tab.set !== this.inventory.weaponSwapSet) { const targetSet: 0 | 1 = x < tab.x + tab.w / 2 ? 0 : 1
sink.swapWeapons() if (targetSet !== this.inventory.weaponSwapSet) {
this.onWeaponSwitch?.() if (typeof sink.swapWeapons === 'function') {
sink.swapWeapons()
} else {
this.onWeaponSwitch?.()
}
} }
return true return true
} }
@ -2292,8 +2312,8 @@ export class HudManager {
this.hotkeys.addOrUpdateSkill({ this.hotkeys.addOrUpdateSkill({
skillId, skillId,
name: catalogEntry.name, name: getSkillName(skillId, 'ENG'),
nameZh: catalogEntry.nameZh, nameZh: getSkillName(skillId, 'CHI'),
level: currentHard, level: currentHard,
manaCost: mana, manaCost: mana,
leftUsable, leftUsable,
@ -2302,6 +2322,43 @@ export class HudManager {
}) })
} }
private computeActiveSkillDmgString(skillId: number): string {
const strBonus = 1 + (this.charSheet.attrs.str || 0) / 100
if (skillId === 0 || skillId === 2 || skillId === 4) {
const w = skillId === 4 ? this.inventory.equipped.weapon2 : this.inventory.equipped.weapon1
if (w) {
const dmgText = w.damageEn ?? w.damage ?? ''
const m = /(\d+)\s*(?:-|to)\s*(\d+)/.exec(dmgText)
if (m) {
const minDmg = Math.max(1, Math.floor(Number(m[1]) * strBonus))
const maxDmg = Math.max(minDmg + 1, Math.floor(Number(m[2]) * strBonus))
return `${minDmg}-${maxDmg}`
}
}
if (skillId === 0) {
const minDmg = Math.max(1, Math.floor(1 * strBonus))
const maxDmg = Math.max(2, Math.floor(2 * strBonus))
return `${minDmg}-${maxDmg}`
}
return '--'
}
const def = SKILLS_BY_ID[skillId]
if (!def) return '--'
const effLvl = Math.max(1, this.skillTree.getEffectiveLevel(skillId))
const calcState = createInitialState((this.currentClass ?? def.classCode ?? 'sor') as any)
for (const [id, pts] of this.skillTree.hardPoints.entries()) {
calcState.hardPoints[id] = pts
}
for (const [id, bon] of this.skillTree.bonusLevels.entries()) {
calcState.specificSoftPoints[id] = bon
}
const dmg = calculateDetailedDamage(calcState, def, effLvl)
if (dmg.hasDamage && dmg.finalMax > 0) {
return `${Math.floor(dmg.finalMin)}-${Math.floor(dmg.finalMax)}`
}
return '--'
}
private buildControlBarState() { private buildControlBarState() {
return { return {
level: this.charSheet.attrs.level, level: this.charSheet.attrs.level,
@ -2664,10 +2721,10 @@ export class HudManager {
maxMana: this.maxMana, maxMana: this.maxMana,
stamina: this.stamina, stamina: this.stamina,
maxStamina: this.maxStamina, maxStamina: this.maxStamina,
// Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc leftSkillDmg:
// (no fabricated per-level numbers). this.charSheet.attrs.leftSkillDmg || this.computeActiveSkillDmgString(this.hotkeys.leftSkillId),
leftSkillDmg: '', rightSkillDmg:
rightSkillDmg: '', this.charSheet.attrs.rightSkillDmg || this.computeActiveSkillDmgString(this.hotkeys.rightSkillId),
}, },
{ {
borderLeftImg: this.images.get('borderLeft') ?? null, borderLeftImg: this.images.get('borderLeft') ?? null,
@ -2868,11 +2925,6 @@ export class HudManager {
) )
} }
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) {
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font, profile.width)
}
// 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree) // 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree)
if ( if (
(this.rightPanel === 'inv' || (this.rightPanel === 'inv' ||

View File

@ -20,6 +20,8 @@ import {
import { import {
getSkillName, getSkillName,
formatDamageTypeEn, formatDamageTypeEn,
getSkillMaxLevel,
SKILL_TBL_STRINGS,
type FormattedTooltipLine, type FormattedTooltipLine,
} from '../../common/skills/skills-meta.ts' } from '../../common/skills/skills-meta.ts'
import { reportMissingArt } from '../scene/world-renderer.ts' import { reportMissingArt } from '../scene/world-renderer.ts'
@ -27,6 +29,7 @@ import { reportMissingArt } from '../scene/world-renderer.ts'
export { export {
getSkillName, getSkillName,
formatDamageTypeEn, formatDamageTypeEn,
getSkillMaxLevel,
type FormattedTooltipLine, type FormattedTooltipLine,
} }
@ -217,6 +220,18 @@ export class SkillTreePanel {
private skillIcons = new Map<number, HTMLImageElement>() private skillIcons = new Map<number, HTMLImageElement>()
private tabBgClass: CharacterClassCode | null = null private tabBgClass: CharacterClassCode | null = null
constructor(initialClass?: CharacterClassCode) {
if (initialClass) {
this.setClass(initialClass)
}
}
formatSkillTooltipLines(skillId: number): FormattedTooltipLine[] {
const vm = this.getTooltipViewModel(skillId)
if (!vm) return []
return SkillTreePanel.formatSkillTooltipLines(vm, this.lang)
}
getSkillTree(): readonly SkillNodeDef[] { getSkillTree(): readonly SkillNodeDef[] {
if (this.characterClass === null) return [] if (this.characterClass === null) return []
const tree = CLASS_SKILL_TREES[this.characterClass] const tree = CLASS_SKILL_TREES[this.characterClass]
@ -320,12 +335,12 @@ export class SkillTreePanel {
* Whether the 1.13c client would send `AddSkillPoint (0x3B)` for `skillId`: unspent points, class tree * Whether the 1.13c client would send `AddSkillPoint (0x3B)` for `skillId`: unspent points, class tree
* membership, `Skills.txt reqlevel` (+1 per hard point) against the real character level, and prerequisites. * membership, `Skills.txt reqlevel` (+1 per hard point) against the real character level, and prerequisites.
*/ */
canAllocate(skillId: number, charLevel: number = this.characterLevel): boolean { canAllocate(skillId: number, charLevel: number = this.characterLevel, maxlvlOverride?: number): boolean {
if (this.unspentSkillPoints <= 0) return false if (this.unspentSkillPoints <= 0) return false
const node = this.getSkillTree().find(n => n.skillId === skillId) const node = this.getSkillTree().find(n => n.skillId === skillId)
if (!node) return false if (!node) return false
const cur = this.getHardPoints(skillId) const cur = this.getHardPoints(skillId)
if (cur >= 20) return false if (cur >= getSkillMaxLevel(skillId, maxlvlOverride)) return false
if (charLevel < node.reqLevel + cur) return false if (charLevel < node.reqLevel + cur) return false
return node.prereqs.every(p => this.getHardPoints(p) > 0) return node.prereqs.every(p => this.getHardPoints(p) > 0)
} }
@ -580,6 +595,7 @@ export class SkillTreePanel {
static formatSkillTooltipLines(vm: TooltipViewModel, lang: TblLang = 'CHI'): FormattedTooltipLine[] { static formatSkillTooltipLines(vm: TooltipViewModel, lang: TblLang = 'CHI'): FormattedTooltipLine[] {
const lines: FormattedTooltipLine[] = [] const lines: FormattedTooltipLine[] = []
const isEn = lang === 'ENG' const isEn = lang === 'ENG'
const tbl = SKILL_TBL_STRINGS[lang]
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center) // 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
lines.push({ lines.push({
@ -592,7 +608,7 @@ export class SkillTreePanel {
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs // 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
if (!vm.levelMet) { if (!vm.levelMet) {
lines.push({ lines.push({
text: isEn ? `Required Level: ${vm.reqlevel}` : `需要角色等级: ${vm.reqlevel}`, text: `${tbl.StrSkill18}${isEn ? ' ' : ''}${vm.reqlevel}`,
color: 'red', color: 'red',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
@ -622,39 +638,28 @@ export class SkillTreePanel {
}) })
} }
// 4. Current Level header: Blue/White // 4. Current Level / First Level header (StrSkill2 / StrSkill17)
if (vm.isLearned) { if (vm.isLearned) {
const soft = vm.effectiveLevel - vm.hardPoints const soft = vm.effectiveLevel - vm.hardPoints
const lvlText = isEn
? `Current Skill Level: ${vm.effectiveLevel}${soft > 0 ? ` (Base ${vm.hardPoints} + Gear +${soft})` : ''}`
: `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
lines.push({ lines.push({
text: lvlText, text: `${tbl.StrSkill2}${vm.effectiveLevel}`,
color: soft > 0 ? 'blue' : 'white', color: soft > 0 ? 'blue' : 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
} else { } else {
lines.push({ lines.push({
text: isEn ? 'Unlearned (Level 1 Preview)' : '未习得 (预览 1 级效果)', text: tbl.StrSkill17,
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: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${vm.current.manaCost}`,
color: 'white', color: 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
} }
if (vm.current.delaySeconds > 0) {
// 5. Current Stats: Mana cost, damage ranges, duration, and effects
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
lines.push({ lines.push({
text: isEn ? `Casting Delay: ${vm.current.delaySeconds} sec` : `施法延迟: ${vm.current.delaySeconds} 秒`, text: `${tbl.StrSkill3}${vm.current.manaCost}`,
color: 'white', color: 'white',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
@ -699,17 +704,18 @@ export class SkillTreePanel {
} }
} }
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points). // 6. Next Level Preview: Header StrSkill1 ("Next Level" / "下一等級") in Green, omitted if unlearned or maxed
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) { const maxLvl = getSkillMaxLevel(vm.skill.id)
if (vm.nextLevel && vm.isLearned && vm.hardPoints < maxLvl) {
lines.push({ lines.push({
text: isEn ? `[Next Level] (Lv ${vm.nextLevel.level})` : `[下一等级] (Lv ${vm.nextLevel.level})`, text: tbl.StrSkill1,
color: 'green', color: 'green',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
}) })
if (vm.nextLevel.manaCost !== vm.current.manaCost) { if (vm.nextLevel.manaCost !== vm.current.manaCost) {
lines.push({ lines.push({
text: isEn ? `Mana Cost: ${vm.nextLevel.manaCost}` : `法力消耗: ${vm.nextLevel.manaCost}`, text: `${tbl.StrSkill3}${vm.nextLevel.manaCost}`,
color: 'green', color: 'green',
font: 'font8', font: 'font8',
align: 'center', align: 'center',
@ -753,45 +759,35 @@ export class SkillTreePanel {
}) })
} }
} }
} else if (vm.hardPoints >= 20) {
lines.push({
text: isEn ? '[Max Base Skill Level Reached (20/20)]' : '[已达技能点投入上限 (20/20)]',
color: 'gold',
font: 'font8',
align: 'center',
})
} }
// 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line // 7. Synergies Section: canonical .tbl Sksyn ("%s Receives Bonuses From:" / "額外的對%s")
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) { if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
const skillTitle = isEn ? vm.skill.name : (vm.skill.nameZh || vm.skill.name)
lines.push({ lines.push({
text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]', text: tbl.Sksyn.replace('%s', skillTitle),
color: 'blue', color: 'white',
font: 'fontexocet10', font: 'font8',
align: 'center', align: 'center',
}) })
for (const syn of vm.synergiesReceived) { for (const syn of vm.synergiesReceived) {
const text = isEn const text = isEn
? (syn.hardPoints > 0 ? `${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`
? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)` : `${syn.providerNameZh}: +${syn.percentPerLevel}% 每等級傷害`
: `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`)
: (syn.hardPoints > 0
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`)
lines.push({ lines.push({
text, text,
color: syn.hardPoints > 0 ? 'blue' : 'gray', color: syn.hardPoints > 0 ? 'blue' : 'gray',
font: 'font8', font: 'font8',
align: 'left', align: 'center',
}) })
} }
} }
if (vm.masteryInfo && vm.masteryInfo.active) { if (vm.masteryInfo && vm.masteryInfo.active) {
lines.push({ lines.push({
text: `• ${isEn ? vm.masteryInfo.descriptionEn : vm.masteryInfo.descriptionZh}`, text: isEn ? vm.masteryInfo.descriptionEn : vm.masteryInfo.descriptionZh,
color: 'blue', color: 'blue',
font: 'font8', font: 'font8',
align: 'left', align: 'center',
}) })
} }

View File

@ -1,6 +1,7 @@
/** /**
* Pure skill metadata, passive/aura/left-click skill sets, and display helpers * Pure skill metadata, canonical 1.13c `skills.txt` / `skilldesc.txt` table columns,
* shared between `src/common/`, `src/server/`, and `src/client/`. * passive/aura/left-click skill predicates, and display helpers shared between
* `src/common/`, `src/server/`, and `src/client/`.
*/ */
import { SKILLS_BY_ID, SKILLS_CATALOG } from '../data/skills-catalog.ts' import { SKILLS_BY_ID, SKILLS_CATALOG } from '../data/skills-catalog.ts'
@ -48,12 +49,446 @@ export interface HotkeySkillEntry {
readonly tabIndex?: number readonly tabIndex?: number
} }
/**
* Canonical 1.13c `skills.txt` + `skilldesc.txt` row metadata.
*/
export interface SkillTableMeta {
readonly skillId: number
readonly skilldesc: string
readonly passive: 0 | 1
readonly leftskill: 0 | 1
readonly aura: 0 | 1
readonly maxlvl: number
readonly itypea1?: string
readonly weapsel?: number
readonly skillPage: number
readonly listRow: number
readonly listPool: number
readonly iconCel: number
readonly strNameKey: string
readonly strShortKey: string
readonly strLongKey: string
readonly strAltKey: string
readonly descline1?: number
readonly desctexta1?: string
}
/**
* Canonical `.tbl` strings for skill tooltips (`string.tbl`, `expansionstring.tbl`, `patchstring.tbl`).
*/
export const SKILL_TBL_STRINGS = {
ENG: {
StrSkill1: 'Next Level',
StrSkill2: 'Current Skill Level: ',
StrSkill3: 'Mana Cost: ',
StrSkill17: 'First Level',
StrSkill18: 'Required Level:',
Sksyn: '%s Receives Bonuses From:',
skilldesc4: 'Quantity ',
},
CHI: {
StrSkill1: '下一等級',
StrSkill2: '目前技能等級:',
StrSkill3: '法力消耗:',
StrSkill17: '第一級',
StrSkill18: '需要等級:',
Sksyn: '額外的對%s',
skilldesc4: '數量 ',
},
} as const
/**
* Canonical `.tbl` strings (`strName`, `strShort`, `strLong`, `strAlt`) for generic & item skills (`#0..#5`, `#217..#220`, `#350`).
*/
export const GENERIC_SKILL_TBL_STRINGS: Readonly<
Record<
number,
{
readonly nameEn: string
readonly nameZh: string
readonly shortEn: string
readonly shortZh: string
readonly longEn: string
readonly longZh: string
}
>
> = {
0: {
nameEn: 'Attack',
nameZh: '攻擊',
shortEn: 'normal attack',
shortZh: '一般攻擊',
longEn: 'normal attack',
longZh: '一般攻擊',
},
1: {
nameEn: 'Kick',
nameZh: '腳踢',
shortEn: 'kick target',
shortZh: '腳踢目標',
longEn: 'kick target',
longZh: '腳踢目標',
},
2: {
nameEn: 'Throw',
nameZh: '投擲',
shortEn: 'throw equipped item',
shortZh: '投擲裝備的物品',
longEn: 'throw equipped item',
longZh: '投擲裝備的物品',
},
3: {
nameEn: 'Unsummon',
nameZh: '取消召喚',
shortEn: 'releases a summoned creature',
shortZh: '釋放掉你所控制生物中,',
longEn: 'of one of your creatures\nrelinquishes control',
longZh: '其中一位召喚生物\n放棄對它的控制',
},
4: {
nameEn: 'Left Hand Throw',
nameZh: '左手投擲',
shortEn: 'throw left hand item',
shortZh: '丟出左手的物品',
longEn: 'throw left hand item',
longZh: '丟出左手的物品',
},
5: {
nameEn: 'Left Hand Swing',
nameZh: '左手揮動',
shortEn: 'swing left hand weapon',
shortZh: '揮動左手的武器',
longEn: 'swing left hand weapon',
longZh: '揮動左手的武器',
},
217: {
nameEn: 'Scroll of Identify',
nameZh: '辨視卷軸',
shortEn: "identify a magic item's properties",
shortZh: '辨視一個魔法物品的屬性',
longEn: "identify a magic item's properties",
longZh: '辨視一個魔法物品的屬性',
},
218: {
nameEn: 'Tome of Identify',
nameZh: '辨視之書',
shortEn: "identify a magic item's properties",
shortZh: '辨視一個魔法物品的屬性',
longEn: "identify a magic item's properties",
longZh: '辨視一個魔法物品的屬性',
},
219: {
nameEn: 'Scroll of Townportal',
nameZh: '城鎮傳送卷軸',
shortEn: 'create a magic portal to the nearest town',
shortZh: '創造出一個魔法傳送門,通往最近的城市',
longEn: 'create a magic portal to the nearest town',
longZh: '創造出一個魔法傳送門,通往最近的城市',
},
220: {
nameEn: 'Tome of Townportal',
nameZh: '城鎮傳送之書',
shortEn: 'create a magic portal to the nearest town',
shortZh: '創造出一個魔法傳送門,通往最近的城市',
longEn: 'create a magic portal to the nearest town',
longZh: '創造出一個魔法傳送門,通往最近的城市',
},
350: {
nameEn: 'Delerium Change',
nameZh: 'Delerium Change',
shortEn: '',
shortZh: '',
longEn: '',
longZh: '',
},
}
const CANONICAL_PASSIVE_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
9, 13, 18, 23, 29, 33,
37, 61, 63, 65,
69, 79, 89,
127, 128, 129, 134, 135, 136, 141, 145, 148, 153,
224,
252, 263,
])
const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
0, 2, 4, 5,
6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
36, 38, 39, 41, 45, 47, 49, 53, 55, 64,
67, 73, 84, 93,
96, 97, 101, 106, 107, 111, 112, 116, 121,
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
])
const CANONICAL_AURA_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
98, 99, 100, 102, 103, 104, 105, 108, 109, 110,
113, 114, 115, 118, 119, 120, 122, 123, 124, 125,
])
const CLASS_FIRST_SKILL_ID: Readonly<Record<CharacterClassCode, number>> = {
ama: 6,
sor: 36,
nec: 66,
pal: 96,
bar: 126,
dru: 221,
ass: 251,
}
function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
const table: Record<number, SkillTableMeta> = {
0: {
skillId: 0,
skilldesc: 'attack',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'weap',
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 2,
strNameKey: 'skillname0',
strShortKey: 'skillsd0',
strLongKey: 'skillld0',
strAltKey: 'skillan0',
},
1: {
skillId: 1,
skilldesc: 'kick',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
weapsel: 4,
skillPage: 0,
listRow: -1,
listPool: 0,
iconCel: 0,
strNameKey: 'skillname1',
strShortKey: 'skillsd1',
strLongKey: 'skillld1',
strAltKey: 'skillan1',
},
2: {
skillId: 2,
skilldesc: 'throw',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'thro',
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 6,
strNameKey: 'skillname2',
strShortKey: 'skillsd2',
strLongKey: 'skillld2',
strAltKey: 'skillan2',
},
3: {
skillId: 3,
skilldesc: 'unsummon',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 4,
strNameKey: 'skillname3',
strShortKey: 'skillsd3',
strLongKey: 'skillld3',
strAltKey: 'skillan3',
},
4: {
skillId: 4,
skilldesc: 'left hand throw',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'thro',
weapsel: 1,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 12,
strNameKey: 'skillname4',
strShortKey: 'skillsd4',
strLongKey: 'skillld4',
strAltKey: 'skillan4',
},
5: {
skillId: 5,
skilldesc: 'left hand swing',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'weap',
weapsel: 1,
skillPage: 0,
listRow: -1,
listPool: 0,
iconCel: 10,
strNameKey: 'skillname5',
strShortKey: 'skillsd5',
strLongKey: 'skillld5',
strAltKey: 'skillan5',
},
217: {
skillId: 217,
skilldesc: 'scroll of identify',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 1,
iconCel: 14,
strNameKey: 'skillname217',
strShortKey: 'skillsd217',
strLongKey: 'skillld217',
strAltKey: 'skillan217',
descline1: 53,
desctexta1: 'isc',
},
218: {
skillId: 218,
skilldesc: 'book of identify',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 0,
iconCel: 14,
strNameKey: 'skillname218',
strShortKey: 'skillsd218',
strLongKey: 'skillld218',
strAltKey: 'skillan218',
descline1: 55,
desctexta1: 'ibk',
},
219: {
skillId: 219,
skilldesc: 'scroll of townportal',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 2,
iconCel: 18,
strNameKey: 'skillname219',
strShortKey: 'skillsd219',
strLongKey: 'skillld219',
strAltKey: 'skillan219',
descline1: 54,
desctexta1: 'tsc',
},
220: {
skillId: 220,
skilldesc: 'book of townportal',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 0,
iconCel: 18,
strNameKey: 'skillname220',
strShortKey: 'skillsd220',
strLongKey: 'skillld220',
strAltKey: 'skillan220',
descline1: 56,
desctexta1: 'tbk',
},
350: {
skillId: 350,
skilldesc: 'delerium change',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 2,
strNameKey: 'Delerium Change',
strShortKey: 'Delerium Change',
strLongKey: 'Delerium Change',
strAltKey: 'Delerium Change',
},
}
for (const s of SKILLS_CATALOG) {
const skillPage = s.tabIndex + 1
const classStart = CLASS_FIRST_SKILL_ID[s.classCode]
const iconCel = Math.max(0, (s.id - classStart) * 2)
table[s.id] = {
skillId: s.id,
skilldesc: s.name.toLowerCase(),
passive: CANONICAL_PASSIVE_COLUMN_ONE.has(s.id) ? 1 : 0,
leftskill: CANONICAL_LEFTSKILL_COLUMN_ONE.has(s.id) ? 1 : 0,
aura: CANONICAL_AURA_COLUMN_ONE.has(s.id) ? 1 : 0,
maxlvl: 0,
skillPage,
listRow: skillPage,
listPool: 0,
iconCel,
strNameKey: `skillname${s.id}`,
strShortKey: `skillsd${s.id}`,
strLongKey: `skillld${s.id}`,
strAltKey: `skillan${s.id}`,
}
}
return table
}
/**
* Canonical 1.13c `skills.txt` + `skilldesc.txt` metadata indexed by skillId.
*/
export const SKILLS_TABLE_META: Readonly<Record<number, SkillTableMeta>> = buildSkillsTableMeta()
export function getSkillTableMeta(skillId: number): SkillTableMeta | undefined {
return SKILLS_TABLE_META[skillId]
}
export function getSkillName(skillId: number, lang: TblLang = getDefaultTblLang()): string { export function getSkillName(skillId: number, lang: TblLang = getDefaultTblLang()): string {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (generic) {
return lang === 'ENG' ? generic.nameEn : generic.nameZh
}
const entry = SKILLS_BY_ID[skillId] ?? SKILLS_CATALOG.find(s => s.id === skillId) const entry = SKILLS_BY_ID[skillId] ?? SKILLS_CATALOG.find(s => s.id === skillId)
if (!entry) return `Skill #${skillId}` if (!entry) return `Skill #${skillId}`
return lang === 'ENG' ? entry.name : (entry.nameZh || entry.name) return lang === 'ENG' ? entry.name : (entry.nameZh || entry.name)
} }
export function getGenericSkillShortDesc(skillId: number, lang: TblLang = getDefaultTblLang()): string | undefined {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (!generic) return undefined
return lang === 'ENG' ? generic.shortEn : generic.shortZh
}
export function getGenericSkillLongDesc(skillId: number, lang: TblLang = getDefaultTblLang()): string | undefined {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (!generic) return undefined
return lang === 'ENG' ? generic.longEn : generic.longZh
}
export function formatDamageTypeEn(type: string): string { export function formatDamageTypeEn(type: string): string {
switch (type) { switch (type) {
case 'fire': case 'fire':
@ -74,115 +509,149 @@ export function formatDamageTypeEn(type: string): string {
} }
/** /**
* Diablo II v1.13c Canonical Passive Skills (Skills.txt `passive = 1`). * Diablo II v1.13c Canonical Passive Skills (`skills.txt` `passive = 1`).
* Passive skills provide permanent bonuses and CANNOT be assigned to * Derived directly from `SKILLS_TABLE_META`.
* left/right action buttons or appear in the Speedbar popup.
*/ */
export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>([ export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>(
// Amazon (Tab 1 Passives: Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33) Object.values(SKILLS_TABLE_META)
9, // Critical Strike .filter(m => m.passive === 1)
13, // Dodge .map(m => m.skillId),
18, // Avoid )
23, // Penetrate
29, // Evade
33, // Pierce
// Sorceress Passives
37, // Warmth
61, // Fire Mastery
63, // Lightning Mastery
65, // Cold Mastery
// Necromancer Passives
69, // Skeleton Mastery
79, // Golem Mastery
89, // Summon Resist
// Barbarian Combat Masteries & Passives
127, // Sword Mastery
128, // Axe Mastery
129, // Mace Mastery
134, // Polearm Mastery
135, // Throwing Mastery
136, // Spear Mastery
141, // Increased Stamina
145, // Iron Skin
148, // Increased Speed
153, // Natural Resistance
// Druid Passives
224, // Lycanthropy
// Assassin Passives
252, // Claw Mastery
263, // Weapon Block
])
export function isPassiveSkill(skillId: number): boolean { export function isPassiveSkill(skillId: number): boolean {
if (skillId === 0) return false return (SKILLS_TABLE_META[skillId]?.passive ?? 0) === 1
return PASSIVE_SKILL_IDS.has(skillId)
} }
/** /**
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`). * Diablo II v1.13c Paladin Aura Skills (`skills.txt` `aura = 1`).
* Derived directly from `SKILLS_TABLE_META`.
*/ */
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([ export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set<number>(
// Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5) Object.values(SKILLS_TABLE_META)
0, 2, 4, 5, .filter(m => m.aura === 1 && m.passive === 0)
// Amazon (20 active skills with leftskill = 1) .map(m => m.skillId),
6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35, )
// Sorceress (10 active skills with leftskill = 1)
36, 38, 39, 41, 45, 47, 49, 53, 55, 64,
// Necromancer (4 active skills with leftskill = 1)
67, 73, 84, 93,
// Paladin (9 active combat skills with leftskill = 1)
96, 97, 101, 106, 107, 111, 112, 116, 121,
// Barbarian (10 active combat skills with leftskill = 1)
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
// Druid (12 active combat/elemental skills with leftskill = 1)
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
// Assassin (14 active martial/trap skills with leftskill = 1)
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
])
export function isLeftUsableSkill(skillId: number): boolean {
if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false
return LEFT_USABLE_SKILL_IDS.has(skillId)
}
/**
* Diablo II v1.13c Paladin Aura Skills (`Skills.txt` `aura = 1`, Tabs 1 & 2).
* Paladin Auras are highlighted in yellow on the Speedbar and Quickbar slots,
* and can only be assigned to the Right-Click slot in authentic Diablo II.
*/
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Defensive Auras (Tab 2)
99, // Prayer
100, // Resist Fire
104, // Defiance
105, // Resist Cold
109, // Cleansing
110, // Resist Lightning
115, // Vigor
120, // Meditation
124, // Redemption
125, // Salvation
// Offensive Auras (Tab 1)
98, // Might
102, // Holy Fire
103, // Thorns
108, // Blessed Aim
113, // Concentration
114, // Holy Freeze
118, // Holy Shock
119, // Sanctuary
122, // Fanaticism
123, // Conviction
])
export function isAuraSkill(skillId: number): boolean { export function isAuraSkill(skillId: number): boolean {
if (PALADIN_AURA_SKILL_IDS.has(skillId)) return true const meta = SKILLS_TABLE_META[skillId]
const info = SKILLS_BY_ID[skillId] if (!meta) return false
if (info && info.classCode === 'pal' && (info.tabIndex === 1 || info.tabIndex === 2)) { return meta.aura === 1 && meta.passive === 0
return true }
/**
* Diablo II v1.13c Skills usable on the Left-Click action button (`skills.txt` `leftskill = 1`).
* Derived directly from `SKILLS_TABLE_META`.
*/
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>(
Object.values(SKILLS_TABLE_META)
.filter(m => m.leftskill === 1 && m.passive === 0 && m.aura === 0)
.map(m => m.skillId),
)
export function isLeftUsableSkill(skillId: number): boolean {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) return false
return meta.leftskill === 1 && meta.passive === 0 && meta.aura === 0
}
/**
* Returns the maximum base skill level from `skills.txt` `maxlvl` column.
* Per D2MOO `PlrMsg.cpp:2969-2973`, if `maxlvl <= 0`, defaults to `20`.
*/
export function getSkillMaxLevel(skillId: number, maxlvlOverride?: number): number {
const raw = maxlvlOverride !== undefined ? maxlvlOverride : (SKILLS_TABLE_META[skillId]?.maxlvl ?? 0)
return raw > 0 ? raw : 20
}
/**
* Returns the canonical `skilldesc.txt` `ListRow` for a skill.
* Throws if the skill has no `skilldesc` entry and is not an item/charge skill.
*/
export function getCanonicalSkillListRow(skillId: number, isItemSkill?: boolean): number {
if (isItemSkill) return 4
const meta = SKILLS_TABLE_META[skillId]
if (!meta) {
throw new Error(`[skills-meta] Missing canonical skilldesc.txt ListRow metadata for skillId=${skillId}`)
} }
return false return meta.listRow
}
/**
* Returns the canonical `skilldesc.txt` `IconCel` for a skill.
* Throws if the skill has no `skilldesc` entry.
*/
export function getCanonicalSkillIconCel(skillId: number): number {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) {
throw new Error(`[skills-meta] Missing canonical skilldesc.txt IconCel metadata for skillId=${skillId}`)
}
return meta.iconCel
}
/**
* Canonical 1.13c item type codes in the `thro` (Throwing Weapon) hierarchy (`ItemTypes.txt`).
*/
export const THROWING_WEAPON_TYPES: ReadonlySet<string> = new Set<string>([
'tpot',
'tkni',
'taxe',
'jave',
'thro',
'ajav',
])
/**
* Canonical 1.13c `Weapons.txt` item codes whose type inherits from `thro`.
*/
export const THROWING_WEAPON_CODES: ReadonlySet<string> = new Set<string>([
'tkf', 'tax', 'bkf', 'bal', 'jav', 'pil', 'ssp', 'glv', 'tsp',
'gps', 'ops', 'gpm', 'opm', 'gpl', 'opl',
'9tk', '9ta', '9bk', '9b8', '9ja', '9pi', '9s9', '9gl', '9ts',
'7tk', '7ta', '7bk', '7b8', '7ja', '7pi', '7s7', '7gl', '7ts',
'am5', 'ama', 'amf',
])
export function isThrowingWeaponCodeOrType(codeOrType?: string | null): boolean {
if (!codeOrType) return false
const norm = codeOrType.trim().toLowerCase()
return THROWING_WEAPON_CODES.has(norm) || THROWING_WEAPON_TYPES.has(norm)
}
export interface WeaponEquipCheckState {
readonly rightHandCode?: string | null
readonly rightHandType?: string | null
readonly leftHandCode?: string | null
readonly leftHandType?: string | null
readonly isRightThrowing?: boolean
readonly isLeftThrowing?: boolean
}
/**
* Evaluates `skills.txt` weapon requirements (`itypea1 = 'thro'`, `weapsel = 1`) for Throw (#2)
* and Left Hand Throw (#4). When `weaponState` is undefined, returns true unless explicitly checked.
*/
export function canUseSkillWithWeapon(skillId: number, weaponState?: WeaponEquipCheckState): boolean {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) return false
if (meta.itypea1 !== 'thro') return true
if (!weaponState) return true
const rightThrow =
weaponState.isRightThrowing ??
(isThrowingWeaponCodeOrType(weaponState.rightHandCode) ||
isThrowingWeaponCodeOrType(weaponState.rightHandType))
const leftThrow =
weaponState.isLeftThrowing ??
(isThrowingWeaponCodeOrType(weaponState.leftHandCode) ||
isThrowingWeaponCodeOrType(weaponState.leftHandType))
if (meta.weapsel === 1) {
return leftThrow
}
return rightThrow || leftThrow
} }
export type { CharacterClassCode } export type { CharacterClassCode }

View File

@ -68,15 +68,15 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
}) })
}) })
describe('Icon Asset & DC6 Frame 0 Resolution', () => { describe('Icon Asset & DC6 Frame 2 Resolution (skilldesc.txt IconCel = 2)', () => {
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => { it('resolves Skill 0 to authentic Skillicon.DC6 frame 2 icon regardless of weapon state', () => {
const iconMapping = resolveSkillIcon(0, false) const iconMapping = resolveSkillIcon(0, false)
expect(iconMapping).toBeDefined() expect(iconMapping).toBeDefined()
expect(iconMapping.skillId).toBe(0) expect(iconMapping.skillId).toBe(0)
expect(iconMapping.name).toBe('Attack') expect(iconMapping.name).toBe('Attack')
expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6') expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
expect(iconMapping.frameIndex).toBe(0) expect(iconMapping.frameIndex).toBe(2)
expect(iconMapping.atlasRect.x).toBe(0) expect(iconMapping.atlasRect.x).toBe(96)
expect(iconMapping.atlasRect.y).toBe(0) expect(iconMapping.atlasRect.y).toBe(0)
expect(iconMapping.atlasRect.width).toBe(48) expect(iconMapping.atlasRect.width).toBe(48)
expect(iconMapping.atlasRect.height).toBe(48) expect(iconMapping.atlasRect.height).toBe(48)

View File

@ -0,0 +1,493 @@
import { describe, expect, it, vi } from 'vitest'
import {
GENERIC_SKILL_TBL_STRINGS,
SKILLS_TABLE_META,
SKILL_TBL_STRINGS,
canUseSkillWithWeapon,
getCanonicalSkillIconCel,
getCanonicalSkillListRow,
getSkillMaxLevel,
isAuraSkill,
isLeftUsableSkill,
isPassiveSkill,
} from '../../src/common/skills/skills-meta.ts'
import {
SkillHotkeysHud,
createCanonicalGenericSkillEntry,
getAvailableSkillsForSide,
getSkillListRow,
resolveSkillIcon,
} from '../../src/client/ui/hotkeys.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
import {
CharacterSheetPanel,
resolveStatValueColor,
} from '../../src/client/ui/character-sheet.ts'
import { D2FontRenderer } from '../../src/client/ui/font.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { WEAPON_SWAP_TABS_BOUNDS } from '../../src/client/ui/inventory.ts'
import {
HudModel,
itemDataToUiInventoryItem,
} from '../../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
function createMockCanvasCtx() {
const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = []
const font = new D2FontRenderer()
vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => {
drawTextCalls.push({
text: String(text),
x,
y,
...(opts?.color !== undefined ? { color: opts.color } : {}),
...(opts?.font !== undefined ? { font: opts.font } : {}),
})
return String(text).length * 8
})
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn((t: string) => ({ width: t.length * 7 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
return { ctx, font, drawTextCalls }
}
describe('Batch 6 Phase 3: Skills, Tooltips, Character Sheet & HUD Hooks (#679, #686, #687, #688, #689)', () => {
describe('Issue #679: Speedbar generic/item skills, skilldesc.txt ListRow/IconCel, weapon gating & fail-fast', () => {
it('defines canonical skilldesc.txt ListRow, ListPool, and IconCel for generic and scroll/tome skills', () => {
expect(getCanonicalSkillListRow(0)).toBe(0)
expect(getCanonicalSkillIconCel(0)).toBe(2)
expect(getCanonicalSkillListRow(1)).toBe(-1) // Kick: never in speedbar
expect(getCanonicalSkillListRow(5)).toBe(-1) // Left Hand Swing: never in speedbar
expect(getCanonicalSkillListRow(2)).toBe(0)
expect(getCanonicalSkillIconCel(2)).toBe(6)
expect(getCanonicalSkillListRow(3)).toBe(0)
expect(getCanonicalSkillIconCel(3)).toBe(4)
expect(getCanonicalSkillListRow(4)).toBe(0)
expect(getCanonicalSkillIconCel(4)).toBe(12)
expect(getCanonicalSkillListRow(217)).toBe(4)
expect(SKILLS_TABLE_META[217]?.listPool).toBe(1)
expect(getCanonicalSkillIconCel(217)).toBe(14)
expect(getCanonicalSkillListRow(218)).toBe(4)
expect(getCanonicalSkillIconCel(218)).toBe(14)
expect(getCanonicalSkillListRow(219)).toBe(4)
expect(SKILLS_TABLE_META[219]?.listPool).toBe(2)
expect(getCanonicalSkillIconCel(219)).toBe(18)
expect(getCanonicalSkillListRow(220)).toBe(4)
expect(getCanonicalSkillIconCel(220)).toBe(18)
})
it('always includes Attack (#0) in left and right speedbars and excludes ListRow < 0 (#1 Kick, #5 Left Hand Swing)', () => {
const kickEntry = {
skillId: 1,
name: 'Kick',
nameZh: '踢擊',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
}
const lhsEntry = {
skillId: 5,
name: 'Left Hand Swing',
nameZh: '左手揮擊',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
}
const leftList = getAvailableSkillsForSide('left', [kickEntry, lhsEntry])
const rightList = getAvailableSkillsForSide('right', [kickEntry, lhsEntry])
expect(leftList.map((s) => s.skillId)).toEqual([0])
expect(rightList.map((s) => s.skillId)).toEqual([0])
})
it('gates Throw (#2) and Left Hand Throw (#4) on equipped throwing weapons (itypea1 = thro, weapsel = 1)', () => {
expect(canUseSkillWithWeapon(2, { rightHandCode: 'ssd', rightHandType: 'swor' })).toBe(false)
expect(canUseSkillWithWeapon(2, { rightHandCode: 'jav', rightHandType: 'jave' })).toBe(true)
expect(canUseSkillWithWeapon(4, { rightHandCode: 'jav', leftHandCode: 'ssd' })).toBe(false)
expect(canUseSkillWithWeapon(4, { rightHandCode: 'ssd', leftHandCode: 'tkf' })).toBe(true)
})
it('syncs generic skills, throwing weapon skills, and inventory scrolls/tomes into hud.hotkeys and fails fast on unknown skillId', () => {
const world = new ClientWorld()
const hud = new HudManager()
const model = new HudModel({ world, hudManager: hud })
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 4, name: 'Barb', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 4 // Barbarian
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 3, level: 1, baseLevel: 1, bonus: 0 }, // Unsummon
{ skillId: 126, level: 5, baseLevel: 5, bonus: 0 }, // Bash
],
})
// Equip throwing knife in left hand (bodyLoc 5) and javelin in right hand (bodyLoc 4)
world.apply({
type: 'ItemEvent',
action: 6,
container: 'equipment',
item: {
id: 101,
code: 'jav',
location: 1,
bodyLoc: 4,
container: 'equipment',
invWidth: 1,
invHeight: 3,
} as unknown as ItemData,
})
world.apply({
type: 'ItemEvent',
action: 6,
container: 'equipment',
item: {
id: 102,
code: 'tkf',
location: 1,
bodyLoc: 5,
container: 'equipment',
invWidth: 1,
invHeight: 2,
} as unknown as ItemData,
})
// Add Tome of Town Portal (stat 70 = 14) and Scroll of Identify (2 stacks) to inventory
world.apply({
type: 'ItemEvent',
action: 4,
container: 'inventory',
item: {
id: 103,
code: 'tbk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 2,
stats: [{ statId: 70, param: 0, value: 14 }],
} as unknown as ItemData,
})
world.apply({
type: 'ItemEvent',
action: 4,
container: 'inventory',
item: {
id: 104,
code: 'isc',
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 1,
invHeight: 1,
} as unknown as ItemData,
})
model.syncFromWorld()
const ids = hud.hotkeys.availableSkills.map((s) => s.skillId)
expect(ids).toContain(0) // Attack
expect(ids).toContain(2) // Throw
expect(ids).toContain(3) // Unsummon
expect(ids).toContain(4) // Left Hand Throw
expect(ids).toContain(126) // Bash
expect(ids).toContain(217) // Scroll of Identify
expect(ids).toContain(220) // Book of Town Portal
expect(hud.hotkeys.getSkillCharges(220)).toBe(14)
expect(hud.hotkeys.getSkillCharges(217)).toBe(1)
// Fail-fast on unknown skillId without skilldesc metadata
;(world.self.skillEntries as Map<number, any>).set(9999, { skillId: 9999, level: 1, baseLevel: 1, bonus: 0 })
expect(() => model.syncFromWorld()).toThrow(/skilldesc/)
expect(() =>
getSkillListRow({
skillId: 9999,
name: 'Invalid',
nameZh: 'Invalid',
level: 1,
manaCost: 0,
leftUsable: false,
rightUsable: true,
}),
).toThrow(/ListRow/)
})
})
describe('Issue #686: Canonical skilldesc.txt & .tbl skill tooltips without invented lines', () => {
it('formats SkillTreePanel tooltips using StrSkill17, StrSkill1, StrSkill18, and Sksyn without invented text', () => {
const panel = new SkillTreePanel('sor')
panel.lang = 'ENG'
panel.characterLevel = 1
// Unlearned Fire Bolt (#36) -> shows StrSkill17 ("First Level"), no "Unlearned (Level 1 Preview)", no raw catalog Base Damage
const unlearnedLines = panel.formatSkillTooltipLines(36)
const unlearnedTexts = unlearnedLines.map((l) => l.text)
expect(unlearnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill17)
expect(unlearnedTexts.some((t) => t.includes('Unlearned (Level 1 Preview)'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('Base Damage:'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('[Synergy Bonuses]'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('FireBolt Receives Bonuses From:') || t.includes('Fire Bolt Receives Bonuses From:'))).toBe(true)
// Learned Meteor (#56) with hardPoints=5, bonusLevels=2 -> no "(Base 5 + Gear +2)", no "Casting Delay:"
panel.hardPoints.set(56, 5)
panel.bonusLevels.set(56, 2)
const learnedLines = panel.formatSkillTooltipLines(56)
const learnedTexts = learnedLines.map((l) => l.text)
expect(learnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill1)
expect(learnedTexts.some((t) => t.includes('(Base '))).toBe(false)
expect(learnedTexts.some((t) => t.includes('Casting Delay'))).toBe(false)
// Maxed skill (hardPoints=20) -> no "[Max Base Skill Level Reached (20/20)]"
panel.hardPoints.set(36, 20)
const maxedLines = panel.formatSkillTooltipLines(36)
expect(maxedLines.some((l) => l.text.includes('Max Base Skill Level Reached'))).toBe(false)
})
it('formats SkillHotkeysHud tooltips for generic/item skills (#0, #218) without Mana Cost: 0', () => {
const hotkeys = new SkillHotkeysHud()
hotkeys.lang = 'ENG'
const atkEntry = createCanonicalGenericSkillEntry(0, 1)!
const ibkEntry = createCanonicalGenericSkillEntry(218, 1, 17)!
hotkeys.setAvailableSkills([atkEntry, ibkEntry])
hotkeys.setSkillCharges(218, 17)
const atkLines = hotkeys.formatTooltipLines(0, 'left', true, 'ENG')
expect(atkLines[0]?.text).toBe('Attack')
expect(atkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
const ibkLines = hotkeys.formatTooltipLines(218, 'right', true, 'ENG')
expect(ibkLines[0]?.text).toBe(GENERIC_SKILL_TBL_STRINGS[218]!.nameEn) // "Tome of Identify"
expect(ibkLines.some((l) => l.text === 'Quantity 17')).toBe(true)
expect(ibkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
})
})
describe('Issue #687: Character Sheet .tbl labels, dual AR & Defense boxes, dynamic stat colors, and resistance gold threshold', () => {
it('computes dynamic stat value colors (current > base -> blue, < base -> red, === base -> white)', () => {
expect(resolveStatValueColor(25, 20)).toBe('blue')
expect(resolveStatValueColor(15, 20)).toBe('red')
expect(resolveStatValueColor(20, 20)).toBe('white')
})
it('renders canonical .tbl labels, Left/Right AR + Defense boxes, dynamic colors, and maxResCap gold threshold', () => {
const sheet = new CharacterSheetPanel()
sheet.visible = true
sheet.lang = 'ENG'
sheet.attrs.str = 35
sheet.attrs.baseStr = 25 // buffed -> blue
sheet.attrs.dex = 15
sheet.attrs.baseDex = 20 // debuffed -> red
sheet.attrs.vit = 25
sheet.attrs.baseVit = 25 // equal -> white
sheet.attrs.leftSkillName = 'Fire Bolt'
sheet.attrs.rightSkillName = 'Frozen Orb'
sheet.attrs.attackRating = 450
sheet.attrs.baseAttackRating = 300 // buffed -> blue
sheet.attrs.rightAttackRating = 520
sheet.attrs.baseRightAttackRating = 520 // equal -> white
sheet.attrs.defense = 180
sheet.attrs.baseDefense = 120 // buffed -> blue
sheet.attrs.fireRes = 75
sheet.attrs.maxFireRes = 85 // 75 < 85 -> white (NOT gold!)
sheet.attrs.coldRes = 80
sheet.attrs.maxColdRes = 80 // 80 >= 80 -> gold!
sheet.attrs.lightRes = -10
sheet.attrs.maxLightRes = 75 // < 0 -> red
const { ctx, font, drawTextCalls } = createMockCanvasCtx()
sheet.draw(
ctx,
{
hp: 100,
maxHp: 120,
mana: 80,
maxMana: 80,
stamina: 90,
maxStamina: 90,
leftSkillDmg: '12-18',
rightSkillDmg: '240-260',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
levelBtnImg: null,
},
font,
)
const findCall = (text: string) => drawTextCalls.find((c) => c.text === text)
expect(findCall('35')?.color).toBe('blue')
expect(findCall('15')?.color).toBe('red')
expect(findCall('450')?.color).toBe('blue')
expect(findCall('520')?.color).toBe('white')
expect(findCall('180')?.color).toBe('blue')
expect(findCall('12-18')).toBeDefined()
expect(findCall('240-260')).toBeDefined()
// Fire res 75 with maxFireRes 85 must be white, whereas Cold res 80 with maxColdRes 80 must be gold
expect(findCall('75%')?.color).toBe('white')
expect(findCall('80%')?.color).toBe('gold')
expect(findCall('-10%')?.color).toBe('red')
// Two-line .tbl resistance labels ("Fire" + "Resistance")
expect(findCall('Fire')).toBeDefined()
expect(findCall('Resistance')).toBeDefined()
})
})
describe('Issue #688: skills.txt column derivation (passive, leftskill, aura, maxlvl) & canAllocate maxlvl check', () => {
it('derives isPassiveSkill, isAuraSkill, isLeftUsableSkill, and getSkillMaxLevel from skills.txt metadata', () => {
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
expect(isAuraSkill(98)).toBe(true) // Might
expect(isAuraSkill(106)).toBe(false) // Zeal
expect(isLeftUsableSkill(36)).toBe(true) // Fire Bolt (leftskill=1)
expect(isLeftUsableSkill(56)).toBe(false) // Meteor (leftskill=0)
expect(getSkillMaxLevel(36)).toBe(20)
expect(getSkillMaxLevel(36, 25)).toBe(25)
})
it('enforces getSkillMaxLevel(skillId) in SkillTreePanel.canAllocate', () => {
const panel = new SkillTreePanel('sor')
panel.unspentSkillPoints = 5
panel.characterLevel = 99
panel.hardPoints.set(36, getSkillMaxLevel(36))
expect(panel.canAllocate(36)).toBe(false)
panel.hardPoints.set(36, getSkillMaxLevel(36) - 1)
expect(panel.canAllocate(36)).toBe(true)
})
})
describe('Issue #689: Attack (#0) always uses skilldesc.txt IconCel = 2 regardless of hasWeapon', () => {
it('returns frameIndex = 2 (x = 96) for both armed and unarmed resolveSkillIcon(0)', () => {
const unarmed = resolveSkillIcon(0, false)
const armed = resolveSkillIcon(0, true)
expect(unarmed.frameIndex).toBe(2)
expect(unarmed.atlasRect.x).toBe(96)
expect(armed.frameIndex).toBe(2)
expect(armed.atlasRect.x).toBe(96)
})
})
describe('Cross-cutting HudModel & HudManager hooks (#664, #665, #673, #681, #683)', () => {
it('forwards graphicIndex to UiInventoryItem.variant in itemDataToUiInventoryItem (#681)', () => {
const ringItem = {
id: 501,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
graphicIndex: 3,
} as unknown as ItemData
const ui = itemDataToUiInventoryItem(ringItem)
expect((ui as any).variant).toBe(3)
})
it('preserves wp.npcMenu.hoveredOptionIdx on sync and anchors menu at Y = Math.max(20, npcScreenY - 150) (#664, #665)', () => {
const world = new ClientWorld()
const hud = new HudManager()
const model = new HudModel({ world, hudManager: hud })
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 1
world.self.x = 5000
world.self.y = 5000
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 77, classId: 148, x: 5002, y: 5002 }, // Akara
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 77 } })
model.syncFromWorld()
expect(hud.worldPanels.npcMenu).not.toBeNull()
// npcScreenY = 300 + (2 + 2) * 8 = 332 -> anchorY = 332 - 150 = 182
expect(hud.worldPanels.npcMenu?.y).toBe(182)
// Hover option index 1 and re-sync -> hoveredOptionIdx must be preserved
hud.worldPanels.npcMenu!.hoveredOptionIdx = 1
model.syncFromWorld()
expect(hud.worldPanels.npcMenu?.hoveredOptionIdx).toBe(1)
})
it('switches weapon sets only when clicking the inactive half (I vs II) of either weapon swap tab (#683) and omits drawAreaBanner (#673)', () => {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({
world,
hudManager: hud,
onCommand: (cmd) => commands.push(cmd),
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 1
model.syncFromWorld()
hud.toggleRightPanel('inv')
expect(hud.inventory.weaponSwapSet).toBe(0)
// Clicking left half ('I') of right weapon swap tab when set is already 0 -> no SwapWeapons command
const rightTab = WEAPON_SWAP_TABS_BOUNDS.right
hud.handleClick(rightTab.x + 10, rightTab.y + 10, 0)
expect(commands).toEqual([])
// Clicking right half ('II') of left weapon swap tab when set is 0 -> emits SwapWeapons command
const leftTab = WEAPON_SWAP_TABS_BOUNDS.left
hud.handleClick(leftTab.x + leftTab.w - 10, leftTab.y + 10, 0)
expect(commands).toEqual([{ type: 'SwapWeapons' }])
// Verify render() never calls worldPanels.drawAreaBanner
const bannerSpy = vi.spyOn(hud.worldPanels, 'drawAreaBanner')
const { ctx } = createMockCanvasCtx()
hud.ctx = ctx
hud.leftPanel = 'none'
hud.rightPanel = 'none'
hud.render()
expect(bannerSpy).not.toHaveBeenCalled()
})
})
})

View File

@ -196,13 +196,14 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
expect(yellowBorder).toBeDefined() expect(yellowBorder).toBeDefined()
}) })
it('includes aura narrative tag in tooltip', () => { it('omits invented aura narrative tag in canonical 1.13c tooltip', () => {
hud.setAvailableSkills([ hud.setAvailableSkills([
{ skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true }, { skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true },
]) ])
const lines = hud.formatTooltipLines(98, 'right', false) const lines = hud.formatTooltipLines(98, 'right', false)
const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow') expect(lines.length).toBeGreaterThan(0)
expect(auraTag).toBeDefined() const auraTag = lines.find(l => l.text.includes('Right-Click Only') || l.text.includes('仅限右键'))
expect(auraTag).toBeUndefined()
}) })
}) })
@ -269,11 +270,11 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
expect(hud.isSkillOnCooldown(56, 1500)).toBe(false) expect(hud.isSkillOnCooldown(56, 1500)).toBe(false)
}) })
it('formats remaining cooldown in tooltip', () => { it('omits invented cooldown countdown line in canonical 1.13c tooltip', () => {
hud.setSkillCooldown(56, 1500, 5000) hud.setSkillCooldown(56, 1500, 5000)
const lines = hud.formatTooltipLines(56, 'right', false) const lines = hud.formatTooltipLines(56, 'right', false)
const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red') const cdLine = lines.find(l => l.text.includes('冷却中') || l.text.includes('[Cooldown:'))
expect(cdLine).toBeDefined() expect(cdLine).toBeUndefined()
}) })
}) })

View File

@ -54,13 +54,12 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => {
} }
}) })
it('maps universal / basic actions to ListRow = 0', () => { it('maps universal / basic actions to ListRow = 0 and excludes ListRow = -1 (#1 Kick, #5 Left Hand Swing)', () => {
const basicActions = [ const basicActions = [
{ skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true }, { skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true }, { skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true }, { skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true },
{ skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true }, { skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true },
{ skillId: 5, name: 'Left Hand Swing', leftUsable: true, rightUsable: true },
{ skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true }, { skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true },
] ]
for (const action of basicActions) { for (const action of basicActions) {
@ -75,6 +74,17 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => {
}) })
expect(row).toBe(0) expect(row).toBe(0)
} }
expect(
getSkillListRow({
skillId: 5,
name: 'Left Hand Swing',
nameZh: 'Left Hand Swing',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
}),
).toBe(-1)
}) })
it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => { it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => {
@ -477,16 +487,16 @@ describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => {
it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => { it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => {
hud.setSkillCharges(219, 12, 20) hud.setSkillCharges(219, 12, 20)
const tpLines = hud.formatTooltipLines(219, 'right', true) const tpLines = hud.formatTooltipLines(219, 'right', true)
expect(tpLines[0].text).toContain('城镇传送卷轴') expect(tpLines[0].text).toContain('傳送卷軸')
expect(tpLines.some(l => l.text.includes('12'))).toBe(true) expect(tpLines.some(l => l.text.includes('12'))).toBe(true)
hud.setSkillCharges(218, 20, 20) hud.setSkillCharges(218, 20, 20)
const idLines = hud.formatTooltipLines(218, 'right', true) const idLines = hud.formatTooltipLines(218, 'right', true)
expect(idLines[0].text).toContain('辨识之书') expect(idLines[0].text).toContain('辨視之書')
expect(idLines.some(l => l.text.includes('20'))).toBe(true) expect(idLines.some(l => l.text.includes('20'))).toBe(true)
const lhtLines = hud.formatTooltipLines(4, 'left', true) const lhtLines = hud.formatTooltipLines(4, 'left', true)
expect(lhtLines[0].text).toContain('左手投掷') expect(lhtLines[0].text).toContain('左手投擲')
}) })
}) })