Fix control bar tooltips, cursor state machine, stamina bar, defense formulas, and HUD asset fallbacks (Closes #644, #645, #646, #647, #648, #649, #651, #652, #653, #654)

This commit is contained in:
troytt 2026-10-02 19:14:47 +00:00
parent 7d6a5bc743
commit aae6000f06
12 changed files with 375 additions and 243 deletions

View File

@ -439,6 +439,14 @@ async function main() {
'strMerc', 'strMerc',
'merca239', 'merca239',
'NotUsed150', 'NotUsed150',
'panelexp',
'panelstamina',
'panelhealth',
'panelmana',
'panelmini',
'panelcmini',
'strlvlup',
'strnewskl',
]) ])
for (const [k] of eng.dict.entries()) { for (const [k] of eng.dict.entries()) {

View File

@ -29,19 +29,23 @@ export interface CharacterSheetDerived {
readonly poisRes: number readonly poisRes: number
} }
const STAT_DEXTERITY = 2 export const STAT_DEXTERITY = 2
const STAT_TOHIT = 19 export const STAT_ITEM_ARMOR_PERCENT = 16
const STAT_ARMORCLASS = 31 export const STAT_TOHIT = 19
const STAT_ITEM_TOHIT_PERCENT = 119 export const STAT_ARMORCLASS = 31
const STAT_FIRERESIST = 39 export const STAT_FIRERESIST = 39
const STAT_MAXFIRERESIST = 40 export const STAT_MAXFIRERESIST = 40
const STAT_LIGHTRESIST = 41 export const STAT_LIGHTRESIST = 41
const STAT_MAXLIGHTRESIST = 42 export const STAT_MAXLIGHTRESIST = 42
const STAT_COLDRESIST = 43 export const STAT_COLDRESIST = 43
const STAT_MAXCOLDRESIST = 44 export const STAT_MAXCOLDRESIST = 44
const STAT_POISONRESIST = 45 export const STAT_POISONRESIST = 45
const STAT_MAXPOISONRESIST = 46 export const STAT_MAXPOISONRESIST = 46
const BASE_MAX_RESIST = 75 export const STAT_ITEM_TOHIT_PERCENT = 119
export const STAT_SKILL_ARMOR_PERCENT = 171
export const STAT_ARMOR_OVERRIDE_PERCENT = 182
export const BASE_MAX_RESIST = 75
export const ABSOLUTE_MAX_RESIST_CAP = 95
function parseResistPenalties(raw: string): readonly number[] { function parseResistPenalties(raw: string): readonly number[] {
const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== '') const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== '')
@ -58,14 +62,41 @@ function parseResistPenalties(raw: string): readonly number[] {
/** `DifficultyLevels.txt` `ResistPenalty` per difficulty (Normal / Nightmare / Hell). */ /** `DifficultyLevels.txt` `ResistPenalty` per difficulty (Normal / Nightmare / Hell). */
export const RESIST_PENALTY_BY_DIFFICULTY: readonly number[] = parseResistPenalties(RAW_DIFFICULTY_LEVELS) export const RESIST_PENALTY_BY_DIFFICULTY: readonly number[] = parseResistPenalties(RAW_DIFFICULTY_LEVELS)
/**
* Computes authentic 1.13c total defense per D2Common.dll ordinal 10431 `UNITS_GetDefense`
* (`D2Common/src/Units/Units.cpp:2337-2392`).
* - While running, defense drops to 0.
* - Base defense = floor(dex / 4) + armorclass (stat 31).
* - Percentage bonuses: item_armor_percent (16) + skill_armor_percent (171).
* - Percentage override: armor_override_percent (182), e.g. Berserk (-100%).
*/
export function computeDefense(stats: StatReader, dex: number, isRunning: boolean = false): number {
if (isRunning) {
return 0
}
const baseDef = Math.floor(dex / 4) + stats.get(STAT_ARMORCLASS)
const armorPercent = stats.get(STAT_ITEM_ARMOR_PERCENT) + stats.get(STAT_SKILL_ARMOR_PERCENT)
const defenseBonus = baseDef <= 0
? Math.trunc((baseDef * armorPercent) / -100)
: Math.trunc((baseDef * armorPercent) / 100)
let totalDefense = baseDef + defenseBonus
const overridePercent = stats.get(STAT_ARMOR_OVERRIDE_PERCENT)
if (overridePercent) {
totalDefense += Math.trunc((totalDefense * overridePercent) / 100)
}
return Math.max(0, totalDefense)
}
function resist(stats: StatReader, statId: number, maxStatId: number, penalty: number): number { function resist(stats: StatReader, statId: number, maxStatId: number, penalty: number): number {
return Math.min(BASE_MAX_RESIST + stats.get(maxStatId), stats.get(statId) + penalty) const maxResist = Math.min(ABSOLUTE_MAX_RESIST_CAP, BASE_MAX_RESIST + stats.get(maxStatId))
return Math.min(maxResist, stats.get(statId) + penalty)
} }
export function computeCharacterSheetDerived( export function computeCharacterSheetDerived(
classCode: CharacterClassCode, classCode: CharacterClassCode,
stats: StatReader, stats: StatReader,
difficulty: 0 | 1 | 2, difficulty: 0 | 1 | 2,
isRunning: boolean = false,
): CharacterSheetDerived { ): CharacterSheetDerived {
const charStats = CANONICAL_CHARSTATS[classCode] const charStats = CANONICAL_CHARSTATS[classCode]
if (!charStats) throw new Error(`computeCharacterSheetDerived: no CharStats row for class ${classCode}`) if (!charStats) throw new Error(`computeCharacterSheetDerived: no CharStats row for class ${classCode}`)
@ -76,7 +107,7 @@ export function computeCharacterSheetDerived(
const attackRating = Math.floor((baseAr * (100 + stats.get(STAT_ITEM_TOHIT_PERCENT))) / 100) const attackRating = Math.floor((baseAr * (100 + stats.get(STAT_ITEM_TOHIT_PERCENT))) / 100)
return { return {
attackRating, attackRating,
defense: Math.floor(dex / 4) + stats.get(STAT_ARMORCLASS), defense: computeDefense(stats, dex, isRunning),
fireRes: resist(stats, STAT_FIRERESIST, STAT_MAXFIRERESIST, penalty), fireRes: resist(stats, STAT_FIRERESIST, STAT_MAXFIRERESIST, penalty),
coldRes: resist(stats, STAT_COLDRESIST, STAT_MAXCOLDRESIST, penalty), coldRes: resist(stats, STAT_COLDRESIST, STAT_MAXCOLDRESIST, penalty),
lightRes: resist(stats, STAT_LIGHTRESIST, STAT_MAXLIGHTRESIST, penalty), lightRes: resist(stats, STAT_LIGHTRESIST, STAT_MAXLIGHTRESIST, penalty),

View File

@ -56,6 +56,10 @@ import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts' import { computeCharacterSheetDerived } from './derived-stats.ts'
import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts' import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts'
import {
CANONICAL_CHI_TBL_DICT,
CANONICAL_ENG_TBL_DICT,
} from '../../common/data/canonical-tbl-strings.ts'
import { import {
resolveMercPortraitIcon, resolveMercPortraitIcon,
resolvePlayerClassPortraitIcon, resolvePlayerClassPortraitIcon,
@ -75,13 +79,13 @@ export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>>
export const CLASS_ID_TO_NAMES: Readonly< export const CLASS_ID_TO_NAMES: Readonly<
Record<CharClassId, { readonly en: string; readonly zh: string }> Record<CharClassId, { readonly en: string; readonly zh: string }>
> = { > = {
0: { en: 'Amazon', zh: '亚马逊' }, 0: { en: CANONICAL_ENG_TBL_DICT['Amazon'] ?? 'Amazon', zh: CANONICAL_CHI_TBL_DICT['Amazon'] ?? '亞馬遜' },
1: { en: 'Sorceress', zh: '女法师' }, 1: { en: CANONICAL_ENG_TBL_DICT['Sorceress'] ?? 'Sorceress', zh: CANONICAL_CHI_TBL_DICT['Sorceress'] ?? '法師' },
2: { en: 'Necromancer', zh: '死灵法师' }, 2: { en: CANONICAL_ENG_TBL_DICT['Necromancer'] ?? 'Necromancer', zh: CANONICAL_CHI_TBL_DICT['Necromancer'] ?? '死靈法師' },
3: { en: 'Paladin', zh: '圣骑士' }, 3: { en: CANONICAL_ENG_TBL_DICT['Paladin'] ?? 'Paladin', zh: CANONICAL_CHI_TBL_DICT['Paladin'] ?? '聖騎士' },
4: { en: 'Barbarian', zh: '野蛮人' }, 4: { en: CANONICAL_ENG_TBL_DICT['Barbarian'] ?? 'Barbarian', zh: CANONICAL_CHI_TBL_DICT['Barbarian'] ?? '野蠻人' },
5: { en: 'Druid', zh: '德鲁伊' }, 5: { en: CANONICAL_ENG_TBL_DICT['Druid'] ?? 'Druid', zh: CANONICAL_CHI_TBL_DICT['Druid'] ?? '德魯依' },
6: { en: 'Assassin', zh: '刺客' }, 6: { en: CANONICAL_ENG_TBL_DICT['Assassin'] ?? 'Assassin', zh: CANONICAL_CHI_TBL_DICT['Assassin'] ?? '刺客' },
} as const } as const
export const BODY_LOC_TO_EQUIP_SLOT: Readonly<Record<number, EquipSlotId>> = { export const BODY_LOC_TO_EQUIP_SLOT: Readonly<Record<number, EquipSlotId>> = {
@ -594,6 +598,7 @@ export class HudModel implements HudCommandSink {
hud.mana = Math.max(0, Math.min(self.maxMana, self.mana)) hud.mana = Math.max(0, Math.min(self.maxMana, self.mana))
hud.maxStamina = self.maxStamina hud.maxStamina = self.maxStamina
hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina)) hud.stamina = Math.max(0, Math.min(self.maxStamina, self.stamina))
hud.isStaminaBlue = Boolean(self.states.has(136) || self.states.has(180))
// 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c). // 2. Character Sheet attributes (server StatList; an absent stat is 0 in 1.13c).
const attrs = hud.charSheet.attrs const attrs = hud.charSheet.attrs
@ -612,7 +617,7 @@ export class HudModel implements HudCommandSink {
// Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known. // Derived totals per 1.13c (CharStats ToHitFactor, DifficultyLevels ResistPenalty); 0 until the class is known.
const derived = self.charClass === null const derived = self.charClass === null
? null ? null
: computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty) : computeCharacterSheetDerived(resolveClassCode(self.charClass), stats, this.world.difficulty, this.isRunMode)
attrs.defense = derived?.defense ?? 0 attrs.defense = derived?.defense ?? 0
attrs.attackRating = derived?.attackRating ?? 0 attrs.attackRating = derived?.attackRating ?? 0
attrs.fireRes = derived?.fireRes ?? 0 attrs.fireRes = derived?.fireRes ?? 0
@ -793,9 +798,9 @@ export class HudModel implements HudCommandSink {
} }
const mercRaw = this.world.merc as any const mercRaw = this.world.merc as any
const mercStats = buildMercStats(mercRaw?.stats) const mercStats = buildMercStats(mercRaw?.stats)
const mercLevel = Number(mercRaw?.level ?? mercStats.get(12) ?? mercStats.level ?? 1) || 1 const mercLevel = Number(mercRaw?.level ?? mercStats.get(12) ?? mercStats.level ?? 0)
const mercMaxHp = Number(mercRaw?.maxLife ?? mercRaw?.maxHp ?? mercStats.get(7) ?? 100) || 100 const mercMaxHp = Number(mercRaw?.maxLife ?? mercRaw?.maxHp ?? mercStats.get(7) ?? 0)
const mercLifePct = Number(mercRaw?.lifePct ?? 100) const mercLifePct = Number(mercRaw?.lifePct ?? 0)
const mercHp = Number( const mercHp = Number(
mercRaw?.life ?? mercRaw?.life ??
mercRaw?.hp ?? mercRaw?.hp ??
@ -808,21 +813,21 @@ export class HudModel implements HudCommandSink {
? `Merc #${mercRaw.nameId}` ? `Merc #${mercRaw.nameId}`
: mercRaw?.mercId : mercRaw?.mercId
? `Merc #${mercRaw.mercId}` ? `Merc #${mercRaw.mercId}`
: 'Mercenary') : '')
const mercCommonFields = { const mercCommonFields = {
name: mercName, name: mercName,
level: mercLevel, level: mercLevel,
hp: Number.isFinite(mercHp) ? mercHp : 100, hp: Number.isFinite(mercHp) ? mercHp : 0,
maxHp: Number.isFinite(mercMaxHp) ? mercMaxHp : 100, maxHp: Number.isFinite(mercMaxHp) ? mercMaxHp : 0,
life: Number.isFinite(mercHp) ? mercHp : 100, life: Number.isFinite(mercHp) ? mercHp : 0,
maxLife: Number.isFinite(mercMaxHp) ? mercMaxHp : 100, maxLife: Number.isFinite(mercMaxHp) ? mercMaxHp : 0,
lifePct: Number.isFinite(mercLifePct) ? mercLifePct : 100, lifePct: Number.isFinite(mercLifePct) ? mercLifePct : 0,
experience: Number(mercRaw?.experience ?? mercStats.get(13) ?? 0) || 0, experience: Number(mercRaw?.experience ?? mercStats.get(13) ?? 0) || 0,
strength: Number(mercRaw?.strength ?? mercStats.get(0) ?? mercStats.strength ?? 50), strength: Number(mercRaw?.strength ?? mercStats.get(0) ?? mercStats.strength ?? 0),
dexterity: Number(mercRaw?.dexterity ?? mercStats.get(2) ?? mercStats.dexterity ?? 40), dexterity: Number(mercRaw?.dexterity ?? mercStats.get(2) ?? mercStats.dexterity ?? 0),
minDamage: Number(mercRaw?.minDamage ?? mercStats.get(21) ?? mercStats.damageMin ?? 10), minDamage: Number(mercRaw?.minDamage ?? mercStats.get(21) ?? mercStats.damageMin ?? 0),
maxDamage: Number(mercRaw?.maxDamage ?? mercStats.get(22) ?? mercStats.damageMax ?? 20), maxDamage: Number(mercRaw?.maxDamage ?? mercStats.get(22) ?? mercStats.damageMax ?? 0),
defense: Number(mercRaw?.defense ?? mercStats.get(31) ?? mercStats.defense ?? 50), defense: Number(mercRaw?.defense ?? mercStats.get(31) ?? mercStats.defense ?? 0),
fireResist: Number(mercRaw?.fireResist ?? mercStats.get(39) ?? mercStats.fireResist ?? 0), fireResist: Number(mercRaw?.fireResist ?? mercStats.get(39) ?? mercStats.fireResist ?? 0),
lightResist: Number(mercRaw?.lightResist ?? mercStats.get(41) ?? mercStats.lightResist ?? 0), lightResist: Number(mercRaw?.lightResist ?? mercStats.get(41) ?? mercStats.lightResist ?? 0),
coldResist: Number(mercRaw?.coldResist ?? mercStats.get(43) ?? mercStats.coldResist ?? 0), coldResist: Number(mercRaw?.coldResist ?? mercStats.get(43) ?? mercStats.coldResist ?? 0),
@ -847,17 +852,7 @@ export class HudModel implements HudCommandSink {
iconKey: mercIconKey, iconKey: mercIconKey,
...mercCommonFields, ...mercCommonFields,
} }
: this.world.containers.merc.size > 0 : null
? {
mercId: 0,
unitId: 0,
nameId: 0,
isDead: false,
reviveCost: 0,
iconKey: mercIconKey,
...mercCommonFields,
}
: null
;(hud as any).merc = syncedMerc ;(hud as any).merc = syncedMerc
;(hud.worldPanels as any).merc = syncedMerc ;(hud.worldPanels as any).merc = syncedMerc

View File

@ -10,6 +10,7 @@
*/ */
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer } from './font.ts'
import { reportMissingArt } from '../scene/world-renderer.ts'
import { import {
BELT_COLS, BELT_COLS,
@ -74,10 +75,7 @@ export class BeltHud extends BeltModel {
if (assets.popbeltImg) { if (assets.popbeltImg) {
ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy) ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy)
} else { } else {
ctx.fillStyle = 'rgba(18, 15, 11, 0.94)' reportMissingArt('ui-belt', 'ctrlpnl_popbelt', 'Missing popbelt background art')
ctx.fillRect(BELT_BASE_X - 1, sy, 125, 31)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(BELT_BASE_X - 1, sy, 125, 31)
} }
} }
} }
@ -103,8 +101,7 @@ export class BeltHud extends BeltModel {
const dy = sy + Math.floor((BELT_CELL_SIZE - rect.h) / 2) const dy = sy + Math.floor((BELT_CELL_SIZE - rect.h) / 2)
ctx.drawImage(assets.itemsAtlasImg, rect.x, rect.y, rect.w, rect.h, dx, dy, rect.w, rect.h) ctx.drawImage(assets.itemsAtlasImg, rect.x, rect.y, rect.w, rect.h, dx, dy, rect.w, rect.h)
} else { } else {
ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : pot.kind === 'scroll' ? '#bfa054' : '#9c24c8' reportMissingArt('ui-belt', pot.invFile || 'potion', 'Missing potion sprite art')
ctx.fillRect(sx + 6, sy + 4, 16, 20)
} }
} }

View File

@ -15,6 +15,7 @@
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer } from './font.ts'
import type { TblLang } from '../i18n/lang.ts' import type { TblLang } from '../i18n/lang.ts'
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
import { reportMissingArt } from '../scene/world-renderer.ts'
export type MiniPanelAction = export type MiniPanelAction =
| 'char' | 'char'
@ -36,6 +37,7 @@ export interface ControlBarState {
readonly isRunning: boolean readonly isRunning: boolean
readonly unspentStatPoints: number readonly unspentStatPoints: number
readonly unspentSkillPoints: number readonly unspentSkillPoints: number
readonly isStaminaBlue?: boolean
} }
export const EXP_BAR_BOUNDS = { x: 256, y: 560, width: 120, height: 4 } as const export const EXP_BAR_BOUNDS = { x: 256, y: 560, width: 120, height: 4 } as const
@ -221,8 +223,7 @@ export class ControlBarHud {
if (assets.ctrlPnlImg) { if (assets.ctrlPnlImg) {
ctx.drawImage(assets.ctrlPnlImg, 0, 496) ctx.drawImage(assets.ctrlPnlImg, 0, 496)
} else { } else {
ctx.fillStyle = '#1c1813' reportMissingArt('ui-control-bar', '800CtrlPnl7', 'Missing control panel art')
ctx.fillRect(0, 545, 800, 55)
} }
// 2. Experience Bar (`x=256, y=560, w=120, h=4`) // 2. Experience Bar (`x=256, y=560, w=120, h=4`)
@ -243,10 +244,7 @@ export class ControlBarHud {
const stamRatio = Math.max(0, Math.min(1, state.stamina / Math.max(1, state.maxStamina))) const stamRatio = Math.max(0, Math.min(1, state.stamina / Math.max(1, state.maxStamina)))
ctx.fillStyle = '#090705' ctx.fillStyle = '#090705'
ctx.fillRect(STAMINA_BAR_BOUNDS.x, STAMINA_BAR_BOUNDS.y, STAMINA_BAR_BOUNDS.width, STAMINA_BAR_BOUNDS.height) ctx.fillRect(STAMINA_BAR_BOUNDS.x, STAMINA_BAR_BOUNDS.y, STAMINA_BAR_BOUNDS.width, STAMINA_BAR_BOUNDS.height)
const stamGrad = ctx.createLinearGradient(0, STAMINA_BAR_BOUNDS.y, 0, STAMINA_BAR_BOUNDS.y + STAMINA_BAR_BOUNDS.height) ctx.fillStyle = state.isStaminaBlue ? '#2858d8' : '#c88414'
stamGrad.addColorStop(0, state.isRunning ? '#ffd452' : '#e8a828')
stamGrad.addColorStop(1, '#8c540c')
ctx.fillStyle = stamGrad
ctx.fillRect( ctx.fillRect(
STAMINA_BAR_BOUNDS.x, STAMINA_BAR_BOUNDS.x,
STAMINA_BAR_BOUNDS.y, STAMINA_BAR_BOUNDS.y,
@ -275,8 +273,7 @@ export class ControlBarHud {
if (assets.miniPanelImg) { if (assets.miniPanelImg) {
ctx.drawImage(assets.miniPanelImg, bounds.x, bounds.y) ctx.drawImage(assets.miniPanelImg, bounds.x, bounds.y)
} else { } else {
ctx.fillStyle = '#241e16' reportMissingArt('ui-control-bar', 'minipanel', 'Missing mini panel background art')
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height)
} }
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const bx = startX + i * 20 const bx = startX + i * 20
@ -303,55 +300,28 @@ export class ControlBarHud {
// 8. Hover Tooltip over Experience / Stamina / Mini-panel buttons // 8. Hover Tooltip over Experience / Stamina / Mini-panel buttons
const isEn = this.lang === 'ENG' const isEn = this.lang === 'ENG'
if (this.hoveredElement === 'exp') { if (this.hoveredElement === 'exp') {
this.drawMiniTooltip( const tmpl = resolveTblKey('panelexp', this.lang) ?? (isEn ? 'Experience: %u / %u' : '經驗: %u / %u')
ctx, const text = tmpl
font, .replace(/%[du]/, String(state.xp))
isEn .replace(/%[du]/, String(state.nextLevelXp))
? `Experience: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)` this.drawMiniTooltip(ctx, font, text, EXP_BAR_BOUNDS.x + 60, 518)
: `经验值: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`,
EXP_BAR_BOUNDS.x + 60,
518,
)
} else if (this.hoveredElement === 'stamina') { } else if (this.hoveredElement === 'stamina') {
this.drawMiniTooltip( const tmpl = resolveTblKey('panelstamina', this.lang) ?? (isEn ? 'Stamina: %d / %d' : '耐力: %d / %d')
ctx, const text = tmpl
font, .replace(/%[du]/, String(Math.round(state.stamina)))
isEn .replace(/%[du]/, String(Math.round(state.maxStamina)))
? `Stamina: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? 'Running' : 'Walking'})` this.drawMiniTooltip(ctx, font, text, STAMINA_BAR_BOUNDS.x + 51, 518)
: `耐力: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? '奔跑中' : '行走中'})`,
STAMINA_BAR_BOUNDS.x + 51,
518,
)
} else if (this.hoveredElement === 'run') { } else if (this.hoveredElement === 'run') {
this.drawMiniTooltip( const text = state.isRunning
ctx, ? (resolveTblKey('RunOn', this.lang) ?? (isEn ? 'Run' : '跑步'))
font, : (resolveTblKey('RunOff', this.lang) ?? (isEn ? 'Walk' : '行走'))
isEn this.drawMiniTooltip(ctx, font, text, RUN_BTN_BOUNDS.x + 8, 518)
? `Toggle Run/Walk (R) — Current: ${state.isRunning ? 'Run' : 'Walk'}`
: `切换行走/奔跑 (R) — 当前: ${state.isRunning ? '奔跑' : '行走'}`,
RUN_BTN_BOUNDS.x + 8,
518,
)
} else if (this.hoveredElement === 'statPlus') { } else if (this.hoveredElement === 'statPlus') {
this.drawMiniTooltip( const text = resolveTblKey('strlvlup', this.lang) ?? (isEn ? 'New Stats' : '新人物狀況')
ctx, this.drawMiniTooltip(ctx, font, text, STAT_PLUS_BTN_BOUNDS.x + 15, 518)
font,
isEn
? `Unallocated Stat Points: ${state.unspentStatPoints} (Click to open Character Sheet A)`
: `未分配属性点: ${state.unspentStatPoints} (点击打开角色面板 A)`,
STAT_PLUS_BTN_BOUNDS.x + 15,
518,
)
} else if (this.hoveredElement === 'skillPlus') { } else if (this.hoveredElement === 'skillPlus') {
this.drawMiniTooltip( const text = resolveTblKey('strnewskl', this.lang) ?? (isEn ? 'New Skill' : '新技能')
ctx, this.drawMiniTooltip(ctx, font, text, SKILL_PLUS_BTN_BOUNDS.x + 15, 518)
font,
isEn
? `Unallocated Skill Points: ${state.unspentSkillPoints} (Click to open Skill Tree T)`
: `未分配技能点: ${state.unspentSkillPoints} (点击打开技能树 T)`,
SKILL_PLUS_BTN_BOUNDS.x + 15,
518,
)
} else if (this.hoveredElement === 'menuToggle') { } else if (this.hoveredElement === 'menuToggle') {
const tip = this.miniPanelOpen const tip = this.miniPanelOpen
? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板')) ? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板'))

View File

@ -22,9 +22,10 @@ import {
type CursorAtlasMetadata, type CursorAtlasMetadata,
type CursorFrameMeta, type CursorFrameMeta,
} from '../../common/items/cursor-meta.ts' } from '../../common/items/cursor-meta.ts'
import { reportMissingArt } from '../scene/world-renderer.ts'
export { BUYSELL_CURSOR_FRAMES, type BuySellCursorMode } export { BUYSELL_CURSOR_FRAMES, type BuySellCursorMode }
export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE' export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE' | 'RELEASE' | 'CLICK_DOWN' | 'TARGET_HOLD'
export type CursorAnimation = 'protate' | 'ohand' | 'orotate' | 'buysell' export type CursorAnimation = 'protate' | 'ohand' | 'orotate' | 'buysell'
export interface CursorRenderInfo { export interface CursorRenderInfo {
@ -62,6 +63,10 @@ export class CursorController {
lastNowMs: number = 0 lastNowMs: number = 0
state: CursorState = 'POINTING' state: CursorState = 'POINTING'
/** 8.8 fixed-point counters for frame progression and release rewind per D2Client.dll 0x6fac647c..0x6fac68e3 */
fixedPointCounter: number = 0
releaseCounter: number = 0
/** /**
* Active D2Client.dll `buysell.dc6` cursor override (`CursorId = 6` at `0x6fba8558`): * Active D2Client.dll `buysell.dc6` cursor override (`CursorId = 6` at `0x6fba8558`):
* - `'buy'` -> frame 3 (`0x6fb3ba30`, `nTradeMode = 2`) * - `'buy'` -> frame 3 (`0x6fb3ba30`, `nTradeMode = 2`)
@ -72,8 +77,8 @@ export class CursorController {
*/ */
tradeCursorMode: BuySellCursorMode | null = null tradeCursorMode: BuySellCursorMode | null = null
/** Milliseconds of inactivity before transitioning from POINTING to OPENING. */ /** Milliseconds of inactivity before transitioning from POINTING to OPENING (D2Client 0x6fac6840 add ecx, 0x1388 = 5000ms). */
idleThresholdMs: number = 2000 idleThresholdMs: number = 5000
/** Frame playback duration for looping swaying animations (protate & orotate). */ /** Frame playback duration for looping swaying animations (protate & orotate). */
frameDurationMs: number = 125 // 8 FPS frameDurationMs: number = 125 // 8 FPS
@ -104,35 +109,63 @@ export class CursorController {
this.lastActivityTime = nowMs this.lastActivityTime = nowMs
this.lastNowMs = nowMs this.lastNowMs = nowMs
this.state = 'POINTING' this.state = 'POINTING'
this.fixedPointCounter = 0
this.releaseCounter = 0
} }
/** /**
* Updates cursor position and resets the idle timer. * Updates cursor position and resets the idle timer.
* If hand is open, transitions to RELEASE state to play ohand in reverse.
*/ */
handleMouseMove(x: number, y: number, nowMs: number = performance.now()): void { handleMouseMove(x: number, y: number, nowMs: number = performance.now()): void {
this.mouseX = x this.mouseX = x
this.mouseY = y this.mouseY = y
if (this.state === 'OPEN_IDLE') {
this.state = 'RELEASE'
this.releaseCounter = (8 << 8) - 0x100
this.lastActivityTime = nowMs
this.lastNowMs = nowMs
return
}
if (this.state === 'OPENING') {
this.state = 'RELEASE'
this.releaseCounter = Math.min((8 << 8) - 0x100, Math.max(0, this.fixedPointCounter))
this.lastActivityTime = nowMs
this.lastNowMs = nowMs
return
}
if (this.state === 'RELEASE') {
this.lastActivityTime = nowMs
this.lastNowMs = nowMs
return
}
this.resetIdle(nowMs) this.resetIdle(nowMs)
} }
/** /**
* Mouse down event handler; resets idle timer to keep cursor in POINTING state. * Mouse down event handler; transitions to CLICK_DOWN state.
*/ */
handleMouseDown(nowMs: number = performance.now()): void { handleMouseDown(nowMs: number = performance.now()): void {
this.resetIdle(nowMs) this.lastActivityTime = nowMs
this.lastNowMs = nowMs
if (this.tradeCursorMode === null) {
this.state = 'CLICK_DOWN'
}
} }
/** /**
* Mouse up event handler; resets idle timer to keep cursor in POINTING state. * Mouse up event handler; reverts from CLICK_DOWN to POINTING.
*/ */
handleMouseUp(nowMs: number = performance.now()): void { handleMouseUp(nowMs: number = performance.now()): void {
this.resetIdle(nowMs) this.lastActivityTime = nowMs
this.lastNowMs = nowMs
if (this.state === 'CLICK_DOWN') {
this.state = 'POINTING'
}
} }
/** /**
* Evaluates the state machine transitions based on elapsed idle time. * Evaluates the state machine transitions based on elapsed idle time and 8.8 fixed-point timers.
* Per D2Client.dll `0x6fac62f0` (`D2CLIENT_UpdateCursorIdle`), idle hand opening only runs
* when `g_nCursorState == 1 && g_nCursorId == 5` (i.e. when `tradeCursorMode === null`).
*/ */
update(nowMs: number = performance.now()): CursorState { update(nowMs: number = performance.now()): CursorState {
this.lastNowMs = nowMs this.lastNowMs = nowMs
@ -141,15 +174,35 @@ export class CursorController {
return this.state return this.state
} }
if (this.state === 'CLICK_DOWN') {
return this.state
}
if (this.state === 'RELEASE') {
this.releaseCounter -= 0x40
if (this.releaseCounter <= 0) {
this.releaseCounter = 0
this.state = 'POINTING'
this.lastActivityTime = nowMs
}
return this.state
}
const idleMs = Math.max(0, nowMs - this.lastActivityTime) const idleMs = Math.max(0, nowMs - this.lastActivityTime)
const openingTotalMs = 8 * this.openingFrameDurationMs const openingTotalMs = 8 * this.openingFrameDurationMs
if (idleMs < this.idleThresholdMs) { if (idleMs < this.idleThresholdMs) {
this.state = 'POINTING' this.state = 'POINTING'
this.fixedPointCounter = 0
} else if (idleMs < this.idleThresholdMs + openingTotalMs) { } else if (idleMs < this.idleThresholdMs + openingTotalMs) {
this.state = 'OPENING' this.state = 'OPENING'
const openingElapsed = idleMs - this.idleThresholdMs
const frameIdx = Math.min(7, Math.floor(openingElapsed / this.openingFrameDurationMs))
const frac = Math.floor(((openingElapsed % this.openingFrameDurationMs) / this.openingFrameDurationMs) * 0x100)
this.fixedPointCounter = (frameIdx << 8) + frac
} else { } else {
this.state = 'OPEN_IDLE' this.state = 'OPEN_IDLE'
this.fixedPointCounter = 8 << 8
} }
return this.state return this.state
@ -177,11 +230,18 @@ export class CursorController {
const idleMs = Math.max(0, nowMs - this.lastActivityTime) const idleMs = Math.max(0, nowMs - this.lastActivityTime)
const openingTotalMs = 8 * this.openingFrameDurationMs const openingTotalMs = 8 * this.openingFrameDurationMs
if (this.state === 'POINTING') { if (this.state === 'POINTING' || this.state === 'CLICK_DOWN') {
anim = 'protate' anim = 'protate'
// Looping pointing hand swaying animation driven by animation clock if (this.state === 'CLICK_DOWN') {
const safeClock = Math.max(0, nowMs) frameIndex = 0
frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8 } else {
// Looping pointing hand swaying animation driven by animation clock
const safeClock = Math.max(0, nowMs)
frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8
}
} else if (this.state === 'RELEASE') {
anim = 'ohand'
frameIndex = Math.min(7, Math.max(0, this.releaseCounter >> 8))
} else if (this.state === 'OPENING') { } else if (this.state === 'OPENING') {
anim = 'ohand' anim = 'ohand'
// Plays ohand 8 frames sequentially // Plays ohand 8 frames sequentially
@ -198,8 +258,15 @@ export class CursorController {
frameIndex = 0 frameIndex = 0
} }
const frameList = this.metadata[anim] ?? this.metadata.protate const frameList = this.metadata[anim]
frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]! if (!frameList || frameList.length === 0) {
throw new Error(`Missing cursor animation sequence '${anim}' in cursor metadata`)
}
const frame = frameList[frameIndex]
if (!frame) {
throw new Error(`Missing cursor frame index ${frameIndex} for animation '${anim}'`)
}
frameMeta = frame
} }
const drawX = Math.round(this.mouseX - frameMeta.hotspotX) const drawX = Math.round(this.mouseX - frameMeta.hotspotX)
@ -251,19 +318,22 @@ export class CursorController {
} }
// Otherwise, render the authentic animated hand cursor or buysell trade cursor // Otherwise, render the authentic animated hand cursor or buysell trade cursor
if (cursorAtlas) { if (!cursorAtlas) {
const info = this.getCurrentFrame(nowMs) reportMissingArt('ui-cursor', 'cursorAtlas', `state=${this.state}`)
ctx.drawImage( return
cursorAtlas,
info.frame.x,
info.frame.y,
info.frame.w,
info.frame.h,
info.drawX,
info.drawY,
info.frame.w,
info.frame.h,
)
} }
const info = this.getCurrentFrame(nowMs)
ctx.drawImage(
cursorAtlas,
info.frame.x,
info.frame.y,
info.frame.w,
info.frame.h,
info.drawX,
info.drawY,
info.frame.w,
info.frame.h,
)
} }
} }

View File

@ -9,6 +9,7 @@
import type { D2FontRenderer } from './font.ts' import type { D2FontRenderer } from './font.ts'
import type { TblLang } from '../i18n/lang.ts' import type { TblLang } from '../i18n/lang.ts'
import { reportMissingArt } from '../scene/world-renderer.ts' import { reportMissingArt } from '../scene/world-renderer.ts'
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
export interface GlobeState { export interface GlobeState {
readonly hp: number readonly hp: number
@ -42,8 +43,6 @@ export const MANA_GLOBE_BOUNDS: GlobeBounds = {
} }
export class GlobesHud { export class GlobesHud {
lifeTextLocked = true
manaTextLocked = true
lifeHovered = false lifeHovered = false
manaHovered = false manaHovered = false
proceduralFallbackCount = 0 proceduralFallbackCount = 0
@ -70,15 +69,7 @@ export class GlobesHud {
this.manaHovered = this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY) this.manaHovered = this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY)
} }
handleClick(logicalX: number, logicalY: number): boolean { handleClick(_logicalX: number, _logicalY: number): boolean {
if (this.hitGlobe(LIFE_GLOBE_BOUNDS, logicalX, logicalY)) {
this.lifeTextLocked = !this.lifeTextLocked
return true
}
if (this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY)) {
this.manaTextLocked = !this.manaTextLocked
return true
}
return false return false
} }
@ -151,33 +142,52 @@ export class GlobesHud {
// 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`) // 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`)
if (assets.overlapLeftImg) { if (assets.overlapLeftImg) {
ctx.drawImage(assets.overlapLeftImg, 27, 508) ctx.drawImage(assets.overlapLeftImg, 27, 508)
} else {
reportMissingArt('ui-globe', 'overlap-left', 'Authentic overlap left asset missing on draw')
} }
if (assets.overlapRightImg) { if (assets.overlapRightImg) {
ctx.drawImage(assets.overlapRightImg, 691, 508) ctx.drawImage(assets.overlapRightImg, 691, 508)
} else {
reportMissingArt('ui-globe', 'overlap-right', 'Authentic overlap right asset missing on draw')
} }
// 4. Numerical readouts above globes (`Life: X / Y`, `Mana: X / Y`) // 4. Numerical readouts strictly on mouse hover via tbl format strings (`panelhealth`, `panelmana`)
const isEn = this.lang === 'ENG' if (this.lifeHovered) {
if (this.lifeTextLocked || this.lifeHovered) { const tmpl = resolveTblKey('panelhealth', this.lang) ?? (this.lang === 'ENG' ? 'Life: %d / %d' : '生命: %d / %d')
font.drawText( const text = tmpl
ctx, .replace(/%[du]/, String(Math.max(0, Math.round(state.hp))))
`${isEn ? 'Life' : '生命'}: ${Math.max(0, Math.round(state.hp))} / ${Math.round(state.maxHp)}`, .replace(/%[du]/, String(Math.round(state.maxHp)))
LIFE_GLOBE_BOUNDS.x + 40, this.drawGlobeTooltip(ctx, font, text, LIFE_GLOBE_BOUNDS.x + 40, 484)
504,
{ font: 'fontexocet10', color: 'white', align: 'center' },
)
} }
if (this.manaTextLocked || this.manaHovered) { if (this.manaHovered) {
font.drawText( const tmpl = resolveTblKey('panelmana', this.lang) ?? (this.lang === 'ENG' ? 'Mana: %d / %d' : '法力: %d / %d')
ctx, const text = tmpl
`${isEn ? 'Mana' : '法力'}: ${Math.max(0, Math.round(state.mana))} / ${Math.round(state.maxMana)}`, .replace(/%[du]/, String(Math.max(0, Math.round(state.mana))))
MANA_GLOBE_BOUNDS.x + 40, .replace(/%[du]/, String(Math.round(state.maxMana)))
504, this.drawGlobeTooltip(ctx, font, text, MANA_GLOBE_BOUNDS.x + 40, 484)
{ font: 'fontexocet10', color: 'white', align: 'center' },
)
} }
} }
private drawGlobeTooltip(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
text: string,
cx: number,
y: number,
): void {
const w = Math.max(100, font.measureText(text, 'fontexocet10') + 16)
const x = Math.max(8, Math.min(800 - w - 8, Math.round(cx - w / 2)))
ctx.fillStyle = 'rgba(6, 6, 6, 0.92)'
ctx.fillRect(x, y, w, 22)
ctx.strokeStyle = '#8c7244'
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, 21)
font.drawText(ctx, text, x + Math.round(w / 2), y + 16, {
font: 'fontexocet10',
color: 'white',
align: 'center',
})
}
private drawSocketBowl(ctx: CanvasRenderingContext2D, b: GlobeBounds): void { private drawSocketBowl(ctx: CanvasRenderingContext2D, b: GlobeBounds): void {
ctx.save() ctx.save()
ctx.beginPath() ctx.beginPath()

View File

@ -47,6 +47,7 @@ import {
WorldPanelsHud, WorldPanelsHud,
computeEscapeMenuLayout, computeEscapeMenuLayout,
hitTestEscapeMenu, hitTestEscapeMenu,
drawStoneBoxFrame,
type EscapeMenuAction, type EscapeMenuAction,
type EscapeMenuRowIndex, type EscapeMenuRowIndex,
type LeftDockPanelKind, type LeftDockPanelKind,
@ -259,13 +260,13 @@ export function computeDockingLayout(
const layout = computeHudLayout(cssW, cssH, dpr, VIEWPORT_800x600) const layout = computeHudLayout(cssW, cssH, dpr, VIEWPORT_800x600)
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0 const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
const leftDockX = -marginW || 0 const leftDockX = 0
const rightDockX = 400 + marginW const rightDockX = 400
const channelLeft = 400 - marginW const channelLeft = 400
const channelRight = 400 + marginW const channelRight = 400
const channelWidthLogical = 2 * marginW const channelWidthLogical = 0
const channelWidthCss = 2 * layout.offsetX const channelWidthCss = 0
const isWidescreen = marginW > 0 const isWidescreen = false
return { return {
...layout, ...layout,
@ -555,6 +556,7 @@ export class HudManager {
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */ /** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
stamina = 0 stamina = 0
maxStamina = 0 maxStamina = 0
isStaminaBlue = false
hp = 0 hp = 0
maxHp = 0 maxHp = 0
mana = 0 mana = 0
@ -1211,10 +1213,15 @@ export class HudManager {
if (!dlg) return if (!dlg) return
const l = this.getGoldDialogLayout() const l = this.getGoldDialogLayout()
const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw' const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw'
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)' const boxPiecesImg = this.images.get('boxPieces') as HTMLImageElement | undefined
ctx.fillRect(l.x, l.y, l.w, l.h) if (boxPiecesImg) {
ctx.strokeStyle = '#6c5c40' drawStoneBoxFrame(ctx, l.x, l.y, l.w, l.h, boxPiecesImg)
ctx.strokeRect(l.x + 0.5, l.y + 0.5, l.w - 1, l.h - 1) } else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)'
ctx.fillRect(l.x, l.y, l.w, l.h)
ctx.strokeStyle = '#6c5c40'
ctx.strokeRect(l.x + 0.5, l.y + 0.5, l.w - 1, l.h - 1)
}
this.font.drawText(ctx, requireTbl(titleKey, this.lang), l.x + l.w / 2, l.y + 22, { this.font.drawText(ctx, requireTbl(titleKey, this.lang), l.x + l.w / 2, l.y + 22, {
font: 'font8', font: 'font8',
color: 'white', color: 'white',
@ -1225,7 +1232,7 @@ export class HudManager {
color: 'gold', color: 'gold',
align: 'center', align: 'center',
}) })
for (const [rect, key] of [[l.ok, 'ok'], [l.cancel, 'Back']] as const) { for (const [rect, key] of [[l.ok, 'ok'], [l.cancel, 'lowercasecancel']] as const) {
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1) ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1)
this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, { this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, {
font: 'font16', font: 'font16',
@ -1496,7 +1503,7 @@ export class HudManager {
return false return false
} }
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false if (logicalX < 0 || logicalX > 800 || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
@ -1505,8 +1512,8 @@ export class HudManager {
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
if (this.hotkeys.openPopup !== null) return true if (this.hotkeys.openPopup !== null) return true
if (this.leftPanel !== 'none' && logicalX >= -marginW && logicalX <= 400 - marginW && logicalY <= 553) return true if (this.leftPanel !== 'none' && logicalX >= 0 && logicalX <= 400 && logicalY >= 0 && logicalY <= 553) return true
if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + marginW && logicalY <= 553) return true if (this.rightPanel !== 'none' && logicalX >= 400 && logicalX <= 800 && logicalY >= 0 && logicalY <= 553) return true
return false return false
} }
@ -2302,6 +2309,7 @@ export class HudManager {
isRunning: this.isRunning, isRunning: this.isRunning,
unspentStatPoints: this.charSheet.attrs.statPoints, unspentStatPoints: this.charSheet.attrs.statPoints,
unspentSkillPoints: this.skillTree.unspentSkillPoints, unspentSkillPoints: this.skillTree.unspentSkillPoints,
isStaminaBlue: this.isStaminaBlue,
} }
} }

View File

@ -3768,6 +3768,12 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"pae": "Zakarum Shield", "pae": "Zakarum Shield",
"paf": "Vortex Shield", "paf": "Vortex Shield",
"pale": "Pale", "pale": "Pale",
"panelcmini": "Close Mini Panel",
"panelexp": "Experience: %u / %u",
"panelhealth": "Life: %d / %d",
"panelmana": "Mana: %d / %d",
"panelmini": "Open Mini Panel",
"panelstamina": "Stamina: %d / %d",
"pax": "Poleaxe", "pax": "Poleaxe",
"picket": "Picket", "picket": "Picket",
"pik": "Pike", "pik": "Pike",
@ -4134,6 +4140,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"strhirespecial7": "Fire", "strhirespecial7": "Fire",
"strhirespecial8": "Lightning, Fast Cast", "strhirespecial8": "Lightning, Fast Cast",
"strhirespecial9": "Cold, Fast Cast", "strhirespecial9": "Cold, Fast Cast",
"strlvlup": "New Stats",
"strnewskl": "New Skill",
"stu": "Studded Leather", "stu": "Studded Leather",
"stun attack": "Stun Attack", "stun attack": "Stun Attack",
"suit": "Suit", "suit": "Suit",
@ -7577,6 +7585,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"pae": "撒卡兰姆盾牌", "pae": "撒卡兰姆盾牌",
"paf": "旋风盾", "paf": "旋风盾",
"pale": "蒼白", "pale": "蒼白",
"panelcmini": "關閉迷你面板",
"panelexp": "經驗: %u / %u",
"panelhealth": "生命: %d / %d",
"panelmana": "法力: %d / %d",
"panelmini": "打開迷你面板",
"panelstamina": "耐力: %d / %d",
"pax": "長柄戰斧", "pax": "長柄戰斧",
"picket": "尖木", "picket": "尖木",
"pik": "矛", "pik": "矛",
@ -7943,6 +7957,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strhirespecial7": "火", "strhirespecial7": "火",
"strhirespecial8": "快速施展閃電", "strhirespecial8": "快速施展閃電",
"strhirespecial9": "快速施展冰冷", "strhirespecial9": "快速施展冰冷",
"strlvlup": "新人物狀況",
"strnewskl": "新技能",
"stu": "鑲嵌甲", "stu": "鑲嵌甲",
"stun attack": "昏眩攻擊", "stun attack": "昏眩攻擊",
"suit": "衣", "suit": "衣",

View File

@ -26,4 +26,31 @@ describe('character sheet derived values (1.13c tables)', () => {
expect(d.coldRes).toBe(-70) expect(d.coldRes).toBe(-70)
expect(d.attackRating).toBe((25 - 7) * 5 - 15) expect(d.attackRating).toBe((25 - 7) * 5 - 15)
}) })
it('clamps maximum resistance at the hard cap of 95% even if max-resist stat is +25 or higher', () => {
const d = computeCharacterSheetDerived('pal', statsOf({ 2: 20, 39: 100, 40: 25, 41: 100, 42: 30 }), 0)
expect(d.fireRes).toBe(95)
expect(d.lightRes).toBe(95)
})
it('calculates defense per UNITS_GetDefense with item/skill armor percent and override percent', () => {
// dex 20 (base def = 5) + armorclass 100 = baseDef 105
// item_armor_percent (16) = 50, skill_armor_percent (171) = 50 -> armorPercent = 100%
// defenseBonus = floor(105 * 100 / 100) = 105 -> totalDefense = 210
const d = computeCharacterSheetDerived('bar', statsOf({ 2: 20, 31: 100, 16: 50, 171: 50 }), 0)
expect(d.defense).toBe(210)
// Berserk: armor_override_percent (182) = -100 -> defense drops to 0
const dBerserk = computeCharacterSheetDerived('bar', statsOf({ 2: 20, 31: 100, 16: 50, 171: 50, 182: -100 }), 0)
expect(dBerserk.defense).toBe(0)
})
it('drops defense to 0 when running is true', () => {
const dRunning = computeCharacterSheetDerived('bar', statsOf({ 2: 20, 31: 100 }), 0, true)
expect(dRunning.defense).toBe(0)
const dWalking = computeCharacterSheetDerived('bar', statsOf({ 2: 20, 31: 100 }), 0, false)
expect(dWalking.defense).toBe(105)
})
}) })

View File

@ -316,11 +316,11 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () =>
expect(scaled.rightDockX).toBe(880) expect(scaled.rightDockX).toBe(880)
expect(scaled.channelWidthCss).toBe(720) expect(scaled.channelWidthCss).toBe(720)
// The classic 800x600 profile still docks against the window margins. // The classic 800x600 profile locks docking to logical 0 and 400 (Issue #648).
const classic = computeDockingLayout(1920, 1080) const classic = computeDockingLayout(1920, 1080)
expect(classic.marginW).toBeCloseTo(133.333, 2) expect(classic.marginW).toBeCloseTo(133.333, 2)
expect(classic.leftDockX).toBeCloseTo(-133.333, 2) expect(classic.leftDockX).toBe(0)
expect(classic.rightDockX).toBeCloseTo(533.333, 2) expect(classic.rightDockX).toBe(400)
}) })
it('intercepts panels and the bar in 1280x720 while leaving the 480px center corridor open', () => { it('intercepts panels and the bar in 1280x720 while leaving the 480px center corridor open', () => {
@ -475,17 +475,13 @@ describe('HUD input and hover coordinate translation across all 4 viewport profi
const lifeY = 555 + barOffsetY const lifeY = 555 + barOffsetY
hud.handleMouseMove(lifeX, lifeY) hud.handleMouseMove(lifeX, lifeY)
expect(hud.globes.lifeHovered).toBe(true) expect(hud.globes.lifeHovered).toBe(true)
const prevLifeLock = hud.globes.lifeTextLocked expect(hud.globes.handleClick(lifeX, lifeY)).toBe(false)
hud.handleClick(lifeX, lifeY, 0)
expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock)
const manaX = 732 + barOffsetX const manaX = 732 + barOffsetX
const manaY = 555 + barOffsetY const manaY = 555 + barOffsetY
hud.handleMouseMove(manaX, manaY) hud.handleMouseMove(manaX, manaY)
expect(hud.globes.manaHovered).toBe(true) expect(hud.globes.manaHovered).toBe(true)
const prevManaLock = hud.globes.manaTextLocked expect(hud.globes.handleClick(manaX, manaY)).toBe(false)
hud.handleClick(manaX, manaY, 0)
expect(hud.globes.manaTextLocked).toBe(!prevManaLock)
// 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575) // 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575)
const leftSkillX = 141 + barOffsetX const leftSkillX = 141 + barOffsetX

View File

@ -141,42 +141,42 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
expect(cursor.state).toBe('POINTING') expect(cursor.state).toBe('POINTING')
expect(cursor.lastActivityTime).toBe(1200) expect(cursor.lastActivityTime).toBe(1200)
// Below 2000ms idle, remains POINTING // Below 5000ms idle, remains POINTING
expect(cursor.update(3199)).toBe('POINTING') expect(cursor.update(6199)).toBe('POINTING')
expect(cursor.state).toBe('POINTING') expect(cursor.state).toBe('POINTING')
}) })
it('transitions to OPENING after 2000ms idle and plays 8 frames sequentially', () => { it('transitions to OPENING after 5000ms idle and plays 8 frames sequentially', () => {
const cursor = new CursorController(CURSOR_METADATA, 1000) const cursor = new CursorController(CURSOR_METADATA, 1000)
cursor.frameDurationMs = 125 cursor.frameDurationMs = 125
cursor.openingFrameDurationMs = 125 cursor.openingFrameDurationMs = 125
// At exactly 2000ms idle (t = 3000ms) // At exactly 5000ms idle (t = 6000ms)
const stateAt2000 = cursor.update(3000) const stateAt5000 = cursor.update(6000)
expect(stateAt2000).toBe('OPENING') expect(stateAt5000).toBe('OPENING')
expect(cursor.state).toBe('OPENING') expect(cursor.state).toBe('OPENING')
// Check frame progression during OPENING (8 frames * 125ms = 1000ms total) // Check frame progression during OPENING (8 frames * 125ms = 1000ms total)
// Frame 0 at t = 3000ms (0ms into opening) // Frame 0 at t = 6000ms (0ms into opening)
let info = cursor.getCurrentFrame(3000) let info = cursor.getCurrentFrame(6000)
expect(info.state).toBe('OPENING') expect(info.state).toBe('OPENING')
expect(info.animation).toBe('ohand') expect(info.animation).toBe('ohand')
expect(info.frameIndex).toBe(0) expect(info.frameIndex).toBe(0)
// Frame 1 at t = 3125ms (125ms into opening) // Frame 1 at t = 6125ms (125ms into opening)
info = cursor.getCurrentFrame(3125) info = cursor.getCurrentFrame(6125)
expect(info.frameIndex).toBe(1) expect(info.frameIndex).toBe(1)
// Frame 4 at t = 3500ms (500ms into opening) // Frame 4 at t = 6500ms (500ms into opening)
info = cursor.getCurrentFrame(3500) info = cursor.getCurrentFrame(6500)
expect(info.frameIndex).toBe(4) expect(info.frameIndex).toBe(4)
// Frame 7 at t = 3875ms (875ms into opening) // Frame 7 at t = 6875ms (875ms into opening)
info = cursor.getCurrentFrame(3875) info = cursor.getCurrentFrame(6875)
expect(info.frameIndex).toBe(7) expect(info.frameIndex).toBe(7)
// Frame 7 clamped right before completion at t = 3999ms // Frame 7 clamped right before completion at t = 6999ms
info = cursor.getCurrentFrame(3999) info = cursor.getCurrentFrame(6999)
expect(info.state).toBe('OPENING') expect(info.state).toBe('OPENING')
expect(info.frameIndex).toBe(7) expect(info.frameIndex).toBe(7)
}) })
@ -186,71 +186,75 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
cursor.frameDurationMs = 125 cursor.frameDurationMs = 125
cursor.openingFrameDurationMs = 125 cursor.openingFrameDurationMs = 125
// 2000ms idle + 1000ms opening = 3000ms total to reach OPEN_IDLE // 5000ms idle + 1000ms opening = 6000ms total to reach OPEN_IDLE
expect(cursor.update(2999)).toBe('OPENING') expect(cursor.update(5999)).toBe('OPENING')
expect(cursor.update(3000)).toBe('OPEN_IDLE') expect(cursor.update(6000)).toBe('OPEN_IDLE')
expect(cursor.state).toBe('OPEN_IDLE') expect(cursor.state).toBe('OPEN_IDLE')
// Frame 0 of orotate at t = 3000ms // Frame 0 of orotate at t = 6000ms
let info = cursor.getCurrentFrame(3000) let info = cursor.getCurrentFrame(6000)
expect(info.state).toBe('OPEN_IDLE') expect(info.state).toBe('OPEN_IDLE')
expect(info.animation).toBe('orotate') expect(info.animation).toBe('orotate')
expect(info.frameIndex).toBe(0) expect(info.frameIndex).toBe(0)
// Frame 1 of orotate at t = 3125ms // Frame 1 of orotate at t = 6125ms
info = cursor.getCurrentFrame(3125) info = cursor.getCurrentFrame(6125)
expect(info.frameIndex).toBe(1) expect(info.frameIndex).toBe(1)
// Frame 7 of orotate at t = 3875ms // Frame 7 of orotate at t = 6875ms
info = cursor.getCurrentFrame(3875) info = cursor.getCurrentFrame(6875)
expect(info.frameIndex).toBe(7) expect(info.frameIndex).toBe(7)
// Loops back to frame 0 at t = 4000ms (1000ms into open idle) // Loops back to frame 0 at t = 7000ms (1000ms into open idle)
info = cursor.getCurrentFrame(4000) info = cursor.getCurrentFrame(7000)
expect(info.state).toBe('OPEN_IDLE') expect(info.state).toBe('OPEN_IDLE')
expect(info.frameIndex).toBe(0) expect(info.frameIndex).toBe(0)
// Loops to frame 3 at t = 4375ms // Loops to frame 3 at t = 7375ms
info = cursor.getCurrentFrame(4375) info = cursor.getCurrentFrame(7375)
expect(info.frameIndex).toBe(3) expect(info.frameIndex).toBe(3)
}) })
it('reverts immediately to POINTING upon mouse movement at any stage', () => { it('transitions to RELEASE and rewinds ohand upon mouse movement when open', () => {
const cursor = new CursorController(CURSOR_METADATA, 0) const cursor = new CursorController(CURSOR_METADATA, 0)
// Fast forward to OPEN_IDLE (t = 5000ms) // Fast forward to OPEN_IDLE (t = 7000ms)
cursor.update(5000) cursor.update(7000)
expect(cursor.state).toBe('OPEN_IDLE') expect(cursor.state).toBe('OPEN_IDLE')
// Mouse moves // Mouse moves -> enters RELEASE state
cursor.handleMouseMove(400, 300, 5001) cursor.handleMouseMove(400, 300, 7001)
expect(cursor.state).toBe('POINTING') expect(cursor.state).toBe('RELEASE')
expect(cursor.lastActivityTime).toBe(5001)
expect(cursor.mouseX).toBe(400) expect(cursor.mouseX).toBe(400)
expect(cursor.mouseY).toBe(300) expect(cursor.mouseY).toBe(300)
const info = cursor.getCurrentFrame(5001) let info = cursor.getCurrentFrame(7001)
expect(info.state).toBe('RELEASE')
expect(info.animation).toBe('ohand')
// Ticks decrement releaseCounter until back to POINTING
while (cursor.state === 'RELEASE') {
cursor.update(7002)
}
expect(cursor.state).toBe('POINTING')
info = cursor.getCurrentFrame(7002)
expect(info.state).toBe('POINTING') expect(info.state).toBe('POINTING')
expect(info.animation).toBe('protate') expect(info.animation).toBe('protate')
}) })
it('reverts immediately to POINTING upon mouse click (mousedown/mouseup) events', () => { it('transitions to CLICK_DOWN on mousedown and reverts to POINTING on mouseup', () => {
const cursor = new CursorController(CURSOR_METADATA, 0) const cursor = new CursorController(CURSOR_METADATA, 0)
// In OPENING state (t = 2500ms) // In POINTING state
cursor.update(2500) cursor.handleMouseDown(500)
expect(cursor.state).toBe('OPENING') expect(cursor.state).toBe('CLICK_DOWN')
let info = cursor.getCurrentFrame(500)
expect(info.state).toBe('CLICK_DOWN')
expect(info.animation).toBe('protate')
expect(info.frameIndex).toBe(0)
// Mousedown triggers reset // Mouseup reverts to POINTING
cursor.handleMouseDown(2501) cursor.handleMouseUp(600)
expect(cursor.state).toBe('POINTING')
// Advance again to OPENING (t = 4501ms)
cursor.update(4502)
expect(cursor.state).toBe('OPENING')
// Mouseup triggers reset
cursor.handleMouseUp(4503)
expect(cursor.state).toBe('POINTING') expect(cursor.state).toBe('POINTING')
}) })
@ -445,14 +449,14 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
expect(hud.cursor.mouseY).toBe(150) expect(hud.cursor.mouseY).toBe(150)
expect(hud.state.cursorState).toBe('POINTING') expect(hud.state.cursorState).toBe('POINTING')
// Advancing past 2000ms idle updates hud.cursor and published state to OPENING // Advancing past 5000ms idle updates hud.cursor and published state to OPENING
hud.cursor.update(2200) hud.cursor.update(5200)
hud.syncPublishedState() hud.syncPublishedState()
expect(hud.cursor.state).toBe('OPENING') expect(hud.cursor.state).toBe('OPENING')
expect(hud.state.cursorState).toBe('OPENING') expect(hud.state.cursorState).toBe('OPENING')
// Advancing past opening duration updates to OPEN_IDLE // Advancing past opening duration updates to OPEN_IDLE
hud.cursor.update(3500) hud.cursor.update(6500)
hud.syncPublishedState() hud.syncPublishedState()
expect(hud.cursor.state).toBe('OPEN_IDLE') expect(hud.cursor.state).toBe('OPEN_IDLE')
expect(hud.state.cursorState).toBe('OPEN_IDLE') expect(hud.state.cursorState).toBe('OPEN_IDLE')