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:
parent
7d6a5bc743
commit
aae6000f06
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<Record<CharClassId, CharacterClassCode>>
|
|||
export const CLASS_ID_TO_NAMES: Readonly<
|
||||
Record<CharClassId, { readonly en: string; readonly zh: string }>
|
||||
> = {
|
||||
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<Record<number, EquipSlotId>> = {
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' : '關閉迷你面板'))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3768,6 +3768,12 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"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<Record<string, string>> = {
|
|||
"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<Record<string, string>> = {
|
|||
"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<Record<string, string>> = {
|
|||
"strhirespecial7": "火",
|
||||
"strhirespecial8": "快速施展閃電",
|
||||
"strhirespecial9": "快速施展冰冷",
|
||||
"strlvlup": "新人物狀況",
|
||||
"strnewskl": "新技能",
|
||||
"stu": "鑲嵌甲",
|
||||
"stun attack": "昏眩攻擊",
|
||||
"suit": "衣",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue