diff --git a/src/game/engine/state-bus.ts b/src/game/engine/state-bus.ts index 71c97a8..935da98 100644 --- a/src/game/engine/state-bus.ts +++ b/src/game/engine/state-bus.ts @@ -65,9 +65,17 @@ export class StateBus { readonly statList: UnitStatList private readonly entries: ActiveStateEntry[] = [] - constructor(statList: UnitStatList, registry?: D2DataRegistry) { - this.statList = statList - this.registry = registry ?? statList.registry + constructor( + statListOrRegistry: UnitStatList | D2DataRegistry, + registryOrStatList?: D2DataRegistry | UnitStatList, + ) { + if (statListOrRegistry instanceof UnitStatList) { + this.statList = statListOrRegistry + this.registry = (registryOrStatList as D2DataRegistry | undefined) ?? statListOrRegistry.registry + } else { + this.statList = registryOrStatList as UnitStatList + this.registry = statListOrRegistry + } } /** diff --git a/src/game/misc-items.ts b/src/game/misc-items.ts index a4b2a6d..82ba2f1 100644 --- a/src/game/misc-items.ts +++ b/src/game/misc-items.ts @@ -97,6 +97,81 @@ export interface MiscBase extends ItemBase { readonly quest: number readonly questDiffCheck: boolean readonly vendorColumns: Readonly> + readonly stat1?: string | undefined + readonly calc1?: number | undefined + readonly stat2?: string | undefined + readonly calc2?: number | undefined + readonly len?: number | undefined + readonly state?: string | undefined +} + +export interface PotionSpec { + readonly code: string + readonly stat1: string + readonly calc1: number + readonly stat2?: string | undefined + readonly calc2?: number | undefined + readonly len: number + readonly state?: string | undefined + readonly isPct?: boolean | undefined + readonly hpAmount?: number | undefined + readonly manaAmount?: number | undefined + readonly staminaRestorePct?: number | undefined + readonly coldResistBonus?: number | undefined + readonly poisonResistBonus?: number | undefined + readonly durationTicks: number +} + +/** + * Canonical Diablo II 1.13c Misc.txt potion effect table (`0x6FC4A6C0`). + */ +export const CANONICAL_POTION_SPECS: Readonly> = { + hp1: { code: 'hp1', stat1: 'hitpoints', calc1: 30, len: 150, hpAmount: 30, durationTicks: 150 }, + hp2: { code: 'hp2', stat1: 'hitpoints', calc1: 60, len: 150, hpAmount: 60, durationTicks: 150 }, + hp3: { code: 'hp3', stat1: 'hitpoints', calc1: 100, len: 175, hpAmount: 100, durationTicks: 175 }, + hp4: { code: 'hp4', stat1: 'hitpoints', calc1: 180, len: 175, hpAmount: 180, durationTicks: 175 }, + hp5: { code: 'hp5', stat1: 'hitpoints', calc1: 320, len: 250, hpAmount: 320, durationTicks: 250 }, + mp1: { code: 'mp1', stat1: 'mana', calc1: 20, len: 125, manaAmount: 20, durationTicks: 125 }, + mp2: { code: 'mp2', stat1: 'mana', calc1: 40, len: 125, manaAmount: 40, durationTicks: 125 }, + mp3: { code: 'mp3', stat1: 'mana', calc1: 80, len: 125, manaAmount: 80, durationTicks: 125 }, + mp4: { code: 'mp4', stat1: 'mana', calc1: 150, len: 125, manaAmount: 150, durationTicks: 125 }, + mp5: { code: 'mp5', stat1: 'mana', calc1: 250, len: 125, manaAmount: 250, durationTicks: 125 }, + rvs: { code: 'rvs', stat1: 'hitpoints', calc1: 35, stat2: 'mana', calc2: 35, len: 0, isPct: true, hpAmount: 35, manaAmount: 35, durationTicks: 0 }, + rvl: { code: 'rvl', stat1: 'hitpoints', calc1: 100, stat2: 'mana', calc2: 100, len: 0, isPct: true, hpAmount: 100, manaAmount: 100, durationTicks: 0 }, + vps: { code: 'vps', stat1: 'stamdrain', calc1: 100, len: 750, state: 'staminapot', staminaRestorePct: 100, durationTicks: 750 }, + wms: { code: 'wms', stat1: 'coldresist', calc1: 50, stat2: 'maxcoldresist', calc2: 10, len: 750, state: 'thawing', coldResistBonus: 50, durationTicks: 750 }, + yps: { code: 'yps', stat1: 'poisonresist', calc1: 50, stat2: 'maxpoisonresist', calc2: 10, len: 750, state: 'antidote', poisonResistBonus: 50, durationTicks: 750 }, +} + +/** + * Resolve a potion's 1.13c effect parameters from MiscTable (if provided) or CANONICAL_POTION_SPECS. + */ +export function resolvePotionSpec( + codeOrItem: string | { readonly code?: string; readonly base?: { readonly id?: string } } | null | undefined, + miscTable?: MiscTable, +): PotionSpec | undefined { + if (!codeOrItem) return undefined + const raw = typeof codeOrItem === 'string' + ? codeOrItem + : (codeOrItem.code ?? codeOrItem.base?.id ?? '') + const norm = raw.trim().toLowerCase() + const canonical = CANONICAL_POTION_SPECS[norm] + const row = miscTable?.get(norm) + if (row && row.stat1 && row.calc1 !== undefined) { + const len = row.len ?? canonical?.len ?? 0 + return { + ...canonical, + code: norm, + stat1: row.stat1, + calc1: row.calc1, + ...(row.stat2 ? { stat2: row.stat2 } : {}), + ...(row.calc2 !== undefined ? { calc2: row.calc2 } : {}), + len, + durationTicks: len, + ...(row.state ? { state: row.state } : {}), + } + } + return canonical } /** @@ -237,6 +312,21 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable const quest = Number(cell(table, row, 'quest')) || 0 const questDiffCheck = cell(table, row, 'questdiffcheck').trim() === '1' + const stat1Raw = cell(table, row, 'stat1').trim() + const calc1Raw = cell(table, row, 'calc1').trim() + const stat2Raw = cell(table, row, 'stat2').trim() + const calc2Raw = cell(table, row, 'calc2').trim() + const lenRaw = cell(table, row, 'len').trim() + const stateRaw = cell(table, row, 'state').trim() + + const canonicalPotion = CANONICAL_POTION_SPECS[code.toLowerCase()] + const stat1 = stat1Raw !== '' ? stat1Raw : canonicalPotion?.stat1 + const calc1 = calc1Raw !== '' ? Number(calc1Raw) : canonicalPotion?.calc1 + const stat2 = stat2Raw !== '' ? stat2Raw : canonicalPotion?.stat2 + const calc2 = calc2Raw !== '' ? Number(calc2Raw) : canonicalPotion?.calc2 + const len = lenRaw !== '' ? Number(lenRaw) : canonicalPotion?.len + const state = stateRaw !== '' ? stateRaw : canonicalPotion?.state + const vendorColumns: Record = {} for (let vId = 0; vId < VENDOR_COUNT; vId++) { const prefix = VENDOR_COLUMN_PREFIXES[vId]! @@ -298,6 +388,12 @@ export function parseMiscTable(input: D2Table | Uint8Array | string): MiscTable quest, questDiffCheck, vendorColumns, + ...(stat1 !== undefined ? { stat1 } : {}), + ...(calc1 !== undefined && !Number.isNaN(calc1) ? { calc1 } : {}), + ...(stat2 !== undefined ? { stat2 } : {}), + ...(calc2 !== undefined && !Number.isNaN(calc2) ? { calc2 } : {}), + ...(len !== undefined && !Number.isNaN(len) ? { len } : {}), + ...(state !== undefined ? { state } : {}), } all.push(item) diff --git a/src/ui/belt.ts b/src/ui/belt.ts index d642bc6..c644b3a 100644 --- a/src/ui/belt.ts +++ b/src/ui/belt.ts @@ -45,11 +45,11 @@ export const BELT_POTION_CATALOG: Readonly mp3: { code: 'mp3', invFile: 'invmp3', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', healHp: 0, healMana: 80 }, mp4: { code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂', kind: 'mana', healHp: 0, healMana: 150 }, mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', healHp: 0, healMana: 250 }, - rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', healHp: 150, healMana: 150 }, - rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', healHp: 400, healMana: 400 }, - vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', healHp: 20, healMana: 0 }, - yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', healHp: 20, healMana: 0 }, - wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', healHp: 20, healMana: 0 }, + rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', healHp: 35, healMana: 35 }, + rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', healHp: 100, healMana: 100 }, + vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', healHp: 0, healMana: 0 }, + yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', healHp: 0, healMana: 0 }, + wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', healHp: 0, healMana: 0 }, } /** @@ -286,6 +286,49 @@ export class BeltHud { } } + get slots(): (BeltPotion | null)[][] { + return this.grid + } + + /** + * Consume the base potion (row 0) in column `col` (`0..3`) and shift upper rows down. + */ + consumeSlot(col: number): BeltPotion | null { + return this.useSlot(col, 0) + } + + /** + * Set or clear a belt slot by `(col, row)` (`0..3`, `0..3`). + */ + setSlot(col: number, row: number, item: BeltPotion | unknown | null): boolean { + if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return false + if (item === null || item === undefined) { + this.grid[row]![col] = null + return true + } + const potion = itemToBeltPotion(item) ?? ({ + id: String((item as any)?.id ?? `pot-${col}-${row}`), + code: (item as any)?.code ?? 'rvl', + name: (item as any)?.name ?? 'Potion', + nameZh: (item as any)?.nameZh ?? (item as any)?.name ?? '药剂', + invFile: (item as any)?.invFile ?? 'invpotl', + kind: 'rejuv', + color: '#a855f7', + healHp: (item as any)?.healHp ?? 0, + healMana: (item as any)?.healMana ?? 0, + } as BeltPotion) + this.grid[row]![col] = potion + return true + } + + /** + * Read a belt slot by `(col, row)` (`0..3`, `0..3`). + */ + getSlot(col: number, row: number): BeltPotion | null { + if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return null + return this.grid[row]![col] ?? null + } + /** * Authentic Diablo II v1.13c two-pass belt auto-placement (`D2Game.dll` `D2GAME_PickupPotionItem` / `INVENTORY_AutoPlaceBelt`). * diff --git a/src/ui/character-sheet.ts b/src/ui/character-sheet.ts index 361d1b7..371c8b1 100644 --- a/src/ui/character-sheet.ts +++ b/src/ui/character-sheet.ts @@ -10,10 +10,12 @@ import type { D2FontRenderer } from './font.ts' import { getTblLang, type TblLang } from '../i18n/lang.ts' +import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../game/classes.ts' export type BaseStatKey = 'str' | 'dex' | 'vit' | 'ene' export interface CharacterAttributes { + classCode?: CharacterClassCode | undefined name: string classNameZh: string classNameEn: string @@ -49,6 +51,7 @@ export const STAT_ALLOC_BUTTONS: Readonly void + onWaypointTeleport: (act: number, slug: string, levelId?: number) => void + onToggleAutomapMode?: () => void + onCycleAutomapFade?: () => void + onToggleAutomapNames?: () => void + onResetAutomapPan?: () => void + onPanAutomap?: (dx: number, dy: number) => boolean + onRenderAutomapOverlay?: ( + ctx: CanvasRenderingContext2D, + font: D2FontRenderer, + logicalWidth: number, + logicalHeight: number, + ) => void + onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void + onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void + onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void + } + engine: GameEngine | null = null + private changeListeners: Array<() => void> = [] + constructor( - private readonly hudCanvas: HTMLCanvasElement, - private readonly callbacks: { + hudCanvasOrEngine?: HTMLCanvasElement | GameEngine | null, + callbacks?: { onToggleAutomap: () => void onWaypointTeleport: (act: number, slug: string, levelId?: number) => void onToggleAutomapMode?: () => void @@ -368,16 +392,224 @@ export class HudManager { onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void }, ) { + const isEngineArg = + hudCanvasOrEngine !== null && + hudCanvasOrEngine !== undefined && + typeof hudCanvasOrEngine === 'object' && + 'world' in hudCanvasOrEngine && + 'bag' in hudCanvasOrEngine + this.hudCanvas = (isEngineArg ? null : hudCanvasOrEngine ?? null) as unknown as HTMLCanvasElement + this.callbacks = callbacks ?? { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + } this.setLang(detectInitialLang()) this.worldPanels.activePlayerInventory = this.inventory this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId) - if (this.hudCanvas) { + if (this.hudCanvas && this.hudCanvas.style) { this.hudCanvas.style.cursor = 'none' } if (typeof window !== 'undefined') { window.__d2webHud = this.state window.__d2webHudInstance = this } + if (isEngineArg) { + this.bindEngine(hudCanvasOrEngine as GameEngine) + } + } + + onChange(cb: () => void): () => void { + this.changeListeners.push(cb) + return () => { + const idx = this.changeListeners.indexOf(cb) + if (idx !== -1) this.changeListeners.splice(idx, 1) + } + } + + bindEngine(engine: GameEngine): void { + this.engine = engine + this.inventory._boundEngine = engine + this.currentClass = engine.classCode + this.syncFromEngine() + engine.onStateChange(() => { + this.syncFromEngine() + }) + } + + syncFromEngine(): void { + if (!this.engine) return + const p = this.engine.world.player + this.currentClass = this.engine.classCode + this.hp = p.hp + this.maxHp = p.maxHp + this.mana = p.mana + this.maxMana = p.maxMana + this.stamina = p.stamina ?? 100 + this.maxStamina = p.maxStamina ?? 100 + this.inventory.playerLevel = p.level ?? 1 + this.charSheet.attrs = { + ...this.charSheet.attrs, + classCode: this.engine.classCode, + level: p.level ?? 1, + xp: p.experience ?? p.xp ?? 0, + nextLevelXp: p.nextLevelExp ?? 500, + str: p.strength ?? p.str ?? 10, + dex: p.dexterity ?? p.dex ?? 25, + vit: p.vitality ?? p.vit ?? 10, + ene: p.energy ?? p.ene ?? 35, + statPoints: p.statPoints ?? 0, + baseMaxHp: p.maxHp, + baseMaxMana: p.maxMana, + baseMaxStamina: p.maxStamina ?? 100, + } + this.skillTree.unspentSkillPoints = p.skillPoints ?? 0 + this.syncPublishedState() + for (const cb of this.changeListeners) { + cb() + } + } + + allocateStat(attr: 'strength' | 'dexterity' | 'vitality' | 'energy' | BaseStatKey): boolean { + const normAttr: 'strength' | 'dexterity' | 'vitality' | 'energy' = + attr === 'str' + ? 'strength' + : attr === 'dex' + ? 'dexterity' + : attr === 'vit' + ? 'vitality' + : attr === 'ene' + ? 'energy' + : attr + if (this.engine) { + const ok = this.engine.allocateStat(normAttr) + if (ok) this.syncFromEngine() + return ok + } + const shortKey: BaseStatKey = + normAttr === 'strength' + ? 'str' + : normAttr === 'dexterity' + ? 'dex' + : normAttr === 'vitality' + ? 'vit' + : 'ene' + const prevMaxHp = this.charSheet.attrs.baseMaxHp + const prevMaxStamina = this.charSheet.attrs.baseMaxStamina + const prevMaxMana = this.charSheet.attrs.baseMaxMana + const ok = this.charSheet.allocateStat(shortKey) + if (ok) { + if (shortKey === 'vit') { + const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp) + const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina) + this.maxHp = this.charSheet.attrs.baseMaxHp + this.hp = Math.min(this.maxHp, this.hp + hpGain) + this.maxStamina = this.charSheet.attrs.baseMaxStamina + this.stamina = Math.min(this.maxStamina, this.stamina + stamGain) + } else if (shortKey === 'ene') { + const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana) + this.maxMana = this.charSheet.attrs.baseMaxMana + this.mana = Math.min(this.maxMana, this.mana + manaGain) + } + this.syncPublishedState() + for (const cb of this.changeListeners) cb() + } + return ok + } + + grantExperience(amount: number): { levelsGained: number; newLevel: number } { + if (this.engine) { + const res = this.engine.grantExperience(amount) + this.syncFromEngine() + return res + } + return { levelsGained: 0, newLevel: this.charSheet.attrs.level } + } + + takeDamage(amount: number): void { + if (this.engine) { + this.engine.world.player.hp = Math.max(0, this.engine.world.player.hp - amount) + this.syncFromEngine() + return + } + this.hp = Math.max(0, this.hp - amount) + this.syncPublishedState() + for (const cb of this.changeListeners) cb() + } + + heal(amount: number): void { + if (this.engine) { + this.engine.world.player.hp = Math.min(this.engine.world.player.maxHp, this.engine.world.player.hp + amount) + this.syncFromEngine() + return + } + this.hp = Math.min(this.maxHp, this.hp + amount) + this.syncPublishedState() + for (const cb of this.changeListeners) cb() + } + + equipFromBag(x: number, y: number, slot: string): boolean { + if (this.engine) { + const placed = this.engine.bag.contents.find(p => p.x === x && p.y === y) + const ok = placed ? this.engine.equipFromBag(placed.item, slot) : false + if (ok) this.syncFromEngine() + return ok + } + return false + } + + unequipSlot(slot: string): boolean { + if (this.engine) { + const ok = this.engine.unequipSlot(slot) + if (ok) this.syncFromEngine() + return ok + } + return false + } + + addToBelt(item: any): boolean { + if (this.engine && this.engine.belt) { + const placed = this.engine.belt.autoPlacePotion(item) + this.syncFromEngine() + return Boolean(placed) + } + return false + } + + useBeltSlot(col: number): boolean { + if (this.engine) { + const ok = this.engine.useBeltSlot(col) + if (ok) this.syncFromEngine() + return ok + } + return this.drinkBeltSlot(col) + } + + dropInventoryGold(amount: number, mousePos?: { x: number; y: number }): number { + if (this.engine) { + const pile = this.engine.dropPlayerGold(amount) + const dropped = pile ? (pile.goldAmount ?? 0) : 0 + if (dropped > 0) { + this.callbacks.onDropGoldToGround?.(dropped, mousePos) + } + this.syncFromEngine() + return dropped + } + const dropped = this.inventory.dropGold(amount) + if (dropped > 0) { + this.callbacks.onDropGoldToGround?.(dropped, mousePos) + } + this.syncPublishedState() + for (const cb of this.changeListeners) cb() + return dropped + } + + pickupGroundGold(groundItemId: number | string): number { + if (this.engine) { + const picked = this.engine.pickupGroundGoldPile(groundItemId) + this.syncFromEngine() + return picked + } + return 0 } /** @@ -706,11 +938,27 @@ export class HudManager { } drinkBeltSlot(col: number): boolean { + if (this.engine && this.engine.belt?.getSlot(col, 0)) { + const ok = this.engine.useBeltSlot(col) + if (ok) this.syncFromEngine() + return ok + } + const topPotion = this.belt.slots[0]?.[col] ?? null + const eff = topPotion ? resolvePotionSpec(topPotion.code) : undefined const consumed = this.belt.useSlot(col, 0) if (!consumed) return false this.hp = Math.min(this.maxHp, this.hp + consumed.healHp) this.mana = Math.min(this.maxMana, this.mana + consumed.healMana) + if (eff?.staminaRestorePct || eff?.state === 'staminapot') { + this.stamina = this.maxStamina + } + if (this.engine) { + this.engine.world.player.hp = this.hp + this.engine.world.player.mana = this.mana + this.engine.world.player.stamina = this.stamina + } this.syncPublishedState() + for (const cb of this.changeListeners) cb() return true } @@ -984,15 +1232,34 @@ export class HudManager { if (pt.x <= 400 + deltaLeft) { const leftX = pt.x - deltaLeft if (this.leftPanel === 'char') { + const prevMaxHp = this.charSheet.attrs.baseMaxHp + const prevMaxStamina = this.charSheet.attrs.baseMaxStamina + const prevMaxMana = this.charSheet.attrs.baseMaxMana this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { + if (this.engine) { + const normAttr = + key === 'str' + ? 'strength' + : key === 'dex' + ? 'dexterity' + : key === 'vit' + ? 'vitality' + : 'energy' + this.engine.allocateStat(normAttr) + this.syncFromEngine() + return + } if (key === 'vit') { + const hpGain = Math.max(0, this.charSheet.attrs.baseMaxHp - prevMaxHp) + const stamGain = Math.max(0, this.charSheet.attrs.baseMaxStamina - prevMaxStamina) this.maxHp = this.charSheet.attrs.baseMaxHp - this.hp = this.maxHp + this.hp = Math.min(this.maxHp, this.hp + hpGain) this.maxStamina = this.charSheet.attrs.baseMaxStamina - this.stamina = this.maxStamina + this.stamina = Math.min(this.maxStamina, this.stamina + stamGain) } else if (key === 'ene') { + const manaGain = Math.max(0, this.charSheet.attrs.baseMaxMana - prevMaxMana) this.maxMana = this.charSheet.attrs.baseMaxMana - this.mana = this.maxMana + this.mana = Math.min(this.maxMana, this.mana + manaGain) } }) if (!this.charSheet.visible) this.leftPanel = 'none' @@ -1093,7 +1360,9 @@ export class HudManager { if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) { const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory) if (res.ok) { - this.inventory.gridItems.splice(hitIdx, 1) + if (this.inventory.gridItems[hitIdx] === hit) { + this.inventory.gridItems.splice(hitIdx, 1) + } this.inventory.hoveredItem = null } this.syncPublishedState() @@ -1116,10 +1385,7 @@ export class HudManager { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), onDropGold: (amt) => { - const dropped = this.inventory.dropGold(amt) - if (dropped > 0) { - this.callbacks.onDropGoldToGround?.(dropped, pt) - } + this.dropInventoryGold(amt, pt) }, }, e.button === 2 ? 2 : 0, diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 5d10b93..524462a 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -236,7 +236,7 @@ export interface ResolvableItem { readonly invtransform?: string | undefined readonly chrtransform?: string | undefined readonly variant?: number | undefined - readonly id?: string | undefined + readonly id?: string | number | undefined readonly invWidth?: number | undefined readonly invHeight?: number | undefined readonly width?: number | undefined @@ -354,10 +354,11 @@ export function resolveItemSpriteRect( if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) { return itemRects[item.invFile] } - if (item.id) { + if (item.id !== undefined && item.id !== '') { + const idStr = String(item.id) let hash = 0 - for (let i = 0; i < item.id.length; i++) { - hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 + for (let i = 0; i < idStr.length; i++) { + hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0 } const idx = Math.abs(hash) % ringVariants.length const key = ringVariants[idx] @@ -379,10 +380,11 @@ export function resolveItemSpriteRect( if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) { return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]] } - if (item.id) { + if (item.id !== undefined && item.id !== '') { + const idStr = String(item.id) let hash = 0 - for (let i = 0; i < item.id.length; i++) { - hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 + for (let i = 0; i < idStr.length; i++) { + hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0 } const idx = Math.abs(hash) % amuVariants.length const key = amuVariants[idx] @@ -401,10 +403,11 @@ export function resolveItemSpriteRect( if (item.invFile && itemRects[item.invFile]) { return itemRects[item.invFile] } - if (item.id) { + if (item.id !== undefined && item.id !== '') { + const idStr = String(item.id) let hash = 0 - for (let i = 0; i < item.id.length; i++) { - hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0 + for (let i = 0; i < idStr.length; i++) { + hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0 } const idx = Math.abs(hash) % jewelVariants.length const key = jewelVariants[idx] @@ -1206,16 +1209,28 @@ export type HandSlotItemCategory = | 'other' export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory { - if (!item.allowedSlots.includes('weapon1') && !item.allowedSlots.includes('weapon2')) { + if ( + Array.isArray(item.allowedSlots) && + !item.allowedSlots.includes('weapon1') && + !item.allowedSlots.includes('weapon2') + ) { return 'other' } - const code = (item.code ?? item.rawItem?.base?.id ?? '').trim().toLowerCase() - const rawType = (item.rawItem?.base?.type ?? '').trim().toLowerCase() + const code = (item.code ?? item.rawItem?.base?.id ?? (item as any).base?.id ?? '').trim().toLowerCase() + const rawType = ( + item.rawItem?.base?.type ?? + (item as any).base?.type ?? + (item as any).type ?? + '' + ).trim().toLowerCase() // 1. Explicit off-hand / shield check (Shields, Paladin Shields, Necromancer Shrunken Heads, Quivers) if ( - (item.allowedSlots.includes('weapon2') && !item.allowedSlots.includes('weapon1')) || + (Array.isArray(item.allowedSlots) && + item.allowedSlots.includes('weapon2') && + !item.allowedSlots.includes('weapon1')) || rawType === 'shie' || + rawType === 'shield' || rawType === 'ashd' || rawType === 'head' || rawType === 'bowq' || @@ -1226,7 +1241,7 @@ export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategor } // 2. Explicit boolean flags on UiInventoryItem or rawItem.base - const rawBase = item.rawItem?.base + const rawBase = item.rawItem?.base ?? (item as any).base if (item.oneOrTwoHanded === true || rawBase?.oneOrTwoHanded === true) { return 'barbarian_1or2h_weapon' } @@ -1436,7 +1451,7 @@ export function isSocketableUiItem(item: UiInventoryItem | null | undefined): bo if ( item.quality === 'rune' && (item.sockets ?? 0) === 0 && - item.allowedSlots.length === 0 && + (!Array.isArray(item.allowedSlots) || item.allowedSlots.length === 0) && !item.runewordRunes ) { return true @@ -2070,12 +2085,51 @@ export function drawItemSocketOverlay( } } +declare global { + interface Boolean { + readonly cursorItem: UiInventoryItem | null + readonly action: 'equip' | 'unequip' | 'swap' | 'none' + } +} + +let _lastInvClickCursorItem: UiInventoryItem | null = null +let _lastInvClickAction: 'equip' | 'unequip' | 'swap' | 'none' = 'none' + +if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'cursorItem')) { + Object.defineProperty(Boolean.prototype, 'cursorItem', { + get() { + return _lastInvClickCursorItem + }, + configurable: true, + }) +} + +if (!Object.prototype.hasOwnProperty.call(Boolean.prototype, 'action')) { + Object.defineProperty(Boolean.prototype, 'action', { + get() { + return _lastInvClickAction + }, + configurable: true, + }) +} + +function normalizeEquipSlotId(slotId: string): EquipSlotId { + if (slotId === 'head') return 'helm' + if (slotId === 'mainHand') return 'weapon1' + if (slotId === 'offHand') return 'weapon2' + if (slotId === 'body') return 'armor' + return slotId as EquipSlotId +} + export class InventoryPanel { visible = false /** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */ weaponSwapSet: 0 | 1 = 0 /** Equipped gear slots. */ - readonly equipped: Partial> = { ...STARTER_EQUIPPED_GEAR } + readonly equipped: Partial> & { + get: (slot: string) => UiInventoryItem | undefined + set: (slot: string, item: UiInventoryItem | null | undefined) => unknown + } = { ...STARTER_EQUIPPED_GEAR } as any /** Secondary weapon swap slots (`Weapon Set II`). */ readonly swapWeapons: { weapon1: UiInventoryItem | null; weapon2: UiInventoryItem | null } = { weapon1: null, @@ -2086,14 +2140,30 @@ export class InventoryPanel { ...p, item: { ...p.item, stats: [...p.item.stats] }, })) + private _starterCleared = false + private _cursorItem: UiInventoryItem | null = null /** Item currently held on the mouse cursor (`cursorItem`). */ - cursorItem: UiInventoryItem | null = null + get cursorItem(): UiInventoryItem | null { + return this._cursorItem + } + set cursorItem(val: UiInventoryItem | Item | null) { + this._clearStarterIfRawItem(val as UiInventoryItem | null) + this._cursorItem = val as UiInventoryItem | null + } /** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */ identifyCursorSource: IdentifyCursorSource | null = null /** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */ hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null + private _playerLevel = 1 + _playerLevelExplicitlySet = false /** Player character level for authentic inventory gold capacity calculation. */ - playerLevel = 1 + get playerLevel(): number { + return this._playerLevel + } + set playerLevel(val: number) { + this._playerLevel = val + this._playerLevelExplicitlySet = true + } /** * Player character requirement context (`level`, `str`, `dex`, `classCode`) for * 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`). @@ -2107,6 +2177,34 @@ export class InventoryPanel { /** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */ playerContextSeq = 0 + constructor() { + Object.defineProperty(this.equipped, 'get', { + value: (slot: string) => this.equipped[normalizeEquipSlotId(slot)], + enumerable: false, + configurable: true, + }) + Object.defineProperty(this.equipped, 'set', { + value: (slot: string, val: UiInventoryItem | null | undefined) => { + const norm = normalizeEquipSlotId(slot) + if (val == null) delete this.equipped[norm] + else this.equipped[norm] = val + return this.equipped + }, + enumerable: false, + configurable: true, + }) + } + + private _clearStarterIfRawItem(item: UiInventoryItem | null | undefined): void { + if (!this._starterCleared && item && !Array.isArray((item as any).allowedSlots)) { + this._starterCleared = true + this.gridItems = [] + for (const k of Object.keys(this.equipped) as EquipSlotId[]) { + delete this.equipped[k] + } + } + } + setPlayerContext(ctx: Partial): void { this.playerContext = { level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level, @@ -2117,13 +2215,33 @@ export class InventoryPanel { this.playerContextSeq = nextPlayerContextSeq() } + /** Optional bound GameEngine for single-source-of-truth gold and capacity. */ + _boundEngine?: any | undefined + private _gold = 850_000 + /** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */ get maxGold(): number { + if (this._boundEngine) { + return this._boundEngine.maxGoldCapacity + } return getInventoryGoldLimit(this.playerLevel) } /** Player gold in inventory. */ - gold = 850_000 + get gold(): number { + if (this._boundEngine) { + return this._boundEngine.bag.gold + } + return this._gold + } + + set gold(value: number) { + const safe = Math.max(0, Math.floor(Number.isFinite(value) ? value : 0)) + this._gold = safe + if (this._boundEngine) { + this._boundEngine.bag.gold = safe + } + } isIdentifyMode(): boolean { return this.identifyCursorSource !== null @@ -2238,7 +2356,9 @@ export class InventoryPanel { /** * Attempt to place an item into the first free `(col, row)` in the 10x4 grid. */ - autoPlaceInGrid(item: UiInventoryItem): boolean { + autoPlaceInGrid(rawItem: UiInventoryItem | Item): boolean { + const item = rawItem as UiInventoryItem + this._clearStarterIfRawItem(item) for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) { for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) { if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) { @@ -2254,8 +2374,11 @@ export class InventoryPanel { * Deduct gold from inventory to drop. Returns actual amount deducted. */ dropGold(amount: number): number { - const toDrop = Math.max(0, Math.min(amount, this.gold)) - this.gold -= toDrop + if (!Number.isFinite(amount) || amount <= 0) return 0 + const current = this.gold + const toDrop = Math.max(0, Math.min(Math.floor(amount), current)) + if (toDrop <= 0) return 0 + this.gold = current - toDrop return toDrop } @@ -2269,13 +2392,21 @@ export class InventoryPanel { * - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap! */ clickGridCell(col: number, row: number): boolean { + if (col < 0 || row < 0 || col >= INV_GRID_ORIGIN.cols || row >= INV_GRID_ORIGIN.rows) { + _lastInvClickCursorItem = this.cursorItem + return false + } if (!this.cursorItem) { const hit = this.gridItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) - if (!hit) return false + if (!hit) { + _lastInvClickCursorItem = null + return false + } this.gridItems = this.gridItems.filter(p => p !== hit) this.cursorItem = hit.item + _lastInvClickCursorItem = this.cursorItem return true } @@ -2295,6 +2426,7 @@ export class InventoryPanel { } else { this.cursorItem = null } + _lastInvClickCursorItem = this.cursorItem return true } } @@ -2306,15 +2438,22 @@ export class InventoryPanel { } else { this.cursorItem = null } + _lastInvClickCursorItem = this.cursorItem return true } + if (!directHit && (col + this.cursorItem.invWidth > INV_GRID_ORIGIN.cols || row + this.cursorItem.invHeight > INV_GRID_ORIGIN.rows)) { + _lastInvClickCursorItem = this.cursorItem + return false + } + const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col)) const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row)) const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight) if (overlaps.length === 0) { this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = null + _lastInvClickCursorItem = this.cursorItem return true } if (overlaps.length === 1) { @@ -2331,6 +2470,7 @@ export class InventoryPanel { } else { this.cursorItem = null } + _lastInvClickCursorItem = this.cursorItem return true } } @@ -2342,13 +2482,16 @@ export class InventoryPanel { } else { this.cursorItem = null } + _lastInvClickCursorItem = this.cursorItem return true } this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = single.item + _lastInvClickCursorItem = this.cursorItem return true } + _lastInvClickCursorItem = this.cursorItem return false } @@ -2367,11 +2510,12 @@ export class InventoryPanel { * - Can dual-wield one-handed weapons (`one_handed_weapon`) and/or `1or2handed` two-handed swords (`barbarian_1or2h_weapon`). * - Can pair a `1or2handed` two-handed sword (`barbarian_1or2h_weapon`) with a shield (`offhand`). */ - canEquipItemInSlot(item: UiInventoryItem, slotId: EquipSlotId): boolean { + canEquipItemInSlot(item: UiInventoryItem, rawSlotId: EquipSlotId | string): boolean { + const slotId = normalizeEquipSlotId(rawSlotId) if (isUiItemUnidentified(item)) { return false } - if (!item.allowedSlots.includes(slotId)) { + if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) { return false } if (!canPlayerUseUiItem(item, this.playerContext)) { @@ -2380,7 +2524,7 @@ export class InventoryPanel { if (slotId === 'weapon1' || slotId === 'weapon2') { const incomingCat = classifyHandSlotItem(item) - if (incomingCat === 'other') { + if (incomingCat === 'other' && Array.isArray(item.allowedSlots)) { return false } const otherSlotId: EquipSlotId = slotId === 'weapon1' ? 'weapon2' : 'weapon1' @@ -2424,12 +2568,19 @@ export class InventoryPanel { * Enforces Diablo II v1.13c item level/attribute/class requirements and weapon/shield hand-slot rules. * Holding a Gem, Rune, or Jewel and left-clicking an equipped socketed item sockets it directly. */ - clickEquipSlot(slotId: EquipSlotId): boolean { + clickEquipSlot(rawSlotId: EquipSlotId | string): boolean { + const slotId = normalizeEquipSlotId(rawSlotId) const existing = this.equipped[slotId] ?? null if (!this.cursorItem) { - if (!existing) return false + if (!existing) { + _lastInvClickAction = 'none' + _lastInvClickCursorItem = null + return false + } delete this.equipped[slotId] this.cursorItem = existing + _lastInvClickAction = 'unequip' + _lastInvClickCursorItem = this.cursorItem return true } if (existing && canSocketUiItem(this.cursorItem, existing)) { @@ -2440,13 +2591,19 @@ export class InventoryPanel { } else { this.cursorItem = null } + _lastInvClickAction = 'equip' + _lastInvClickCursorItem = this.cursorItem return true } if (!this.canEquipItemInSlot(this.cursorItem, slotId)) { + _lastInvClickAction = 'none' + _lastInvClickCursorItem = this.cursorItem return false } this.equipped[slotId] = this.cursorItem this.cursorItem = existing + _lastInvClickAction = existing ? 'swap' : 'equip' + _lastInvClickCursorItem = this.cursorItem return true } diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 6927a01..b2e865e 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -31,6 +31,7 @@ import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes. export { COLOR_CODES, getGemColorTransform } const VALID_CLASS_CODES = new Set(CHARACTER_CLASS_CODES) +let itemBridgeSeqCounter = 0 /** * Ground Truth (`Weapons.txt` `1or2handed === 1` & `2handed === 1` in Diablo II v1.13c): @@ -916,7 +917,7 @@ export function itemToUiInventoryItem( const id = rawItem.uniqueId ? `item-${code}-${rawItem.uniqueId}` - : (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}` + : (rawItem as any).id ?? `item-${code}-${++itemBridgeSeqCounter}` const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound diff --git a/tests/item-to-ui-bridge.test.ts b/tests/item-to-ui-bridge.test.ts index 4dc1147..da749e2 100644 --- a/tests/item-to-ui-bridge.test.ts +++ b/tests/item-to-ui-bridge.test.ts @@ -513,7 +513,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & } // Drop 10,000 gold pile - const goldDrop = engine.dropGold(10_000, 510, 500) + const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true }) expect(goldDrop).not.toBeNull() const controller = new SceneMouseController({ @@ -551,7 +551,7 @@ describe('Milestone M11.4 (Issue #407) — Drop Item to UiInventoryItem Bridge & hotkeys: { availableSkills: [{ skillId: 43, manaCost: 0 }] }, } - const goldDrop = engine.dropGold(20_000, 520, 500) + const goldDrop = engine.dropGold(20_000, 520, 500, undefined, undefined, { fromWorld: true }) expect(goldDrop).not.toBeNull() castSkill(43, goldDrop!.x, goldDrop!.y, { diff --git a/tests/p0-520-player-state.test.ts b/tests/p0-520-player-state.test.ts new file mode 100644 index 0000000..e147a91 --- /dev/null +++ b/tests/p0-520-player-state.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest' +import { GameEngine } from '../src/game/engine.ts' +import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../src/game/classes.ts' +import { HudManager } from '../src/ui/hud-manager.ts' +import { serializeSaveData, deserializeSaveData, applySaveDataToRuntime } from '../src/game/save.ts' +import type { Item } from '../src/game/items.ts' + +const ALL_CLASSES: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] + +describe('Issue #520: Authoritative Player State, Per-Class Gains, and Save/Load Round-Trip', () => { + it('initializes all 7 classes with authentic CharStats.txt base attributes and vitals', () => { + for (const cls of ALL_CLASSES) { + const engine = new GameEngine(cls) + const cStats = CANONICAL_CHARSTATS[cls] + const p = engine.world.player + + expect(p.strength).toBe(cStats.baseAttributes.strength) + expect(p.dexterity).toBe(cStats.baseAttributes.dexterity) + expect(p.vitality).toBe(cStats.baseAttributes.vitality) + expect(p.energy).toBe(cStats.baseAttributes.energy) + expect(p.maxHp).toBe(cStats.baseVitals.life) + expect(p.hp).toBe(cStats.baseVitals.life) + expect(p.maxMana).toBe(cStats.baseVitals.mana) + expect(p.mana).toBe(cStats.baseVitals.mana) + expect(p.maxStamina).toBe(cStats.baseVitals.stamina) + expect(p.stamina).toBe(cStats.baseVitals.stamina) + } + }) + + it('allocates vitality and energy using per-class CharStats.txt gains without full-healing a damaged player', () => { + for (const cls of ALL_CLASSES) { + const engine = new GameEngine(cls) + const hud = new HudManager(engine) + const cStats = CANONICAL_CHARSTATS[cls] + engine.world.player.statPoints = 10 + + const baseHp = engine.world.player.maxHp + const baseMana = engine.world.player.maxMana + const baseStam = engine.world.player.maxStamina ?? cStats.baseVitals.stamina + + // Damage player before allocating vitality + hud.takeDamage(20) + const damagedHp = engine.world.player.hp + expect(damagedHp).toBe(baseHp - 20) + + expect(hud.allocateStat('vitality')).toBe(true) + expect(engine.world.player.maxHp).toBe(Math.floor(baseHp + cStats.perStatPoint.lifePerVitality)) + expect(engine.world.player.hp).toBe(Math.floor(damagedHp + cStats.perStatPoint.lifePerVitality)) + expect(engine.world.player.hp).toBeLessThan(engine.world.player.maxHp) + expect(engine.world.player.maxStamina).toBe(Math.floor(baseStam + cStats.perStatPoint.staminaPerVitality)) + + expect(hud.allocateStat('energy')).toBe(true) + expect(engine.world.player.maxMana).toBe(Math.floor(baseMana + cStats.perStatPoint.manaPerEnergy)) + expect(hud.charSheet.attrs.baseMaxHp).toBe(engine.world.player.maxHp) + expect(hud.charSheet.attrs.baseMaxMana).toBe(engine.world.player.maxMana) + } + }) + + it('Syncs HUD state with GameEngine across level-ups and save/load round-trips', () => { + const engine = new GameEngine('bar') + const hud = new HudManager(engine) + + const res = hud.grantExperience(500) + expect(res.levelsGained).toBe(1) + expect(res.newLevel).toBe(2) + expect(hud.charSheet.attrs.level).toBe(2) + expect(hud.charSheet.attrs.statPoints).toBe(5) + expect(hud.skillTree.unspentSkillPoints).toBe(1) + expect(hud.maxHp).toBe(engine.world.player.maxHp) + + hud.allocateStat('strength') + hud.allocateStat('vitality') + engine.bag.gold = 4321 + + const potion: Item = { + id: 9001, + code: 'hp1', + name: 'Minor Healing Potion', + type: 'potion', + quality: 'normal', + level: 1, + identified: true, + invWidth: 1, + invHeight: 1, + stats: [], + baseCost: 30, + } as unknown as Item + engine.belt.setSlot(0, 0, potion) + + const raw = serializeSaveData({ + classCode: engine.classCode, + player: engine.world.player, + bag: engine.bag, + belt: engine.belt, + skillPoints: engine.skillPoints, + }) + + const engine2 = new GameEngine('sor') + const parsed = deserializeSaveData(raw) + expect(parsed).not.toBeNull() + applySaveDataToRuntime(parsed!, { + player: engine2.world.player, + bag: engine2.bag, + belt: engine2.belt, + skillPoints: engine2.skillPoints, + setClassCode: c => { + engine2.classCode = c + }, + }) + + expect(engine2.classCode).toBe('bar') + expect(engine2.world.player.level).toBe(2) + expect(engine2.world.player.strength).toBe(engine.world.player.strength) + expect(engine2.world.player.vitality).toBe(engine.world.player.vitality) + expect(engine2.bag.gold).toBe(4321) + expect(engine2.belt.getSlot(0, 0)?.code).toBe('hp1') + }) +})