From aae6000f0646cfe26768b57add62c5a666ec7038 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 19:14:47 +0000 Subject: [PATCH] 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) --- src/baker/packers/pack-canonical-tbl.ts | 8 ++ src/client/ui-model/derived-stats.ts | 61 +++++++-- src/client/ui-model/hud-model.ts | 61 ++++----- src/client/ui/belt.ts | 9 +- src/client/ui/control-bar.ts | 76 ++++------- src/client/ui/cursor.ts | 128 ++++++++++++++---- src/client/ui/globes.ts | 68 ++++++---- src/client/ui/hud-manager.ts | 38 ++++-- src/common/data/canonical-tbl-strings.ts | 16 +++ tests/client/derived-stats.test.ts | 27 ++++ .../client/viewport-formula-profiles.test.ts | 14 +- tests/cursor.test.ts | 112 +++++++-------- 12 files changed, 375 insertions(+), 243 deletions(-) diff --git a/src/baker/packers/pack-canonical-tbl.ts b/src/baker/packers/pack-canonical-tbl.ts index f4e784b..c141fbf 100644 --- a/src/baker/packers/pack-canonical-tbl.ts +++ b/src/baker/packers/pack-canonical-tbl.ts @@ -439,6 +439,14 @@ async function main() { 'strMerc', 'merca239', 'NotUsed150', + 'panelexp', + 'panelstamina', + 'panelhealth', + 'panelmana', + 'panelmini', + 'panelcmini', + 'strlvlup', + 'strnewskl', ]) for (const [k] of eng.dict.entries()) { diff --git a/src/client/ui-model/derived-stats.ts b/src/client/ui-model/derived-stats.ts index c659977..0e10c91 100644 --- a/src/client/ui-model/derived-stats.ts +++ b/src/client/ui-model/derived-stats.ts @@ -29,19 +29,23 @@ export interface CharacterSheetDerived { readonly poisRes: number } -const STAT_DEXTERITY = 2 -const STAT_TOHIT = 19 -const STAT_ARMORCLASS = 31 -const STAT_ITEM_TOHIT_PERCENT = 119 -const STAT_FIRERESIST = 39 -const STAT_MAXFIRERESIST = 40 -const STAT_LIGHTRESIST = 41 -const STAT_MAXLIGHTRESIST = 42 -const STAT_COLDRESIST = 43 -const STAT_MAXCOLDRESIST = 44 -const STAT_POISONRESIST = 45 -const STAT_MAXPOISONRESIST = 46 -const BASE_MAX_RESIST = 75 +export const STAT_DEXTERITY = 2 +export const STAT_ITEM_ARMOR_PERCENT = 16 +export const STAT_TOHIT = 19 +export const STAT_ARMORCLASS = 31 +export const STAT_FIRERESIST = 39 +export const STAT_MAXFIRERESIST = 40 +export const STAT_LIGHTRESIST = 41 +export const STAT_MAXLIGHTRESIST = 42 +export const STAT_COLDRESIST = 43 +export const STAT_MAXCOLDRESIST = 44 +export const STAT_POISONRESIST = 45 +export const STAT_MAXPOISONRESIST = 46 +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[] { 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). */ 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 { - 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( classCode: CharacterClassCode, stats: StatReader, difficulty: 0 | 1 | 2, + isRunning: boolean = false, ): CharacterSheetDerived { const charStats = CANONICAL_CHARSTATS[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) return { attackRating, - defense: Math.floor(dex / 4) + stats.get(STAT_ARMORCLASS), + defense: computeDefense(stats, dex, isRunning), fireRes: resist(stats, STAT_FIRERESIST, STAT_MAXFIRERESIST, penalty), coldRes: resist(stats, STAT_COLDRESIST, STAT_MAXCOLDRESIST, penalty), lightRes: resist(stats, STAT_LIGHTRESIST, STAT_MAXLIGHTRESIST, penalty), diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 5de0535..0395f31 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -56,6 +56,10 @@ import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.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 { resolveMercPortraitIcon, resolvePlayerClassPortraitIcon, @@ -75,13 +79,13 @@ export const CLASS_ID_TO_CODE: Readonly> export const CLASS_ID_TO_NAMES: Readonly< Record > = { - 0: { en: 'Amazon', zh: '亚马逊' }, - 1: { en: 'Sorceress', zh: '女法师' }, - 2: { en: 'Necromancer', zh: '死灵法师' }, - 3: { en: 'Paladin', zh: '圣骑士' }, - 4: { en: 'Barbarian', zh: '野蛮人' }, - 5: { en: 'Druid', zh: '德鲁伊' }, - 6: { en: 'Assassin', zh: '刺客' }, + 0: { en: CANONICAL_ENG_TBL_DICT['Amazon'] ?? 'Amazon', zh: CANONICAL_CHI_TBL_DICT['Amazon'] ?? '亞馬遜' }, + 1: { en: CANONICAL_ENG_TBL_DICT['Sorceress'] ?? 'Sorceress', zh: CANONICAL_CHI_TBL_DICT['Sorceress'] ?? '法師' }, + 2: { en: CANONICAL_ENG_TBL_DICT['Necromancer'] ?? 'Necromancer', zh: CANONICAL_CHI_TBL_DICT['Necromancer'] ?? '死靈法師' }, + 3: { en: CANONICAL_ENG_TBL_DICT['Paladin'] ?? 'Paladin', zh: CANONICAL_CHI_TBL_DICT['Paladin'] ?? '聖騎士' }, + 4: { en: CANONICAL_ENG_TBL_DICT['Barbarian'] ?? 'Barbarian', zh: CANONICAL_CHI_TBL_DICT['Barbarian'] ?? '野蠻人' }, + 5: { en: CANONICAL_ENG_TBL_DICT['Druid'] ?? 'Druid', zh: CANONICAL_CHI_TBL_DICT['Druid'] ?? '德魯依' }, + 6: { en: CANONICAL_ENG_TBL_DICT['Assassin'] ?? 'Assassin', zh: CANONICAL_CHI_TBL_DICT['Assassin'] ?? '刺客' }, } as const export const BODY_LOC_TO_EQUIP_SLOT: Readonly> = { @@ -594,6 +598,7 @@ export class HudModel implements HudCommandSink { hud.mana = Math.max(0, Math.min(self.maxMana, self.mana)) hud.maxStamina = self.maxStamina 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). 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. const derived = self.charClass === 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.attackRating = derived?.attackRating ?? 0 attrs.fireRes = derived?.fireRes ?? 0 @@ -793,9 +798,9 @@ export class HudModel implements HudCommandSink { } const mercRaw = this.world.merc as any const mercStats = buildMercStats(mercRaw?.stats) - const mercLevel = Number(mercRaw?.level ?? mercStats.get(12) ?? mercStats.level ?? 1) || 1 - const mercMaxHp = Number(mercRaw?.maxLife ?? mercRaw?.maxHp ?? mercStats.get(7) ?? 100) || 100 - const mercLifePct = Number(mercRaw?.lifePct ?? 100) + const mercLevel = Number(mercRaw?.level ?? mercStats.get(12) ?? mercStats.level ?? 0) + const mercMaxHp = Number(mercRaw?.maxLife ?? mercRaw?.maxHp ?? mercStats.get(7) ?? 0) + const mercLifePct = Number(mercRaw?.lifePct ?? 0) const mercHp = Number( mercRaw?.life ?? mercRaw?.hp ?? @@ -808,21 +813,21 @@ export class HudModel implements HudCommandSink { ? `Merc #${mercRaw.nameId}` : mercRaw?.mercId ? `Merc #${mercRaw.mercId}` - : 'Mercenary') + : '') const mercCommonFields = { name: mercName, level: mercLevel, - hp: Number.isFinite(mercHp) ? mercHp : 100, - maxHp: Number.isFinite(mercMaxHp) ? mercMaxHp : 100, - life: Number.isFinite(mercHp) ? mercHp : 100, - maxLife: Number.isFinite(mercMaxHp) ? mercMaxHp : 100, - lifePct: Number.isFinite(mercLifePct) ? mercLifePct : 100, + hp: Number.isFinite(mercHp) ? mercHp : 0, + maxHp: Number.isFinite(mercMaxHp) ? mercMaxHp : 0, + life: Number.isFinite(mercHp) ? mercHp : 0, + maxLife: Number.isFinite(mercMaxHp) ? mercMaxHp : 0, + lifePct: Number.isFinite(mercLifePct) ? mercLifePct : 0, experience: Number(mercRaw?.experience ?? mercStats.get(13) ?? 0) || 0, - strength: Number(mercRaw?.strength ?? mercStats.get(0) ?? mercStats.strength ?? 50), - dexterity: Number(mercRaw?.dexterity ?? mercStats.get(2) ?? mercStats.dexterity ?? 40), - minDamage: Number(mercRaw?.minDamage ?? mercStats.get(21) ?? mercStats.damageMin ?? 10), - maxDamage: Number(mercRaw?.maxDamage ?? mercStats.get(22) ?? mercStats.damageMax ?? 20), - defense: Number(mercRaw?.defense ?? mercStats.get(31) ?? mercStats.defense ?? 50), + strength: Number(mercRaw?.strength ?? mercStats.get(0) ?? mercStats.strength ?? 0), + dexterity: Number(mercRaw?.dexterity ?? mercStats.get(2) ?? mercStats.dexterity ?? 0), + minDamage: Number(mercRaw?.minDamage ?? mercStats.get(21) ?? mercStats.damageMin ?? 0), + maxDamage: Number(mercRaw?.maxDamage ?? mercStats.get(22) ?? mercStats.damageMax ?? 0), + defense: Number(mercRaw?.defense ?? mercStats.get(31) ?? mercStats.defense ?? 0), fireResist: Number(mercRaw?.fireResist ?? mercStats.get(39) ?? mercStats.fireResist ?? 0), lightResist: Number(mercRaw?.lightResist ?? mercStats.get(41) ?? mercStats.lightResist ?? 0), coldResist: Number(mercRaw?.coldResist ?? mercStats.get(43) ?? mercStats.coldResist ?? 0), @@ -847,17 +852,7 @@ export class HudModel implements HudCommandSink { iconKey: mercIconKey, ...mercCommonFields, } - : this.world.containers.merc.size > 0 - ? { - mercId: 0, - unitId: 0, - nameId: 0, - isDead: false, - reviveCost: 0, - iconKey: mercIconKey, - ...mercCommonFields, - } - : null + : null ;(hud as any).merc = syncedMerc ;(hud.worldPanels as any).merc = syncedMerc diff --git a/src/client/ui/belt.ts b/src/client/ui/belt.ts index 12e03b6..1b5b79d 100644 --- a/src/client/ui/belt.ts +++ b/src/client/ui/belt.ts @@ -10,6 +10,7 @@ */ import type { D2FontRenderer } from './font.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' import { BELT_COLS, @@ -74,10 +75,7 @@ export class BeltHud extends BeltModel { if (assets.popbeltImg) { ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy) } else { - ctx.fillStyle = 'rgba(18, 15, 11, 0.94)' - ctx.fillRect(BELT_BASE_X - 1, sy, 125, 31) - ctx.strokeStyle = '#5a4a32' - ctx.strokeRect(BELT_BASE_X - 1, sy, 125, 31) + reportMissingArt('ui-belt', 'ctrlpnl_popbelt', 'Missing popbelt background art') } } } @@ -103,8 +101,7 @@ export class BeltHud extends BeltModel { 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) } else { - ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : pot.kind === 'scroll' ? '#bfa054' : '#9c24c8' - ctx.fillRect(sx + 6, sy + 4, 16, 20) + reportMissingArt('ui-belt', pot.invFile || 'potion', 'Missing potion sprite art') } } diff --git a/src/client/ui/control-bar.ts b/src/client/ui/control-bar.ts index 543b388..d49b85e 100644 --- a/src/client/ui/control-bar.ts +++ b/src/client/ui/control-bar.ts @@ -15,6 +15,7 @@ import type { D2FontRenderer } from './font.ts' import type { TblLang } from '../i18n/lang.ts' import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' export type MiniPanelAction = | 'char' @@ -36,6 +37,7 @@ export interface ControlBarState { readonly isRunning: boolean readonly unspentStatPoints: number readonly unspentSkillPoints: number + readonly isStaminaBlue?: boolean } 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) { ctx.drawImage(assets.ctrlPnlImg, 0, 496) } else { - ctx.fillStyle = '#1c1813' - ctx.fillRect(0, 545, 800, 55) + reportMissingArt('ui-control-bar', '800CtrlPnl7', 'Missing control panel art') } // 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))) ctx.fillStyle = '#090705' 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) - stamGrad.addColorStop(0, state.isRunning ? '#ffd452' : '#e8a828') - stamGrad.addColorStop(1, '#8c540c') - ctx.fillStyle = stamGrad + ctx.fillStyle = state.isStaminaBlue ? '#2858d8' : '#c88414' ctx.fillRect( STAMINA_BAR_BOUNDS.x, STAMINA_BAR_BOUNDS.y, @@ -275,8 +273,7 @@ export class ControlBarHud { if (assets.miniPanelImg) { ctx.drawImage(assets.miniPanelImg, bounds.x, bounds.y) } else { - ctx.fillStyle = '#241e16' - ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height) + reportMissingArt('ui-control-bar', 'minipanel', 'Missing mini panel background art') } for (let i = 0; i < items.length; i++) { const bx = startX + i * 20 @@ -303,55 +300,28 @@ export class ControlBarHud { // 8. Hover Tooltip over Experience / Stamina / Mini-panel buttons const isEn = this.lang === 'ENG' if (this.hoveredElement === 'exp') { - this.drawMiniTooltip( - ctx, - font, - isEn - ? `Experience: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)` - : `经验值: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`, - EXP_BAR_BOUNDS.x + 60, - 518, - ) + const tmpl = resolveTblKey('panelexp', this.lang) ?? (isEn ? 'Experience: %u / %u' : '經驗: %u / %u') + const text = tmpl + .replace(/%[du]/, String(state.xp)) + .replace(/%[du]/, String(state.nextLevelXp)) + this.drawMiniTooltip(ctx, font, text, EXP_BAR_BOUNDS.x + 60, 518) } else if (this.hoveredElement === 'stamina') { - this.drawMiniTooltip( - ctx, - font, - isEn - ? `Stamina: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? 'Running' : 'Walking'})` - : `耐力: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? '奔跑中' : '行走中'})`, - STAMINA_BAR_BOUNDS.x + 51, - 518, - ) + const tmpl = resolveTblKey('panelstamina', this.lang) ?? (isEn ? 'Stamina: %d / %d' : '耐力: %d / %d') + const text = tmpl + .replace(/%[du]/, String(Math.round(state.stamina))) + .replace(/%[du]/, String(Math.round(state.maxStamina))) + this.drawMiniTooltip(ctx, font, text, STAMINA_BAR_BOUNDS.x + 51, 518) } else if (this.hoveredElement === 'run') { - this.drawMiniTooltip( - ctx, - font, - isEn - ? `Toggle Run/Walk (R) — Current: ${state.isRunning ? 'Run' : 'Walk'}` - : `切换行走/奔跑 (R) — 当前: ${state.isRunning ? '奔跑' : '行走'}`, - RUN_BTN_BOUNDS.x + 8, - 518, - ) + const text = state.isRunning + ? (resolveTblKey('RunOn', this.lang) ?? (isEn ? 'Run' : '跑步')) + : (resolveTblKey('RunOff', this.lang) ?? (isEn ? 'Walk' : '行走')) + this.drawMiniTooltip(ctx, font, text, RUN_BTN_BOUNDS.x + 8, 518) } else if (this.hoveredElement === 'statPlus') { - this.drawMiniTooltip( - ctx, - font, - isEn - ? `Unallocated Stat Points: ${state.unspentStatPoints} (Click to open Character Sheet A)` - : `未分配属性点: ${state.unspentStatPoints} (点击打开角色面板 A)`, - STAT_PLUS_BTN_BOUNDS.x + 15, - 518, - ) + const text = resolveTblKey('strlvlup', this.lang) ?? (isEn ? 'New Stats' : '新人物狀況') + this.drawMiniTooltip(ctx, font, text, STAT_PLUS_BTN_BOUNDS.x + 15, 518) } else if (this.hoveredElement === 'skillPlus') { - this.drawMiniTooltip( - ctx, - font, - isEn - ? `Unallocated Skill Points: ${state.unspentSkillPoints} (Click to open Skill Tree T)` - : `未分配技能点: ${state.unspentSkillPoints} (点击打开技能树 T)`, - SKILL_PLUS_BTN_BOUNDS.x + 15, - 518, - ) + const text = resolveTblKey('strnewskl', this.lang) ?? (isEn ? 'New Skill' : '新技能') + this.drawMiniTooltip(ctx, font, text, SKILL_PLUS_BTN_BOUNDS.x + 15, 518) } else if (this.hoveredElement === 'menuToggle') { const tip = this.miniPanelOpen ? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板')) diff --git a/src/client/ui/cursor.ts b/src/client/ui/cursor.ts index 4d0908f..d0f8c29 100644 --- a/src/client/ui/cursor.ts +++ b/src/client/ui/cursor.ts @@ -22,9 +22,10 @@ import { type CursorAtlasMetadata, type CursorFrameMeta, } from '../../common/items/cursor-meta.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' 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 interface CursorRenderInfo { @@ -62,6 +63,10 @@ export class CursorController { lastNowMs: number = 0 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`): * - `'buy'` -> frame 3 (`0x6fb3ba30`, `nTradeMode = 2`) @@ -72,8 +77,8 @@ export class CursorController { */ tradeCursorMode: BuySellCursorMode | null = null - /** Milliseconds of inactivity before transitioning from POINTING to OPENING. */ - idleThresholdMs: number = 2000 + /** Milliseconds of inactivity before transitioning from POINTING to OPENING (D2Client 0x6fac6840 add ecx, 0x1388 = 5000ms). */ + idleThresholdMs: number = 5000 /** Frame playback duration for looping swaying animations (protate & orotate). */ frameDurationMs: number = 125 // 8 FPS @@ -104,35 +109,63 @@ export class CursorController { this.lastActivityTime = nowMs this.lastNowMs = nowMs this.state = 'POINTING' + this.fixedPointCounter = 0 + this.releaseCounter = 0 } /** * 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 { this.mouseX = x 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) } /** - * 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 { - 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 { - 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. - * Per D2Client.dll `0x6fac62f0` (`D2CLIENT_UpdateCursorIdle`), idle hand opening only runs - * when `g_nCursorState == 1 && g_nCursorId == 5` (i.e. when `tradeCursorMode === null`). + * Evaluates the state machine transitions based on elapsed idle time and 8.8 fixed-point timers. */ update(nowMs: number = performance.now()): CursorState { this.lastNowMs = nowMs @@ -141,15 +174,35 @@ export class CursorController { 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 openingTotalMs = 8 * this.openingFrameDurationMs if (idleMs < this.idleThresholdMs) { this.state = 'POINTING' + this.fixedPointCounter = 0 } else if (idleMs < this.idleThresholdMs + openingTotalMs) { 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 { this.state = 'OPEN_IDLE' + this.fixedPointCounter = 8 << 8 } return this.state @@ -177,11 +230,18 @@ export class CursorController { const idleMs = Math.max(0, nowMs - this.lastActivityTime) const openingTotalMs = 8 * this.openingFrameDurationMs - if (this.state === 'POINTING') { + if (this.state === 'POINTING' || this.state === 'CLICK_DOWN') { anim = 'protate' - // Looping pointing hand swaying animation driven by animation clock - const safeClock = Math.max(0, nowMs) - frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8 + if (this.state === 'CLICK_DOWN') { + frameIndex = 0 + } 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') { anim = 'ohand' // Plays ohand 8 frames sequentially @@ -198,8 +258,15 @@ export class CursorController { frameIndex = 0 } - const frameList = this.metadata[anim] ?? this.metadata.protate - frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]! + const frameList = this.metadata[anim] + 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) @@ -251,19 +318,22 @@ export class CursorController { } // Otherwise, render the authentic animated hand cursor or buysell trade cursor - if (cursorAtlas) { - 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, - ) + if (!cursorAtlas) { + reportMissingArt('ui-cursor', 'cursorAtlas', `state=${this.state}`) + return } + + 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, + ) } } diff --git a/src/client/ui/globes.ts b/src/client/ui/globes.ts index 9bed2d7..5961776 100644 --- a/src/client/ui/globes.ts +++ b/src/client/ui/globes.ts @@ -9,6 +9,7 @@ import type { D2FontRenderer } from './font.ts' import type { TblLang } from '../i18n/lang.ts' import { reportMissingArt } from '../scene/world-renderer.ts' +import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' export interface GlobeState { readonly hp: number @@ -42,8 +43,6 @@ export const MANA_GLOBE_BOUNDS: GlobeBounds = { } export class GlobesHud { - lifeTextLocked = true - manaTextLocked = true lifeHovered = false manaHovered = false proceduralFallbackCount = 0 @@ -70,15 +69,7 @@ export class GlobesHud { this.manaHovered = this.hitGlobe(MANA_GLOBE_BOUNDS, logicalX, logicalY) } - 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 - } + handleClick(_logicalX: number, _logicalY: number): boolean { return false } @@ -151,33 +142,52 @@ export class GlobesHud { // 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`) if (assets.overlapLeftImg) { ctx.drawImage(assets.overlapLeftImg, 27, 508) + } else { + reportMissingArt('ui-globe', 'overlap-left', 'Authentic overlap left asset missing on draw') } if (assets.overlapRightImg) { 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`) - const isEn = this.lang === 'ENG' - if (this.lifeTextLocked || this.lifeHovered) { - font.drawText( - ctx, - `${isEn ? 'Life' : '生命'}: ${Math.max(0, Math.round(state.hp))} / ${Math.round(state.maxHp)}`, - LIFE_GLOBE_BOUNDS.x + 40, - 504, - { font: 'fontexocet10', color: 'white', align: 'center' }, - ) + // 4. Numerical readouts strictly on mouse hover via tbl format strings (`panelhealth`, `panelmana`) + if (this.lifeHovered) { + const tmpl = resolveTblKey('panelhealth', this.lang) ?? (this.lang === 'ENG' ? 'Life: %d / %d' : '生命: %d / %d') + const text = tmpl + .replace(/%[du]/, String(Math.max(0, Math.round(state.hp)))) + .replace(/%[du]/, String(Math.round(state.maxHp))) + this.drawGlobeTooltip(ctx, font, text, LIFE_GLOBE_BOUNDS.x + 40, 484) } - if (this.manaTextLocked || this.manaHovered) { - font.drawText( - ctx, - `${isEn ? 'Mana' : '法力'}: ${Math.max(0, Math.round(state.mana))} / ${Math.round(state.maxMana)}`, - MANA_GLOBE_BOUNDS.x + 40, - 504, - { font: 'fontexocet10', color: 'white', align: 'center' }, - ) + if (this.manaHovered) { + const tmpl = resolveTblKey('panelmana', this.lang) ?? (this.lang === 'ENG' ? 'Mana: %d / %d' : '法力: %d / %d') + const text = tmpl + .replace(/%[du]/, String(Math.max(0, Math.round(state.mana)))) + .replace(/%[du]/, String(Math.round(state.maxMana))) + this.drawGlobeTooltip(ctx, font, text, MANA_GLOBE_BOUNDS.x + 40, 484) } } + 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 { ctx.save() ctx.beginPath() diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index e3cfe55..087f444 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -47,6 +47,7 @@ import { WorldPanelsHud, computeEscapeMenuLayout, hitTestEscapeMenu, + drawStoneBoxFrame, type EscapeMenuAction, type EscapeMenuRowIndex, type LeftDockPanelKind, @@ -259,13 +260,13 @@ export function computeDockingLayout( const layout = computeHudLayout(cssW, cssH, dpr, VIEWPORT_800x600) const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0 - const leftDockX = -marginW || 0 - const rightDockX = 400 + marginW - const channelLeft = 400 - marginW - const channelRight = 400 + marginW - const channelWidthLogical = 2 * marginW - const channelWidthCss = 2 * layout.offsetX - const isWidescreen = marginW > 0 + const leftDockX = 0 + const rightDockX = 400 + const channelLeft = 400 + const channelRight = 400 + const channelWidthLogical = 0 + const channelWidthCss = 0 + const isWidescreen = false return { ...layout, @@ -555,6 +556,7 @@ export class HudManager { /** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */ stamina = 0 maxStamina = 0 + isStaminaBlue = false hp = 0 maxHp = 0 mana = 0 @@ -1211,10 +1213,15 @@ export class HudManager { if (!dlg) return const l = this.getGoldDialogLayout() const titleKey = dlg.mode === 0 ? 'strDropGoldHowMuch' : dlg.mode === 3 ? 'strBankGoldDeposit' : 'strBankGoldWithdraw' - 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) + const boxPiecesImg = this.images.get('boxPieces') as HTMLImageElement | undefined + if (boxPiecesImg) { + drawStoneBoxFrame(ctx, l.x, l.y, l.w, l.h, boxPiecesImg) + } 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, { font: 'font8', color: 'white', @@ -1225,7 +1232,7 @@ export class HudManager { color: 'gold', 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) this.font.drawText(ctx, requireTbl(key, this.lang), rect.x + rect.w / 2, rect.y + 16, { font: 'font16', @@ -1496,7 +1503,7 @@ export class HudManager { 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.isPointInHireMercDialog(logicalX, logicalY)) 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.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true if (this.hotkeys.openPopup !== null) return true - if (this.leftPanel !== 'none' && logicalX >= -marginW && logicalX <= 400 - marginW && logicalY <= 553) return true - if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + 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 && logicalX <= 800 && logicalY >= 0 && logicalY <= 553) return true return false } @@ -2302,6 +2309,7 @@ export class HudManager { isRunning: this.isRunning, unspentStatPoints: this.charSheet.attrs.statPoints, unspentSkillPoints: this.skillTree.unspentSkillPoints, + isStaminaBlue: this.isStaminaBlue, } } diff --git a/src/common/data/canonical-tbl-strings.ts b/src/common/data/canonical-tbl-strings.ts index a9fddd3..aeae1bc 100644 --- a/src/common/data/canonical-tbl-strings.ts +++ b/src/common/data/canonical-tbl-strings.ts @@ -3768,6 +3768,12 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "pae": "Zakarum Shield", "paf": "Vortex Shield", "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", "picket": "Picket", "pik": "Pike", @@ -4134,6 +4140,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "strhirespecial7": "Fire", "strhirespecial8": "Lightning, Fast Cast", "strhirespecial9": "Cold, Fast Cast", + "strlvlup": "New Stats", + "strnewskl": "New Skill", "stu": "Studded Leather", "stun attack": "Stun Attack", "suit": "Suit", @@ -7577,6 +7585,12 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "pae": "撒卡兰姆盾牌", "paf": "旋风盾", "pale": "蒼白", + "panelcmini": "關閉迷你面板", + "panelexp": "經驗: %u / %u", + "panelhealth": "生命: %d / %d", + "panelmana": "法力: %d / %d", + "panelmini": "打開迷你面板", + "panelstamina": "耐力: %d / %d", "pax": "長柄戰斧", "picket": "尖木", "pik": "矛", @@ -7943,6 +7957,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "strhirespecial7": "火", "strhirespecial8": "快速施展閃電", "strhirespecial9": "快速施展冰冷", + "strlvlup": "新人物狀況", + "strnewskl": "新技能", "stu": "鑲嵌甲", "stun attack": "昏眩攻擊", "suit": "衣", diff --git a/tests/client/derived-stats.test.ts b/tests/client/derived-stats.test.ts index 707f0a6..cc68878 100644 --- a/tests/client/derived-stats.test.ts +++ b/tests/client/derived-stats.test.ts @@ -26,4 +26,31 @@ describe('character sheet derived values (1.13c tables)', () => { expect(d.coldRes).toBe(-70) 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) + }) }) + diff --git a/tests/client/viewport-formula-profiles.test.ts b/tests/client/viewport-formula-profiles.test.ts index 57c1b00..6d1cec2 100644 --- a/tests/client/viewport-formula-profiles.test.ts +++ b/tests/client/viewport-formula-profiles.test.ts @@ -316,11 +316,11 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () => expect(scaled.rightDockX).toBe(880) 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) expect(classic.marginW).toBeCloseTo(133.333, 2) - expect(classic.leftDockX).toBeCloseTo(-133.333, 2) - expect(classic.rightDockX).toBeCloseTo(533.333, 2) + expect(classic.leftDockX).toBe(0) + expect(classic.rightDockX).toBe(400) }) 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 hud.handleMouseMove(lifeX, lifeY) expect(hud.globes.lifeHovered).toBe(true) - const prevLifeLock = hud.globes.lifeTextLocked - hud.handleClick(lifeX, lifeY, 0) - expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock) + expect(hud.globes.handleClick(lifeX, lifeY)).toBe(false) const manaX = 732 + barOffsetX const manaY = 555 + barOffsetY hud.handleMouseMove(manaX, manaY) expect(hud.globes.manaHovered).toBe(true) - const prevManaLock = hud.globes.manaTextLocked - hud.handleClick(manaX, manaY, 0) - expect(hud.globes.manaTextLocked).toBe(!prevManaLock) + expect(hud.globes.handleClick(manaX, manaY)).toBe(false) // 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575) const leftSkillX = 141 + barOffsetX diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts index a1ec9ba..df5dd84 100644 --- a/tests/cursor.test.ts +++ b/tests/cursor.test.ts @@ -141,42 +141,42 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { expect(cursor.state).toBe('POINTING') expect(cursor.lastActivityTime).toBe(1200) - // Below 2000ms idle, remains POINTING - expect(cursor.update(3199)).toBe('POINTING') + // Below 5000ms idle, remains POINTING + expect(cursor.update(6199)).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) cursor.frameDurationMs = 125 cursor.openingFrameDurationMs = 125 - // At exactly 2000ms idle (t = 3000ms) - const stateAt2000 = cursor.update(3000) - expect(stateAt2000).toBe('OPENING') + // At exactly 5000ms idle (t = 6000ms) + const stateAt5000 = cursor.update(6000) + expect(stateAt5000).toBe('OPENING') expect(cursor.state).toBe('OPENING') // Check frame progression during OPENING (8 frames * 125ms = 1000ms total) - // Frame 0 at t = 3000ms (0ms into opening) - let info = cursor.getCurrentFrame(3000) + // Frame 0 at t = 6000ms (0ms into opening) + let info = cursor.getCurrentFrame(6000) expect(info.state).toBe('OPENING') expect(info.animation).toBe('ohand') expect(info.frameIndex).toBe(0) - // Frame 1 at t = 3125ms (125ms into opening) - info = cursor.getCurrentFrame(3125) + // Frame 1 at t = 6125ms (125ms into opening) + info = cursor.getCurrentFrame(6125) expect(info.frameIndex).toBe(1) - // Frame 4 at t = 3500ms (500ms into opening) - info = cursor.getCurrentFrame(3500) + // Frame 4 at t = 6500ms (500ms into opening) + info = cursor.getCurrentFrame(6500) expect(info.frameIndex).toBe(4) - // Frame 7 at t = 3875ms (875ms into opening) - info = cursor.getCurrentFrame(3875) + // Frame 7 at t = 6875ms (875ms into opening) + info = cursor.getCurrentFrame(6875) expect(info.frameIndex).toBe(7) - // Frame 7 clamped right before completion at t = 3999ms - info = cursor.getCurrentFrame(3999) + // Frame 7 clamped right before completion at t = 6999ms + info = cursor.getCurrentFrame(6999) expect(info.state).toBe('OPENING') expect(info.frameIndex).toBe(7) }) @@ -186,71 +186,75 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => { cursor.frameDurationMs = 125 cursor.openingFrameDurationMs = 125 - // 2000ms idle + 1000ms opening = 3000ms total to reach OPEN_IDLE - expect(cursor.update(2999)).toBe('OPENING') - expect(cursor.update(3000)).toBe('OPEN_IDLE') + // 5000ms idle + 1000ms opening = 6000ms total to reach OPEN_IDLE + expect(cursor.update(5999)).toBe('OPENING') + expect(cursor.update(6000)).toBe('OPEN_IDLE') expect(cursor.state).toBe('OPEN_IDLE') - // Frame 0 of orotate at t = 3000ms - let info = cursor.getCurrentFrame(3000) + // Frame 0 of orotate at t = 6000ms + let info = cursor.getCurrentFrame(6000) expect(info.state).toBe('OPEN_IDLE') expect(info.animation).toBe('orotate') expect(info.frameIndex).toBe(0) - // Frame 1 of orotate at t = 3125ms - info = cursor.getCurrentFrame(3125) + // Frame 1 of orotate at t = 6125ms + info = cursor.getCurrentFrame(6125) expect(info.frameIndex).toBe(1) - // Frame 7 of orotate at t = 3875ms - info = cursor.getCurrentFrame(3875) + // Frame 7 of orotate at t = 6875ms + info = cursor.getCurrentFrame(6875) expect(info.frameIndex).toBe(7) - // Loops back to frame 0 at t = 4000ms (1000ms into open idle) - info = cursor.getCurrentFrame(4000) + // Loops back to frame 0 at t = 7000ms (1000ms into open idle) + info = cursor.getCurrentFrame(7000) expect(info.state).toBe('OPEN_IDLE') expect(info.frameIndex).toBe(0) - // Loops to frame 3 at t = 4375ms - info = cursor.getCurrentFrame(4375) + // Loops to frame 3 at t = 7375ms + info = cursor.getCurrentFrame(7375) 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) - // Fast forward to OPEN_IDLE (t = 5000ms) - cursor.update(5000) + // Fast forward to OPEN_IDLE (t = 7000ms) + cursor.update(7000) expect(cursor.state).toBe('OPEN_IDLE') - // Mouse moves - cursor.handleMouseMove(400, 300, 5001) - expect(cursor.state).toBe('POINTING') - expect(cursor.lastActivityTime).toBe(5001) + // Mouse moves -> enters RELEASE state + cursor.handleMouseMove(400, 300, 7001) + expect(cursor.state).toBe('RELEASE') expect(cursor.mouseX).toBe(400) 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.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) - // In OPENING state (t = 2500ms) - cursor.update(2500) - expect(cursor.state).toBe('OPENING') + // In POINTING state + cursor.handleMouseDown(500) + 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 - cursor.handleMouseDown(2501) - 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) + // Mouseup reverts to POINTING + cursor.handleMouseUp(600) 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.state.cursorState).toBe('POINTING') - // Advancing past 2000ms idle updates hud.cursor and published state to OPENING - hud.cursor.update(2200) + // Advancing past 5000ms idle updates hud.cursor and published state to OPENING + hud.cursor.update(5200) hud.syncPublishedState() expect(hud.cursor.state).toBe('OPENING') expect(hud.state.cursorState).toBe('OPENING') // Advancing past opening duration updates to OPEN_IDLE - hud.cursor.update(3500) + hud.cursor.update(6500) hud.syncPublishedState() expect(hud.cursor.state).toBe('OPEN_IDLE') expect(hud.state.cursorState).toBe('OPEN_IDLE')