2145 lines
78 KiB
TypeScript
2145 lines
78 KiB
TypeScript
/**
|
|
* HUD Model Bridge (`src/client/ui-model/hud-model.ts`).
|
|
*
|
|
* Bridges authoritative `ClientWorld` state (`SelfState`, `ContainerStore`, `ClientUnit`s,
|
|
* `QuestState`, `WaypointMenu`), `LocalMovementPredictor`, and `AutomapView` into `HudManager`
|
|
* (`src/ui/hud-manager.ts`), and translates HUD panel interactions into protocol-neutral
|
|
* `ClientCommand` messages.
|
|
*
|
|
* Zero imports from `src/netproto/{bncs,mcp,d2gs,crypto}/**` — consumes only `src/netproto/index.ts`.
|
|
*/
|
|
|
|
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
|
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
|
import {
|
|
CANONICAL_ELITE_MODIFIERS,
|
|
FIXED_UNIQUE_BOSS_HCIDX_SET,
|
|
applyMonsterUModResistances,
|
|
getMonsterKindByHcIdx,
|
|
getSuperUniqueByIndex,
|
|
monsterDisplayName,
|
|
rollUniqueMonsterNameKeys,
|
|
type Difficulty,
|
|
} from '../../common/units/monsters-data.ts'
|
|
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
|
|
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
|
import { ClickButtonId } from '../../netproto/index.ts'
|
|
import type {
|
|
CharClassId,
|
|
ClientCommand,
|
|
Hand,
|
|
ItemData,
|
|
} from '../../netproto/index.ts'
|
|
import { BELT_COLS, BELT_ROWS, itemToBeltPotion } from '../ui/belt.ts'
|
|
import type { BaseStatKey } from '../ui/character-sheet.ts'
|
|
import { isAuraSkill, isLeftUsableSkill, isPassiveSkill } from '../ui/hotkeys.ts'
|
|
import type { HudCommandSink, HudManager } from '../ui/hud-manager.ts'
|
|
import {
|
|
type EquipSlotId,
|
|
type GridPlacement,
|
|
type UiInventoryItem,
|
|
} from '../ui/inventory.ts'
|
|
import {
|
|
GAMBLE_NPC_CLASS_IDS,
|
|
MONSTER_UMOD_TBL_KEYS,
|
|
lookupMonsterTblString,
|
|
type MonsterTargetHudInfo,
|
|
} from '../ui/world-panels.ts'
|
|
import { requireTbl } from '../i18n/lang.ts'
|
|
import { getTownNpcDescriptorByHcIdx, type TownNpcServiceDescriptor } from '../../common/units/npc-table.ts'
|
|
import type { VendorPriceContext, VendorPriceFn, VendorRepairAllFn } from './vendor-price.ts'
|
|
import type { AutomapView } from '../automap/automap-view.ts'
|
|
import type { LocalMovementPredictor } from '../motion/predictor.ts'
|
|
import type { ClientUnit } from '../world/client-unit.ts'
|
|
import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts'
|
|
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,
|
|
resolvePetPortraitInfo,
|
|
} from '../../common/units/pet-type-table.ts'
|
|
|
|
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
|
0: 'ama',
|
|
1: 'sor',
|
|
2: 'nec',
|
|
3: 'pal',
|
|
4: 'bar',
|
|
5: 'dru',
|
|
6: 'ass',
|
|
} as const
|
|
|
|
export const CLASS_ID_TO_NAMES: Readonly<
|
|
Record<CharClassId, { readonly en: string; readonly zh: string }>
|
|
> = {
|
|
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>> = {
|
|
1: 'helm',
|
|
2: 'amulet',
|
|
3: 'armor',
|
|
4: 'weapon1',
|
|
5: 'weapon2',
|
|
6: 'ring1',
|
|
7: 'ring2',
|
|
8: 'belt',
|
|
9: 'boots',
|
|
10: 'gloves',
|
|
} as const
|
|
|
|
export const EQUIP_SLOT_TO_BODY_LOC: Readonly<Record<EquipSlotId, number>> = {
|
|
helm: 1,
|
|
amulet: 2,
|
|
armor: 3,
|
|
weapon1: 4,
|
|
weapon2: 5,
|
|
ring1: 6,
|
|
ring2: 7,
|
|
belt: 8,
|
|
boots: 9,
|
|
gloves: 10,
|
|
} as const
|
|
|
|
export const STAT_KEY_TO_D2_STAT_ID: Readonly<Record<BaseStatKey, number>> = {
|
|
str: D2StatId.Strength,
|
|
ene: D2StatId.Energy,
|
|
dex: D2StatId.Dexterity,
|
|
vit: D2StatId.Vitality,
|
|
} as const
|
|
|
|
/**
|
|
* Converts a decoded `ItemData` record (`0x9C` / `0x9D`) from `ClientWorld` into a canonical 1.13c `UiInventoryItem`
|
|
* suitable for rendering and interaction inside `HudManager` (`InventoryPanel`, `WorldPanelsHud`, `BeltHud`).
|
|
*/
|
|
export function itemDataToUiInventoryItem(
|
|
item: ItemData,
|
|
socketChildren?: readonly ItemData[],
|
|
): UiInventoryItem {
|
|
return onlineItemDataToUiInventoryItem(item, socketChildren)
|
|
}
|
|
|
|
export interface HudModelOptions {
|
|
readonly world: ClientWorld
|
|
readonly hudManager?: HudManager | null | undefined
|
|
readonly predictor?: LocalMovementPredictor | null | undefined
|
|
readonly automap?: AutomapView | null | undefined
|
|
readonly onCommand?: ((cmd: ClientCommand) => void) | undefined
|
|
/** Store price provider (D2Common #10107); required before a store opens. Production: `vendorPrice113c`. */
|
|
readonly vendorPrice?: VendorPriceFn | undefined
|
|
/** Repair-all cost provider (D2Common #10071); required before a store opens. Production: `vendorRepairAll113c`. */
|
|
readonly vendorRepairAll?: VendorRepairAllFn | undefined
|
|
/** UI sound sink (sell sound 0xdd); required before a store opens. */
|
|
readonly playUiSound?: ((soundId: number) => void) | undefined
|
|
/** Clock for the store click throttle (ms); defaults to `performance.now`. */
|
|
readonly now?: (() => number) | undefined
|
|
}
|
|
|
|
/** D2Client store click throttle: one buy / sell / repair request per action every 2000 ms. */
|
|
export const NPC_TRADE_THROTTLE_MS = 2000
|
|
|
|
/**
|
|
* S2C `0x2A` NPCTransaction result -> `string.tbl` key of the message D2Client shows (indices 3356..3363).
|
|
* Results 0 (buy), 1 (sell) and 2 (repair all) succeed silently; any other unknown code shows TransactionResults9.
|
|
*/
|
|
export function npcTransactionMessageKey(result: number): string | null {
|
|
switch (result) {
|
|
case 0:
|
|
case 1:
|
|
case 2:
|
|
return null
|
|
case 7:
|
|
case 8:
|
|
return 'TransactionResults6' // 3358
|
|
case 10:
|
|
return 'TransactionResults4' // 3356
|
|
case 11:
|
|
return 'TransactionResults8' // 3360
|
|
case 12:
|
|
return 'TransactionResults10' // 3362
|
|
case 15:
|
|
return 'TransactionResults11' // 3363
|
|
case 16:
|
|
return 'TransactionResults5' // 3357
|
|
default:
|
|
return 'TransactionResults9' // 3361
|
|
}
|
|
}
|
|
|
|
/** Sound D2Client plays on a successful sell (S2C 0x2A result 1). */
|
|
export const NPC_SELL_SOUND_ID = 0xdd
|
|
|
|
/** `UnitType` id of monsters / NPCs in `UnitRef`. */
|
|
const UNIT_TYPE_MONSTER = 1
|
|
|
|
const NPC_STATE_CLOSED: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
|
|
|
|
export interface HudModelSnapshot {
|
|
readonly charName: string
|
|
/** `null` until the server assigns the class (MCP char list / `0x59` UnitAssign). */
|
|
readonly charClass: CharClassId | null
|
|
readonly classCode: CharacterClassCode | null
|
|
readonly level: number
|
|
readonly xp: number
|
|
readonly hp: number
|
|
readonly maxHp: number
|
|
readonly mana: number
|
|
readonly maxMana: number
|
|
readonly stamina: number
|
|
readonly maxStamina: number
|
|
readonly str: number
|
|
readonly dex: number
|
|
readonly vit: number
|
|
readonly ene: number
|
|
readonly statPoints: number
|
|
readonly skillPoints: number
|
|
readonly gold: number
|
|
readonly stashGold: number
|
|
readonly leftSkillId: number
|
|
readonly rightSkillId: number
|
|
readonly isRunning: boolean
|
|
readonly weaponSet: 0 | 1
|
|
readonly inventoryCount: number
|
|
readonly equippedCount: number
|
|
readonly beltCount: number
|
|
readonly stashCount: number
|
|
readonly cubeCount: number
|
|
readonly cursorItemId: number | null
|
|
readonly unlockedWaypoints: readonly number[]
|
|
readonly panels: Readonly<Record<string, boolean>>
|
|
}
|
|
|
|
/**
|
|
* Reactive model synchronizing `ClientWorld` state into `HudManager` and dispatching
|
|
* `ClientCommand` actions triggered by user HUD interactions.
|
|
*/
|
|
export class HudModel implements HudCommandSink {
|
|
readonly world: ClientWorld
|
|
hudManager: HudManager | null
|
|
predictor: LocalMovementPredictor | null
|
|
automap: AutomapView | null
|
|
onCommand?: ((cmd: ClientCommand) => void) | undefined
|
|
|
|
private unsubWorld: (() => void) | null = null
|
|
private hoveredMonsterId: number | null = null
|
|
private escapeMenuOpen = false
|
|
private readonly priceProvider: VendorPriceFn | null
|
|
private readonly repairAllProvider: VendorRepairAllFn | null
|
|
private readonly uiSound: ((soundId: number) => void) | null
|
|
private readonly now: () => number
|
|
/** NPC/stash state seen by the last sync; panels react to transitions only. */
|
|
private prevNpc: NpcInteractionState = NPC_STATE_CLOSED
|
|
private prevTradeActive = false
|
|
private prevAct = -1
|
|
/** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */
|
|
private handledNpcTransactionSeq: number
|
|
private readonly lastNpcActionAt = new Map<string, number>()
|
|
/** Message shown for the last non-success S2C 0x2A (string.tbl index + localized text). */
|
|
lastNpcTransactionMessage: { readonly tblKey: string; readonly text: string } | null = null
|
|
|
|
constructor(options: HudModelOptions) {
|
|
this.world = options.world
|
|
this.hudManager = options.hudManager ?? null
|
|
this.predictor = options.predictor ?? null
|
|
this.automap = options.automap ?? null
|
|
this.onCommand = options.onCommand
|
|
this.priceProvider = options.vendorPrice ?? null
|
|
this.repairAllProvider = options.vendorRepairAll ?? null
|
|
this.uiSound = options.playUiSound ?? null
|
|
this.now = options.now ?? (() => performance.now())
|
|
this.handledNpcTransactionSeq = this.world.lastNpcTransaction?.seq ?? 0
|
|
if (this.hudManager) {
|
|
this.bindHudCallbacks(this.hudManager)
|
|
}
|
|
|
|
this.unsubWorld = this.world.onChange(() => {
|
|
this.syncFromWorld()
|
|
})
|
|
}
|
|
|
|
get hud(): HudManager | null {
|
|
return this.hudManager
|
|
}
|
|
|
|
set hud(hudManager: HudManager | null) {
|
|
this.attachHudManager(hudManager)
|
|
}
|
|
|
|
private bindHudCallbacks(hud: HudManager): void {
|
|
hud.commandSink = this
|
|
hud.onPlayUiSound = (soundId) => {
|
|
this.uiSound?.(soundId)
|
|
}
|
|
hud.hotkeys.onSelectSkill = (side, skillId) => {
|
|
this.selectSkill(side, skillId)
|
|
}
|
|
;(hud as any).onWeaponSwitch = () => {
|
|
this.swapWeapons()
|
|
}
|
|
;(hud as any).onMercEquipSlotClick = (slot: number) => this.mercItem(slot)
|
|
;(hud as any).onHireMercClick = (npcId: number, mercId: number) =>
|
|
this.hireMerc(
|
|
npcId ||
|
|
this.world.npcInteraction.npcId ||
|
|
(hud.worldPanels as any)?.activeNpcId ||
|
|
(hud.worldPanels as any)?.vendorNpcId ||
|
|
0,
|
|
mercId,
|
|
)
|
|
;(hud as any).onPartyActionClick = (action: number, targetPlayerId: number) =>
|
|
this.partyAction(action, targetPlayerId)
|
|
;(hud as any).onHostileToggleClick = (targetPlayerId: number, hostile: boolean, button?: number) =>
|
|
this.setHostile(targetPlayerId, hostile, button ?? 1)
|
|
;(hud as any).onTradeButtonClick = (buttonId: number, complement?: number) =>
|
|
this.clickButton(buttonId, complement ?? 0)
|
|
;(hud as any).onConfirmQuestRewardClick = (itemId: number, npcId?: number) =>
|
|
this.confirmQuestReward(itemId, npcId)
|
|
const wp = hud.worldPanels as any
|
|
wp.onQuestMessage = (npcId: number, messageId = 0) => this.questMessage(npcId, messageId)
|
|
wp.onNpcEntityAction = (action: number, npcId: number, complement?: number) =>
|
|
this.npcEntityAction(action, npcId, complement ?? 0)
|
|
wp.onHireMerc = (npcId: number, mercId: number) =>
|
|
this.hireMerc(
|
|
npcId ||
|
|
this.world.npcInteraction.npcId ||
|
|
wp.activeNpcId ||
|
|
wp.vendorNpcId ||
|
|
0,
|
|
mercId,
|
|
)
|
|
wp.onResurrectMerc = (npcId?: number) => this.resurrectMerc(npcId)
|
|
wp.onMercItem = (slot: number) => this.mercItem(slot)
|
|
wp.onPartyAction = (action: number, targetPlayerId: number) => this.partyAction(action, targetPlayerId)
|
|
wp.onHostile = (targetPlayerId: number, hostile: boolean, button?: number) =>
|
|
this.setHostile(targetPlayerId, hostile, button ?? 1)
|
|
wp.onSetHostile = (targetPlayerId: number, hostile: boolean, button?: number) =>
|
|
this.setHostile(targetPlayerId, hostile, button ?? 1)
|
|
wp.onClickButton = (buttonId: number, complement?: number) => this.clickButton(buttonId, complement ?? 0)
|
|
wp.onConfirmQuestReward = (itemId: number, npcId?: number) =>
|
|
this.confirmQuestReward(itemId, typeof npcId === 'number' ? npcId : undefined)
|
|
wp.onMercEquipSlotClick = (slot: number) => this.mercItem(slot)
|
|
wp.onHireMercClick = (npcId: number, mercId: number) =>
|
|
this.hireMerc(
|
|
npcId ||
|
|
(this.world.npcInteraction.npcId ?? 0) ||
|
|
wp.activeNpcId ||
|
|
wp.vendorNpcId ||
|
|
0,
|
|
mercId,
|
|
)
|
|
wp.onPartyActionClick = (action: number, targetPlayerId: number) =>
|
|
this.partyAction(action, targetPlayerId)
|
|
wp.onHostileToggleClick = (targetPlayerId: number, hostile: boolean, button?: 1 | 2) =>
|
|
this.setHostile(targetPlayerId, hostile, button ?? 1)
|
|
wp.onTradeButtonClick = (buttonId: number, complement = 0) =>
|
|
this.clickButton(buttonId, complement)
|
|
wp.onConfirmQuestRewardClick = (itemId: number, npcIdOrPanel?: number | string) =>
|
|
this.confirmQuestReward(
|
|
itemId,
|
|
typeof npcIdOrPanel === 'number' ? npcIdOrPanel : undefined,
|
|
)
|
|
wp.onNpcMenuAction = (actionId: string, npcId?: number, descriptor?: TownNpcServiceDescriptor) =>
|
|
this.handleNpcMenuAction(actionId, npcId, descriptor)
|
|
wp.onCloseNpcPanel = () => this.closeActiveNpcPanel()
|
|
}
|
|
|
|
/**
|
|
* Detach the world listener when disposing the session.
|
|
*/
|
|
dispose(): void {
|
|
if (this.hudManager?.commandSink === this) {
|
|
this.hudManager.commandSink = null
|
|
this.hudManager.onPlayUiSound = undefined
|
|
}
|
|
if (this.unsubWorld) {
|
|
this.unsubWorld()
|
|
this.unsubWorld = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bind or replace the active `HudManager` instance and synchronize immediately.
|
|
*/
|
|
attachHudManager(hudManager: HudManager | null): void {
|
|
if (this.hudManager && this.hudManager !== hudManager && this.hudManager.commandSink === this) {
|
|
this.hudManager.commandSink = null
|
|
this.hudManager.onPlayUiSound = undefined
|
|
}
|
|
this.hudManager = hudManager
|
|
if (hudManager) {
|
|
this.bindHudCallbacks(hudManager)
|
|
hudManager.escapeMenuOpen = this.escapeMenuOpen
|
|
this.syncFromWorld()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a `ClientWorld` `ItemData` into a canonical 1.13c `UiInventoryItem` with its socketed children.
|
|
*/
|
|
toUiItem(item: ItemData): UiInventoryItem {
|
|
const ui = itemDataToUiInventoryItem(item, this.world.containers.sockets.get(item.id))
|
|
;(ui as any).gridX = (ui as any).gridX ?? item.gridX ?? item.x ?? (ui as any).col ?? 0
|
|
;(ui as any).gridY = (ui as any).gridY ?? item.gridY ?? item.y ?? (ui as any).row ?? 0
|
|
return ui
|
|
}
|
|
|
|
/**
|
|
* Whether the player currently carries any unidentified item across inventory, equipment, stash, or cube.
|
|
*/
|
|
hasUnidentifiedItems(): boolean {
|
|
for (const item of this.world.containers.inventory.values()) {
|
|
if (item.identified === false) return true
|
|
}
|
|
for (const item of this.world.containers.equipment.values()) {
|
|
if (item.identified === false) return true
|
|
}
|
|
for (const item of this.world.containers.stash.values()) {
|
|
if (item.identified === false) return true
|
|
}
|
|
for (const item of this.world.containers.cube.values()) {
|
|
if (item.identified === false) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Count unidentified items carried by player (inventory + Horadric cube).
|
|
*/
|
|
getUnidentifiedItemCount(): number {
|
|
let count = 0
|
|
const checkItem = (item: ItemData | undefined): boolean => {
|
|
if (!item) return false
|
|
if (item.identified === false) return true
|
|
if (item.flags && typeof (item.flags as any).identified === 'boolean') {
|
|
return (item.flags as any).identified === false
|
|
}
|
|
return false
|
|
}
|
|
for (const item of this.world.containers.inventory.values()) {
|
|
if (checkItem(item)) count++
|
|
}
|
|
for (const item of this.world.containers.cube.values()) {
|
|
if (checkItem(item)) count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
/**
|
|
* Build a pure snapshot of the HUD state derived from `ClientWorld`.
|
|
*/
|
|
getSnapshot(): HudModelSnapshot {
|
|
const self = this.world.self
|
|
const stats = self.stats
|
|
const classCode = self.charClass === null ? null : resolveClassCode(self.charClass)
|
|
|
|
const invItems = [...this.world.containers.inventory.values()]
|
|
const eqItems = [...this.world.containers.equipment.values()]
|
|
const beltItems = [...this.world.containers.belt.values()]
|
|
const stashItems = [...this.world.containers.stash.values()]
|
|
const cubeItems = [...this.world.containers.cube.values()]
|
|
const cursorItem = this.world.containers.cursor
|
|
const leftPanel = (this.hudManager?.leftPanel ?? 'none') as string
|
|
const rightPanel = (this.hudManager?.rightPanel ?? 'none') as string
|
|
const escapeMenuOpen = this.hudManager ? this.hudManager.escapeMenuOpen : this.escapeMenuOpen
|
|
this.escapeMenuOpen = escapeMenuOpen
|
|
|
|
return {
|
|
charName: self.name,
|
|
charClass: self.charClass,
|
|
classCode,
|
|
level: self.level,
|
|
xp: self.experience,
|
|
hp: self.life,
|
|
maxHp: self.maxLife,
|
|
mana: self.mana,
|
|
maxMana: self.maxMana,
|
|
stamina: self.stamina,
|
|
maxStamina: self.maxStamina,
|
|
str: stats.get(D2StatId.Strength),
|
|
dex: stats.get(D2StatId.Dexterity),
|
|
vit: stats.get(D2StatId.Vitality),
|
|
ene: stats.get(D2StatId.Energy),
|
|
statPoints: stats.get(D2StatId.StatPts),
|
|
skillPoints: stats.get(D2StatId.NewSkills),
|
|
gold: stats.get(D2StatId.Gold),
|
|
stashGold: stats.get(D2StatId.GoldBank),
|
|
leftSkillId: self.leftSkill,
|
|
rightSkillId: self.rightSkill,
|
|
isRunning: this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true),
|
|
weaponSet: self.weaponSet,
|
|
inventoryCount: invItems.length,
|
|
equippedCount: eqItems.length,
|
|
beltCount: beltItems.length,
|
|
stashCount: stashItems.length,
|
|
cubeCount: cubeItems.length,
|
|
cursorItemId: cursorItem ? cursorItem.id : null,
|
|
unlockedWaypoints: [...this.world.waypoints].sort((a, b) => a - b),
|
|
panels: {
|
|
inventory: rightPanel === 'inv' || rightPanel === 'inventory',
|
|
inv: rightPanel === 'inv' || rightPanel === 'inventory',
|
|
skill: rightPanel === 'skill',
|
|
skillTree: rightPanel === 'skill',
|
|
char: leftPanel === 'char',
|
|
character: leftPanel === 'char',
|
|
quest: leftPanel === 'quest',
|
|
questLog: leftPanel === 'quest',
|
|
waypoint: leftPanel === 'waypoint',
|
|
party: leftPanel === 'party' || leftPanel === 'waypoint',
|
|
merc: leftPanel === 'merc',
|
|
hire: leftPanel === 'hire',
|
|
trade: leftPanel === 'trade',
|
|
imbue: leftPanel === 'imbue',
|
|
add_sockets: leftPanel === 'add_sockets',
|
|
personalize: leftPanel === 'personalize',
|
|
stash: leftPanel === 'stash',
|
|
cube: leftPanel === 'cube',
|
|
vendor: leftPanel === 'vendor',
|
|
automap: this.automap ? this.automap.getMode() !== 'off' : false,
|
|
escapeMenu: escapeMenuOpen,
|
|
},
|
|
}
|
|
}
|
|
|
|
get isRunMode(): boolean {
|
|
return this.hudManager ? this.hudManager.isRunning : (this.predictor?.getSnapshot().running ?? true)
|
|
}
|
|
|
|
get leftSkillId(): number {
|
|
return this.world.self.leftSkill
|
|
}
|
|
|
|
get rightSkillId(): number {
|
|
return this.world.self.rightSkill
|
|
}
|
|
|
|
/**
|
|
* Alias for `syncFromWorld()`.
|
|
*/
|
|
sync(): void {
|
|
this.syncFromWorld()
|
|
}
|
|
|
|
/**
|
|
* Toggle a left or right HUD panel (`inventory`, `character`, `skillTree`, `quest`/`questLog`,
|
|
* `waypoint`/`party`, `merc`, `automap`, `escapeMenu`).
|
|
*/
|
|
togglePanel(panel: string): boolean {
|
|
if (panel === 'escapeMenu') {
|
|
if (this.hudManager) {
|
|
this.escapeMenuOpen = this.hudManager.toggleEscapeMenu()
|
|
return this.escapeMenuOpen
|
|
}
|
|
this.escapeMenuOpen = !this.escapeMenuOpen
|
|
return this.escapeMenuOpen
|
|
}
|
|
if (panel === 'automap') {
|
|
this.toggleAutomap()
|
|
return this.automap ? this.automap.getMode() !== 'off' : false
|
|
}
|
|
if (!this.hudManager) return false
|
|
switch (panel) {
|
|
case 'inventory':
|
|
case 'inv':
|
|
this.hudManager.toggleRightPanel('inv')
|
|
return this.hudManager.rightPanel === 'inv'
|
|
case 'skill':
|
|
case 'skillTree':
|
|
this.hudManager.toggleRightPanel('skill')
|
|
return this.hudManager.rightPanel === 'skill'
|
|
case 'char':
|
|
case 'character':
|
|
this.hudManager.toggleLeftPanel('char')
|
|
return this.hudManager.leftPanel === 'char'
|
|
case 'quest':
|
|
case 'questLog':
|
|
this.hudManager.toggleLeftPanel('quest')
|
|
return this.hudManager.leftPanel === 'quest'
|
|
case 'waypoint':
|
|
this.hudManager.toggleLeftPanel('waypoint')
|
|
return this.hudManager.leftPanel === 'waypoint'
|
|
case 'party':
|
|
this.hudManager.toggleLeftPanel('party' as any)
|
|
return (this.hudManager.leftPanel as string) === 'party'
|
|
case 'merc':
|
|
this.hudManager.toggleLeftPanel('merc' as any)
|
|
return (this.hudManager.leftPanel as string) === 'merc'
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
closeAllPanels(): void {
|
|
this.escapeMenuOpen = false
|
|
this.hudManager?.closeAllPanels()
|
|
}
|
|
|
|
/**
|
|
* Cycle or toggle Automap reveal mode (`off` -> `level` -> `act` -> `off`).
|
|
*/
|
|
toggleAutomap(): void {
|
|
if (!this.automap) return
|
|
const cur = this.automap.getMode()
|
|
const next = cur === 'off' ? 'level' : cur === 'level' ? 'act' : 'off'
|
|
this.automap.setMode(next)
|
|
}
|
|
|
|
/**
|
|
* Synchronize `ClientWorld` state into `HudManager` (vitals, character sheet, skills,
|
|
* inventory/equipment/stash/cube/belt containers, waypoints, death, mercenary, party, trade, and NPC state).
|
|
*/
|
|
syncFromWorld(): void {
|
|
const hud = this.hudManager
|
|
if (!hud) return
|
|
|
|
this.bindHudCallbacks(hud)
|
|
|
|
const self = this.world.self
|
|
const stats = self.stats
|
|
|
|
// 0. Class: not ready until the server assigns it. No class-dependent panel state is synthesized before that.
|
|
if (self.charClass !== null) {
|
|
hud.setCharacterClass(resolveClassCode(self.charClass))
|
|
const classNames = CLASS_ID_TO_NAMES[self.charClass]
|
|
hud.charSheet.attrs.classNameEn = classNames.en
|
|
hud.charSheet.attrs.classNameZh = classNames.zh
|
|
}
|
|
|
|
// 1. Vitals (globes & stamina bar): verbatim server values (0x95 / stats 6-11), absent = 0.
|
|
hud.maxHp = self.maxLife
|
|
hud.hp = Math.max(0, Math.min(self.maxLife, self.life))
|
|
hud.maxMana = self.maxMana
|
|
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
|
|
attrs.name = self.name
|
|
attrs.level = self.level
|
|
attrs.xp = self.experience
|
|
attrs.nextLevelXp = nextLevelExperience(self.level)
|
|
attrs.str = stats.get(D2StatId.Strength)
|
|
attrs.dex = stats.get(D2StatId.Dexterity)
|
|
attrs.vit = stats.get(D2StatId.Vitality)
|
|
attrs.ene = stats.get(D2StatId.Energy)
|
|
attrs.statPoints = stats.get(D2StatId.StatPts)
|
|
attrs.baseMaxHp = self.maxLife
|
|
attrs.baseMaxMana = self.maxMana
|
|
attrs.baseMaxStamina = self.maxStamina
|
|
// 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, this.isRunMode)
|
|
attrs.defense = derived?.defense ?? 0
|
|
attrs.attackRating = derived?.attackRating ?? 0
|
|
attrs.fireRes = derived?.fireRes ?? 0
|
|
attrs.coldRes = derived?.coldRes ?? 0
|
|
attrs.lightRes = derived?.lightRes ?? 0
|
|
attrs.poisRes = derived?.poisRes ?? 0
|
|
|
|
// 3. Gold (stat 14 inventory gold, stat 15 stash gold).
|
|
hud.inventory.gold = stats.get(D2StatId.Gold)
|
|
hud.worldPanels.stashGold = stats.get(D2StatId.GoldBank)
|
|
|
|
// 4. Skills & Hotkeys: rebuilt from the server skill list (0x94 / 0x21 / 0x22) on every sync.
|
|
hud.skillTree.unspentSkillPoints = stats.get(D2StatId.NewSkills)
|
|
hud.skillTree.characterLevel = self.level
|
|
hud.skillTree.hardPoints.clear()
|
|
hud.skillTree.bonusLevels.clear()
|
|
hud.hotkeys.setAvailableSkills([])
|
|
for (const [skillId, sl] of self.skillEntries.entries()) {
|
|
const hard = Math.max(0, sl.baseLevel ?? sl.level)
|
|
const bonus = Math.max(0, sl.bonus ?? 0)
|
|
if (hard > 0) hud.skillTree.hardPoints.set(skillId, hard)
|
|
if (bonus > 0) hud.skillTree.bonusLevels.set(skillId, bonus)
|
|
if (isPassiveSkill(skillId)) continue
|
|
const catalogEntry = SKILLS_BY_ID[skillId]
|
|
if (!catalogEntry) continue
|
|
const effSkillLevel = hard + bonus
|
|
const charges = self.skillCharges?.get(skillId)
|
|
hud.hotkeys.addOrUpdateSkill({
|
|
skillId,
|
|
name: catalogEntry.name,
|
|
nameZh: catalogEntry.nameZh,
|
|
level: effSkillLevel,
|
|
manaCost: effSkillLevel > 0 ? calculateManaCost(catalogEntry, effSkillLevel) : 0,
|
|
leftUsable: isLeftUsableSkill(skillId),
|
|
rightUsable: true,
|
|
isAura: isAuraSkill(skillId),
|
|
...(charges !== undefined ? { charges } : {}),
|
|
})
|
|
if (charges !== undefined) {
|
|
hud.hotkeys.setSkillCharges(skillId, charges)
|
|
}
|
|
}
|
|
for (const [slot, binding] of this.world.skillHotkeys.entries()) {
|
|
const fKey = `F${slot + 1}`
|
|
hud.hotkeys.bindHotkey(fKey, binding.hand, binding.skill)
|
|
}
|
|
hud.hotkeys.leftSkillId = self.leftSkill
|
|
hud.hotkeys.rightSkillId = self.rightSkill
|
|
hud.inventory.weaponSwapSet = self.weaponSet === 1 ? 1 : 0
|
|
|
|
// 5. Containers: ClientWorld is authoritative; empty containers clear the HUD.
|
|
for (const key of Object.keys(hud.inventory.equipped) as EquipSlotId[]) {
|
|
delete hud.inventory.equipped[key]
|
|
}
|
|
hud.inventory.swapWeapons.weapon1 = null
|
|
hud.inventory.swapWeapons.weapon2 = null
|
|
for (const item of this.world.containers.equipment.values()) {
|
|
const bodyLoc = item.bodyLoc || item.equippedSlot
|
|
if (bodyLoc === 11) {
|
|
hud.inventory.swapWeapons.weapon1 = this.toUiItem(item)
|
|
continue
|
|
}
|
|
if (bodyLoc === 12) {
|
|
hud.inventory.swapWeapons.weapon2 = this.toUiItem(item)
|
|
continue
|
|
}
|
|
const slot = BODY_LOC_TO_EQUIP_SLOT[bodyLoc]
|
|
if (slot) {
|
|
hud.inventory.equipped[slot] = this.toUiItem(item)
|
|
}
|
|
}
|
|
|
|
const placements: GridPlacement[] = [...this.world.containers.inventory.values()].map((item) => ({
|
|
item: this.toUiItem(item),
|
|
col: item.gridX ?? item.x ?? 0,
|
|
row: item.gridY ?? item.y ?? 0,
|
|
}))
|
|
hud.inventory.gridItems = placements
|
|
|
|
hud.worldPanels.stashItems = [...this.world.containers.stash.values()].map((item) => ({
|
|
item: this.toUiItem(item),
|
|
col: item.gridX ?? item.x ?? 0,
|
|
row: item.gridY ?? item.y ?? 0,
|
|
}))
|
|
|
|
hud.worldPanels.cubeItems = [...this.world.containers.cube.values()].map((item) => ({
|
|
item: this.toUiItem(item),
|
|
col: item.gridX ?? item.x ?? 0,
|
|
row: item.gridY ?? item.y ?? 0,
|
|
}))
|
|
|
|
for (let r = 0; r < BELT_ROWS; r++) {
|
|
for (let c = 0; c < BELT_COLS; c++) {
|
|
hud.belt.grid[r]![c] = null
|
|
}
|
|
}
|
|
for (const item of this.world.containers.belt.values()) {
|
|
const slotIndex = item.gridX ?? item.x ?? 0
|
|
if (slotIndex < 0 || slotIndex >= BELT_ROWS * BELT_COLS) continue
|
|
const row = Math.floor(slotIndex / BELT_COLS)
|
|
const col = slotIndex % BELT_COLS
|
|
const pot = itemToBeltPotion({ id: String(item.id), code: item.code }) ?? {
|
|
id: String(item.id),
|
|
code: item.code,
|
|
name: item.code.toUpperCase(),
|
|
nameZh: item.code.toUpperCase(),
|
|
kind: 'hp' as const,
|
|
healHp: 0,
|
|
healMana: 0,
|
|
invFile: 'invpotr',
|
|
}
|
|
hud.belt.grid[row]![col] = pot
|
|
}
|
|
|
|
const cursorItem = this.world.containers.cursor
|
|
hud.inventory.cursorItem = cursorItem ? this.toUiItem(cursorItem) : null
|
|
|
|
// 5b. Death state
|
|
;(hud as any).isDead = this.world.isDead
|
|
;(hud.worldPanels as any).isDead = this.world.isDead
|
|
|
|
// 5c. Mercenary state & equipment (slots 1=head/helm, 3=torso/armor, 4=rightHand/weapon1, 5=leftHand/weapon2)
|
|
const findMercSlotItem = (slotNum: number): ItemData | undefined => {
|
|
const bySlot = this.world.containers.mercBySlot?.get(slotNum) ?? this.world.containers.merc.get(slotNum)
|
|
if (bySlot && (bySlot.bodyLoc || bySlot.equippedSlot || slotNum) === slotNum) {
|
|
return bySlot
|
|
}
|
|
for (const it of this.world.containers.merc.values()) {
|
|
if ((it.bodyLoc || it.equippedSlot) === slotNum) return it
|
|
}
|
|
return undefined
|
|
}
|
|
const rawMerc1 = findMercSlotItem(1)
|
|
const rawMerc3 = findMercSlotItem(3)
|
|
const rawMerc4 = findMercSlotItem(4)
|
|
const rawMerc5 = findMercSlotItem(5)
|
|
const mapMercNumericItem = (it: ItemData | undefined) =>
|
|
it ? { ...this.toUiItem(it), id: it.id as unknown as string } : null
|
|
const mapMercStringItem = (it: ItemData | undefined) =>
|
|
it ? { ...this.toUiItem(it), id: String(it.id) } : null
|
|
const mercEquipped = {
|
|
head: mapMercNumericItem(rawMerc1),
|
|
helm: mapMercNumericItem(rawMerc1),
|
|
torso: mapMercNumericItem(rawMerc3),
|
|
armor: mapMercNumericItem(rawMerc3),
|
|
rightHand: mapMercNumericItem(rawMerc4),
|
|
weapon1: mapMercNumericItem(rawMerc4),
|
|
leftHand: mapMercNumericItem(rawMerc5),
|
|
weapon2: mapMercNumericItem(rawMerc5),
|
|
}
|
|
const mercHeadUi = mapMercStringItem(rawMerc1)
|
|
const mercTorsoUi = mapMercStringItem(rawMerc3)
|
|
const mercRightUi = mapMercStringItem(rawMerc4)
|
|
const mercLeftUi = mapMercStringItem(rawMerc5)
|
|
const mercEquipment = {
|
|
head: mercHeadUi,
|
|
helm: mercHeadUi,
|
|
torso: mercTorsoUi,
|
|
armor: mercTorsoUi,
|
|
rightHand: mercRightUi,
|
|
weapon1: mercRightUi,
|
|
leftHand: mercLeftUi,
|
|
weapon2: mercLeftUi,
|
|
}
|
|
const buildMercStats = (rawStats: unknown) => {
|
|
const map = rawStats instanceof Map ? new Map(rawStats) : new Map<number, number>()
|
|
const getStat = (id: number, key: string, fallback = 0): number => {
|
|
if (rawStats instanceof Map && rawStats.has(id)) return Number(rawStats.get(id)) || fallback
|
|
if (rawStats && typeof rawStats === 'object' && key in (rawStats as Record<string, unknown>)) {
|
|
const val = Number((rawStats as Record<string, unknown>)[key])
|
|
if (Number.isFinite(val)) return val
|
|
}
|
|
return fallback
|
|
}
|
|
return Object.assign(map, {
|
|
strength: getStat(0, 'strength', 0),
|
|
dexterity: getStat(2, 'dexterity', 0),
|
|
damageMin: getStat(21, 'damageMin', 0),
|
|
damageMax: getStat(22, 'damageMax', 0),
|
|
defense: getStat(31, 'defense', 0),
|
|
fireResist: getStat(39, 'fireResist', 0),
|
|
lightResist: getStat(41, 'lightResist', 0),
|
|
coldResist: getStat(43, 'coldResist', 0),
|
|
poisonResist: getStat(45, 'poisonResist', 0),
|
|
level: getStat(12, 'level', 1),
|
|
})
|
|
}
|
|
const mercRaw = this.world.merc as any
|
|
const mercStats = buildMercStats(mercRaw?.stats)
|
|
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 ??
|
|
mercStats.get(6) ??
|
|
Math.max(0, Math.round((Math.max(0, mercLifePct) / 100) * mercMaxHp)),
|
|
)
|
|
const mercName =
|
|
mercRaw?.name ||
|
|
(mercRaw?.nameId !== undefined && mercRaw.nameId > 0
|
|
? `Merc #${mercRaw.nameId}`
|
|
: mercRaw?.mercId
|
|
? `Merc #${mercRaw.mercId}`
|
|
: '')
|
|
const mercCommonFields = {
|
|
name: mercName,
|
|
level: mercLevel,
|
|
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 ?? 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),
|
|
poisonResist: Number(mercRaw?.poisonResist ?? mercStats.get(45) ?? mercStats.poisonResist ?? 0),
|
|
stats: mercStats,
|
|
headItem: mercHeadUi,
|
|
bodyItem: mercTorsoUi,
|
|
weaponItem: mercRightUi,
|
|
shieldItem: mercLeftUi,
|
|
helm: mercHeadUi,
|
|
armor: mercTorsoUi,
|
|
weapon1: mercRightUi,
|
|
weapon2: mercLeftUi,
|
|
equipment: mercEquipment,
|
|
equipped: mercEquipped,
|
|
}
|
|
const mercIconKey = resolveMercPortraitIcon(this.world.merc?.classId ?? mercRaw?.classId)
|
|
const syncedMerc = this.world.merc
|
|
? {
|
|
...this.world.merc,
|
|
unitId: mercRaw.unitId ?? this.world.merc.mercId,
|
|
iconKey: mercIconKey,
|
|
...mercCommonFields,
|
|
}
|
|
: null
|
|
;(hud as any).merc = syncedMerc
|
|
;(hud.worldPanels as any).merc = syncedMerc
|
|
|
|
// 5c2. Summoned Minions (PetType.txt & Hireables.cpp)
|
|
const summonPortraits: Array<{
|
|
readonly petType: number
|
|
readonly iconKey: string
|
|
readonly label: string
|
|
readonly count: number
|
|
readonly lifePct: number
|
|
readonly drawHp: boolean
|
|
readonly iconType: number
|
|
}> = []
|
|
|
|
if (this.world.pets && this.world.pets.size > 0) {
|
|
const activePets = [...this.world.pets.values()].filter(
|
|
p => !p.isDead && (p.ownerId === this.world.selfId || this.world.selfId === 0),
|
|
)
|
|
const byType = new Map<number, typeof activePets>()
|
|
for (const p of activePets) {
|
|
if (p.petType === 7) continue // Mercenary handled separately
|
|
const arr = byType.get(p.petType) ?? []
|
|
arr.push(p)
|
|
byType.set(p.petType, arr)
|
|
}
|
|
for (const [petTypeIdx, pets] of byType.entries()) {
|
|
const first = pets[0]!
|
|
const portInfo = resolvePetPortraitInfo(petTypeIdx, first.classId)
|
|
if (!portInfo) continue
|
|
const count = pets.length
|
|
const avgLifePct = Math.round(pets.reduce((sum, p) => sum + (p.lifePct ?? 100), 0) / count)
|
|
summonPortraits.push({
|
|
petType: petTypeIdx,
|
|
iconKey: portInfo.iconKey,
|
|
label: ((hud as any).lang === 'CHI' || (hud as any)._lang === 'CHI') ? portInfo.nameZh : portInfo.nameEn,
|
|
count,
|
|
lifePct: avgLifePct,
|
|
drawHp: portInfo.meta.drawHp,
|
|
iconType: portInfo.meta.iconType,
|
|
})
|
|
}
|
|
}
|
|
;(hud as any).summonPortraits = summonPortraits
|
|
|
|
const rawHireList =
|
|
(this.world.mercHireList && this.world.mercHireList.length > 0
|
|
? this.world.mercHireList
|
|
: (this.world.merc as any)?.hireList) ?? []
|
|
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
|
|
const difficulty = this.world.difficulty ?? 0
|
|
const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined)
|
|
const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0
|
|
const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI'
|
|
const hireCandidates = resolveHirelingCandidates(
|
|
rawHireList,
|
|
playerLevel,
|
|
difficulty,
|
|
actIndex,
|
|
this.world.expansion ?? true,
|
|
hireLang,
|
|
)
|
|
;(hud as any).hireList = hireCandidates
|
|
;(hud as any).mercHireList = hireCandidates
|
|
;(hud.worldPanels as any).hireList = hireCandidates
|
|
;(hud.worldPanels as any).mercHireList = hireCandidates
|
|
;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0
|
|
;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0
|
|
;(hud.worldPanels as any).hireMercPanel = {
|
|
open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire',
|
|
candidates: hireCandidates,
|
|
}
|
|
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
|
|
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 0
|
|
;(hud.worldPanels as any).hasDeadMercenary = hasDeadMercenary
|
|
;(hud.worldPanels as any).deadMercenaryReviveCost = deadMercenaryReviveCost
|
|
;(hud.worldPanels as any).hasUnidentifiedItems = this.hasUnidentifiedItems()
|
|
|
|
// 5d. Party state
|
|
const partyList = [...this.world.party.values()]
|
|
.filter((m: any) => {
|
|
const pid = m.unitId ?? m.playerId ?? 0
|
|
if (pid === 0) return false
|
|
if (this.world.selfId !== 0 && pid === this.world.selfId) return false
|
|
return true
|
|
})
|
|
.map((m: any) => {
|
|
const pid = m.unitId ?? m.playerId ?? 0
|
|
const resolvedName = m.name || m.charName || `Player #${pid}`
|
|
const resolvedLevel = m.level ?? m.charLevel ?? 1
|
|
const resolvedPartyState =
|
|
m.partyState ??
|
|
(typeof m.relationship === 'string' && m.relationship.length > 0
|
|
? m.relationship
|
|
: m.hostile
|
|
? 'hostile'
|
|
: m.partyId !== undefined && m.partyId !== 0xffff
|
|
? 'in_party'
|
|
: 'none')
|
|
const resolvedRelationship =
|
|
typeof m.relationship === 'string' && m.relationship.length > 0
|
|
? m.relationship
|
|
: resolvedPartyState
|
|
const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed)
|
|
return {
|
|
unitId: pid,
|
|
playerId: pid,
|
|
name: resolvedName,
|
|
charName: resolvedName,
|
|
charClass: m.charClass ?? 0,
|
|
portraitIcon: resolvePlayerClassPortraitIcon(m.charClass),
|
|
level: resolvedLevel,
|
|
charLevel: resolvedLevel,
|
|
partyId: m.partyId ?? 0xffff,
|
|
relationship: resolvedRelationship,
|
|
partyState: resolvedPartyState,
|
|
hostile: Boolean(m.hostile),
|
|
lootPermit: lootFlag,
|
|
lootAllowed: lootFlag,
|
|
lifePct: m.lifePct ?? 100,
|
|
areaId: m.levelId ?? m.areaId ?? 0,
|
|
levelId: m.levelId ?? m.areaId ?? 0,
|
|
x: m.x ?? 0,
|
|
y: m.y ?? 0,
|
|
}
|
|
})
|
|
;(hud as any).partyMembers = partyList
|
|
;(hud.worldPanels as any).partyMembers = partyList
|
|
|
|
// 5e. Player-to-player Trade state
|
|
const selfTradeItems = this.world.containers.tradeGrid.map((item) => this.toUiItem(item))
|
|
const partnerTradeItems = this.world.containers.partnerTradeGrid.map((item) => this.toUiItem(item))
|
|
const selfGold = Number((this.world.trade as any).selfGold ?? (this.world.trade as any).myGold ?? 0) || 0
|
|
const partnerGold = Number(this.world.trade.partnerGold ?? 0) || 0
|
|
const selfAccepted = Boolean((this.world.trade as any).selfAccepted ?? (this.world.trade as any).myAccepted)
|
|
const partnerAccepted = Boolean(this.world.trade.partnerAccepted)
|
|
const tradeState = {
|
|
...this.world.trade,
|
|
selfGold,
|
|
myGold: selfGold,
|
|
partnerGold,
|
|
selfAccepted,
|
|
myAccepted: selfAccepted,
|
|
partnerAccepted,
|
|
selfItems: selfTradeItems,
|
|
myItems: selfTradeItems,
|
|
partnerItems: partnerTradeItems,
|
|
}
|
|
;(hud as any).trade = tradeState
|
|
;(hud.worldPanels as any).trade = tradeState
|
|
if (this.world.trade.active) {
|
|
hud.leftPanel = 'trade' as any
|
|
;(hud.worldPanels as any).activePanel = 'trade'
|
|
hud.rightPanel = 'inv'
|
|
hud.inventory.visible = true
|
|
} else if (this.prevTradeActive && (hud.leftPanel as string) === 'trade') {
|
|
hud.leftPanel = 'none'
|
|
;(hud.worldPanels as any).activePanel = 'none'
|
|
}
|
|
this.prevTradeActive = this.world.trade.active
|
|
|
|
// 6. Area, Waypoints & Quests
|
|
if (this.world.areaId > 0) {
|
|
hud.worldPanels.setCurrentLevel(this.world.areaId)
|
|
if (this.prevAct !== this.world.act) {
|
|
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
|
|
this.prevAct = this.world.act
|
|
}
|
|
}
|
|
hud.worldPanels.setUnlockedWaypoints([...this.world.waypoints])
|
|
hud.worldPanels.applyServerQuestFlags(this.world.quests)
|
|
|
|
// 7. Target monster plaque
|
|
this.syncHoveredMonsterTarget()
|
|
|
|
// 8. NPC menu / store / stash panels and S2C 0x2A replies.
|
|
this.syncNpcInteraction(hud)
|
|
|
|
hud.syncPublishedState()
|
|
}
|
|
|
|
private syncNpcInteraction(hud: HudManager): void {
|
|
const npc = this.world.npcInteraction
|
|
const prev = this.prevNpc
|
|
this.prevNpc = npc
|
|
const wp = hud.worldPanels
|
|
|
|
const activeWin = npc.activeWindow ?? npc.window
|
|
const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire'
|
|
|
|
// a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors).
|
|
if (
|
|
npc.npcId !== null &&
|
|
activeWin === 'none' &&
|
|
!isHireOpen &&
|
|
(npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking))
|
|
) {
|
|
const desc = this.tryNpcDescriptor(npc.npcId)
|
|
if (desc) {
|
|
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
|
|
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 0
|
|
const unidentifiedItemCount = this.getUnidentifiedItemCount()
|
|
const deadMercenaryName =
|
|
(this.world.merc as any)?.name ??
|
|
((this.world.merc as any)?.nameId ? `Merc #${(this.world.merc as any).nameId}` : '')
|
|
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
|
|
;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, {
|
|
unidentifiedItemCount,
|
|
hasUnidentifiedItems: unidentifiedItemCount > 0,
|
|
hasDeadMercenary,
|
|
deadMercenaryReviveCost,
|
|
deadMercenaryName,
|
|
playerLevel,
|
|
questFlags: this.world.quests,
|
|
})
|
|
}
|
|
}
|
|
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {
|
|
if (npc.npcId === null || isHireOpen) {
|
|
wp.npcMenu = null
|
|
}
|
|
}
|
|
|
|
// b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`).
|
|
const npcWin = npc.window as string
|
|
if ((npcWin === 'trade' || npcWin === 'gamble') && npc.npcId !== null) {
|
|
const npcId = npc.npcId
|
|
const desc = this.npcDescriptor(npcId)
|
|
if (!desc) throw new Error(`HudModel: store opened for NPC #${npcId} without a service descriptor`)
|
|
const ctx = this.priceContext(npcId)
|
|
const price = this.requireVendorPrice()
|
|
const gamble = npcWin === 'gamble'
|
|
const items = [...this.world.containers.shop.values()].map((item) => ({
|
|
storePage: item.storePage,
|
|
placement: {
|
|
item: { ...this.toUiItem(item), buyCost: price(item, gamble ? 2 : 0, ctx) },
|
|
col: item.gridX,
|
|
row: item.gridY,
|
|
},
|
|
}))
|
|
wp.setVendorShop({ npcId, descriptor: desc, mode: npcWin as 'trade' | 'gamble', items })
|
|
const canRepair = desc.canRepair && !gamble
|
|
wp.vendorRepairAllCost = canRepair ? this.requireVendorRepairAll()(ctx) : null
|
|
this.decoratePlayerItemPrices(hud, price, ctx, canRepair)
|
|
if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel()
|
|
} else if (
|
|
npcWin === 'hire' ||
|
|
activeWin === 'hire' ||
|
|
npcWin === 'imbue' ||
|
|
npcWin === 'add_sockets' ||
|
|
npcWin === 'personalize'
|
|
) {
|
|
const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin
|
|
hud.leftPanel = targetWin as any
|
|
;(wp as any).activePanel = targetWin
|
|
if (targetWin === 'hire') {
|
|
wp.npcMenu = null
|
|
}
|
|
if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') {
|
|
hud.rightPanel = 'inv'
|
|
hud.inventory.visible = true
|
|
}
|
|
} else if (prev.window !== 'none') {
|
|
if (prev.window === 'trade' || prev.window === 'gamble') {
|
|
wp.clearVendorShop()
|
|
hud.hideLeftPanelFromWorld('vendor')
|
|
} else if ((hud.leftPanel as string) === prev.window) {
|
|
hud.leftPanel = 'none'
|
|
;(wp as any).activePanel = 'none'
|
|
}
|
|
}
|
|
|
|
// c. Stash: opened by S2C 0x77 action 0x10, closed when 0x4F 0x12 is sent.
|
|
if (npc.stashOpen && !prev.stashOpen) hud.showStashPanel()
|
|
else if (!npc.stashOpen && prev.stashOpen) hud.hideLeftPanelFromWorld('stash')
|
|
|
|
// d. S2C 0x2A NPCTransaction: react once per reply.
|
|
const txn = this.world.lastNpcTransaction
|
|
if (txn && txn.seq !== this.handledNpcTransactionSeq) {
|
|
this.handledNpcTransactionSeq = txn.seq
|
|
const msgKey = npcTransactionMessageKey(txn.result)
|
|
if (msgKey !== null) {
|
|
const text = requireTbl(msgKey, hud.lang)
|
|
this.lastNpcTransactionMessage = { tblKey: msgKey, text }
|
|
wp.showVendorStatus(text, 'white')
|
|
} else if (txn.result === 1) {
|
|
this.requirePlayUiSound()(NPC_SELL_SOUND_ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Sell value (and repair cost at smiths) on the player's bag and equipped items while a store is open. */
|
|
private decoratePlayerItemPrices(hud: HudManager, price: VendorPriceFn, ctx: VendorPriceContext, canRepair: boolean): void {
|
|
const decorate = (ui: UiInventoryItem, raw: ItemData): UiInventoryItem => ({
|
|
...ui,
|
|
sellCost: price(raw, 1, ctx),
|
|
...(canRepair && raw.maxDurability !== undefined && raw.maxDurability > 0 ? { repairCost: price(raw, 3, ctx) } : {}),
|
|
})
|
|
hud.inventory.gridItems = hud.inventory.gridItems.map((pl) => {
|
|
const raw = this.world.containers.inventory.get(Number(pl.item.id))
|
|
if (!raw) throw new Error(`HudModel: inventory item ${pl.item.id} missing from ClientWorld`)
|
|
return { ...pl, item: decorate(pl.item, raw) }
|
|
})
|
|
for (const raw of this.world.containers.equipment.values()) {
|
|
const slot = BODY_LOC_TO_EQUIP_SLOT[raw.bodyLoc || raw.equippedSlot]
|
|
const ui = slot ? hud.inventory.equipped[slot] : undefined
|
|
if (slot && ui) hud.inventory.equipped[slot] = decorate(ui, raw)
|
|
}
|
|
}
|
|
|
|
private npcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined {
|
|
return getTownNpcDescriptorByHcIdx(this.npcClassOf(npcId))
|
|
}
|
|
|
|
private tryNpcDescriptor(npcId: number): TownNpcServiceDescriptor | undefined {
|
|
const unit = this.world.getUnit('monster', npcId)
|
|
return unit ? getTownNpcDescriptorByHcIdx(unit.classId) : undefined
|
|
}
|
|
|
|
/** MonStats hcIdx of NPC unit `npcId`; throws when the unit is not in the world. */
|
|
private npcClassOf(npcId: number): number {
|
|
const unit = this.world.getUnit('monster', npcId)
|
|
if (!unit) throw new Error(`HudModel: NPC unit #${npcId} is not in the world`)
|
|
return unit.classId
|
|
}
|
|
|
|
private priceContext(npcId: number): VendorPriceContext {
|
|
return { world: this.world, npcClass: this.npcClassOf(npcId) }
|
|
}
|
|
|
|
private requireVendorPrice(): VendorPriceFn {
|
|
if (!this.priceProvider) throw new Error('HudModel: no vendorPrice provider (HudModelOptions.vendorPrice)')
|
|
return this.priceProvider
|
|
}
|
|
|
|
private requireVendorRepairAll(): VendorRepairAllFn {
|
|
if (!this.repairAllProvider) throw new Error('HudModel: no vendorRepairAll provider (HudModelOptions.vendorRepairAll)')
|
|
return this.repairAllProvider
|
|
}
|
|
|
|
private requirePlayUiSound(): (soundId: number) => void {
|
|
if (!this.uiSound) throw new Error('HudModel: no playUiSound sink (HudModelOptions.playUiSound)')
|
|
return this.uiSound
|
|
}
|
|
|
|
/** The open store (`npcInteraction.window` trade / gamble); throws when none is open. */
|
|
private requireOpenStore(action: string): { npcId: number; gamble: boolean; ctx: VendorPriceContext } {
|
|
const npc = this.world.npcInteraction
|
|
if (npc.npcId === null || (npc.window !== 'trade' && npc.window !== 'gamble')) {
|
|
throw new Error(`HudModel.${action}: no store window is open`)
|
|
}
|
|
return { npcId: npc.npcId, gamble: npc.window === 'gamble', ctx: this.priceContext(npc.npcId) }
|
|
}
|
|
|
|
/** `true` when `action` ran less than 2000 ms ago (the click is dropped, like D2Client). */
|
|
private isThrottled(action: string): boolean {
|
|
const t = this.now()
|
|
const last = this.lastNpcActionAt.get(action)
|
|
if (last !== undefined && t - last < NPC_TRADE_THROTTLE_MS) return true
|
|
this.lastNpcActionAt.set(action, t)
|
|
return false
|
|
}
|
|
|
|
/** Player item by id from the bag, the paperdoll or the cursor. */
|
|
private requirePlayerItem(itemId: number, action: string): ItemData {
|
|
const c = this.world.containers
|
|
const item = c.inventory.get(itemId) ?? c.equipment.get(itemId) ?? (c.cursor?.id === itemId ? c.cursor : undefined)
|
|
if (!item) throw new Error(`HudModel.${action}: item #${itemId} is not carried by the player`)
|
|
return item
|
|
}
|
|
|
|
private findWorldItem(itemId: number): ItemData | undefined {
|
|
return (
|
|
this.world.containers.allById.get(itemId) ??
|
|
(this.world as any).items?.get?.(itemId) ??
|
|
this.world.containers.inventory.get(itemId) ??
|
|
this.world.containers.equipment.get(itemId) ??
|
|
this.world.containers.stash.get(itemId) ??
|
|
this.world.containers.cube.get(itemId) ??
|
|
(this.world.containers.cursor?.id === itemId ? this.world.containers.cursor : undefined)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Update the hovered/targeted monster unit ID and refresh `hudManager.targetMonster`.
|
|
*/
|
|
setHoveredMonster(monsterUnitId: number | null): void {
|
|
this.hoveredMonsterId = monsterUnitId
|
|
this.syncHoveredMonsterTarget()
|
|
this.hudManager?.syncPublishedState()
|
|
}
|
|
|
|
private syncHoveredMonsterTarget(): void {
|
|
const hud = this.hudManager
|
|
if (!hud) return
|
|
if (this.hoveredMonsterId === null) {
|
|
hud.targetMonster = null
|
|
return
|
|
}
|
|
const unit = this.world.getUnit('monster', this.hoveredMonsterId)
|
|
if (!unit || unit.lifePct <= 0 || unit.mode === 0 || unit.mode === 12) {
|
|
hud.targetMonster = null
|
|
return
|
|
}
|
|
// D2Client.dll 0x6fb708a3..0x6fb708c4: skip town NPCs, non-killable units, and empty NameStr
|
|
const kind = getMonsterKindByHcIdx(unit.classId, this.world.difficulty)
|
|
if (kind && (kind.inTown || !kind.killable || !kind.nameKey)) {
|
|
hud.targetMonster = null
|
|
return
|
|
}
|
|
hud.targetMonster = clientUnitToMonsterTargetInfo(unit, this.world.difficulty)
|
|
}
|
|
|
|
private emitCommand(cmd: ClientCommand): void {
|
|
this.onCommand?.(cmd)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HUD User Action -> ClientCommand Dispatchers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Allocate a base attribute point (`str`, `dex`, `vit`, `ene`) and emit `AddStatPoint (0x3A)`.
|
|
*/
|
|
allocateStat(key: BaseStatKey): boolean {
|
|
const statId = STAT_KEY_TO_D2_STAT_ID[key]
|
|
this.emitCommand({ type: 'AddStatPoint', statId })
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Request a skill point allocation (`AddSkillPoint 0x3B`). The tree updates when the server answers (0x21).
|
|
*/
|
|
allocateSkill(skillId: number): boolean {
|
|
this.emitCommand({ type: 'AddSkillPoint', skillId })
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Request the active left or right hand skill (`0x3C SelectSkill`). The quickbar updates on `0x23`.
|
|
*/
|
|
selectSkill(hand: Hand, skillId: number, itemId = 0xffffffff): void {
|
|
this.emitCommand({ type: 'SelectSkill', hand, skill: skillId, itemId })
|
|
}
|
|
|
|
/**
|
|
* Use the potion at belt `slotIndex` (`0..15`, or column `0..3`) via `BeltUse (0x26)` with its real server item id.
|
|
* Returns `false` (no command) when ClientWorld has no item at that slot/column.
|
|
*/
|
|
useBeltSlot(slotIndex: number, shift = false): boolean {
|
|
if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= 16) return false
|
|
const bySlot = this.world.containers.beltBySlot?.get(slotIndex)
|
|
const beltItems = [...this.world.containers.belt.values()]
|
|
const getSlot = (it: ItemData): number => it.gridX ?? it.x ?? 0
|
|
const exact = bySlot ?? beltItems.find((it) => getSlot(it) === slotIndex)
|
|
const beltItem =
|
|
exact ??
|
|
(slotIndex < BELT_COLS
|
|
? beltItems
|
|
.filter((it) => getSlot(it) >= 0 && getSlot(it) % BELT_COLS === slotIndex)
|
|
.sort((a, b) => getSlot(a) - getSlot(b))[0]
|
|
: undefined)
|
|
if (!beltItem) return false
|
|
if (shift) {
|
|
this.emitCommand({
|
|
type: 'BeltUse',
|
|
itemId: beltItem.id >>> 0,
|
|
useOnMerc: 1,
|
|
})
|
|
} else {
|
|
this.emitCommand({
|
|
type: 'BeltUse',
|
|
itemId: beltItem.id >>> 0,
|
|
shift: false,
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
|
|
onUseBeltSlot(slotIndex: number, shift = false): boolean {
|
|
return this.useBeltSlot(slotIndex, shift)
|
|
}
|
|
|
|
/**
|
|
* Swap the held cursor item with an occupied equipment slot (`0x1D ItemSwap`, 9B).
|
|
*/
|
|
swapEquipItem(itemId: number, slot: EquipSlotId | string | number): void {
|
|
const aliasMap: Record<string, number> = {
|
|
...EQUIP_SLOT_TO_BODY_LOC,
|
|
head: 1,
|
|
neck: 2,
|
|
torso: 3,
|
|
rightHand: 4,
|
|
leftHand: 5,
|
|
rightRing: 6,
|
|
leftRing: 7,
|
|
feet: 9,
|
|
hands: 10,
|
|
altRightHand: 11,
|
|
altLeftHand: 12,
|
|
}
|
|
const bodyLoc = typeof slot === 'number' ? slot : aliasMap[slot]
|
|
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(bodyLoc) || bodyLoc <= 0) return
|
|
this.emitCommand({ type: 'ItemSwap', itemId: itemId >>> 0, slot: bodyLoc >>> 0 })
|
|
}
|
|
|
|
onSwapEquipItem(itemId: number, slot: EquipSlotId | string | number): void {
|
|
this.swapEquipItem(itemId, slot)
|
|
}
|
|
|
|
/**
|
|
* Swap the held cursor item with an occupied container item (`0x1F SwapContainerItem`, 17B).
|
|
*/
|
|
swapContainerItem(
|
|
cursorItemId: number,
|
|
targetItemId: number,
|
|
x: number,
|
|
y: number,
|
|
_container?: number,
|
|
): void {
|
|
if (
|
|
!Number.isFinite(cursorItemId) ||
|
|
cursorItemId <= 0 ||
|
|
!Number.isFinite(targetItemId) ||
|
|
targetItemId <= 0
|
|
) {
|
|
return
|
|
}
|
|
if (x < 0 || y < 0) return
|
|
this.emitCommand({
|
|
type: 'SwapContainerItem',
|
|
cursorItemId: cursorItemId >>> 0,
|
|
targetItemId: targetItemId >>> 0,
|
|
replacedItemId: targetItemId >>> 0,
|
|
x: x >>> 0,
|
|
y: y >>> 0,
|
|
})
|
|
}
|
|
|
|
onSwapContainerItem(
|
|
cursorItemId: number,
|
|
targetItemId: number,
|
|
x: number,
|
|
y: number,
|
|
container?: number,
|
|
): void {
|
|
this.swapContainerItem(cursorItemId, targetItemId, x, y, container)
|
|
}
|
|
|
|
/**
|
|
* Put the held cursor item into belt slot `slot` (`0x23 BeltPut`, 9B).
|
|
*/
|
|
putBeltItem(itemId: number, slot: number): void {
|
|
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(slot) || slot < 0 || slot >= 16) return
|
|
this.emitCommand({ type: 'BeltPut', itemId: itemId >>> 0, slot: slot >>> 0 })
|
|
}
|
|
|
|
onPutBeltItem(itemId: number, slot: number): void {
|
|
this.putBeltItem(itemId, slot)
|
|
}
|
|
|
|
/**
|
|
* Remove a potion from the belt onto the cursor (`0x24 BeltRemove`, 5B).
|
|
*/
|
|
removeBeltItem(itemId: number): void {
|
|
if (!Number.isFinite(itemId) || itemId <= 0) return
|
|
this.emitCommand({ type: 'BeltRemove', itemId: itemId >>> 0 })
|
|
}
|
|
|
|
onRemoveBeltItem(itemId: number): void {
|
|
this.removeBeltItem(itemId)
|
|
}
|
|
|
|
/**
|
|
* Swap the held cursor potion with a belt potion (`0x25 BeltSwap`, 9B).
|
|
*/
|
|
swapBeltItem(cursorItemId: number, beltItemId: number): void {
|
|
if (!Number.isFinite(cursorItemId) || cursorItemId <= 0 || !Number.isFinite(beltItemId) || beltItemId <= 0) return
|
|
this.emitCommand({ type: 'BeltSwap', cursorItemId: cursorItemId >>> 0, beltItemId: beltItemId >>> 0 })
|
|
}
|
|
|
|
onSwapBeltItem(cursorItemId: number, beltItemId: number): void {
|
|
this.swapBeltItem(cursorItemId, beltItemId)
|
|
}
|
|
|
|
/**
|
|
* Identify `itemId` using identify scroll/tome `scrollId` (`0x27 IdentifyItem`, 9B).
|
|
*/
|
|
identifyItem(itemId: number, scrollId: number): void {
|
|
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(scrollId) || scrollId <= 0) return
|
|
this.emitCommand({ type: 'IdentifyItem', itemId: itemId >>> 0, scrollId: scrollId >>> 0 })
|
|
}
|
|
|
|
onIdentifyItem(itemId: number, scrollId: number): void {
|
|
this.identifyItem(itemId, scrollId)
|
|
}
|
|
|
|
/**
|
|
* Insert socket filler `itemId` into socketed host `targetItemId` (`0x28 SocketItem`, 9B).
|
|
* No-op when `targetItem` is already at full socket capacity.
|
|
*/
|
|
socketItem(itemId: number, targetItemId: number): void {
|
|
if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(targetItemId) || targetItemId <= 0) return
|
|
const targetItem = this.findWorldItem(targetItemId)
|
|
if (targetItem) {
|
|
const maxSockets = (targetItem as any).sockets ?? targetItem.totalSockets ?? 0
|
|
if (maxSockets > 0) {
|
|
const used = this.world.containers.sockets.get(targetItemId)?.length ?? 0
|
|
if (used >= maxSockets) return
|
|
}
|
|
}
|
|
this.emitCommand({ type: 'SocketItem', itemId: itemId >>> 0, targetItemId: targetItemId >>> 0 })
|
|
}
|
|
|
|
onSocketItem(itemId: number, targetItemId: number): void {
|
|
this.socketItem(itemId, targetItemId)
|
|
}
|
|
|
|
/**
|
|
* Stack scroll `scrollId` into tome `bookId` (`0x29 ScrollToBook`, 9B).
|
|
* Validates `isc -> ibk` and `tsc -> tbk` compatibility when both items exist in `ClientWorld`.
|
|
*/
|
|
scrollToBook(scrollId: number, bookId: number): void {
|
|
if (!Number.isFinite(scrollId) || scrollId <= 0 || !Number.isFinite(bookId) || bookId <= 0) return
|
|
const scroll = this.findWorldItem(scrollId)
|
|
const book = this.findWorldItem(bookId)
|
|
if (scroll && book) {
|
|
const sCode = scroll.code.trim()
|
|
const bCode = book.code.trim()
|
|
const compatible = (sCode === 'isc' && bCode === 'ibk') || (sCode === 'tsc' && bCode === 'tbk')
|
|
if (!compatible) return
|
|
}
|
|
this.emitCommand({ type: 'ScrollToBook', scrollId: scrollId >>> 0, bookId: bookId >>> 0 })
|
|
}
|
|
|
|
onScrollToBook(scrollId: number, bookId: number): void {
|
|
this.scrollToBook(scrollId, bookId)
|
|
}
|
|
|
|
/**
|
|
* Send NPC quest dialogue message (`0x31 QuestMessage`, 9B).
|
|
*/
|
|
questMessage(npcId: number, messageId = 0): void {
|
|
if (!Number.isFinite(npcId) || npcId <= 0) return
|
|
this.emitCommand({ type: 'QuestMessage', npcId: npcId >>> 0, messageId: messageId >>> 0 })
|
|
}
|
|
|
|
/**
|
|
* Send generic NPC entity action (`0x38 NpcEntityAction`, 13B).
|
|
*/
|
|
npcEntityAction(action: number, npcId: number, complement = 0): void {
|
|
if (!Number.isFinite(npcId) || npcId <= 0) return
|
|
this.emitCommand({
|
|
type: 'NpcEntityAction',
|
|
action: action | 0,
|
|
npcId: npcId >>> 0,
|
|
complement: complement >>> 0,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Hire a mercenary (`0x36 MercHire`, 9B).
|
|
*/
|
|
hireMerc(npcId: number, mercId: number): void {
|
|
if (!Number.isFinite(npcId) || npcId <= 0 || !Number.isFinite(mercId) || mercId <= 0) return
|
|
this.emitCommand({ type: 'MercHire', npcId: npcId >>> 0, mercId: mercId >>> 0 })
|
|
}
|
|
|
|
/**
|
|
* Resurrect the player's dead mercenary (`0x62 MercResurrect`, 5B).
|
|
*/
|
|
resurrectMerc(npcId?: number): void {
|
|
const resolvedNpcId = npcId ?? this.world.npcInteraction.npcId ?? 0
|
|
if (!Number.isFinite(resolvedNpcId) || resolvedNpcId <= 0) return
|
|
this.emitCommand({ type: 'MercResurrect', npcId: resolvedNpcId >>> 0 })
|
|
}
|
|
|
|
/**
|
|
* Equip/unequip a mercenary item slot (`0x61 MercItem`, 3B).
|
|
*/
|
|
mercItem(slot: number): void {
|
|
if (!Number.isFinite(slot) || slot < 0 || slot > 12) return
|
|
this.emitCommand({ type: 'MercItem', slot: slot & 0xffff })
|
|
}
|
|
|
|
/**
|
|
* Resurrect the player after death (`0x41 Resurrect`, 1B).
|
|
*/
|
|
resurrect(): void {
|
|
this.emitCommand({ type: 'Resurrect' })
|
|
}
|
|
|
|
onResurrect(): void {
|
|
this.resurrect()
|
|
}
|
|
|
|
/**
|
|
* Send a party action (`0x5E PartyAction`, 6B: `0x06` invite, `0x07` cancel, `0x08` accept, `0x09` leave).
|
|
*/
|
|
partyAction(action: number, targetPlayerId: number): void {
|
|
if (!Number.isFinite(targetPlayerId) || targetPlayerId <= 0) return
|
|
this.emitCommand({ type: 'PartyAction', action: action & 0xff, targetPlayerId: targetPlayerId >>> 0 })
|
|
}
|
|
|
|
/**
|
|
* Toggle hostility or corpse loot permission (`0x5D Hostile`, 7B).
|
|
*/
|
|
setHostile(targetPlayerId: number, hostile: boolean | number, button = 1): void {
|
|
if (!Number.isFinite(targetPlayerId) || targetPlayerId <= 0) return
|
|
this.emitCommand({
|
|
type: 'Hostile',
|
|
targetPlayerId: targetPlayerId >>> 0,
|
|
hostile: Boolean(hostile),
|
|
button: button === 2 ? 2 : 1,
|
|
})
|
|
}
|
|
|
|
hostile(targetPlayerId: number, hostile: boolean | number, button = 1): void {
|
|
this.setHostile(targetPlayerId, hostile, button)
|
|
}
|
|
|
|
/**
|
|
* Send a UI button click (`0x4F ClickButton`, 7B).
|
|
*/
|
|
clickButton(buttonId: number, complement = 0): void {
|
|
this.emitCommand({
|
|
type: 'ClickButton',
|
|
button: buttonId & 0xffff,
|
|
buttonId: buttonId & 0xffff,
|
|
complement: complement >>> 0,
|
|
amount: complement >>> 0,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Confirm a quest reward item action (Charsi imbue, Larzuk socket, Anya personalize) via `0x38 NpcEntityAction`.
|
|
*/
|
|
confirmQuestReward(itemId: number, explicitNpcId?: number): void {
|
|
if (!Number.isFinite(itemId) || itemId <= 0) return
|
|
const npcId =
|
|
explicitNpcId ||
|
|
this.world.npcInteraction.npcId ||
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ||
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ||
|
|
(this.hudManager?.worldPanels as any)?.activeNpcId ||
|
|
(this.hudManager?.worldPanels as any)?.vendorNpcId ||
|
|
0
|
|
this.emitCommand({
|
|
type: 'NpcEntityAction',
|
|
action: 0,
|
|
npcId: npcId >>> 0,
|
|
complement: itemId >>> 0,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Close the active NPC interaction and all HUD panels.
|
|
*/
|
|
closeActiveNpcPanel(): void {
|
|
const npcId =
|
|
this.world.npcInteraction.npcId ??
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ??
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ??
|
|
(this.hudManager?.worldPanels as any)?.activeNpcId ??
|
|
null
|
|
if (npcId !== null && npcId > 0) {
|
|
this.closeNpc(npcId)
|
|
}
|
|
this.hudManager?.closeAllPanels()
|
|
}
|
|
|
|
/**
|
|
* Handle an NPC menu option selection (`talk`, `identify`, `hire`, `resurrect`, `travel_*`, `imbue`, `add_sockets`, `personalize`).
|
|
*/
|
|
handleNpcMenuAction(
|
|
actionId: string,
|
|
npcId?: number,
|
|
descriptor?: TownNpcServiceDescriptor,
|
|
): void {
|
|
const resolvedNpcId =
|
|
npcId ||
|
|
this.world.npcInteraction.npcId ||
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcUnitId ||
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.npcId ||
|
|
(this.hudManager?.worldPanels as any)?.activeNpcId ||
|
|
0
|
|
if (this.hudManager && resolvedNpcId > 0) {
|
|
;(this.hudManager.worldPanels as any).activeNpcId = resolvedNpcId
|
|
}
|
|
const resolvedDesc =
|
|
descriptor ??
|
|
(this.hudManager?.worldPanels as any)?.npcMenu?.descriptor ??
|
|
(resolvedNpcId > 0 ? this.tryNpcDescriptor(resolvedNpcId) : undefined)
|
|
|
|
switch (actionId) {
|
|
case 'talk': {
|
|
if (this.hudManager && resolvedDesc) {
|
|
;(this.hudManager.worldPanels as any).openNpcSpeech?.(resolvedDesc)
|
|
}
|
|
this.questMessage(resolvedNpcId, 0)
|
|
break
|
|
}
|
|
case 'identify': {
|
|
this.identifyAllAtNpc(resolvedNpcId)
|
|
break
|
|
}
|
|
case 'respec': {
|
|
this.npcEntityAction(12, resolvedNpcId, 0)
|
|
break
|
|
}
|
|
case 'hire': {
|
|
this.npcEntityAction(3, resolvedNpcId, 0)
|
|
;(this.world.npcInteraction as any).window = 'hire'
|
|
;(this.world.npcInteraction as any).activeWindow = 'hire'
|
|
if (this.hudManager) {
|
|
this.hudManager.leftPanel = 'hire' as any
|
|
;(this.hudManager.worldPanels as any).activePanel = 'hire'
|
|
;(this.hudManager.worldPanels as any).npcMenu = null
|
|
}
|
|
break
|
|
}
|
|
case 'resurrect':
|
|
case 'resurrect_merc': {
|
|
this.resurrectMerc(resolvedNpcId)
|
|
break
|
|
}
|
|
case 'travel_east': {
|
|
const complement = resolvedDesc?.hcIdx === 210 ? 75 : 40
|
|
this.npcEntityAction(0, resolvedNpcId, complement)
|
|
break
|
|
}
|
|
case 'travel_west': {
|
|
const complement = resolvedDesc?.hcIdx === 175 ? 1 : 40
|
|
this.npcEntityAction(0, resolvedNpcId, complement)
|
|
break
|
|
}
|
|
case 'travel_harrogath': {
|
|
this.npcEntityAction(0, resolvedNpcId, 109)
|
|
break
|
|
}
|
|
case 'imbue':
|
|
case 'add_sockets':
|
|
case 'personalize': {
|
|
;(this.world.npcInteraction as any).window = actionId
|
|
if (this.hudManager) {
|
|
this.hudManager.leftPanel = actionId as any
|
|
;(this.hudManager.worldPanels as any).activePanel = actionId
|
|
this.hudManager.rightPanel = 'inv'
|
|
this.hudManager.inventory.visible = true
|
|
}
|
|
break
|
|
}
|
|
case 'trade':
|
|
case 'trade_repair': {
|
|
if (this.priceProvider && this.repairAllProvider && this.uiSound) {
|
|
this.openNpcTrade(resolvedNpcId)
|
|
} else {
|
|
this.npcEntityAction(1, resolvedNpcId, 0)
|
|
}
|
|
break
|
|
}
|
|
case 'gamble': {
|
|
if (this.priceProvider && this.repairAllProvider && this.uiSound) {
|
|
this.openNpcGamble(resolvedNpcId)
|
|
} else {
|
|
this.npcEntityAction(2, resolvedNpcId, 0)
|
|
}
|
|
break
|
|
}
|
|
case 'cancel': {
|
|
if (resolvedNpcId > 0) this.closeNpc(resolvedNpcId)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request a weapon set swap (`SwapWeapons 0x60`); `weaponSet` changes when the server confirms.
|
|
*/
|
|
swapWeapons(): void {
|
|
this.emitCommand({ type: 'SwapWeapons' })
|
|
}
|
|
|
|
/**
|
|
* Toggle run/walk state (`R` hotkey or control bar button).
|
|
*/
|
|
toggleRunWalk(): boolean {
|
|
const next = this.hudManager
|
|
? !this.hudManager.isRunning
|
|
: !(this.predictor?.getSnapshot().running ?? true)
|
|
if (this.hudManager) {
|
|
this.hudManager.isRunning = next
|
|
this.hudManager.syncPublishedState()
|
|
}
|
|
if (this.predictor) {
|
|
this.predictor.setRunning(next)
|
|
}
|
|
return next
|
|
}
|
|
|
|
/**
|
|
* Teleport to `destAreaId` via an open waypoint (`0x49 WaypointGo`).
|
|
*/
|
|
teleportWaypoint(waypointUnitId: number, destAreaId: number): void {
|
|
this.hudManager?.closeAllPanels()
|
|
this.emitCommand({
|
|
type: 'WaypointGo',
|
|
waypointId: waypointUnitId,
|
|
destAreaId,
|
|
})
|
|
}
|
|
|
|
private requireWaypointUnitId(): number {
|
|
const id = this.world.waypointUnitId
|
|
if (id === null) throw new Error('HudModel.teleportWaypoint: no waypoint menu has been opened by the server (0x63)')
|
|
return id
|
|
}
|
|
|
|
/** Waypoint teleport from the HUD waypoint panel, using the waypoint object from the last `0x63`. */
|
|
teleportToLevel(destAreaId: number): void {
|
|
this.teleportWaypoint(this.requireWaypointUnitId(), destAreaId)
|
|
}
|
|
|
|
/**
|
|
* Drop `amount` inventory gold on the ground (`0x50 DropGold`).
|
|
*/
|
|
dropGold(amount: number): void {
|
|
const unitId = this.world.selfId
|
|
if (!unitId) throw new Error('HudModel.dropGold: self unit id not assigned yet')
|
|
if (!Number.isInteger(amount) || amount <= 0) return
|
|
this.emitCommand({ type: 'DropGold', unitId, amount })
|
|
}
|
|
|
|
/**
|
|
* Right-click use of an inventory item (`0x20 ItemUse`) at the player's current position.
|
|
*/
|
|
useItem(itemId: number): void {
|
|
const self = this.world.self
|
|
this.emitCommand({ type: 'ItemUse', itemId, at: { x: self.x, y: self.y } })
|
|
}
|
|
|
|
onUseInventoryItem(itemId: number): void {
|
|
this.useItem(itemId)
|
|
}
|
|
|
|
/**
|
|
* Request casting a Town Portal (`TownPortal`).
|
|
*/
|
|
castTownPortal(): void {
|
|
this.emitCommand({ type: 'TownPortal' })
|
|
}
|
|
|
|
/**
|
|
* Pick up an inventory/container item to the cursor (`0x19 ItemToCursor`).
|
|
*/
|
|
pickupItemToCursor(itemId: number): void {
|
|
this.emitCommand({ type: 'ItemToCursor', itemId })
|
|
}
|
|
|
|
/**
|
|
* Place the held cursor item into a container grid (`0x18 ItemPlace`).
|
|
* `buffer`: `0` = inventory, `3` = cube, `4` = stash.
|
|
*/
|
|
placeCursorItem(itemId: number, col: number, row: number, buffer = 0): void {
|
|
this.emitCommand({ type: 'ItemPlace', itemId, x: col, y: row, buffer })
|
|
}
|
|
|
|
/**
|
|
* Equip an item onto a body slot (`0x1A ItemEquip`).
|
|
*/
|
|
equipItem(itemId: number, slot: EquipSlotId | number): void {
|
|
const bodyLoc = typeof slot === 'number' ? slot : EQUIP_SLOT_TO_BODY_LOC[slot]
|
|
this.emitCommand({ type: 'ItemEquip', itemId, slot: bodyLoc })
|
|
}
|
|
|
|
/**
|
|
* Unequip an item from a body slot (`0x1C ItemUnequip`).
|
|
*/
|
|
unequipItem(slot: EquipSlotId | number): void {
|
|
const bodyLoc = typeof slot === 'number' ? slot : EQUIP_SLOT_TO_BODY_LOC[slot]
|
|
this.emitCommand({ type: 'ItemUnequip', slot: bodyLoc })
|
|
}
|
|
|
|
/**
|
|
* Drop the cursor item onto the ground (`0x17 ItemDrop`).
|
|
*/
|
|
dropItem(itemId: number): void {
|
|
this.emitCommand({ type: 'ItemDrop', itemId })
|
|
}
|
|
|
|
/**
|
|
* Trigger Horadric Cube transmute (`0x4F ClickButton` `buttonId = 0x18`).
|
|
*/
|
|
transmuteCube(): void {
|
|
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.Transmute, amount: 0 })
|
|
}
|
|
|
|
/**
|
|
* Buy an item from an active NPC vendor (`0x32 NpcBuy`).
|
|
*/
|
|
buyFromNpc(p: { npcId: number; itemId: number; itemMode: number; gamble: boolean; shiftFill: boolean; cost: number }): void {
|
|
this.emitCommand({ type: 'NpcBuy', ...p })
|
|
}
|
|
|
|
/**
|
|
* Sell an item to an active NPC vendor (`0x33 NpcSell`).
|
|
*/
|
|
sellToNpc(p: { npcId: number; itemId: number; itemMode: number; cost: number }): void {
|
|
this.emitCommand({ type: 'NpcSell', ...p })
|
|
}
|
|
|
|
/**
|
|
* Repair one item at an NPC smith (`0x35` with itemMode / current durability).
|
|
*/
|
|
repairItemAtNpc(p: { npcId: number; itemId: number; itemMode: number; durability: number }): void {
|
|
this.emitCommand({ type: 'NpcRepairItem', ...p })
|
|
}
|
|
|
|
/**
|
|
* Repair all items at an NPC smith (`0x35` field4 = 0x80000000).
|
|
*/
|
|
repairAllAtNpc(npcId: number): void {
|
|
this.emitCommand({ type: 'NpcRepairAll', npcId })
|
|
}
|
|
|
|
/**
|
|
* Identify all items via Deckard Cain (`0x34 NpcIdentifyAll`).
|
|
*/
|
|
identifyAllAtNpc(npcId: number): void {
|
|
this.emitCommand({ type: 'NpcIdentifyAll', npcId })
|
|
}
|
|
|
|
/** NPC menu "trade" / "trade/repair" (`0x38` action 1). Throws when no price provider is configured. */
|
|
openNpcTrade(npcId: number): void {
|
|
this.requireVendorPrice()
|
|
this.requireVendorRepairAll()
|
|
this.requirePlayUiSound()
|
|
this.emitCommand({ type: 'NpcOpenTrade', npcId })
|
|
this.syncFromWorld()
|
|
}
|
|
|
|
/** NPC menu "gamble" (`0x38` action 2); only the six 1.13c gamble NPC classes. */
|
|
openNpcGamble(npcId: number): void {
|
|
const npcClass = this.npcClassOf(npcId)
|
|
if (!GAMBLE_NPC_CLASS_IDS.has(npcClass)) throw new Error(`HudModel.openNpcGamble: NPC class ${npcClass} does not gamble`)
|
|
this.requireVendorPrice()
|
|
this.requireVendorRepairAll()
|
|
this.requirePlayUiSound()
|
|
this.emitCommand({ type: 'NpcOpenGamble', npcId })
|
|
this.syncFromWorld()
|
|
}
|
|
|
|
/** Close the NPC menu / store (`0x30 NpcCancel`); the server sends nothing back. */
|
|
closeNpc(npcId: number): void {
|
|
this.emitCommand({ type: 'NpcCancel', target: { unitType: UNIT_TYPE_MONSTER, id: npcId } })
|
|
this.syncFromWorld()
|
|
}
|
|
|
|
/** Store grid click on shop item `itemId`: `0x32 NpcBuy` with the #10107 buy / gamble price. */
|
|
vendorBuy(itemId: number, shiftFill: boolean): boolean {
|
|
const store = this.requireOpenStore('vendorBuy')
|
|
const item = this.world.containers.shop.get(itemId)
|
|
if (!item) throw new Error(`HudModel.vendorBuy: shop item #${itemId} not in the store`)
|
|
const cost = this.requireVendorPrice()(item, store.gamble ? 2 : 0, store.ctx)
|
|
if (this.isThrottled('buy')) return false
|
|
this.buyFromNpc({
|
|
npcId: store.npcId,
|
|
itemId,
|
|
itemMode: 0,
|
|
gamble: store.gamble,
|
|
shiftFill: store.gamble ? false : shiftFill,
|
|
cost,
|
|
})
|
|
return true
|
|
}
|
|
|
|
/** Sell player item `itemId`: `0x33 NpcSell` with the #10107 sell price. */
|
|
vendorSell(itemId: number): boolean {
|
|
const store = this.requireOpenStore('vendorSell')
|
|
const item = this.requirePlayerItem(itemId, 'vendorSell')
|
|
const cost = this.requireVendorPrice()(item, 1, store.ctx)
|
|
if (this.isThrottled('sell')) return false
|
|
this.sellToNpc({ npcId: store.npcId, itemId, itemMode: 0, cost })
|
|
return true
|
|
}
|
|
|
|
/** Repair player item `itemId` (`0x35`); durability is the item's current value (absent stat = 0). */
|
|
vendorRepairItem(itemId: number): boolean {
|
|
const store = this.requireOpenStore('vendorRepairItem')
|
|
const item = this.requirePlayerItem(itemId, 'vendorRepairItem')
|
|
if (this.isThrottled('repair')) return false
|
|
this.repairItemAtNpc({ npcId: store.npcId, itemId, itemMode: 0, durability: item.curDurability ?? 0 })
|
|
return true
|
|
}
|
|
|
|
/** Repair All button (`0x35` field4 = 0x80000000). */
|
|
vendorRepairAll(): boolean {
|
|
const store = this.requireOpenStore('vendorRepairAll')
|
|
if (this.isThrottled('repair')) return false
|
|
this.repairAllAtNpc(store.npcId)
|
|
return true
|
|
}
|
|
|
|
/** Gold dialog mode 3: `0x4F ClickButton` 0x14 DepositGold. Non-positive amounts send nothing. */
|
|
depositGold(amount: number): void {
|
|
if (!Number.isInteger(amount) || amount <= 0) return
|
|
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.DepositGold, amount })
|
|
}
|
|
|
|
/** Gold dialog mode 4: `0x4F ClickButton` 0x13 WithdrawGold. Non-positive amounts send nothing. */
|
|
withdrawGold(amount: number): void {
|
|
if (!Number.isInteger(amount) || amount <= 0) return
|
|
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.WithdrawGold, amount })
|
|
}
|
|
|
|
/** Stash panel closed: `0x4F ClickButton` 0x12 CloseStash (no reply). */
|
|
closeStash(): void {
|
|
this.emitCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })
|
|
this.syncFromWorld()
|
|
}
|
|
}
|
|
|
|
export function clientUnitToMonsterTargetInfo(
|
|
unit: ClientUnit,
|
|
difficulty: 0 | 1 | 2 | Difficulty = 0,
|
|
): MonsterTargetHudInfo {
|
|
const hpPct = Math.max(0, Math.min(128, unit.lifePct))
|
|
const kind = getMonsterKindByHcIdx(unit.classId, difficulty)
|
|
|
|
const isSuperUnique = Boolean(unit.isSuperUnique || (unit.flags & 0x02) !== 0)
|
|
const isUnique = Boolean(!isSuperUnique && (unit.isUnique || (unit.flags & 0x08) !== 0))
|
|
const isChampion = Boolean(
|
|
!isSuperUnique && !isUnique && (unit.isChampion || (unit.flags & 0x04) !== 0),
|
|
)
|
|
const isMinion = Boolean(
|
|
!isSuperUnique && !isUnique && !isChampion && (unit.flags & 0x10) !== 0,
|
|
)
|
|
const isBoss = Boolean(kind?.boss)
|
|
|
|
const rank: MonsterTargetHudInfo['rank'] = isSuperUnique
|
|
? 'superunique'
|
|
: isUnique
|
|
? 'unique'
|
|
: isChampion
|
|
? 'champion'
|
|
: isMinion
|
|
? 'minion'
|
|
: isBoss
|
|
? 'boss'
|
|
: 'normal'
|
|
|
|
const su =
|
|
isSuperUnique && unit.superUniqueId !== undefined
|
|
? getSuperUniqueByIndex(unit.superUniqueId)
|
|
: undefined
|
|
|
|
const explicitName = unit.name && unit.name.trim().length > 0 ? unit.name.trim() : undefined
|
|
const nameKey = su?.nameKey ?? explicitName ?? kind?.nameKey
|
|
|
|
let resolvedName =
|
|
explicitName ??
|
|
(nameKey
|
|
? (lookupMonsterTblString(nameKey, 'en') ??
|
|
monsterDisplayName(nameKey, 'ENG') ??
|
|
nameKey)
|
|
: `Monster #${unit.classId}`)
|
|
|
|
if (
|
|
!explicitName &&
|
|
isUnique &&
|
|
unit.nameSeed !== undefined &&
|
|
!FIXED_UNIQUE_BOSS_HCIDX_SET.has(unit.classId)
|
|
) {
|
|
if (unit.bossMods?.includes(12)) {
|
|
resolvedName = lookupMonsterTblString('Blood Raven', 'en') ?? 'Blood Raven'
|
|
} else {
|
|
const partKeys = rollUniqueMonsterNameKeys(unit.nameSeed)
|
|
resolvedName = partKeys.map(k => lookupMonsterTblString(k, 'en') ?? k).join(' ')
|
|
}
|
|
}
|
|
|
|
// D2Client.dll 0x6facfedb & 0x6fb6fca0: Uber bosses (hcIdx 704..709) suppress MonUMod labels and immunities
|
|
const isUberBoss = unit.classId >= 704 && unit.classId <= 709
|
|
|
|
const rawModIds: number[] = isUberBoss
|
|
? []
|
|
: unit.bossMods && unit.bossMods.length > 0
|
|
? [...unit.bossMods]
|
|
: su?.modifiers && su.modifiers.length > 0
|
|
? [...su.modifiers]
|
|
: []
|
|
if (!isUberBoss && isChampion && (unit.flags & 0x40) !== 0 && !rawModIds.includes(36)) {
|
|
rawModIds.unshift(36)
|
|
}
|
|
|
|
const modifiers = rawModIds.map(
|
|
(m) => CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)?.name ?? String(m),
|
|
)
|
|
const affixes = rawModIds.map((m) => {
|
|
const key = String(m)
|
|
const entry =
|
|
MONSTER_UMOD_TBL_KEYS[key] ??
|
|
CANONICAL_ELITE_MODIFIERS.find((e) => e.id === m)
|
|
if (entry) {
|
|
return lookupMonsterTblString(entry.tblKey, 'en') ?? entry.label
|
|
}
|
|
return `Mod #${m}`
|
|
})
|
|
|
|
const descKey =
|
|
!isUberBoss && kind?.descKey && kind.descKey.length > 0
|
|
? kind.descKey
|
|
: undefined
|
|
|
|
const resistances =
|
|
!isUberBoss && kind?.resistances
|
|
? applyMonsterUModResistances(kind.resistances, rawModIds)
|
|
: undefined
|
|
|
|
return {
|
|
name: resolvedName,
|
|
hcIdx: unit.classId,
|
|
...(unit.nameSeed !== undefined ? { nameSeed: unit.nameSeed } : {}),
|
|
...(nameKey !== undefined ? { nameKey } : {}),
|
|
...(descKey !== undefined ? { descKey } : {}),
|
|
hp: hpPct,
|
|
maxHp: 128,
|
|
rank,
|
|
...(kind?.isUndead ? { isUndead: true } : {}),
|
|
...(kind?.isDemon ? { isDemon: true } : {}),
|
|
...(isBoss ? { isBoss: true } : {}),
|
|
modifiers,
|
|
affixes,
|
|
...(resistances !== undefined ? { resistances } : {}),
|
|
}
|
|
}
|
|
|
|
/** Resolve a server class id to its class code; throws on an id outside 1.13c's 0..6. */
|
|
export function resolveClassCode(classId: CharClassId | number): CharacterClassCode {
|
|
const code = (CLASS_ID_TO_CODE as Record<number, CharacterClassCode | undefined>)[classId]
|
|
if (!code) throw new Error(`HudModel: unknown character class id ${classId}`)
|
|
return code
|
|
}
|
|
|
|
/** XP needed for the next level from `D2_EXPERIENCE_TABLE_99` (index L = XP to reach level L); 0 at level 99 / unknown. */
|
|
export function nextLevelExperience(level: number): number {
|
|
if (level < 1 || level >= 99) return 0
|
|
return D2_EXPERIENCE_TABLE_99[level + 1] ?? 0
|
|
}
|
|
|
|
/** XP threshold of the current level from `D2_EXPERIENCE_TABLE_99`; 0 when the level is unknown. */
|
|
export function currentLevelExperience(level: number): number {
|
|
if (level < 1 || level > 99) return 0
|
|
return D2_EXPERIENCE_TABLE_99[level] ?? 0
|
|
}
|