diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 7b46c1b..2b3154f 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -10,7 +10,7 @@ */ 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 { CANONICAL_ELITE_MODIFIERS, FIXED_UNIQUE_BOSS_HCIDX_SET, @@ -21,7 +21,16 @@ import { rollUniqueMonsterNameKeys, type Difficulty, } 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 { ClickButtonId } from '../../netproto/index.ts' import type { @@ -32,7 +41,12 @@ import type { } from '../../netproto/index.ts' import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.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 EquipSlotId, @@ -129,7 +143,15 @@ export function itemDataToUiInventoryItem( item: ItemData, socketChildren?: readonly ItemData[], ): 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 { @@ -633,68 +655,51 @@ export class HudModel implements HudCommandSink { attrs.xp = self.experience attrs.nextLevelXp = nextLevelExperience(self.level) attrs.str = stats.get(D2StatId.Strength) + attrs.baseStr = stats.getBase(D2StatId.Strength) attrs.dex = stats.get(D2StatId.Dexterity) + attrs.baseDex = stats.getBase(D2StatId.Dexterity) attrs.vit = stats.get(D2StatId.Vitality) + attrs.baseVit = stats.getBase(D2StatId.Vitality) attrs.ene = stats.get(D2StatId.Energy) + attrs.baseEne = stats.getBase(D2StatId.Energy) attrs.statPoints = stats.get(D2StatId.StatPts) - attrs.baseMaxHp = self.maxLife - attrs.baseMaxMana = self.maxMana - attrs.baseMaxStamina = self.maxStamina + const rawBaseMaxHp = stats.getBase(D2StatId.MaxHp) + attrs.baseMaxHp = rawBaseMaxHp > 0 ? Math.floor(rawBaseMaxHp / 256) : self.maxLife + 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. - const derived = self.charClass === null + const classCode = self.charClass === null ? null : resolveClassCode(self.charClass) + const derived = classCode === 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.baseDefense = classCode === null + ? 0 + : Math.max(0, Math.floor(attrs.baseDex / 4) + stats.getBase(D2StatId.ArmorClass)) 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.coldRes = derived?.coldRes ?? 0 attrs.lightRes = derived?.lightRes ?? 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). hud.inventory.gold = stats.get(D2StatId.Gold) hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank) - // 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync. - 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 + // 4. Containers (equipment & inventory) synced before speedbar weapon/scroll checks. 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[]) { delete hud.inventory.equipped[key] } @@ -723,6 +728,123 @@ export class HudModel implements HudCommandSink { })) 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() + 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) => ({ item: this.toUiItem(item), col: item.gridX ?? item.x ?? 0, @@ -1068,6 +1190,55 @@ export class HudModel implements HudCommandSink { 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 { const npc = this.world.npcInteraction const prev = this.prevNpc @@ -1093,7 +1264,7 @@ export class HudModel implements HudCommandSink { (this.world.merc as any)?.name ?? ((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) - ;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, { + const menuCtx = { unidentifiedItemCount, hasUnidentifiedItems: unidentifiedItemCount > 0, hasDeadMercenary, @@ -1101,7 +1272,33 @@ export class HudModel implements HudCommandSink { deadMercenaryName, playerLevel, 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) { diff --git a/src/client/ui/character-sheet.ts b/src/client/ui/character-sheet.ts index 159584e..470127c 100644 --- a/src/client/ui/character-sheet.ts +++ b/src/client/ui/character-sheet.ts @@ -8,13 +8,48 @@ * and Fire / Cold / Lightning / Poison resistances. */ -import type { D2FontRenderer } from './font.ts' -import { getTblLang, type TblLang } from '../i18n/lang.ts' +import type { D2FontRenderer, D2ColorCode } from './font.ts' +import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts' +import { getSkillName } from '../../common/skills/skills-meta.ts' 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_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 void): boolean { 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) - font.drawText(ctx, isEn ? 'Level' : '等级', ox + 33, oy + 46, { 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, requireTbl('strchrlvl', this.lang), ox + 33, oy + 46, { + 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, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, { font: 'font6', color: 'white', align: 'center' }) + font.drawText(ctx, requireTbl('strchrexp', this.lang), ox + 124, oy + 46, { + 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, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { font: 'font6', color: 'white', align: 'center' }) + font.drawText(ctx, requireTbl('strchrnxtlvl', this.lang), ox + 252, oy + 46, { + 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`) - const statRows: readonly { key: BaseStatKey; label: string; y: number; val: number }[] = [ - { key: 'str', label: isEn ? 'Strength' : '力量', y: 94, val: this.attrs.str }, - { key: 'dex', label: isEn ? 'Dexterity' : '敏捷', y: 156, val: this.attrs.dex }, - { key: 'vit', label: isEn ? 'Vitality' : '体力', y: 242, val: this.attrs.vit }, - { key: 'ene', label: isEn ? 'Energy' : '精力', y: 304, val: this.attrs.ene }, + const statRows: readonly { + key: BaseStatKey + label: string + y: number + 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) { 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: 'fontexocet10', - color: 'blue', + color: resolveStatValueColor(r.val, r.base), align: 'center', }) 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 (isEn) { - font.drawText(ctx, 'Stat Points', ox + 56, oy + 363, { - font: 'font6', - color: 'red', - align: 'center', - }) - font.drawText(ctx, 'Remaining', ox + 56, oy + 371, { - font: 'font6', - color: 'red', - align: 'center', - }) - } else { - font.drawText(ctx, '剩余属性点', ox + 56, oy + 368, { - font: 'font8', - color: 'red', - align: 'center', - }) - } + const statPtsLabel = requireTbl('strchrstat', this.lang) + const remLabel = requireTbl('strchrrema', this.lang) + font.drawText(ctx, statPtsLabel, ox + 56, oy + 363, { + font: isEn ? 'font6' : 'font8', + color: 'red', + align: 'center', + }) + font.drawText(ctx, remLabel, ox + 56, oy + 373, { + font: isEn ? 'font6' : 'font8', + color: 'red', + align: 'center', + }) font.drawText(ctx, String(this.attrs.statPoints), ox + 122, oy + 368, { font: 'fontexocet10', color: 'red', @@ -202,140 +261,165 @@ export class CharacterSheetPanel { }) } - // 6. Combat Ratings, Stamina, Life, Mana - if (isEn) { - font.drawText(ctx, 'Left Attack', ox + 214, oy + 90, { - font: 'font6', - color: 'tan', - align: 'center', - }) - font.drawText(ctx, 'Damage', ox + 214, oy + 98, { - font: 'font6', - color: 'tan', - align: 'center', - }) - } else { - font.drawText(ctx, '左键技能伤害', ox + 164, oy + 94, { - font: 'font8', - color: 'tan', - }) - } - font.drawText(ctx, liveRuntime.leftSkillDmg, ox + 288, oy + 94, { + // 6. Combat Ratings (Left/Right Skill Damage, Left/Right Attack Rating, Defense), Stamina, Life, Mana + const dmgLabel = requireTbl('strchrskm', this.lang) + const defaultAtkName = getSkillName(0, this.lang) + const leftSkillLabel = this.attrs.leftSkillName || defaultAtkName + const rightSkillLabel = this.attrs.rightSkillName || defaultAtkName + const leftDmgStr = liveRuntime.leftSkillDmg || this.attrs.leftSkillDmg || '1-2' + const rightDmgStr = liveRuntime.rightSkillDmg || this.attrs.rightSkillDmg || '1-2' + + font.drawText(ctx, leftSkillLabel, ox + 214, oy + 89, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) + font.drawText(ctx, dmgLabel, ox + 214, oy + 98, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) + font.drawText(ctx, leftDmgStr, ox + 288, oy + 94, { font: 'font6', - color: 'blue', + color: this.attrs.leftSkillDmgColor ?? 'white', align: 'center', }) - if (isEn) { - font.drawText(ctx, 'Right Attack', ox + 214, oy + 114, { - font: 'font6', - color: 'tan', - align: 'center', - }) - font.drawText(ctx, 'Damage', ox + 214, oy + 122, { - font: 'font6', - color: 'tan', - align: 'center', - }) - } else { - font.drawText(ctx, '右键技能伤害', ox + 164, oy + 118, { - font: 'font8', - color: 'tan', - }) - } - font.drawText(ctx, liveRuntime.rightSkillDmg, ox + 288, oy + 118, { + font.drawText(ctx, rightSkillLabel, ox + 214, oy + 113, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) + font.drawText(ctx, dmgLabel, ox + 214, oy + 122, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) + font.drawText(ctx, rightDmgStr, ox + 288, oy + 118, { font: 'font6', - color: 'blue', + color: this.attrs.rightSkillDmgColor ?? 'white', align: 'center', }) - font.drawText(ctx, isEn ? 'Attack Rating' : '命中率', ox + 166, oy + 156, { font: 'font8', color: 'tan' }) - font.drawText(ctx, String(this.attrs.attackRating), ox + 290, oy + 156, { + // Three boxes next to Dexterity (y=138..159 Left AR, y=164..185 Right AR, y=192..213 Defense) + 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', - color: 'white', + color: resolveStatValueColor(this.attrs.attackRating, this.attrs.baseAttackRating), align: 'center', }) - - font.drawText(ctx, isEn ? 'Defense' : '防御力', ox + 166, oy + 204, { font: 'font8', color: 'tan' }) + // Box 2: Right-hand Attack Rating (y=164..185, baseline oy + 178) + 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: 'font8', - color: 'blue', + color: resolveStatValueColor(this.attrs.defense, this.attrs.baseDefense), align: 'center', }) - font.drawText(ctx, isEn ? 'Stamina' : '耐力', ox + 166, oy + 242, { font: 'font8', color: 'tan' }) - font.drawText(ctx, `${Math.round(liveRuntime.maxStamina)}`, ox + 251, oy + 242, { + // Stamina, Life, Mana + 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', - color: 'white', + color: 'tan', + }) + font.drawText(ctx, `${maxStam}`, ox + 251, oy + 242, { + font: 'font8', + color: resolveStatValueColor(maxStam, baseMaxStam), align: 'center', }) - font.drawText(ctx, `${Math.round(liveRuntime.stamina)}`, ox + 291, oy + 242, { + font.drawText(ctx, `${curStam}`, ox + 291, oy + 242, { font: 'font8', color: 'white', align: 'center', }) - font.drawText(ctx, isEn ? 'Life' : '生命', ox + 166, oy + 266, { font: 'font8', color: 'tan' }) - font.drawText(ctx, `${Math.round(liveRuntime.maxHp)}`, ox + 251, oy + 266, { + const maxHp = Math.round(liveRuntime.maxHp) + 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', - color: 'blue', + color: 'tan', + }) + font.drawText(ctx, `${maxHp}`, ox + 251, oy + 266, { + font: 'font8', + color: resolveStatValueColor(maxHp, baseMaxHp), align: 'center', }) - font.drawText(ctx, `${Math.round(liveRuntime.hp)}`, ox + 291, oy + 266, { + font.drawText(ctx, `${curHp}`, ox + 291, oy + 266, { font: 'font8', color: 'white', align: 'center', }) - font.drawText(ctx, isEn ? 'Mana' : '法力', ox + 166, oy + 304, { font: 'font8', color: 'tan' }) - font.drawText(ctx, `${Math.round(liveRuntime.maxMana)}`, ox + 251, oy + 304, { + const maxMana = Math.round(liveRuntime.maxMana) + 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', - color: 'blue', + color: 'tan', + }) + font.drawText(ctx, `${maxMana}`, ox + 251, oy + 304, { + font: 'font8', + color: resolveStatValueColor(maxMana, baseMaxMana), align: 'center', }) - font.drawText(ctx, `${Math.round(liveRuntime.mana)}`, ox + 291, oy + 304, { + font.drawText(ctx, `${curMana}`, ox + 291, oy + 304, { font: 'font8', color: 'white', align: 'center', }) - // 7. Four Elemental Resistances + // 7. Four Elemental Resistances (`strchrfir`, `strchrcol`, `strchrlit`, `strchrpos`) const resRows = [ - { labelEn: ['Fire Resistance'], labelZh: '火焰抗性', y: 344, val: this.attrs.fireRes }, - { labelEn: ['Cold Resistance'], labelZh: '冰冷抗性', y: 368, val: this.attrs.coldRes }, - { labelEn: ['Lightning', 'Resistance'], labelZh: '闪电抗性', y: 392, val: this.attrs.lightRes }, - { labelEn: ['Poison Resistance'], labelZh: '毒素抗性', y: 416, val: this.attrs.poisRes }, + { tblKey: 'strchrfir', y: 344, val: this.attrs.fireRes, maxCap: this.attrs.maxFireRes ?? 75 }, + { tblKey: 'strchrcol', y: 368, val: this.attrs.coldRes, maxCap: this.attrs.maxColdRes ?? 75 }, + { tblKey: 'strchrlit', y: 392, val: this.attrs.lightRes, maxCap: this.attrs.maxLightRes ?? 75 }, + { tblKey: 'strchrpos', y: 416, val: this.attrs.poisRes, maxCap: this.attrs.maxPoisonRes ?? 75 }, ] as const for (const res of resRows) { - if (isEn) { - if (res.labelEn.length === 2) { - font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y - 4, { - font: 'font6', - color: 'tan', - align: 'center', - }) - font.drawText(ctx, res.labelEn[1], ox + 222, oy + res.y + 4, { - font: 'font6', - color: 'tan', - align: 'center', - }) - } else { - font.drawText(ctx, res.labelEn[0], ox + 222, oy + res.y, { - font: 'font6', - color: 'tan', - align: 'center', - }) - } + const rawLabel = requireTbl(res.tblKey, this.lang) + const labelLines = rawLabel.split('\n').map(s => s.trim()).filter(Boolean) + if (labelLines.length >= 2) { + font.drawText(ctx, labelLines[0]!, ox + 222, oy + res.y - 4, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) + font.drawText(ctx, labelLines[1]!, ox + 222, oy + res.y + 4, { + font: isEn ? 'font6' : 'font8', + color: 'tan', + align: 'center', + }) } else { - font.drawText(ctx, res.labelZh, ox + 222, oy + res.y, { - font: 'font8', + font.drawText(ctx, labelLines[0] ?? rawLabel, ox + 222, oy + res.y, { + font: isEn ? 'font6' : 'font8', color: 'tan', align: 'center', }) } font.drawText(ctx, `${res.val}%`, ox + 291, oy + res.y, { 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', }) } diff --git a/src/client/ui/hotkeys.ts b/src/client/ui/hotkeys.ts index 26d43c4..acc1e80 100644 --- a/src/client/ui/hotkeys.ts +++ b/src/client/ui/hotkeys.ts @@ -11,7 +11,7 @@ import type { D2FontRenderer, D2ColorCode } from './font.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 type { TblLang } from '../i18n/lang.ts' @@ -22,6 +22,15 @@ import { isLeftUsableSkill, PALADIN_AURA_SKILL_IDS, isAuraSkill, + SKILLS_TABLE_META, + SKILL_TBL_STRINGS, + GENERIC_SKILL_TBL_STRINGS, + getSkillName, + getGenericSkillLongDesc, + getCanonicalSkillListRow, + getCanonicalSkillIconCel, + canUseSkillWithWeapon, + type WeaponEquipCheckState, type HotkeySkillEntry, } from '../../common/skills/skills-meta.ts' @@ -32,6 +41,8 @@ export { isLeftUsableSkill, PALADIN_AURA_SKILL_IDS, isAuraSkill, + canUseSkillWithWeapon, + type WeaponEquipCheckState, 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 { - if (skillId === 0) { - return { - skillId: 0, - 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) { +export function resolveSkillIcon(skillId: number, _hasWeapon = true): SkillIconMapping { + if (skillId in GENERIC_SKILL_TBL_STRINGS) { + const iconCel = getCanonicalSkillIconCel(skillId) + const name = getSkillName(skillId, 'ENG') return { skillId, - name: skillId === 217 ? 'Scroll of Identify' : 'Book of Identify', + name, iconPath: `/skills/icon_${skillId}.png`, isGeneric: true, dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', - frameIndex: 14, - atlasRect: { x: 672, 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 }, + frameIndex: iconCel, + atlasRect: { x: iconCel * 48, y: 0, width: 48, height: 48, w: 48, h: 48 }, } } + const meta = SKILLS_TABLE_META[skillId] return { skillId, - name: `Skill ${skillId}`, + name: getSkillName(skillId, 'ENG'), iconPath: `/skills/icon_${skillId}.png`, isGeneric: false, dc6Path: '', dc6Source: '', - frameIndex: skillId, + frameIndex: meta ? meta.iconCel : skillId, atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 }, } } /** - * Resolves authentic 1.13c ListRow for Speedbar popup (0..4). - * Row 0 (bottom): Basic universal actions (Attack 0, Throw 2, Unsummon 3, etc.) - * Rows 1..3: Character skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) - * Row 4 (top): Item skills (Scrolls/Books of TP & ID, or items marked as item skills) + * Resolves authentic 1.13c `ListRow` for Speedbar popup (`-1..4`) from `skilldesc.txt`. + * - Row -1: Excluded from speedbar (Kick #1, Left Hand Swing #5) + * - Row 0 (bottom): Basic universal actions (Attack #0, Throw #2, Unsummon #3, Left Hand Throw #4) + * - 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 { - if (typeof (skill as any).listRow === 'number') { - return (skill as any).listRow + if (typeof (skill as { listRow?: unknown }).listRow === 'number') { + return (skill as { listRow: number }).listRow } const id = skill.skillId - // 1. Item skills (Scrolls/Books of TP & ID, charged items, or marked as item skills) - if (id === 217 || id === 218 || id === 219 || id === 220 || (skill as any).isItemSkill || skill.charges !== undefined) { + if ( + id === 217 || + id === 218 || + id === 219 || + id === 220 || + Boolean((skill as { isItemSkill?: boolean }).isItemSkill) || + skill.charges !== undefined + ) { return 4 } - // 2. Universal / basic actions - if (id === 0 || id === 1 || id === 2 || id === 3 || id === 4 || id === 5 || id === 350 || (skill as any).isGeneric) { + if (id in SKILLS_TABLE_META) { + 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 } - // 3. Class skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) - 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 + return getCanonicalSkillListRow(id, false) } /** * Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup. */ export function getSkillReqLevel(skill: HotkeySkillEntry): number { - if (typeof (skill as any).reqlevel === 'number') { - return (skill as any).reqlevel + if (typeof (skill as { reqlevel?: unknown }).reqlevel === 'number') { + return (skill as { reqlevel: number }).reqlevel } const catalogEntry = SKILLS_BY_ID[skill.skillId] if (catalogEntry && typeof catalogEntry.reqlevel === 'number') { @@ -178,12 +140,53 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number { 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( side: 'left' | 'right', skills: readonly HotkeySkillEntry[], + weaponState?: WeaponEquipCheckState, ): readonly HotkeySkillEntry[] { - return skills.filter(s => { + return ensureDefaultAttackSkill(skills).filter(s => { 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 (isAuraSkill(s.skillId) || s.isAura) return false if (!isLeftUsableSkill(s.skillId)) return false @@ -323,13 +326,18 @@ export class SkillHotkeysHud { } } + weaponEquipState?: WeaponEquipCheckState | undefined + setAvailableSkills( skills: readonly HotkeySkillEntry[], defaultLeft?: number, defaultRight?: number, ): void { - // Passive skills strictly cannot enter the quickbar - this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive) + // Passive skills and ListRow < 0 skills (Kick #1, Left Hand Swing #5) strictly cannot enter the quickbar + this.availableSkills = skills.filter(s => { + if (isPassiveSkill(s.skillId) || s.isPassive) return false + return getSkillListRow(s) >= 0 + }) if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) { this.leftSkillId = defaultLeft } @@ -341,6 +349,7 @@ export class SkillHotkeysHud { addOrUpdateSkill(entry: HotkeySkillEntry): void { if (isPassiveSkill(entry.skillId) || entry.isPassive) return + if (getSkillListRow(entry) < 0) return const idx = this.availableSkills.findIndex(s => s.skillId === entry.skillId) if (idx >= 0) { this.availableSkills[idx] = entry @@ -422,7 +431,7 @@ export class SkillHotkeysHud { } 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 { @@ -653,77 +662,15 @@ export class SkillHotkeysHud { ctx.fillStyle = '#14110d' ctx.fillRect(x, y, 48, 48) - if (skillId === 0) { - if (this.hasEquippedWeapon) { - 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) { + if (skillId in GENERIC_SKILL_TBL_STRINGS) { + const iconCel = getCanonicalSkillIconCel(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) } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { - ctx.drawImage(genericSkillsImg, 14 * 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) + 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) @@ -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( skillId: number, @@ -795,6 +743,7 @@ export class SkillHotkeysHud { ): FormattedTooltipLine[] { const lines: FormattedTooltipLine[] = [] const isEn = lang === 'ENG' + const tbl = SKILL_TBL_STRINGS[lang] const pushHotkeyLine = () => { 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) - if (skillId === 0) { + // 1. Generic & Item Skills (#0..#5, #217..#220, #350) from skilldesc.txt + .tbl + if (skillId in GENERIC_SKILL_TBL_STRINGS) { lines.push({ - text: isEn ? 'Attack' : '普通攻击', + text: getSkillName(skillId, lang), color: 'gold', font: 'fontexocet10', align: 'center', }) pushHotkeyLine() - lines.push({ - text: isEn - ? 'Normal physical attack with equipped weapon or bare hands.' - : '使用当前装备的武器或赤手空拳进行基础物理攻击。', - color: 'white', - font: 'font8', - align: 'center', - }) - lines.push({ - text: isEn ? 'Mana Cost: 0' : '法力消耗: 0', - 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', - }) + const longDesc = getGenericSkillLongDesc(skillId, lang) + if (longDesc) { + for (const descLine of longDesc.split('\n')) { + const trimmed = descLine.trim() + if (trimmed) { + lines.push({ + text: trimmed, + color: 'white', + font: 'font8', + align: 'center', + }) + } + } } - return lines - } - - if (skillId === 219 || skillId === 220) { - const isBook = skillId === 220 - lines.push({ - text: isEn - ? (isBook ? 'Tome of Town Portal' : 'Scroll of Town Portal') - : (isBook ? '城镇传送之书' : '城镇传送卷轴'), - 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', - }) + const meta = SKILLS_TABLE_META[skillId] + if (meta?.descline1 && meta.descline1 >= 53 && meta.descline1 <= 56) { + const charges = this.getSkillCharges(skillId) + if (charges !== undefined) { + lines.push({ + text: `${tbl.skilldesc4}${charges}`, + color: charges > 0 ? 'white' : 'red', + font: 'font8', + align: 'center', + }) + } } return lines } // 2. Class Skills - const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null const entry = this.availableSkills.find(s => s.skillId === 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 nameEn = vm?.skill.name ?? entry?.name ?? catalogEntry?.name ?? `Skill ${skillId}` @@ -993,29 +832,7 @@ export class SkillHotkeysHud { // Hotkey Info pushHotkeyLine() - // Aura Tag - 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 + // Charges Tag (for item-granted charged skills) const charges = this.getSkillCharges(skillId) const maxCharges = this.getSkillMaxCharges(skillId) if (charges !== undefined) { @@ -1030,32 +847,19 @@ export class SkillHotkeysHud { } if (vm) { - // Current level + // Current level (StrSkill2: "Current Skill Level: " / "目前技能等級:") 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({ - text: lvlText, + text: `${tbl.StrSkill2}${vm.effectiveLevel}`, color: soft > 0 ? 'blue' : 'white', font: 'font8', align: 'center', }) - // Mana cost + // Mana cost (StrSkill3: "Mana Cost: " / "法力消耗:") if (vm.current.manaCost > 0 || vm.skill.mana > 0) { lines.push({ - text: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${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} 秒`, + text: `${tbl.StrSkill3}${vm.current.manaCost}`, color: 'white', font: 'font8', align: 'center', @@ -1117,69 +921,44 @@ export class SkillHotkeysHud { } } - // Synergies received + // Synergies received (canonical .tbl Sksyn) if (vm.synergiesReceived && vm.synergiesReceived.length > 0) { + const skillTitle = isEn ? nameEn : nameZh lines.push({ - text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]', - color: 'blue', - font: 'fontexocet10', + text: tbl.Sksyn.replace('%s', skillTitle), + color: 'white', + font: 'font8', align: 'center', }) for (const syn of vm.synergiesReceived) { const text = isEn - ? (syn.hardPoints > 0 - ? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)` - : `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`) - : (syn.hardPoints > 0 - ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)` - : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`) + ? `${syn.providerName}: +${syn.percentPerLevel}% Damage per Level` + : `${syn.providerNameZh}: +${syn.percentPerLevel}% 每等級傷害` lines.push({ text, - color: syn.hardPoints > 0 ? 'blue' : 'gray', + color: 'white', font: 'font8', - align: 'left', + align: 'center', }) } } } else { - // Fallback without ViewModel const level = entry?.level ?? 1 - const mana = entry?.manaCost ?? catalogEntry?.mana ?? 0 + const mana = entry?.manaCost ?? 0 lines.push({ - text: isEn ? `Current Skill Level: ${level}` : `当前技能等级: ${level}`, + text: `${tbl.StrSkill2}${level}`, color: 'white', font: 'font8', align: 'center', }) if (mana > 0) { lines.push({ - text: isEn ? `Mana Cost: ${mana}` : `法力消耗: ${mana}`, + text: `${tbl.StrSkill3}${mana}`, color: 'white', font: 'font8', 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 diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 57a33ec..4d93572 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -20,7 +20,12 @@ import { BeltHud } from './belt.ts' import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts' import { ControlBarHud, type MiniPanelAction } from './control-bar.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 { resolveMercPortraitIcon, @@ -2081,6 +2086,20 @@ export class HudManager { 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)) { this.syncPublishedState() return @@ -2160,15 +2179,16 @@ export class HudManager { private forwardInventoryPanelClick(x: number, y: number, isRightClick: boolean): boolean { const sink = this._commandSink if (!sink) return false - const swapTabs = [ - { ...WEAPON_SWAP_TABS_BOUNDS.left, set: 0 as const }, - { ...WEAPON_SWAP_TABS_BOUNDS.right, set: 1 as const }, - ] + const swapTabs = [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right] for (const tab of swapTabs) { if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) { - if (tab.set !== this.inventory.weaponSwapSet) { - sink.swapWeapons() - this.onWeaponSwitch?.() + const targetSet: 0 | 1 = x < tab.x + tab.w / 2 ? 0 : 1 + if (targetSet !== this.inventory.weaponSwapSet) { + if (typeof sink.swapWeapons === 'function') { + sink.swapWeapons() + } else { + this.onWeaponSwitch?.() + } } return true } @@ -2292,8 +2312,8 @@ export class HudManager { this.hotkeys.addOrUpdateSkill({ skillId, - name: catalogEntry.name, - nameZh: catalogEntry.nameZh, + name: getSkillName(skillId, 'ENG'), + nameZh: getSkillName(skillId, 'CHI'), level: currentHard, manaCost: mana, 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() { return { level: this.charSheet.attrs.level, @@ -2664,10 +2721,10 @@ export class HudManager { maxMana: this.maxMana, stamina: this.stamina, maxStamina: this.maxStamina, - // Skill damage is not shown until it is computed from Skills.txt / missiles via the 1.13c skill calc - // (no fabricated per-level numbers). - leftSkillDmg: '', - rightSkillDmg: '', + leftSkillDmg: + this.charSheet.attrs.leftSkillDmg || this.computeActiveSkillDmgString(this.hotkeys.leftSkillId), + rightSkillDmg: + this.charSheet.attrs.rightSkillDmg || this.computeActiveSkillDmgString(this.hotkeys.rightSkillId), }, { borderLeftImg: this.images.get('borderLeft') ?? null, @@ -2868,11 +2925,6 @@ export class HudManager { ) } - // 9. Area Entry Gothic Banner (`Entering: `, 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) if ( (this.rightPanel === 'inv' || diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index 01837d6..ded5764 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -20,6 +20,8 @@ import { import { getSkillName, formatDamageTypeEn, + getSkillMaxLevel, + SKILL_TBL_STRINGS, type FormattedTooltipLine, } from '../../common/skills/skills-meta.ts' import { reportMissingArt } from '../scene/world-renderer.ts' @@ -27,6 +29,7 @@ import { reportMissingArt } from '../scene/world-renderer.ts' export { getSkillName, formatDamageTypeEn, + getSkillMaxLevel, type FormattedTooltipLine, } @@ -217,6 +220,18 @@ export class SkillTreePanel { private skillIcons = new Map() 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[] { if (this.characterClass === null) return [] 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 * 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 const node = this.getSkillTree().find(n => n.skillId === skillId) if (!node) return false const cur = this.getHardPoints(skillId) - if (cur >= 20) return false + if (cur >= getSkillMaxLevel(skillId, maxlvlOverride)) return false if (charLevel < node.reqLevel + cur) return false return node.prereqs.every(p => this.getHardPoints(p) > 0) } @@ -580,6 +595,7 @@ export class SkillTreePanel { static formatSkillTooltipLines(vm: TooltipViewModel, lang: TblLang = 'CHI'): FormattedTooltipLine[] { const lines: FormattedTooltipLine[] = [] const isEn = lang === 'ENG' + const tbl = SKILL_TBL_STRINGS[lang] // 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center) lines.push({ @@ -592,7 +608,7 @@ export class SkillTreePanel { // 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs if (!vm.levelMet) { lines.push({ - text: isEn ? `Required Level: ${vm.reqlevel}` : `需要角色等级: ${vm.reqlevel}`, + text: `${tbl.StrSkill18}${isEn ? ' ' : ''}${vm.reqlevel}`, color: 'red', font: 'font8', 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) { 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({ - text: lvlText, + text: `${tbl.StrSkill2}${vm.effectiveLevel}`, color: soft > 0 ? 'blue' : 'white', font: 'font8', align: 'center', }) } else { lines.push({ - text: isEn ? 'Unlearned (Level 1 Preview)' : '未习得 (预览 1 级效果)', - color: 'gray', - font: 'font8', - align: 'center', - }) - } - - // 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8) - if (vm.current.manaCost > 0 || vm.skill.mana > 0) { - lines.push({ - text: isEn ? `Mana Cost: ${vm.current.manaCost}` : `法力消耗: ${vm.current.manaCost}`, + text: tbl.StrSkill17, color: 'white', font: 'font8', 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({ - text: isEn ? `Casting Delay: ${vm.current.delaySeconds} sec` : `施法延迟: ${vm.current.delaySeconds} 秒`, + text: `${tbl.StrSkill3}${vm.current.manaCost}`, color: 'white', font: 'font8', 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). - if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) { + // 6. Next Level Preview: Header StrSkill1 ("Next Level" / "下一等級") in Green, omitted if unlearned or maxed + const maxLvl = getSkillMaxLevel(vm.skill.id) + if (vm.nextLevel && vm.isLearned && vm.hardPoints < maxLvl) { lines.push({ - text: isEn ? `[Next Level] (Lv ${vm.nextLevel.level})` : `[下一等级] (Lv ${vm.nextLevel.level})`, + text: tbl.StrSkill1, color: 'green', font: 'font8', align: 'center', }) if (vm.nextLevel.manaCost !== vm.current.manaCost) { lines.push({ - text: isEn ? `Mana Cost: ${vm.nextLevel.manaCost}` : `法力消耗: ${vm.nextLevel.manaCost}`, + text: `${tbl.StrSkill3}${vm.nextLevel.manaCost}`, color: 'green', font: 'font8', 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) { + const skillTitle = isEn ? vm.skill.name : (vm.skill.nameZh || vm.skill.name) lines.push({ - text: isEn ? '[Synergy Bonuses]' : '[协同技能加成]', - color: 'blue', - font: 'fontexocet10', + text: tbl.Sksyn.replace('%s', skillTitle), + color: 'white', + font: 'font8', align: 'center', }) for (const syn of vm.synergiesReceived) { const text = isEn - ? (syn.hardPoints > 0 - ? `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level (Current +${syn.currentBonusPct}%)` - : `• ${syn.providerName}: +${syn.percentPerLevel}% Damage per Level`) - : (syn.hardPoints > 0 - ? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)` - : `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`) + ? `${syn.providerName}: +${syn.percentPerLevel}% Damage per Level` + : `${syn.providerNameZh}: +${syn.percentPerLevel}% 每等級傷害` lines.push({ text, color: syn.hardPoints > 0 ? 'blue' : 'gray', font: 'font8', - align: 'left', + align: 'center', }) } } if (vm.masteryInfo && vm.masteryInfo.active) { lines.push({ - text: `• ${isEn ? vm.masteryInfo.descriptionEn : vm.masteryInfo.descriptionZh}`, + text: isEn ? vm.masteryInfo.descriptionEn : vm.masteryInfo.descriptionZh, color: 'blue', font: 'font8', - align: 'left', + align: 'center', }) } diff --git a/src/common/skills/skills-meta.ts b/src/common/skills/skills-meta.ts index 70afff6..4285542 100644 --- a/src/common/skills/skills-meta.ts +++ b/src/common/skills/skills-meta.ts @@ -1,6 +1,7 @@ /** - * Pure skill metadata, passive/aura/left-click skill sets, and display helpers - * shared between `src/common/`, `src/server/`, and `src/client/`. + * Pure skill metadata, canonical 1.13c `skills.txt` / `skilldesc.txt` table columns, + * 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' @@ -48,12 +49,446 @@ export interface HotkeySkillEntry { 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 = new Set([ + 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 = new Set([ + 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 = new Set([ + 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> = { + ama: 6, + sor: 36, + nec: 66, + pal: 96, + bar: 126, + dru: 221, + ass: 251, +} + +function buildSkillsTableMeta(): Readonly> { + const table: Record = { + 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> = buildSkillsTableMeta() + +export function getSkillTableMeta(skillId: number): SkillTableMeta | undefined { + return SKILLS_TABLE_META[skillId] +} + 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) if (!entry) return `Skill #${skillId}` 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 { switch (type) { case 'fire': @@ -74,115 +509,149 @@ export function formatDamageTypeEn(type: string): string { } /** - * Diablo II v1.13c Canonical Passive Skills (Skills.txt `passive = 1`). - * Passive skills provide permanent bonuses and CANNOT be assigned to - * left/right action buttons or appear in the Speedbar popup. + * Diablo II v1.13c Canonical Passive Skills (`skills.txt` `passive = 1`). + * Derived directly from `SKILLS_TABLE_META`. */ -export const PASSIVE_SKILL_IDS: ReadonlySet = new Set([ - // Amazon (Tab 1 Passives: Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33) - 9, // Critical Strike - 13, // Dodge - 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 const PASSIVE_SKILL_IDS: ReadonlySet = new Set( + Object.values(SKILLS_TABLE_META) + .filter(m => m.passive === 1) + .map(m => m.skillId), +) export function isPassiveSkill(skillId: number): boolean { - if (skillId === 0) return false - return PASSIVE_SKILL_IDS.has(skillId) + return (SKILLS_TABLE_META[skillId]?.passive ?? 0) === 1 } /** - * 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 = new Set([ - // Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5) - 0, 2, 4, 5, - // Amazon (20 active skills with leftskill = 1) - 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 = new Set([ - // 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 const PALADIN_AURA_SKILL_IDS: ReadonlySet = new Set( + Object.values(SKILLS_TABLE_META) + .filter(m => m.aura === 1 && m.passive === 0) + .map(m => m.skillId), +) export function isAuraSkill(skillId: number): boolean { - if (PALADIN_AURA_SKILL_IDS.has(skillId)) return true - const info = SKILLS_BY_ID[skillId] - if (info && info.classCode === 'pal' && (info.tabIndex === 1 || info.tabIndex === 2)) { - return true + const meta = SKILLS_TABLE_META[skillId] + if (!meta) return false + return meta.aura === 1 && meta.passive === 0 +} + +/** + * 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 = new Set( + 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 = new Set([ + 'tpot', + 'tkni', + 'taxe', + 'jave', + 'thro', + 'ajav', +]) + +/** + * Canonical 1.13c `Weapons.txt` item codes whose type inherits from `thro`. + */ +export const THROWING_WEAPON_CODES: ReadonlySet = new Set([ + '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 } + + diff --git a/tests/attack-skill.test.ts b/tests/attack-skill.test.ts index 9015692..fdfdf95 100644 --- a/tests/attack-skill.test.ts +++ b/tests/attack-skill.test.ts @@ -68,15 +68,15 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => }) }) - describe('Icon Asset & DC6 Frame 0 Resolution', () => { - it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => { + describe('Icon Asset & DC6 Frame 2 Resolution (skilldesc.txt IconCel = 2)', () => { + it('resolves Skill 0 to authentic Skillicon.DC6 frame 2 icon regardless of weapon state', () => { const iconMapping = resolveSkillIcon(0, false) expect(iconMapping).toBeDefined() expect(iconMapping.skillId).toBe(0) expect(iconMapping.name).toBe('Attack') expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6') - expect(iconMapping.frameIndex).toBe(0) - expect(iconMapping.atlasRect.x).toBe(0) + expect(iconMapping.frameIndex).toBe(2) + expect(iconMapping.atlasRect.x).toBe(96) expect(iconMapping.atlasRect.y).toBe(0) expect(iconMapping.atlasRect.width).toBe(48) expect(iconMapping.atlasRect.height).toBe(48) diff --git a/tests/client/skills-charsheet-batch6.test.ts b/tests/client/skills-charsheet-batch6.test.ts new file mode 100644 index 0000000..2feeb3b --- /dev/null +++ b/tests/client/skills-charsheet-batch6.test.ts @@ -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).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() + }) + }) +}) diff --git a/tests/hotkeys-parity.test.ts b/tests/hotkeys-parity.test.ts index f562f7a..f3eab84 100644 --- a/tests/hotkeys-parity.test.ts +++ b/tests/hotkeys-parity.test.ts @@ -196,13 +196,14 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' expect(yellowBorder).toBeDefined() }) - it('includes aura narrative tag in tooltip', () => { + it('omits invented aura narrative tag in canonical 1.13c tooltip', () => { hud.setAvailableSkills([ { skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true }, ]) const lines = hud.formatTooltipLines(98, 'right', false) - const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow') - expect(auraTag).toBeDefined() + expect(lines.length).toBeGreaterThan(0) + 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) }) - it('formats remaining cooldown in tooltip', () => { + it('omits invented cooldown countdown line in canonical 1.13c tooltip', () => { hud.setSkillCooldown(56, 1500, 5000) const lines = hud.formatTooltipLines(56, 'right', false) - const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red') - expect(cdLine).toBeDefined() + const cdLine = lines.find(l => l.text.includes('冷却中') || l.text.includes('[Cooldown:')) + expect(cdLine).toBeUndefined() }) }) diff --git a/tests/speedbar-authentic-parity.test.ts b/tests/speedbar-authentic-parity.test.ts index c77f23d..697ce06 100644 --- a/tests/speedbar-authentic-parity.test.ts +++ b/tests/speedbar-authentic-parity.test.ts @@ -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 = [ { skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true }, { skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true }, { skillId: 3, name: 'Unsummon', leftUsable: false, 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 }, ] 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( + 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', () => { @@ -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', () => { hud.setSkillCharges(219, 12, 20) 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) hud.setSkillCharges(218, 20, 20) 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) const lhtLines = hud.formatTooltipLines(4, 'left', true) - expect(lhtLines[0].text).toContain('左手投掷') + expect(lhtLines[0].text).toContain('左手投擲') }) })