diablo2-web/src/ui/belt.ts

247 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II v1.13c Belt & Pop-up Belt (`Panel\ctrlpnl_popbelt.dc6`).
*
* Implements:
* - 4 columns × 4 rows (16 slots) potion belt storage
* - Bottom row (slots 1..4) always visible in the 800x600 control panel at x=423, y=561
* - Hover or `~` key toggles full 4×4 pop-up rows (`ctrlpnl_popbelt.dc6`, 125×32 per row)
* - Pressing `1`–`4` or right-clicking drinks the bottom potion in that column and automatically
* gravity-shifts upper rows down (`row 1 -> 0`, `row 2 -> 1`, `row 3 -> 2`)
*/
import type { D2FontRenderer } from './font.ts'
export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina'
export interface BeltPotion {
readonly id: string
readonly code: string
readonly invFile: string
readonly name: string
readonly nameZh: string
readonly kind: BeltPotionKind
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 = 561
export const BELT_PITCH = 31
export const BELT_CELL_SIZE = 29
export const DEFAULT_BELT_POTIONS: readonly BeltPotion[] = [
{
id: 'pot-hp-1',
code: 'hp4',
invFile: 'invhp4',
name: 'Greater Healing Potion',
nameZh: '强效治疗药剂',
kind: 'hp',
healHp: 180,
healMana: 0,
},
{
id: 'pot-hp-2',
code: 'hp5',
invFile: 'invhp5',
name: 'Super Healing Potion',
nameZh: '超级治疗药剂',
kind: 'hp',
healHp: 320,
healMana: 0,
},
{
id: 'pot-mp-1',
code: 'mp4',
invFile: 'invmp4',
name: 'Greater Mana Potion',
nameZh: '强效法力药剂',
kind: 'mana',
healHp: 0,
healMana: 150,
},
{
id: 'pot-rv-1',
code: 'rvl',
invFile: 'invvpl',
name: 'Full Rejuvenation Potion',
nameZh: '全面回复活力药剂',
kind: 'rejuv',
healHp: 400,
healMana: 400,
},
]
export class BeltHud {
/** 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
constructor() {
this.seedDefaultPotions()
}
seedDefaultPotions(): void {
for (let row = 0; row < BELT_ROWS; row++) {
for (let col = 0; col < BELT_COLS; col++) {
const template = DEFAULT_BELT_POTIONS[col]!
this.grid[row]![col] = {
...template,
id: `${template.id}-r${row}-c${col}`,
}
}
}
}
get isExpanded(): boolean {
return this.lockedOpen || this.hovered
}
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 >= BELT_ROWS) 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 && row >= 0 && row < BELT_ROWS) {
const prev = this.grid[row]![col] ?? null
this.grid[row]![col] = potion
return prev
}
for (let r = 0; r < BELT_ROWS; r++) {
if (this.grid[r]![col] === null) {
this.grid[r]![col] = potion
return null
}
}
return potion
}
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 ? BELT_ROWS : 1
for (let row = 0; row < visibleRows; row++) {
const sy = BELT_BASE_Y - row * BELT_PITCH
for (let col = 0; col < BELT_COLS; col++) {
const sx = BELT_BASE_X + col * BELT_PITCH
if (
logicalX >= sx &&
logicalX <= sx + BELT_CELL_SIZE &&
logicalY >= sy &&
logicalY <= sy + BELT_CELL_SIZE
) {
return { row, col }
}
}
}
return null
}
handleMouseMove(logicalX: number, logicalY: number): void {
const topY = this.isExpanded ? BELT_BASE_Y - (BELT_ROWS - 1) * BELT_PITCH - 4 : BELT_BASE_Y - 2
this.hovered =
logicalX >= BELT_BASE_X - 4 &&
logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH + 4 &&
logicalY >= topY &&
logicalY <= BELT_BASE_Y + BELT_CELL_SIZE + 4
this.hoveredSlot = this.slotAtPoint(logicalX, logicalY)
}
draw(
ctx: CanvasRenderingContext2D,
assets: {
popbeltImg: HTMLImageElement | null
itemsAtlasImg: HTMLImageElement | null
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
},
font: D2FontRenderer,
): void {
const visibleRows = this.isExpanded ? BELT_ROWS : 1
// Draw expanded rows 1..3 background (`ctrlpnl_popbelt.dc6`)
if (visibleRows > 1) {
for (let row = 1; row < visibleRows; row++) {
const sy = BELT_BASE_Y - row * BELT_PITCH - 2
if (assets.popbeltImg) {
ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy)
} else {
ctx.fillStyle = 'rgba(18, 15, 11, 0.94)'
ctx.fillRect(BELT_BASE_X - 1, sy, 125, 31)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(BELT_BASE_X - 1, sy, 125, 31)
}
}
}
// Draw each cell & potion sprite
for (let row = 0; row < visibleRows; row++) {
const sy = BELT_BASE_Y - row * BELT_PITCH
for (let col = 0; col < BELT_COLS; col++) {
const sx = BELT_BASE_X + col * BELT_PITCH
const pot = this.grid[row]![col]
const isHovered = this.hoveredSlot?.row === row && this.hoveredSlot?.col === col
if (isHovered) {
ctx.fillStyle = 'rgba(200, 161, 90, 0.24)'
ctx.fillRect(sx, sy, BELT_CELL_SIZE, BELT_CELL_SIZE)
}
if (pot) {
const rect = assets.itemRects[pot.invFile]
if (assets.itemsAtlasImg && rect) {
const dx = sx + Math.floor((BELT_CELL_SIZE - rect.w) / 2)
const dy = sy + Math.floor((BELT_CELL_SIZE - rect.h) / 2)
ctx.drawImage(assets.itemsAtlasImg, rect.x, rect.y, rect.w, rect.h, dx, dy, rect.w, rect.h)
} else {
ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : '#9c24c8'
ctx.fillRect(sx + 6, sy + 4, 16, 20)
}
}
// Draw hotkey badge `1`..`4` on bottom row (row 0)
if (row === 0) {
font.drawText(ctx, String(col + 1), sx + 3, sy + 27, {
font: 'font8',
color: 'gold',
})
}
}
}
}
}