247 lines
7.2 KiB
TypeScript
247 lines
7.2 KiB
TypeScript
/**
|
||
* 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',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|