260 lines
11 KiB
TypeScript
260 lines
11 KiB
TypeScript
/**
|
|
* Diablo II v1.13c 800x600 Bottom Control Panel (`Panel\800CtrlPnl7.dc6`).
|
|
*
|
|
* Houses:
|
|
* - Experience Bar (x=256, y=560, w=120, h=4) with tick notches & hover readout
|
|
* - Stamina Bar (x=273, y=573, w=102, h=18) with run drain & walk/stand recovery
|
|
* - Walk / Run Toggle Button (`Panel\runbutton.dc6` at x=255, y=572, w=16, h=20, hotkey `R`)
|
|
* - Central Mini-Panel Toggle (`Panel\menubutton.dc6` at x=393, y=562, w=15, h=24) &
|
|
* 6-button Mini-Panel Popup (`Panel\minipanel.dc6` at x=312, y=524, w=176, h=26):
|
|
* [Character, Inventory, Skill Tree, Automap, Quest Log, Waypoint/Menu]
|
|
* - Unallocated Stat Points & Skill Points Red `+` Buttons (`Panel\Level.dc6` + `Levelsocket.dc6`)
|
|
*/
|
|
|
|
import type { D2FontRenderer } from './font.ts'
|
|
|
|
export type MiniPanelAction = 'char' | 'inv' | 'skill' | 'automap' | 'quest' | 'waypoint'
|
|
|
|
export interface ControlBarState {
|
|
readonly level: number
|
|
readonly xp: number
|
|
readonly currentLevelXp: number
|
|
readonly nextLevelXp: number
|
|
readonly stamina: number
|
|
readonly maxStamina: number
|
|
readonly isRunning: boolean
|
|
readonly unspentStatPoints: number
|
|
readonly unspentSkillPoints: number
|
|
}
|
|
|
|
export const EXP_BAR_BOUNDS = { x: 256, y: 560, width: 120, height: 4 } as const
|
|
export const EXP_BAR_HOVER_BOUNDS = { x: 256, y: 558, width: 120, height: 8 } as const
|
|
export const STAMINA_BAR_BOUNDS = { x: 273, y: 573, width: 102, height: 18 } as const
|
|
export const RUN_BTN_BOUNDS = { x: 255, y: 572, width: 16, height: 20 } as const
|
|
export const MENU_TOGGLE_BOUNDS = { x: 393, y: 562, width: 15, height: 24 } as const
|
|
export const MINIPANEL_BOUNDS = { x: 312, y: 522, width: 176, height: 26 } as const
|
|
export const STAT_PLUS_BTN_BOUNDS = { x: 206, y: 558, width: 30, height: 30 } as const
|
|
export const SKILL_PLUS_BTN_BOUNDS = { x: 565, y: 558, width: 30, height: 30 } as const
|
|
|
|
const MINIPANEL_ITEMS: readonly { action: MiniPanelAction; label: string; hotkey: string }[] = [
|
|
{ action: 'char', label: '角色属性', hotkey: 'A/C' },
|
|
{ action: 'inv', label: '物品栏', hotkey: 'I/B' },
|
|
{ action: 'skill', label: '技能树', hotkey: 'T' },
|
|
{ action: 'automap', label: '自动地图', hotkey: 'Tab' },
|
|
{ action: 'quest', label: '任务日志', hotkey: 'Q' },
|
|
{ action: 'waypoint', label: '小站/储物箱', hotkey: 'V' },
|
|
]
|
|
|
|
export class ControlBarHud {
|
|
miniPanelOpen = true
|
|
hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null
|
|
|
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
|
this.hoveredElement = null
|
|
|
|
if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) {
|
|
const idx = Math.min(
|
|
MINIPANEL_ITEMS.length - 1,
|
|
Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)),
|
|
)
|
|
this.hoveredElement = MINIPANEL_ITEMS[idx]!.action
|
|
return
|
|
}
|
|
if (this.inRect(logicalX, logicalY, EXP_BAR_HOVER_BOUNDS)) {
|
|
this.hoveredElement = 'exp'
|
|
} else if (this.inRect(logicalX, logicalY, STAMINA_BAR_BOUNDS)) {
|
|
this.hoveredElement = 'stamina'
|
|
} else if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) {
|
|
this.hoveredElement = 'run'
|
|
} else if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) {
|
|
this.hoveredElement = 'menu'
|
|
} else if (this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) {
|
|
this.hoveredElement = 'statPlus'
|
|
} else if (this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) {
|
|
this.hoveredElement = 'skillPlus'
|
|
}
|
|
}
|
|
|
|
handleClick(
|
|
logicalX: number,
|
|
logicalY: number,
|
|
state: ControlBarState,
|
|
callbacks: {
|
|
onToggleRun: () => void
|
|
onMiniPanelAction: (action: MiniPanelAction) => void
|
|
},
|
|
): boolean {
|
|
if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) {
|
|
const idx = Math.min(
|
|
MINIPANEL_ITEMS.length - 1,
|
|
Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)),
|
|
)
|
|
callbacks.onMiniPanelAction(MINIPANEL_ITEMS[idx]!.action)
|
|
return true
|
|
}
|
|
if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) {
|
|
this.miniPanelOpen = !this.miniPanelOpen
|
|
return true
|
|
}
|
|
if (this.inRect(logicalX, logicalY, RUN_BTN_BOUNDS)) {
|
|
callbacks.onToggleRun()
|
|
return true
|
|
}
|
|
if (state.unspentStatPoints > 0 && this.inRect(logicalX, logicalY, STAT_PLUS_BTN_BOUNDS)) {
|
|
callbacks.onMiniPanelAction('char')
|
|
return true
|
|
}
|
|
if (state.unspentSkillPoints > 0 && this.inRect(logicalX, logicalY, SKILL_PLUS_BTN_BOUNDS)) {
|
|
callbacks.onMiniPanelAction('skill')
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private inRect(x: number, y: number, r: { x: number; y: number; width: number; height: number }): boolean {
|
|
return x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
|
|
}
|
|
|
|
draw(
|
|
ctx: CanvasRenderingContext2D,
|
|
state: ControlBarState,
|
|
assets: {
|
|
ctrlPnlImg: HTMLImageElement | null
|
|
runBtnImg: HTMLImageElement | null
|
|
menuBtnImg: HTMLImageElement | null
|
|
miniPanelImg: HTMLImageElement | null
|
|
miniPanelBtnsImg: HTMLImageElement | null
|
|
levelBtnImg: HTMLImageElement | null
|
|
levelSocketImg: HTMLImageElement | null
|
|
},
|
|
font: D2FontRenderer,
|
|
): void {
|
|
// 1. Draw stitched 800x104 stone control panel (`800CtrlPnl7.dc6`) at y = 496..600
|
|
if (assets.ctrlPnlImg) {
|
|
ctx.drawImage(assets.ctrlPnlImg, 0, 496)
|
|
} else {
|
|
ctx.fillStyle = '#1c1813'
|
|
ctx.fillRect(0, 545, 800, 55)
|
|
}
|
|
|
|
// 2. Experience Bar (`x=256, y=560, w=120, h=4`)
|
|
const xpSpan = Math.max(1, state.nextLevelXp - state.currentLevelXp)
|
|
const xpRatio = Math.max(0, Math.min(1, (state.xp - state.currentLevelXp) / xpSpan))
|
|
ctx.fillStyle = '#070605'
|
|
ctx.fillRect(EXP_BAR_BOUNDS.x, EXP_BAR_BOUNDS.y, EXP_BAR_BOUNDS.width, EXP_BAR_BOUNDS.height)
|
|
ctx.fillStyle = '#d8d0a8'
|
|
ctx.fillRect(EXP_BAR_BOUNDS.x, EXP_BAR_BOUNDS.y, Math.round(EXP_BAR_BOUNDS.width * xpRatio), EXP_BAR_BOUNDS.height)
|
|
|
|
// Tick notches (10 segments, 12px each per D2 800x600 specification)
|
|
ctx.fillStyle = '#1e1812'
|
|
for (let i = 1; i < 10; i++) {
|
|
ctx.fillRect(EXP_BAR_BOUNDS.x + i * 12, EXP_BAR_BOUNDS.y, 1, EXP_BAR_BOUNDS.height)
|
|
}
|
|
|
|
// 3. Stamina Bar (`x=273, y=573, w=102, h=18`)
|
|
const stamRatio = Math.max(0, Math.min(1, state.stamina / Math.max(1, state.maxStamina)))
|
|
ctx.fillStyle = '#090705'
|
|
ctx.fillRect(STAMINA_BAR_BOUNDS.x, STAMINA_BAR_BOUNDS.y, STAMINA_BAR_BOUNDS.width, STAMINA_BAR_BOUNDS.height)
|
|
const stamGrad = ctx.createLinearGradient(0, STAMINA_BAR_BOUNDS.y, 0, STAMINA_BAR_BOUNDS.y + STAMINA_BAR_BOUNDS.height)
|
|
stamGrad.addColorStop(0, state.isRunning ? '#ffd452' : '#e8a828')
|
|
stamGrad.addColorStop(1, '#8c540c')
|
|
ctx.fillStyle = stamGrad
|
|
ctx.fillRect(
|
|
STAMINA_BAR_BOUNDS.x,
|
|
STAMINA_BAR_BOUNDS.y,
|
|
Math.round(STAMINA_BAR_BOUNDS.width * stamRatio),
|
|
STAMINA_BAR_BOUNDS.height,
|
|
)
|
|
|
|
// 4. Run / Walk Toggle Button (`runbutton.dc6`, 4 frames of 16x20: 0=walk, 2=run)
|
|
if (assets.runBtnImg) {
|
|
const frameIdx = state.isRunning ? 2 : 0
|
|
ctx.drawImage(assets.runBtnImg, frameIdx * 16, 0, 16, 20, RUN_BTN_BOUNDS.x, RUN_BTN_BOUNDS.y, 16, 20)
|
|
}
|
|
|
|
// 5. Central Mini-panel Toggle Triangle (`menubutton.dc6`, 4 frames of 15x24)
|
|
if (assets.menuBtnImg) {
|
|
const frameIdx = this.miniPanelOpen ? 2 : 0
|
|
ctx.drawImage(assets.menuBtnImg, frameIdx * 15, 0, 15, 24, MENU_TOGGLE_BOUNDS.x, MENU_TOGGLE_BOUNDS.y, 15, 24)
|
|
}
|
|
|
|
// 6. Central Mini-panel Popup (`minipanel.dc6` 176x26 + `minipanelbtn.dc6` 20x20 icons)
|
|
if (this.miniPanelOpen) {
|
|
if (assets.miniPanelImg) {
|
|
ctx.drawImage(assets.miniPanelImg, MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y)
|
|
} else {
|
|
ctx.fillStyle = '#241e16'
|
|
ctx.fillRect(MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y, MINIPANEL_BOUNDS.width, MINIPANEL_BOUNDS.height)
|
|
}
|
|
for (let i = 0; i < MINIPANEL_ITEMS.length; i++) {
|
|
const bx = MINIPANEL_BOUNDS.x + 8 + i * 27
|
|
const by = MINIPANEL_BOUNDS.y + 3
|
|
const item = MINIPANEL_ITEMS[i]!
|
|
const isHovered = this.hoveredElement === item.action
|
|
if (assets.miniPanelBtnsImg) {
|
|
const frameIdx = i * 2 + (isHovered ? 1 : 0)
|
|
ctx.drawImage(assets.miniPanelBtnsImg, frameIdx * 20, 0, 20, 20, bx, by, 20, 20)
|
|
}
|
|
if (isHovered) {
|
|
ctx.strokeStyle = '#e8c26b'
|
|
ctx.strokeRect(bx - 0.5, by - 0.5, 21, 21)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. Unallocated Stat Points (`+`) & Skill Points (`+`) Red Buttons (`Level.dc6`)
|
|
if (state.unspentStatPoints > 0) {
|
|
if (assets.levelSocketImg) ctx.drawImage(assets.levelSocketImg, STAT_PLUS_BTN_BOUNDS.x - 2, STAT_PLUS_BTN_BOUNDS.y - 3)
|
|
if (assets.levelBtnImg) ctx.drawImage(assets.levelBtnImg, 0, 0, 30, 30, STAT_PLUS_BTN_BOUNDS.x, STAT_PLUS_BTN_BOUNDS.y, 30, 30)
|
|
}
|
|
if (state.unspentSkillPoints > 0) {
|
|
if (assets.levelSocketImg) ctx.drawImage(assets.levelSocketImg, SKILL_PLUS_BTN_BOUNDS.x - 2, SKILL_PLUS_BTN_BOUNDS.y - 3)
|
|
if (assets.levelBtnImg) ctx.drawImage(assets.levelBtnImg, 0, 0, 30, 30, SKILL_PLUS_BTN_BOUNDS.x, SKILL_PLUS_BTN_BOUNDS.y, 30, 30)
|
|
}
|
|
|
|
// 8. Hover Tooltip over Experience / Stamina / Mini-panel buttons
|
|
if (this.hoveredElement === 'exp') {
|
|
this.drawMiniTooltip(
|
|
ctx,
|
|
font,
|
|
`经验值: ${state.xp.toLocaleString()} / ${state.nextLevelXp.toLocaleString()} (${(xpRatio * 100).toFixed(1)}%)`,
|
|
EXP_BAR_BOUNDS.x + 60,
|
|
518,
|
|
)
|
|
} else if (this.hoveredElement === 'stamina') {
|
|
this.drawMiniTooltip(
|
|
ctx,
|
|
font,
|
|
`耐力: ${Math.round(state.stamina)} / ${Math.round(state.maxStamina)} (${state.isRunning ? '奔跑中' : '行走中'})`,
|
|
STAMINA_BAR_BOUNDS.x + 51,
|
|
518,
|
|
)
|
|
} else if (this.hoveredElement === 'run') {
|
|
this.drawMiniTooltip(ctx, font, `切换行走/奔跑 (R) — 当前: ${state.isRunning ? '奔跑' : '行走'}`, RUN_BTN_BOUNDS.x + 8, 518)
|
|
} else if (this.hoveredElement === 'statPlus') {
|
|
this.drawMiniTooltip(ctx, font, `未分配属性点: ${state.unspentStatPoints} (点击打开角色面板 A)`, STAT_PLUS_BTN_BOUNDS.x + 15, 518)
|
|
} else if (this.hoveredElement === 'skillPlus') {
|
|
this.drawMiniTooltip(ctx, font, `未分配技能点: ${state.unspentSkillPoints} (点击打开技能树 T)`, SKILL_PLUS_BTN_BOUNDS.x + 15, 518)
|
|
} else if (this.hoveredElement && this.hoveredElement !== 'menu') {
|
|
const found = MINIPANEL_ITEMS.find(m => m.action === this.hoveredElement)
|
|
if (found) {
|
|
this.drawMiniTooltip(ctx, font, `${found.label} (${found.hotkey})`, 400, 502)
|
|
}
|
|
}
|
|
}
|
|
|
|
private drawMiniTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer, text: string, cx: number, y: number): void {
|
|
const w = Math.max(120, font.measureText(text, 'fontexocet10') + 20)
|
|
const x = Math.max(8, Math.min(800 - w - 8, Math.round(cx - w / 2)))
|
|
ctx.fillStyle = 'rgba(6, 6, 6, 0.92)'
|
|
ctx.fillRect(x, y, w, 24)
|
|
ctx.strokeStyle = '#8c7244'
|
|
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, 23)
|
|
font.drawText(ctx, text, x + Math.round(w / 2), y + 17, {
|
|
font: 'fontexocet10',
|
|
color: 'gold',
|
|
align: 'center',
|
|
})
|
|
}
|
|
}
|