410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
/**
|
||
* Pure Belt Potion Catalog & Compatibility Model (`src/common/items/belt-model.ts`).
|
||
*
|
||
* Shared across `src/common/`, `src/server/`, and `src/client/`.
|
||
* Zero DOM or Node globals.
|
||
*/
|
||
|
||
export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll'
|
||
|
||
export type BeltPotionCategory = 'health' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll'
|
||
|
||
export interface BeltPotion {
|
||
readonly id: string
|
||
readonly code: string
|
||
readonly invFile: string
|
||
readonly name: string
|
||
readonly nameZh: string
|
||
readonly kind: BeltPotionKind
|
||
readonly category?: BeltPotionCategory
|
||
readonly healHp: number
|
||
readonly healMana: number
|
||
}
|
||
|
||
export const BELT_COLS = 4
|
||
export const BELT_ROWS = 4
|
||
export const BELT_BASE_X = 423
|
||
export const BELT_BASE_Y = 562
|
||
export const BELT_PITCH = 31
|
||
export const BELT_PITCH_X = 31
|
||
export const BELT_PITCH_Y = 32
|
||
export const BELT_CELL_SIZE = 29
|
||
|
||
export function getBeltSlotBox(col: number, row: number): { x: number; y: number; width: number; height: number } {
|
||
return {
|
||
x: BELT_BASE_X + col * BELT_PITCH_X,
|
||
y: BELT_BASE_Y - row * BELT_PITCH_Y,
|
||
width: BELT_CELL_SIZE,
|
||
height: BELT_CELL_SIZE,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rows granted by equipped belt item code per 1.13c `Armor.txt` + `Belts.txt`.
|
||
* Default (no belt) has 1 row (4 boxes).
|
||
*/
|
||
export const BELT_CODE_TO_ROWS: Readonly<Record<string, number>> = {
|
||
// Normal belts
|
||
lbl: 2, // Sash (8 boxes)
|
||
vbl: 2, // Light Belt (8 boxes)
|
||
mbl: 3, // Belt (12 boxes)
|
||
tbl: 3, // Heavy Belt (12 boxes)
|
||
hbl: 4, // Plated Belt / Girdle (16 boxes)
|
||
// Exceptional belts
|
||
zlb: 4, // Demonhide Sash (16 boxes)
|
||
zvb: 4, // Sharkskin Belt (16 boxes)
|
||
zmb: 4, // Mesh Belt (16 boxes)
|
||
ztb: 4, // Battle Belt (16 boxes)
|
||
zhb: 4, // War Belt (16 boxes)
|
||
// Elite belts
|
||
ulc: 4, // Spiderweb Sash (16 boxes)
|
||
uvc: 4, // Vampirefang Belt (16 boxes)
|
||
umc: 4, // Mithril Coil (16 boxes)
|
||
utc: 4, // Troll Belt (16 boxes)
|
||
uhc: 4, // Colossus Girdle (16 boxes)
|
||
}
|
||
|
||
export function getBeltRowsForCode(beltCode: string | null | undefined): number {
|
||
if (!beltCode) return 1
|
||
const code = beltCode.trim().toLowerCase()
|
||
return BELT_CODE_TO_ROWS[code] ?? 4
|
||
}
|
||
|
||
/**
|
||
* Authoritative Diablo II v1.13c `misc.txt` belt-compatible potion & scroll definitions (`belt = 1`).
|
||
* Icon filenames (invfile) strictly match misc.txt definitions:
|
||
* - vps (Stamina): invwps
|
||
* - yps (Antidote): invnps
|
||
* - wms (Thawing): invyps
|
||
* - tsc (Town Portal Scroll): invbsc
|
||
* - isc (Identify Scroll): invrsc
|
||
*/
|
||
export const BELT_POTION_CATALOG: Readonly<Record<string, Omit<BeltPotion, 'id'>>> = {
|
||
hp1: { code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 },
|
||
hp2: { code: 'hp2', invFile: 'invhp2', name: 'Light Healing Potion', nameZh: '轻度治疗药剂', kind: 'hp', category: 'health', healHp: 60, healMana: 0 },
|
||
hp3: { code: 'hp3', invFile: 'invhp3', name: 'Healing Potion', nameZh: '治疗药剂', kind: 'hp', category: 'health', healHp: 100, healMana: 0 },
|
||
hp4: { code: 'hp4', invFile: 'invhp4', name: 'Greater Healing Potion', nameZh: '强效治疗药剂', kind: 'hp', category: 'health', healHp: 180, healMana: 0 },
|
||
hp5: { code: 'hp5', invFile: 'invhp5', name: 'Super Healing Potion', nameZh: '超级治疗药剂', kind: 'hp', category: 'health', healHp: 320, healMana: 0 },
|
||
mp1: { code: 'mp1', invFile: 'invmp1', name: 'Minor Mana Potion', nameZh: '轻微法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 20 },
|
||
mp2: { code: 'mp2', invFile: 'invmp2', name: 'Light Mana Potion', nameZh: '轻度法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 40 },
|
||
mp3: { code: 'mp3', invFile: 'invmp3', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 80 },
|
||
mp4: { code: 'mp4', invFile: 'invmp4', name: 'Greater Mana Potion', nameZh: '强效法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 150 },
|
||
mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 250 },
|
||
rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 35, healMana: 35 },
|
||
rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 100, healMana: 100 },
|
||
vps: { code: 'vps', invFile: 'invwps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', category: 'stamina', healHp: 0, healMana: 0 },
|
||
yps: { code: 'yps', invFile: 'invnps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', category: 'antidote', healHp: 0, healMana: 0 },
|
||
wms: { code: 'wms', invFile: 'invyps', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', category: 'thawing', healHp: 0, healMana: 0 },
|
||
// Classic potions (1.13c misc.txt)
|
||
hpo: { code: 'hpo', invFile: 'invhpo', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 },
|
||
mpo: { code: 'mpo', invFile: 'invmpo', name: 'Minor Mana Potion', nameZh: '轻微法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 20 },
|
||
hpf: { code: 'hpf', invFile: 'invhpf', name: 'Healing Potion', nameZh: '治疗药剂', kind: 'hp', category: 'health', healHp: 100, healMana: 0 },
|
||
mpf: { code: 'mpf', invFile: 'invmpf', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 80 },
|
||
// Scrolls with belt=1 in Misc.txt
|
||
tsc: { code: 'tsc', invFile: 'invbsc', name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 },
|
||
isc: { code: 'isc', invFile: 'invrsc', name: 'Scroll of Identify', nameZh: '辨识卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 },
|
||
}
|
||
|
||
/**
|
||
* Checks whether two belt items belong to the same column family per `D2Common.dll` `BELT_CheckPotionType`.
|
||
* Scrolls require identical item code; potions match by kind family.
|
||
*/
|
||
export function areBeltPotionsCompatible(a: BeltPotion, b: BeltPotion): boolean {
|
||
if (a.kind === 'scroll' || b.kind === 'scroll') {
|
||
return a.code === b.code
|
||
}
|
||
return a.kind === b.kind
|
||
}
|
||
|
||
/**
|
||
* Converts a raw `Item`, `UiInventoryItem`, `BeltPotion`, or item-like object into a `BeltPotion`
|
||
* if and only if it is an authentic belt-compatible item (potions and scrolls with `belt = 1`).
|
||
* Excludes throwing potions (`gpm`, `opm`, `gpl`, `opl`, `gps`, `ops` which are weapons with `beltable=0`).
|
||
*/
|
||
export function itemToBeltPotion(item: unknown): BeltPotion | null {
|
||
if (!item || typeof item !== 'object') return null
|
||
const raw = item as Record<string, any>
|
||
|
||
// Unwrap GroundItemEntity or nested item object
|
||
const inner = raw.item && typeof raw.item === 'object' && !raw.kind ? (raw.item as Record<string, any>) : raw
|
||
|
||
// Already a valid BeltPotion
|
||
if (
|
||
typeof inner.kind === 'string' &&
|
||
['hp', 'mana', 'rejuv', 'stamina', 'antidote', 'thawing', 'scroll'].includes(inner.kind) &&
|
||
typeof inner.code === 'string'
|
||
) {
|
||
const code = inner.code.trim().toLowerCase()
|
||
const catalogEntry = BELT_POTION_CATALOG[code]
|
||
return {
|
||
id: typeof inner.id === 'string' ? inner.id : `pot-${code}`,
|
||
code,
|
||
invFile: inner.invFile || catalogEntry?.invFile || `inv${code}`,
|
||
name: typeof inner.name === 'string' ? inner.name : (catalogEntry?.name ?? 'Potion'),
|
||
nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (catalogEntry?.nameZh ?? inner.name ?? '药剂'),
|
||
kind: inner.kind as BeltPotionKind,
|
||
category: inner.category ?? catalogEntry?.category ?? (inner.kind === 'hp' ? 'health' : (inner.kind as BeltPotionCategory)),
|
||
healHp: typeof inner.healHp === 'number' ? inner.healHp : (catalogEntry?.healHp ?? 0),
|
||
healMana: typeof inner.healMana === 'number' ? inner.healMana : (catalogEntry?.healMana ?? 0),
|
||
}
|
||
}
|
||
|
||
const base = inner.base && typeof inner.base === 'object' ? (inner.base as Record<string, any>) : undefined
|
||
// Exclude weapon items (e.g. throwing gas/oil potions have kind === 'weapon')
|
||
if (base?.kind === 'weapon' || inner.kind === 'weapon') {
|
||
return null
|
||
}
|
||
|
||
const rawCode = String(inner.code ?? base?.id ?? '').trim().toLowerCase()
|
||
if (!rawCode || ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops'].includes(rawCode)) {
|
||
return null
|
||
}
|
||
|
||
const catalogEntry = BELT_POTION_CATALOG[rawCode]
|
||
if (catalogEntry) {
|
||
const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`)
|
||
return {
|
||
...catalogEntry,
|
||
id: idStr,
|
||
...(typeof inner.invFile === 'string' && inner.invFile.length > 0 ? { invFile: inner.invFile } : {}),
|
||
...(typeof inner.name === 'string' && inner.name.length > 0 ? { name: inner.name } : {}),
|
||
...(typeof inner.nameZh === 'string' && inner.nameZh.length > 0 ? { nameZh: inner.nameZh } : {}),
|
||
}
|
||
}
|
||
|
||
// Check if item has explicit beltable property from misc.txt
|
||
if (inner.beltable === 1 || inner.belt === 1 || base?.beltable === 1 || base?.belt === 1) {
|
||
const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`)
|
||
return {
|
||
id: idStr,
|
||
code: rawCode,
|
||
invFile: typeof inner.invFile === 'string' && inner.invFile.length > 0 ? inner.invFile : (base?.invFile ?? `inv${rawCode}`),
|
||
name: typeof inner.name === 'string' ? inner.name : (base?.name ?? rawCode),
|
||
nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (base?.nameZh ?? inner.name ?? rawCode),
|
||
kind: 'hp',
|
||
category: 'health',
|
||
healHp: 0,
|
||
healMana: 0,
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export function isAutoBeltablePotion(item: unknown): boolean {
|
||
return itemToBeltPotion(item) !== null
|
||
}
|
||
|
||
export class BeltModel {
|
||
/** 4 columns × 4 rows (`grid[row][col]`, row 0 is bottom visible row). */
|
||
readonly grid: (BeltPotion | null)[][] = Array.from({ length: BELT_ROWS }, () =>
|
||
Array.from({ length: BELT_COLS }, () => null),
|
||
)
|
||
/** Whether `~` key locked the 4×4 popup belt open. */
|
||
lockedOpen = false
|
||
/** Whether mouse is hovering over the belt area. */
|
||
hovered = false
|
||
/** Currently hovered slot `(row, col)`. */
|
||
hoveredSlot: { row: number; col: number } | null = null
|
||
|
||
private _equippedBeltCode: string | null = null
|
||
private _hasEquippedBelt = false
|
||
|
||
get maxRows(): number {
|
||
if (!this._hasEquippedBelt) return 4
|
||
return getBeltRowsForCode(this._equippedBeltCode)
|
||
}
|
||
|
||
setEquippedBelt(beltItemOrCode: unknown | string | null): void {
|
||
this._hasEquippedBelt = true
|
||
if (beltItemOrCode === null || beltItemOrCode === undefined) {
|
||
this._equippedBeltCode = null
|
||
return
|
||
}
|
||
if (typeof beltItemOrCode === 'string') {
|
||
this._equippedBeltCode = beltItemOrCode.trim().toLowerCase()
|
||
} else {
|
||
const item = beltItemOrCode as Record<string, any>
|
||
const code = item.code ?? item.base?.id ?? item.baseId ?? item.id
|
||
this._equippedBeltCode = typeof code === 'string' ? code.trim().toLowerCase() : null
|
||
}
|
||
}
|
||
|
||
get isExpanded(): boolean {
|
||
return (this.lockedOpen || this.hovered) && this.maxRows > 1
|
||
}
|
||
|
||
get activeRows(): number {
|
||
return this.isExpanded ? this.maxRows : 1
|
||
}
|
||
|
||
togglePopup(): void {
|
||
this.lockedOpen = !this.lockedOpen
|
||
}
|
||
|
||
/**
|
||
* Drink the potion in column `col` (0..3) at `row` (default 0) and shift upper rows down.
|
||
*/
|
||
useSlot(col: number, row = 0): BeltPotion | null {
|
||
if (col < 0 || col >= BELT_COLS || row < 0 || row >= this.maxRows) return null
|
||
const consumed = this.grid[row]?.[col] ?? null
|
||
if (!consumed) return null
|
||
for (let r = row; r < BELT_ROWS - 1; r++) {
|
||
this.grid[r]![col] = this.grid[r + 1]![col] ?? null
|
||
}
|
||
this.grid[BELT_ROWS - 1]![col] = null
|
||
return consumed
|
||
}
|
||
|
||
/**
|
||
* Place a potion into the first empty row in column `col`, or into a specific `(row, col)`.
|
||
*/
|
||
placePotion(potion: BeltPotion, col: number, row?: number): BeltPotion | null {
|
||
if (col < 0 || col >= BELT_COLS) return potion
|
||
if (row !== undefined) {
|
||
if (row < 0 || row >= this.maxRows) return potion
|
||
const prev = this.grid[row]![col] ?? null
|
||
this.grid[row]![col] = potion
|
||
return prev
|
||
}
|
||
for (let r = 0; r < this.maxRows; r++) {
|
||
if (this.grid[r]![col] === null) {
|
||
this.grid[r]![col] = potion
|
||
return null
|
||
}
|
||
}
|
||
return potion
|
||
}
|
||
|
||
/**
|
||
* Clear all slots in the belt.
|
||
*/
|
||
clear(): void {
|
||
for (let r = 0; r < BELT_ROWS; r++) {
|
||
for (let c = 0; c < BELT_COLS; c++) {
|
||
this.grid[r]![c] = null
|
||
}
|
||
}
|
||
}
|
||
|
||
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 >= this.maxRows) return false
|
||
if (item === null || item === undefined) {
|
||
this.grid[row]![col] = null
|
||
return true
|
||
}
|
||
const potion = itemToBeltPotion(item)
|
||
if (!potion) return false
|
||
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`).
|
||
*/
|
||
autoPlacePotionSlot(potionOrItem: BeltPotion | unknown): { row: number; col: number; potion: BeltPotion } | null {
|
||
const potion = itemToBeltPotion(potionOrItem)
|
||
if (!potion) return null
|
||
|
||
// Pass 1: Match existing potion family in row 0, fill lowest empty upper row (1..maxRows-1)
|
||
for (let col = 0; col < BELT_COLS; col++) {
|
||
const basePot = this.grid[0]![col]
|
||
if (basePot !== null && areBeltPotionsCompatible(basePot, potion)) {
|
||
for (let row = 1; row < this.maxRows; row++) {
|
||
if (this.grid[row]![col] === null) {
|
||
this.grid[row]![col] = potion
|
||
return { row, col, potion }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pass 2: Place into the first empty base column (row 0)
|
||
for (let col = 0; col < BELT_COLS; col++) {
|
||
if (this.grid[0]![col] === null) {
|
||
this.grid[0]![col] = potion
|
||
return { row: 0, col, potion }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Automatically places a picked-up potion into the belt following D2 1.13c `INVENTORY_GetFreeBeltSlot`.
|
||
*/
|
||
autoPlacePotion(potionOrItem: BeltPotion | unknown): boolean {
|
||
return this.autoPlacePotionSlot(potionOrItem) !== null
|
||
}
|
||
|
||
/**
|
||
* Boolean alias around `autoPlacePotion` for ground pickup callers.
|
||
*/
|
||
tryAutoStore(potionOrItem: BeltPotion | unknown): boolean {
|
||
return this.autoPlacePotionSlot(potionOrItem) !== null
|
||
}
|
||
|
||
countTotalPotions(): number {
|
||
let count = 0
|
||
for (let r = 0; r < BELT_ROWS; r++) {
|
||
for (let c = 0; c < BELT_COLS; c++) {
|
||
if (this.grid[r]![c] !== null) count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
slotAtPoint(logicalX: number, logicalY: number): { row: number; col: number } | null {
|
||
const visibleRows = this.isExpanded ? this.maxRows : 1
|
||
for (let row = 0; row < visibleRows; row++) {
|
||
for (let col = 0; col < BELT_COLS; col++) {
|
||
const box = getBeltSlotBox(col, row)
|
||
if (
|
||
logicalX >= box.x &&
|
||
logicalX <= box.x + box.width &&
|
||
logicalY >= box.y &&
|
||
logicalY <= box.y + box.height
|
||
) {
|
||
return { row, col }
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||
const visibleRows = this.maxRows
|
||
const topY = this.isExpanded
|
||
? BELT_BASE_Y - (visibleRows - 1) * BELT_PITCH_Y - 4
|
||
: BELT_BASE_Y - 2
|
||
this.hovered =
|
||
logicalX >= BELT_BASE_X - 4 &&
|
||
logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH_X + 4 &&
|
||
logicalY >= topY &&
|
||
logicalY <= BELT_BASE_Y + BELT_CELL_SIZE + 4
|
||
this.hoveredSlot = this.slotAtPoint(logicalX, logicalY)
|
||
}
|
||
}
|