diablo2-web/src/ui/world-panels.ts

635 lines
31 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 World & Extended UI Panels:
* 1. Quest Log Panel (`Menu\questbackground.dc6`, hotkey `Q`)
* 2. Waypoint Teleport Panel (`Menu\waygatebackground.dc6`, hotkey `V` or clicking scene Waypoint)
* 3. Private Stash (`Panel\TradeStash.dc6`, 6x8 grid) & Horadric Cube (`Panel\supertransmogrifier.dc6`, 3x4 grid)
* 4. Town NPC Stone Interaction Menu (`npcmenu.cpp`) & Vendor Panel (`Panel\NPCInv.dc6`)
* 5. Monster Target Top Plaque (`text.cpp`), Item Hover Tooltip (`showitems.cpp`),
* Ground `Alt` Item Labels, Area Entry Gothic Banner (`Font30`), and Escape Menu (`menupanel.dc6`).
*/
import type { D2ColorCode, D2FontRenderer } from './font.ts'
import type { UiInventoryItem } from './inventory.ts'
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
export interface QuestEntry {
readonly id: string
readonly act: number
readonly index: number
readonly titleEn: string
readonly titleZh: string
readonly status: 'completed' | 'active' | 'locked'
readonly description: string
}
export interface WaypointEntry {
readonly act: number
readonly index: number
readonly waypointId: number
readonly levelId: number
readonly slug: string
readonly nameEn: string
readonly nameZh: string
readonly unlocked: boolean
}
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: [
{ id: 'a1q1', act: 1, index: 0, titleEn: 'Den of Evil', titleZh: '邪恶洞窟', status: 'completed', description: '阿卡拉委托你肃清鲜血荒地地下的邪恶洞窟。任务已完成,奖励 +1 技能点。' },
{ id: 'a1q2', act: 1, index: 1, titleEn: "Sisters' Burial Grounds", titleZh: '修女埋骨之地', status: 'completed', description: '在冰冷之原的埋骨之地击败堕落的血鸟 (Blood Raven),卡夏为你配备了罗格佣兵。' },
{ id: 'a1q3', act: 1, index: 2, titleEn: 'The Search for Cain', titleZh: '凯恩的搜寻', status: 'completed', description: '从黑暗森林取得艾尼弗斯卷轴,激活乱石旷野的五根石柱,前往崔斯特瑞姆救出迪卡·凯恩。' },
{ id: 'a1q4', act: 1, index: 3, titleEn: 'The Forgotten Tower', titleZh: '遗忘之塔', status: 'active', description: '探索黑色荒地中的五层遗忘之塔,击败女伯爵 (The Countess) 夺回财宝与符文。' },
{ id: 'a1q5', act: 1, index: 4, titleEn: 'Tools of the Trade', titleZh: '交易的工具', status: 'active', description: '深入修道院兵营从铁匠手中夺回赫拉迪姆之锤,并交还给恰西进行灌魔。' },
{ id: 'a1q6', act: 1, index: 5, titleEn: 'Sisters to the Slaughter', titleZh: '屠杀修女', status: 'active', description: '进入地下墓穴第四层,消灭痛苦之王安达利尔 (Andariel),打通前往鲁高因的道路。' },
],
2: [
{ id: 'a2q1', act: 2, index: 0, titleEn: "Radament's Lair", titleZh: '罗达门特的巢穴', status: 'active', description: '在鲁·高因下水道第三层消灭罗达门特,夺回复仇之书(+1 技能点)与赫拉迪姆卷轴。' },
{ id: 'a2q2', act: 2, index: 1, titleEn: 'The Horadric Staff', titleZh: '赫拉迪克之杖', status: 'active', description: '在死亡之殿取得赫拉迪克方块,在遥远绿洲取得国王之杖,在利爪蝮蛇神殿取得蝮蛇护符,将其在方块中合成。' },
{ id: 'a2q3', act: 2, index: 2, titleEn: 'Tainted Sun', titleZh: '堕落的太阳', status: 'active', description: '利爪蝮蛇的邪恶仪式遮蔽了太阳,深入利爪蝮蛇神殿摧毁太阳祭坛,恢复鲁·高因的光明。' },
{ id: 'a2q4', act: 2, index: 3, titleEn: 'Arcane Sanctuary', titleZh: '神秘避难所', status: 'active', description: '穿过杰海因王宫底层的传送门,进入大法师赫拉森的神秘避难所,搜寻真正的塔·拉夏古墓印记。' },
{ id: 'a2q5', act: 2, index: 4, titleEn: 'The Summoner', titleZh: '召唤者', status: 'active', description: '在神秘避难所尽头击败堕落的召唤者,开启通往术士峡谷的传送门。' },
{ id: 'a2q6', act: 2, index: 5, titleEn: 'The Seven Tombs', titleZh: '七座古墓', status: 'active', description: '在术士峡谷搜寻带有真正印记的塔·拉夏古墓,用赫拉迪克之杖开启密室,击败督瑞尔 (Duriel)。' },
],
3: [
{ id: 'a3q1', act: 3, index: 0, titleEn: 'The Golden Bird', titleZh: '黄金鸟', status: 'active', description: '在库拉斯特丛林击败独特怪物取得玉制小人,交还给马席夫换取黄金鸟雕像,阿尔卡特为你配制永久增加 20 点生命上限的药剂。' },
{ id: 'a3q2', act: 3, index: 1, titleEn: 'Blade of the Old Religion', titleZh: '古代宗教之刃', status: 'active', description: '在剥皮丛林吉得宾祭坛夺回附魔匕首吉得宾,将其带给奥玛斯以加固海港魔法屏障。' },
{ id: 'a3q3', act: 3, index: 2, titleEn: "Khalim's Will", titleZh: '克林姆的意志', status: 'active', description: '搜集克林姆的眼球(蜘蛛洞穴)、脑(剥皮地窖)、心脏(库拉斯特下水道)与连枷,合成克林姆的意志击碎强制之球。' },
{ id: 'a3q4', act: 3, index: 3, titleEn: "Lam Esen's Tome", titleZh: '蓝·森的古书', status: 'active', description: '在库拉斯特商场的废弃神庙中搜寻蓝·森的黑书并交给艾席拉,获得 +5 属性点奖励。' },
{ id: 'a3q5', act: 3, index: 4, titleEn: 'The Blackened Temple', titleZh: '黑暗神殿', status: 'active', description: '在崔凡克消灭最高评议会的堕落狂热信徒,用克林姆的意志击碎强制之球,打开通往憎恨囚牢的大门。' },
{ id: 'a3q6', act: 3, index: 5, titleEn: 'The Guardian', titleZh: '守卫者', status: 'active', description: '深入憎恨囚牢第三层,击败憎恨之王墨菲斯托 (Mephisto),夺取他的灵魂石并穿越地狱之门。' },
],
4: [
{ id: 'a4q1', act: 4, index: 0, titleEn: 'The Fallen Angel', titleZh: '堕落天使', status: 'active', description: '泰瑞尔委托你进入绝望平原,击败被恶魔囚禁扭曲的堕落大天使衣卒尔 (Izual),释放其灵魂并获得 +2 技能点。' },
{ id: 'a4q2', act: 4, index: 1, titleEn: "Hell's Forge", titleZh: '地狱熔炉', status: 'active', description: '护送墨菲斯托的灵魂石前往火焰之河的地狱熔炉,击败铁匠海法斯特夺取地狱熔炉之锤,粉碎邪恶灵魂石。' },
{ id: 'a4q3', act: 4, index: 2, titleEn: "Terror's End", titleZh: '恐怖的终结', status: 'active', description: '深入混沌避难所激活五座封印,击败三位庇护所守卫,最终消灭恐惧之王迪亚波罗 (Diablo)。' },
],
5: [
{ id: 'a5q1', act: 5, index: 0, titleEn: 'Siege on Harrogath', titleZh: '围攻哈洛加斯', status: 'active', description: '在血腥丘陵击败督军山克 (Shenk the Overseer),解除恶魔投石机对哈洛加斯的围攻,拉苏克将为你的一件装备打孔。' },
{ id: 'a5q2', act: 5, index: 1, titleEn: 'Rescue on Mount Arreat', titleZh: '亚瑞特山救援', status: 'active', description: '在冰冻高地寻找并释放被恶魔囚禁的 15 名野蛮人斥候,夸尔凯克将奖励你符文组合与招募野蛮人佣兵的特权。' },
{ id: 'a5q3', act: 5, index: 2, titleEn: 'Prison of Ice', titleZh: '冰之囚', status: 'active', description: '在冰河深处寻找被尼拉塞克封冻的安亚 (Anya),使用马拉配制的解冻药水解救她,安亚将赠予全职业抗性卷轴(+10% 全抗)。' },
{ id: 'a5q4', act: 5, index: 3, titleEn: 'Betrayal of Harrogath', titleZh: '哈洛加斯的背叛', status: 'active', description: '穿过安亚开启的红门进入瓦特之厅,击败背叛者尼拉塞克 (Nihlathak),阻止他将圣山遗物交给巴尔。' },
{ id: 'a5q5', act: 5, index: 4, titleEn: 'Rite of Passage', titleZh: '通过祭典', status: 'active', description: '登上亚瑞特山顶触碰古代人祭坛,在不返回城镇的情况下同时击败三位远古野蛮人守卫(科力克、马道克、塔力克),获得海量经验晋级。' },
{ id: 'a5q6', act: 5, index: 5, titleEn: 'Eve of Destruction', titleZh: '毁灭前夕', status: 'active', description: '深入世界之石要塞与毁灭王座,清除巴尔的五波恶魔仆从,追击毁灭之王巴尔进入世界之石大厅并将其消灭。' },
],
}
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
export const ALL_QUESTS: readonly QuestEntry[] = [
...ACT_QUESTS[1]!,
...ACT_QUESTS[2]!,
...ACT_QUESTS[3]!,
...ACT_QUESTS[4]!,
...ACT_QUESTS[5]!,
]
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
1: [
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true },
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true },
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true },
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true },
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true },
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true },
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true },
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true },
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true },
],
2: [
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true },
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true },
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true },
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true },
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true },
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true },
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true },
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true },
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true },
],
3: [
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true },
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true },
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true },
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true },
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true },
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true },
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true },
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true },
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true },
],
4: [
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true },
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true },
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true },
],
5: [
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true },
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true },
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true },
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true },
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true },
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true },
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true },
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true },
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true },
],
}
export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
for (const list of Object.values(ACT_WAYPOINTS)) {
const found = list.find(w => w.slug === slug)
if (found) return found
}
return undefined
}
export class WorldPanelsHud {
selectedActTab = 1
selectedQuestIdx = 0
stashGold = 2_500_000
cubeTransmuteCount = 0
escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
npcMenu: {
npcName: string
x: number
y: number
dialogLines: readonly string[]
talking: boolean
} | null = null
/** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
return this.questStatuses.get(quest.id) ?? quest.status
}
setQuestStatus(questId: string, status: 'completed' | 'active' | 'locked'): void {
this.questStatuses.set(questId, status)
}
private getQuestsAtlasImage(): HTMLImageElement | null {
if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.questsAtlas ?? '/ui/quests-atlas.png'
this.cachedQuestsAtlasImg = img
return img
}
return null
}
/** Area entry banner state (`Entering: <Level Name>`). */
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
showAreaBanner(titleZh: string, titleEn: string, nowMs = performance.now()): void {
this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 }
}
handleLeftDockClick(
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
logicalX: number,
logicalY: number,
callbacks: {
onClose: () => void
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
},
): boolean {
if (logicalX > 400) return false
const ox = 80
const oy = 60
// Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`)
if (logicalY >= oy + 380 && logicalY <= oy + 422 && (logicalX <= ox + 58 || logicalX >= ox + 260)) {
callbacks.onClose()
return true
}
// Act Tabs (`Act I..V` at top of Quest / Waypoint panels: `oy + 8 .. oy + 40`)
if ((kind === 'quest' || kind === 'waypoint') && logicalY >= oy + 6 && logicalY <= oy + 42) {
const tabIdx = Math.min(5, Math.max(1, Math.floor((logicalX - (ox + 8)) / 61) + 1))
this.selectedActTab = tabIdx
if (kind === 'quest') {
const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
if (this.selectedQuestIdx >= actQuests.length) {
this.selectedQuestIdx = 0
}
}
return true
}
if (kind === 'quest') {
// 6 Quest Sockets (`2 rows × 3 cols`)
const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
for (let i = 0; i < actQuests.length; i++) {
const col = i % 3
const row = Math.floor(i / 3)
const sx = ox + 28 + col * 92
const sy = oy + 64 + row * 100
if (logicalX >= sx && logicalX <= sx + 80 && logicalY >= sy && logicalY <= sy + 92) {
this.selectedQuestIdx = i
return true
}
}
} else if (kind === 'waypoint') {
const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < list.length; i++) {
const wy = oy + 62 + i * 36
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
const wp = list[i]!
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
callbacks.onClose()
return true
}
}
} else if (kind === 'cube') {
// Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
this.cubeTransmuteCount += 1
return true
}
}
return true
}
drawLeftDockPanel(
ctx: CanvasRenderingContext2D,
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
assets: {
borderLeftImg: HTMLImageElement | null
questBgImg: HTMLImageElement | null
waypointBgImg: HTMLImageElement | null
stashBgImg: HTMLImageElement | null
cubeBgImg: HTMLImageElement | null
vendorBgImg: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
questsAtlasImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
// 1. Left 800x600 Gothic Stone Border (`800BorderFrame.dc6` frames 0..4)
if (assets.borderLeftImg) {
ctx.drawImage(assets.borderLeftImg, 0, 0)
}
const ox = 80
const oy = 60
if (kind === 'quest') {
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font)
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
if (this.selectedQuestIdx >= quests.length) {
this.selectedQuestIdx = 0
}
const atlas = assets.questsAtlasImg ?? this.getQuestsAtlasImage()
for (let i = 0; i < 6; i++) {
// In Act 4, sockets 3, 4, 5 (indices 3, 4, 5) are unassigned
if (i >= quests.length) {
continue
}
const q = quests[i]!
const col = i % 3
const row = Math.floor(i / 3)
const sx = ox + 28 + col * 92
const sy = oy + 64 + row * 100
const selected = this.selectedQuestIdx === i
const status = this.getQuestStatus(q)
const rectKey = status === 'completed' ? `questdone_${q.id}` : q.id
const rect = BAKED_UI_MANIFEST.questRects[rectKey]
const ix = sx + 4
const iy = sy + 1
if (atlas && rect) {
ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, ix, iy, rect.w, rect.h)
} else {
ctx.fillStyle = status === 'completed' ? 'rgba(42, 36, 22, 0.85)' : 'rgba(18, 16, 14, 0.88)'
ctx.fillRect(sx, sy, 80, 88)
font.drawText(ctx, `Q${i + 1}`, sx + 40, sy + 32, {
font: 'font16',
color: status === 'completed' ? 'gold' : 'white',
align: 'center',
})
font.drawText(ctx, q.titleZh, sx + 40, sy + 56, {
font: 'font8',
color: status === 'completed' ? 'gold' : 'tan',
align: 'center',
})
font.drawText(ctx, status === 'completed' ? '已完成' : '进行中', sx + 40, sy + 76, {
font: 'font8',
color: status === 'completed' ? 'green' : 'blue',
align: 'center',
})
}
ctx.strokeStyle = selected ? '#e8c26b' : status === 'completed' ? '#6c5838' : '#3a3224'
ctx.lineWidth = selected ? 2 : 1
ctx.strokeRect(sx + 0.5, sy + 0.5, 79, 87)
}
const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
if (activeQ) {
const status = this.getQuestStatus(activeQ)
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
ctx.fillRect(ox + 18, oy + 292, 284, 86)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 292.5, 283, 85)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
font.drawText(ctx, `${activeQ.titleZh} (${activeQ.titleEn}) ${statusTag}`, ox + 160, oy + 314, {
font: 'fontexocet10',
color: status === 'completed' ? 'gold' : 'white',
align: 'center',
})
this.drawWrappedText(ctx, font, activeQ.description, ox + 28, oy + 336, 264, 18)
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font)
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < wps.length; i++) {
const wp = wps[i]!
const wy = oy + 62 + i * 36
ctx.fillStyle = 'rgba(18, 16, 13, 0.85)'
ctx.fillRect(ox + 22, wy, 276, 31)
ctx.strokeStyle = '#5a4a32'
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
// Blue active waypoint diamond icon
ctx.fillStyle = '#4888ff'
ctx.fillRect(ox + 32, wy + 9, 12, 12)
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
font: 'fontexocet10',
color: 'gold',
})
}
} else if (kind === 'stash') {
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
font.drawText(ctx, `私人储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 32, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
} else if (kind === 'vendor') {
if (assets.vendorBgImg) ctx.drawImage(assets.vendorBgImg, ox, oy)
font.drawText(ctx, '阿卡拉 · 魔法药剂与卷轴商店', ox + 160, oy + 26, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
}
if (assets.buySellBtnImg) {
ctx.drawImage(assets.buySellBtnImg, 10 * 32, 0, 32, 32, ox + 272, oy + 388, 32, 32)
}
}
private drawActTabs(ctx: CanvasRenderingContext2D, ox: number, oy: number, font: D2FontRenderer): void {
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
for (let i = 0; i < 5; i++) {
const act = i + 1
const active = this.selectedActTab === act
const tx = ox + 8 + i * 61
ctx.fillStyle = active ? '#32281c' : '#181410'
ctx.fillRect(tx, oy + 8, 58, 28)
ctx.strokeStyle = active ? '#e8c26b' : '#584a34'
ctx.strokeRect(tx + 0.5, oy + 8.5, 57, 27)
font.drawText(ctx, `Act ${numerals[i]!}`, tx + 29, oy + 27, {
font: 'fontexocet10',
color: active ? 'gold' : 'tan',
align: 'center',
})
}
}
private drawWrappedText(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
text: string,
x: number,
y: number,
maxW: number,
lineH: number,
): void {
let line = ''
let curY = y
for (const ch of text) {
if (font.measureText(line + ch, 'font8') > maxW) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
line = ch
curY += lineH
} else {
line += ch
}
}
if (line) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
}
}
/**
* Draw Top-Center Monster Target Health Bar & Affix Plaque (`text.cpp` / `panel.cpp`).
*/
drawMonsterTargetBar(
ctx: CanvasRenderingContext2D,
monster: {
name: string
hp: number
maxHp: number
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
affixes?: readonly string[]
subType?: string
} | null,
font: D2FontRenderer,
): void {
if (!monster || monster.hp <= 0) return
const barW = 220
const barH = 22
const x = 400 - barW / 2
const y = 42
const ratio = Math.max(0, Math.min(1, monster.hp / Math.max(1, monster.maxHp)))
ctx.fillStyle = 'rgba(28, 8, 8, 0.88)'
ctx.fillRect(x, y, barW, barH)
ctx.fillStyle = 'rgba(156, 20, 20, 0.92)'
ctx.fillRect(x + 1, y + 1, Math.round((barW - 2) * ratio), barH - 2)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(x + 0.5, y + 0.5, barW - 1, barH - 1)
const titleColor: D2ColorCode =
monster.rank === 'unique' || monster.rank === 'superunique' || monster.rank === 'boss'
? 'gold'
: monster.rank === 'champion'
? 'blue'
: 'white'
font.drawText(ctx, monster.name, 400, y + 16, {
font: 'fontexocet10',
color: titleColor,
align: 'center',
})
if (monster.affixes && monster.affixes.length > 0) {
font.drawText(ctx, monster.affixes.join(' · '), 400, y + 36, {
font: 'font8',
color: 'tan',
align: 'center',
})
} else if (monster.subType) {
font.drawText(ctx, monster.subType, 400, y + 36, {
font: 'font8',
color: 'white',
align: 'center',
})
}
}
/**
* Draw Canonical Diablo II Item Hover Tooltip (`showitems.cpp`).
*/
drawItemTooltip(
ctx: CanvasRenderingContext2D,
hover: { item: UiInventoryItem; x: number; y: number } | null,
font: D2FontRenderer,
): void {
if (!hover) return
const { item } = hover
const lines: { text: string; color: D2ColorCode; font: 'fontexocet10' | 'font8' }[] = []
const titleColor: D2ColorCode =
item.quality === 'unique' || item.quality === 'rune'
? 'gold'
: item.quality === 'set'
? 'green'
: item.quality === 'rare'
? 'yellow'
: item.quality === 'magic'
? 'blue'
: item.quality === 'craft'
? 'orange'
: 'white'
// Header: Title, Runeword Runes line, Base item name
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
if (item.runewordRunes) {
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
}
if (item.baseNameZh) {
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
}
// Base Stats Block
if (item.defense !== undefined) {
lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' })
}
if (item.damage !== undefined) {
const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage)
lines.push({ text: dmgText, color: 'white', font: 'font8' })
}
if (item.durability !== undefined) {
lines.push({
text: '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max,
color: 'white',
font: 'font8',
})
}
if (item.speedText) {
lines.push({ text: item.speedText, color: 'white', font: 'font8' })
}
if (item.reqStr !== undefined) {
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
}
if (item.reqLevel !== undefined) {
lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' })
}
// Magic Affixes
for (const st of item.stats) {
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
}
// Sockets Block
if (
item.sockets !== undefined &&
item.sockets > 0 &&
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
) {
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
}
// Set Pieces & Set Bonuses Block
if (item.setPieces) {
for (const piece of item.setPieces) {
lines.push({ text: piece, color: 'gray', font: 'font8' })
}
}
if (item.setBonuses) {
for (const bonus of item.setBonuses) {
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
}
}
// Viewport Dimensions & Dynamic Vertical Flip / Clamping
const lineH = 17, padX = 16, padY = 12
const maxW = Math.max(...lines.map(l => font.measureText(l.text, l.font)))
const boxW = Math.max(190, maxW + padX * 2)
const boxH = lines.length * lineH + padY * 2
const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8
const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, 600 - boxH - 8)
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
this.lastTooltipRect = { bx, by, boxW, boxH }
// Rendering
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(bx, by, boxW, boxH)
ctx.strokeStyle = '#8c7244'
ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1)
lines.forEach((l, idx) => {
font.drawText(ctx, l.text, bx + Math.round(boxW / 2), by + padY + 12 + idx * lineH, {
font: l.font,
color: l.color,
align: 'center',
})
})
}
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
const remain = this.areaBanner.untilMs - nowMs
const alpha = remain < 800 ? remain / 800 : 1
ctx.save()
ctx.globalAlpha = Math.max(0, Math.min(1, alpha))
font.drawText(ctx, `进入:${this.areaBanner.titleZh}`, 400, 142, {
font: 'font30',
color: 'gold',
align: 'center',
})
font.drawText(ctx, `Entering ${this.areaBanner.titleEn}`, 400, 166, {
font: 'fontexocet10',
color: 'tan',
align: 'center',
})
ctx.restore()
}
}