fix(state): unify player HP/MP/gold/inventory single source of truth in GameEngine (#520)
Delegate HUD player state reads and mutations to GameEngine as the single authoritative owner of HP, MP, gold, inventory, belt, and equipment state. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
parent
995f594fe7
commit
7367f2a36a
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -97,6 +97,81 @@ export interface MiscBase extends ItemBase {
|
|||
readonly quest: number
|
||||
readonly questDiffCheck: boolean
|
||||
readonly vendorColumns: Readonly<Record<number, VendorItemColumns>>
|
||||
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<Record<string, PotionSpec>> = {
|
||||
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<number, VendorItemColumns> = {}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ export const BELT_POTION_CATALOG: Readonly<Record<string, Omit<BeltPotion, 'id'>
|
|||
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`).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<Record<BaseStatKey, { x: number; y: nu
|
|||
export class CharacterSheetPanel {
|
||||
visible = false
|
||||
lang: TblLang = getTblLang()
|
||||
classCode: CharacterClassCode = 'sor'
|
||||
attrs: CharacterAttributes = {
|
||||
name: 'Isendra',
|
||||
classNameZh: '女法师',
|
||||
|
|
@ -72,21 +75,34 @@ export class CharacterSheetPanel {
|
|||
poisRes: 75,
|
||||
}
|
||||
|
||||
private resolveClassCode(): CharacterClassCode {
|
||||
const en = (this.attrs.classNameEn ?? '').toLowerCase()
|
||||
if (en.includes('barbarian')) return 'bar'
|
||||
if (en.includes('paladin')) return 'pal'
|
||||
if (en.includes('amazon')) return 'ama'
|
||||
if (en.includes('necromancer')) return 'nec'
|
||||
if (en.includes('druid')) return 'dru'
|
||||
if (en.includes('assassin')) return 'ass'
|
||||
if (en.includes('sorceress')) return 'sor'
|
||||
return this.classCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate 1 unspent stat point into `key`, applying Sorceress 1.13c per-point gains:
|
||||
* - +1 Vitality = +2 Life, +1 Stamina (`CharStats.txt` Sorceress row)
|
||||
* - +1 Energy = +2 Mana (`CharStats.txt` Sorceress row)
|
||||
* - +1 Dexterity = +5 Attack Rating, +0.25 Defense
|
||||
* Allocate 1 unspent stat point into `key`, applying 1.13c per-class `CharStats.txt` gains:
|
||||
* - +1 Vitality = +`lifePerVitality` Life, +`staminaPerVitality` Stamina
|
||||
* - +1 Energy = +`manaPerEnergy` Mana
|
||||
* - +1 Dexterity = +5 Attack Rating, +1 Defense
|
||||
*/
|
||||
allocateStat(key: BaseStatKey): boolean {
|
||||
if (this.attrs.statPoints <= 0) return false
|
||||
const cStats = CANONICAL_CHARSTATS[this.resolveClassCode()] ?? CANONICAL_CHARSTATS.sor
|
||||
this.attrs.statPoints -= 1
|
||||
this.attrs[key] += 1
|
||||
if (key === 'vit') {
|
||||
this.attrs.baseMaxHp += 2
|
||||
this.attrs.baseMaxStamina += 1
|
||||
this.attrs.baseMaxHp += cStats.perStatPoint.lifePerVitality
|
||||
this.attrs.baseMaxStamina += cStats.perStatPoint.staminaPerVitality
|
||||
} else if (key === 'ene') {
|
||||
this.attrs.baseMaxMana += 2
|
||||
this.attrs.baseMaxMana += cStats.perStatPoint.manaPerEnergy
|
||||
} else if (key === 'dex') {
|
||||
this.attrs.attackRating += 5
|
||||
this.attrs.defense += 1
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import {
|
|||
type CursorState,
|
||||
} from './cursor.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import type { GameEngine } from '../game/engine.ts'
|
||||
import { resolvePotionSpec } from '../game/misc-items.ts'
|
||||
import {
|
||||
createStarterProfileForClass,
|
||||
type ClassProfileState,
|
||||
|
|
@ -347,9 +349,31 @@ export class HudManager {
|
|||
this.globes.lang = tbl
|
||||
}
|
||||
|
||||
private readonly hudCanvas: HTMLCanvasElement
|
||||
private readonly callbacks: {
|
||||
onToggleAutomap: () => 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,
|
||||
|
|
|
|||
|
|
@ -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<Record<EquipSlotId, UiInventoryItem>> = { ...STARTER_EQUIPPED_GEAR }
|
||||
readonly equipped: Partial<Record<EquipSlotId, UiInventoryItem>> & {
|
||||
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<PlayerItemRequirementContext>): 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.
|
|||
export { COLOR_CODES, getGemColorTransform }
|
||||
|
||||
const VALID_CLASS_CODES = new Set<string>(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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue