1828 lines
55 KiB
TypeScript
1828 lines
55 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Authentic Front-End & Battle.net 800x600 Canvas Renderer (`src/frontend/frontend-renderer.ts`).
|
|
*
|
|
* Renders all 6 pre-game stages using 100% original English MPQ assets packed into `/ui/frontend/`:
|
|
* - Stage 1 (`main_menu`): `gameselectscreenEXP.dc6` + 2-layer additive burning D2 logo + engraved buttons
|
|
* - Stage 2 (`bnet_connecting`): `PopUp_340x224.dc6` modal over `gameselectscreenEXP.dc6`
|
|
* - Stage 2b (`bnet_login`): Battle.net Account Name & Password login screen (mock auth accepts any credentials)
|
|
* - Stage 3 (`char_create`): 7-Class Campfire (`charactercreationscreenEXP.dc6` + `fire.dc6` + 7 class state machines)
|
|
* - Stage 4 (`char_select`): 8-Slot Character Selection Roster (`characterselectscreenEXP.dc6` + `TN` avatars)
|
|
* - Stage 5 (`bnet_lobby`): Battle.net Lobby, Chat Gem (`gembutton.dc6`), and `Create Game` panel (`creategamebckg.dc6`)
|
|
* - Stage 6 (`door_loading`): 10-Frame Dark Wanderer Door Opening Loading Screen (`Loadingscreen.dc6`)
|
|
*/
|
|
|
|
import { CursorController } from '../ui/cursor.ts'
|
|
import type { FontAtlasMeta } from '../ui/font.ts'
|
|
import {
|
|
CAMPFIRE_CLASS_ORDER,
|
|
cancelConnecting,
|
|
clickBattleNetButton,
|
|
confirmCharacterSelect,
|
|
enterCharCreate,
|
|
hoverCampfireClass,
|
|
selectCampfireClass,
|
|
submitBattleNetLogin,
|
|
submitCharacterCreate,
|
|
submitCreateGame,
|
|
toggleChatGem,
|
|
type ActiveInputField,
|
|
type BnetFlowState,
|
|
type CampfireClassId,
|
|
type GameDifficulty,
|
|
} from './flow-state.ts'
|
|
|
|
export interface PackedAnimFrame {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly w: number
|
|
readonly h: number
|
|
readonly anchorX: number
|
|
readonly anchorY: number
|
|
}
|
|
|
|
export interface PackedAnimStripMeta {
|
|
readonly file: string
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly frameCount: number
|
|
readonly frames: readonly PackedAnimFrame[]
|
|
}
|
|
|
|
export interface FrontendManifest {
|
|
readonly version: string
|
|
readonly decodedDc6Count: number
|
|
readonly animStrips: Record<string, PackedAnimStripMeta>
|
|
readonly campfireClasses: Record<string, Record<string, PackedAnimStripMeta>>
|
|
readonly charAvatars: Record<string, PackedAnimStripMeta>
|
|
readonly fontColors: readonly number[]
|
|
readonly strings: {
|
|
readonly singlePlayer: string
|
|
readonly battleNet: string
|
|
readonly otherMultiplayer: string
|
|
readonly exitDiablo2: string
|
|
readonly credits: string
|
|
readonly cinematics: string
|
|
readonly gatewayPrefix: string
|
|
readonly connectingToFastest: string
|
|
readonly checkingVersions: string
|
|
readonly accessingAccount: string
|
|
readonly accountName: string
|
|
readonly password: string
|
|
readonly logIn: string
|
|
readonly accountSettings: string
|
|
readonly createNewAccount: string
|
|
readonly exit: string
|
|
readonly ok: string
|
|
readonly cancel: string
|
|
readonly chooseClassTitle: string
|
|
readonly characterName: string
|
|
readonly expansionCharacter: string
|
|
readonly hardcore: string
|
|
readonly ladderCharacter: string
|
|
readonly selectCharacterTitle: string
|
|
readonly createNewCharacter: string
|
|
readonly convertToExpansion: string
|
|
readonly deleteCharacter: string
|
|
readonly expiresIn90Days: string
|
|
readonly classes: Record<CampfireClassId, { readonly name: string; readonly desc: string }>
|
|
readonly lobby: {
|
|
readonly send: string
|
|
readonly whisper: string
|
|
readonly help: string
|
|
readonly squelch: string
|
|
readonly unsquelch: string
|
|
readonly emote: string
|
|
readonly create: string
|
|
readonly join: string
|
|
readonly ladder: string
|
|
readonly channel: string
|
|
readonly quit: string
|
|
readonly createGameTitle: string
|
|
readonly gameName: string
|
|
readonly gamePassword: string
|
|
readonly gameDescription: string
|
|
readonly maxPlayers: string
|
|
readonly charDifference: string
|
|
readonly selectDifficulty: string
|
|
readonly normal: string
|
|
readonly nightmare: string
|
|
readonly hell: string
|
|
readonly createGameBtn: string
|
|
readonly gemActivated: string
|
|
readonly gemDeactivated: string
|
|
}
|
|
}
|
|
}
|
|
|
|
export type FrontendFontName =
|
|
| 'font6'
|
|
| 'font8'
|
|
| 'font16'
|
|
| 'font24'
|
|
| 'font30'
|
|
| 'font42'
|
|
| 'fontexocet8'
|
|
| 'fontexocet10'
|
|
| 'fontformal10'
|
|
| 'fontformal11'
|
|
| 'fontformal12'
|
|
| 'fontridiculous'
|
|
|
|
/**
|
|
* PL2 text color indices (`D2Gfx_DrawChar` 0x6f876580..0x6f87658f):
|
|
* 0 = White, 1 = Red, 2 = Green, 3 = Blue, 4 = Gold, 5 = DarkGray, 7 = Tan, 8 = Orange, 9 = Yellow
|
|
*/
|
|
export type FrontendColorIndex = 0 | 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9
|
|
|
|
export interface UiButtonDef {
|
|
readonly id: string
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly w: number
|
|
readonly h: number
|
|
readonly sprite: string
|
|
readonly label?: string
|
|
readonly font?: FrontendFontName
|
|
readonly color?: FrontendColorIndex
|
|
readonly disabled?: boolean
|
|
readonly onClick: () => void
|
|
}
|
|
|
|
const STATIC_UI_IMAGES = [
|
|
'bg-main-menu.png',
|
|
'bg-char-create.png',
|
|
'bg-char-select.png',
|
|
'bg-waiting-room.png',
|
|
'popup-340x224.png',
|
|
'popup-okcancel.png',
|
|
'create-game-bg.png',
|
|
'charselectbox.png',
|
|
'realmselect.png',
|
|
'btn-realm-thin.png',
|
|
'btn-wide.png',
|
|
'btn-3wide.png',
|
|
'btn-wide02.png',
|
|
'btn-medium.png',
|
|
'btn-short.png',
|
|
'btn-tall.png',
|
|
'textbox.png',
|
|
'clickbox.png',
|
|
'joingameclickbox.png',
|
|
'radiobutton.png',
|
|
'gembutton.png',
|
|
'btn-chatleft.png',
|
|
'btn-game.png',
|
|
'btn-create-cancel.png',
|
|
'btn-create-game.png',
|
|
'grey-level-box.png',
|
|
'logo-main-black.png',
|
|
'logo-main-fire.png',
|
|
'logo-bnet-black.png',
|
|
'logo-bnet-fire.png',
|
|
'campfire/fire-strip.png',
|
|
'loading-door-strip.png',
|
|
] as const
|
|
|
|
export class FrontendRenderer {
|
|
readonly canvas: HTMLCanvasElement
|
|
readonly ctx: CanvasRenderingContext2D
|
|
readonly state: BnetFlowState
|
|
readonly cursor = new CursorController()
|
|
|
|
manifest: FrontendManifest | null = null
|
|
fontMetas: Record<string, FontAtlasMeta> = {}
|
|
images = new Map<string, HTMLImageElement>()
|
|
cursorAtlas: HTMLImageElement | null = null
|
|
pressedButtonId: string | null = null
|
|
lastClickSlotTime = 0
|
|
lastClickSlotIndex = -1
|
|
ready = false
|
|
|
|
/** Callback invoked when Stage 6 (`door_loading`) begins so `bnet-main.ts` can boot Act 1 in background. */
|
|
onEnterDoorLoading?: (state: BnetFlowState) => void
|
|
|
|
constructor(canvas: HTMLCanvasElement, state: BnetFlowState) {
|
|
this.canvas = canvas
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) throw new Error('Failed to acquire 2D context for bnet-canvas')
|
|
ctx.imageSmoothingEnabled = false
|
|
this.ctx = ctx
|
|
this.state = state
|
|
this.cursor.handleMouseMove(400, 300)
|
|
}
|
|
|
|
private loadImage(url: string, key: string): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
this.images.set(key, img)
|
|
resolve()
|
|
}
|
|
img.onerror = () => reject(new Error(`Failed to load frontend image: ${url}`))
|
|
img.src = url
|
|
})
|
|
}
|
|
|
|
async load(baseUrl = '/ui/frontend'): Promise<void> {
|
|
const [manifestRes, fontRes] = await Promise.all([
|
|
fetch(`${baseUrl}/manifest.json`),
|
|
fetch(`${baseUrl}/fonts/metrics.json`),
|
|
])
|
|
if (!manifestRes.ok) throw new Error(`Failed to fetch ${baseUrl}/manifest.json`)
|
|
if (!fontRes.ok) throw new Error(`Failed to fetch ${baseUrl}/fonts/metrics.json`)
|
|
|
|
this.manifest = (await manifestRes.json()) as FrontendManifest
|
|
this.fontMetas = (await fontRes.json()) as Record<string, FontAtlasMeta>
|
|
|
|
// Synchronize frame counts from manifest into flow state
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const clsStrips = this.manifest.campfireClasses[id]
|
|
if (!clsStrips) continue
|
|
const slot = this.state.campfireClasses[id]
|
|
for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) {
|
|
if (clsStrips[phase]) {
|
|
slot.frameCount[phase] = clsStrips[phase]!.frameCount
|
|
}
|
|
}
|
|
}
|
|
|
|
const promises: Promise<void>[] = []
|
|
for (const rel of STATIC_UI_IMAGES) {
|
|
promises.push(this.loadImage(`${baseUrl}/${rel}`, rel))
|
|
}
|
|
|
|
// Load 7-class campfire strips
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const clsStrips = this.manifest.campfireClasses[id]
|
|
if (!clsStrips) continue
|
|
for (const strip of Object.values(clsStrips)) {
|
|
promises.push(this.loadImage(`${baseUrl}/${strip.file}`, strip.file))
|
|
}
|
|
}
|
|
|
|
// Load 7-class TN avatar strips
|
|
for (const strip of Object.values(this.manifest.charAvatars)) {
|
|
promises.push(this.loadImage(`${baseUrl}/${strip.file}`, strip.file))
|
|
}
|
|
|
|
// Load primary bitmap font atlases used across Front-End & Battle.net screens
|
|
const activeFonts: FrontendFontName[] = [
|
|
'font6',
|
|
'font8',
|
|
'font16',
|
|
'font24',
|
|
'font30',
|
|
'font42',
|
|
'fontexocet10',
|
|
'fontformal10',
|
|
'fontformal11',
|
|
'fontformal12',
|
|
'fontridiculous',
|
|
]
|
|
const activeColors: FrontendColorIndex[] = [0, 1, 2, 3, 4, 5, 7, 8, 9]
|
|
for (const fn of activeFonts) {
|
|
for (const c of activeColors) {
|
|
const rel = `fonts/${fn}-c${c}.png`
|
|
promises.push(this.loadImage(`${baseUrl}/${rel}`, rel))
|
|
}
|
|
}
|
|
|
|
// Load cursor-hand.png
|
|
promises.push(
|
|
new Promise<void>((resolve) => {
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
this.cursorAtlas = img
|
|
resolve()
|
|
}
|
|
img.onerror = () => resolve()
|
|
img.src = '/ui/cursor-hand.png'
|
|
}),
|
|
)
|
|
|
|
await Promise.all(promises)
|
|
this.ready = true
|
|
}
|
|
|
|
/**
|
|
* Measure pixel width of a single line of text in the specified D2 bitmap font.
|
|
*/
|
|
measureBitmapText(text: string, font: FrontendFontName): number {
|
|
const meta = this.fontMetas[font]
|
|
if (!meta) return text.length * 8
|
|
let width = 0
|
|
for (let i = 0; i < text.length; i++) {
|
|
const code = text.charCodeAt(i) & 0xff
|
|
const g = meta.glyphs[code]
|
|
width += g ? g.width : 6
|
|
}
|
|
return width
|
|
}
|
|
|
|
/**
|
|
* Draw a single line or multi-line (`\n`) string using pre-baked colored DC6 bitmap font atlases.
|
|
*/
|
|
drawBitmapText(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
options: {
|
|
font?: FrontendFontName
|
|
color?: FrontendColorIndex
|
|
align?: 'left' | 'center' | 'right'
|
|
vAlign?: 'top' | 'middle'
|
|
lineGap?: number
|
|
blendMode?: GlobalCompositeOperation
|
|
} = {},
|
|
): void {
|
|
const font = options.font ?? 'font16'
|
|
const color = options.color ?? 0
|
|
const align = options.align ?? 'left'
|
|
const vAlign = options.vAlign ?? 'top'
|
|
const meta = this.fontMetas[font]
|
|
const atlas = this.images.get(`fonts/${font}-c${color}.png`)
|
|
if (!meta || !atlas) return
|
|
|
|
const lines = text.split('\n')
|
|
const frameH = meta.glyphs[65]?.frameH || meta.lineHeight
|
|
const stepY = options.lineGap ?? Math.max(meta.lineHeight, Math.round(frameH * 0.78))
|
|
const totalBlockH = frameH + (lines.length - 1) * stepY
|
|
let curY = vAlign === 'middle' ? Math.round(y - totalBlockH / 2) : Math.round(y)
|
|
|
|
if (options.blendMode) {
|
|
this.ctx.save()
|
|
this.ctx.globalCompositeOperation = options.blendMode
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const lineW = this.measureBitmapText(line, font)
|
|
let curX = Math.round(x)
|
|
if (align === 'center') curX = Math.round(x - lineW / 2)
|
|
else if (align === 'right') curX = Math.round(x - lineW)
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const code = line.charCodeAt(i) & 0xff
|
|
const g = meta.glyphs[code]
|
|
if (!g) continue
|
|
if (code !== 32 && g.frameW > 0 && g.frameH > 0) {
|
|
this.ctx.drawImage(atlas, g.x, g.y, g.frameW, g.frameH, curX, curY, g.frameW, g.frameH)
|
|
}
|
|
curX += g.width
|
|
}
|
|
curY += stepY
|
|
}
|
|
|
|
if (options.blendMode) {
|
|
this.ctx.restore()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Word-wrap text to `maxWidth` pixels and draw each line using `drawBitmapText`.
|
|
*/
|
|
drawWrappedBitmapText(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
options: {
|
|
font?: FrontendFontName
|
|
color?: FrontendColorIndex
|
|
align?: 'left' | 'center'
|
|
lineStep?: number
|
|
} = {},
|
|
): void {
|
|
const font = options.font ?? 'font16'
|
|
const words = text.split(/\s+/)
|
|
const lines: string[] = []
|
|
let current = ''
|
|
for (const word of words) {
|
|
const candidate = current ? `${current} ${word}` : word
|
|
if (this.measureBitmapText(candidate, font) <= maxWidth || !current) {
|
|
current = candidate
|
|
} else {
|
|
lines.push(current)
|
|
current = word
|
|
}
|
|
}
|
|
if (current) lines.push(current)
|
|
|
|
const meta = this.fontMetas[font]
|
|
const step = options.lineStep ?? ((meta?.lineHeight ?? 14) + 3)
|
|
for (let i = 0; i < lines.length; i++) {
|
|
this.drawBitmapText(lines[i]!, x, y + i * step, {
|
|
font,
|
|
color: options.color ?? 0,
|
|
align: options.align ?? 'center',
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw an animated strip frame at `(controlX + frame.anchorX, controlY + frame.anchorY)`.
|
|
*/
|
|
private drawAnimStripFrame(
|
|
strip: PackedAnimStripMeta | undefined,
|
|
frameIdx: number,
|
|
controlX: number,
|
|
controlY: number,
|
|
additive = false,
|
|
): void {
|
|
if (!strip || strip.frames.length === 0) return
|
|
const img = this.images.get(strip.file)
|
|
if (!img) return
|
|
const safeIdx = ((frameIdx % strip.frames.length) + strip.frames.length) % strip.frames.length
|
|
const f = strip.frames[safeIdx]!
|
|
if (additive) {
|
|
this.ctx.save()
|
|
this.ctx.globalCompositeOperation = 'screen'
|
|
this.ctx.drawImage(img, f.x, f.y, f.w, f.h, controlX + f.anchorX, controlY + f.anchorY, f.w, f.h)
|
|
this.ctx.restore()
|
|
} else {
|
|
this.ctx.drawImage(img, f.x, f.y, f.w, f.h, controlX + f.anchorX, controlY + f.anchorY, f.w, f.h)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw a 2-state button (`state = 0` Up, `state = 1` Down) strictly following
|
|
* `D2Win.dll` `Button_Draw` (`0x6f8efa80`) and `Button_DrawText` (`0x6f8ef8d0`):
|
|
* - `h >= 35` (`0x23`): `fontexocet10` (`fontId = 9`), `yAdjust = 4`
|
|
* - `h < 35`: `fontridiculous` (`fontId = 10`), `yAdjust = h === 32 ? 3 : h > 20 ? 2 : 1`
|
|
* - `color = 0`, `blendMode = 'multiply'` (`D2Gdi` `blendMode = 4` `multiplyBlend[src][dst]`)
|
|
* - Pressed offset `(-2, +2)` (`0x6f8ef94e sub ebx, 2; add edi, 2`)
|
|
*/
|
|
private drawButton(btn: UiButtonDef): void {
|
|
const img = this.images.get(btn.sprite)
|
|
const isDown = !btn.disabled && this.pressedButtonId === btn.id
|
|
if (img) {
|
|
const stateW = Math.floor(img.width / 2)
|
|
const stateH = img.height
|
|
const sx = isDown ? stateW : 0
|
|
if (btn.disabled) {
|
|
this.ctx.save()
|
|
this.ctx.filter = 'brightness(0.55)'
|
|
this.ctx.drawImage(img, sx, 0, stateW, stateH, btn.x, btn.y, btn.w, btn.h)
|
|
this.ctx.restore()
|
|
} else {
|
|
this.ctx.drawImage(img, sx, 0, stateW, stateH, btn.x, btn.y, btn.w, btn.h)
|
|
}
|
|
}
|
|
if (btn.label) {
|
|
const dx = isDown ? -2 : 0
|
|
const dy = isDown ? 2 : 0
|
|
const font: FrontendFontName = btn.font ?? (btn.h >= 35 ? 'fontexocet10' : 'fontridiculous')
|
|
const yAdjust = btn.h >= 35 ? 4 : btn.h === 32 ? 3 : btn.h > 20 ? 2 : 1
|
|
const meta = this.fontMetas[font]
|
|
const lineHeight = meta?.lineHeight ?? (btn.h >= 35 ? 13 : 8)
|
|
const frameH = meta?.glyphs[65]?.frameH ?? lineHeight
|
|
const centerX = btn.x + Math.floor(btn.w / 2) + dx
|
|
const color: FrontendColorIndex = btn.color ?? 0
|
|
|
|
if (btn.label.includes('\n')) {
|
|
// Two-line button (`[esi+0x40] & 0x40` at 0x6f8ef954..0x6f8efa55)
|
|
const lines = btn.label.split('\n')
|
|
const third = Math.trunc((btn.h - lineHeight) / 3)
|
|
const yBottom1 = btn.y + btn.h - 2 * third + yAdjust + dy
|
|
const yBottom2 = btn.y + btn.h - third + yAdjust + dy
|
|
this.drawBitmapText(lines[0] ?? '', centerX, yBottom1 - frameH, {
|
|
font,
|
|
color,
|
|
align: 'center',
|
|
vAlign: 'top',
|
|
blendMode: 'multiply',
|
|
})
|
|
this.drawBitmapText(lines[1] ?? '', centerX, yBottom2 - frameH, {
|
|
font,
|
|
color,
|
|
align: 'center',
|
|
vAlign: 'top',
|
|
blendMode: 'multiply',
|
|
})
|
|
} else {
|
|
const yBottom = btn.y + btn.h - ((btn.h - lineHeight) >> 1) + yAdjust + dy
|
|
this.drawBitmapText(btn.label, centerX, yBottom - frameH, {
|
|
font,
|
|
color,
|
|
align: 'center',
|
|
vAlign: 'top',
|
|
blendMode: 'multiply',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw a Diablo II editbox (`textbox.png` or custom rect) with text and a blinking vertical cursor.
|
|
*/
|
|
private drawEditBox(
|
|
field: Exclude<ActiveInputField, null>,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
value: string,
|
|
options: {
|
|
drawFrameImage?: boolean
|
|
maskChar?: string
|
|
font?: FrontendFontName
|
|
color?: FrontendColorIndex
|
|
} = {},
|
|
): void {
|
|
if (options.drawFrameImage) {
|
|
const tbImg = this.images.get('textbox.png')
|
|
if (tbImg) {
|
|
this.ctx.drawImage(tbImg, 0, 0, tbImg.width, tbImg.height, x, y, w, h)
|
|
}
|
|
}
|
|
const displayValue = options.maskChar ? options.maskChar.repeat(value.length) : value
|
|
const font = options.font ?? 'fontformal12'
|
|
const color = options.color ?? 0
|
|
const padX = 7
|
|
this.drawBitmapText(displayValue, x + padX, y + Math.floor(h / 2), {
|
|
font,
|
|
color,
|
|
align: 'left',
|
|
vAlign: 'middle',
|
|
})
|
|
|
|
if (this.state.activeInput === field && Math.floor(this.state.globalTick / 10) % 2 === 0) {
|
|
const textW = this.measureBitmapText(displayValue, font)
|
|
const cursorX = Math.min(x + w - 6, x + padX + textW + 1)
|
|
this.ctx.fillStyle = '#e8d088'
|
|
this.ctx.fillRect(cursorX, y + 5, 2, h - 10)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the list of interactive buttons for the current stage.
|
|
*/
|
|
getActiveButtons(): UiButtonDef[] {
|
|
const s = this.manifest?.strings
|
|
if (!s) return []
|
|
|
|
switch (this.state.stage) {
|
|
case 'main_menu':
|
|
// Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0)
|
|
return [
|
|
{
|
|
id: 'single_player',
|
|
x: 264,
|
|
y: 289,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.singlePlayer,
|
|
onClick: () => {
|
|
clickBattleNetButton(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'battle_net',
|
|
x: 264,
|
|
y: 331,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.battleNet,
|
|
onClick: () => {
|
|
clickBattleNetButton(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'gateway',
|
|
x: 264,
|
|
y: 366,
|
|
w: 272,
|
|
h: 25,
|
|
sprite: 'btn-wide02.png',
|
|
label: `${s.gatewayPrefix}${this.state.gateway.toUpperCase()}`,
|
|
onClick: () => {
|
|
const gateways = ['U.S. East', 'U.S. West', 'Asia', 'Europe']
|
|
const idx = gateways.indexOf(this.state.gateway)
|
|
this.state.gateway = gateways[(idx + 1) % gateways.length]!
|
|
},
|
|
},
|
|
{
|
|
id: 'other_multiplayer',
|
|
x: 264,
|
|
y: 398,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.otherMultiplayer,
|
|
onClick: () => {
|
|
clickBattleNetButton(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'credits',
|
|
x: 264,
|
|
y: 503,
|
|
w: 135,
|
|
h: 25,
|
|
sprite: 'btn-short.png',
|
|
label: s.credits,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'cinematics',
|
|
x: 402,
|
|
y: 503,
|
|
w: 135,
|
|
h: 25,
|
|
sprite: 'btn-short.png',
|
|
label: s.cinematics,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'exit_d2',
|
|
x: 264,
|
|
y: 533,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-3wide.png',
|
|
label: s.exitDiablo2,
|
|
onClick: () => {},
|
|
},
|
|
]
|
|
|
|
case 'bnet_connecting':
|
|
return [
|
|
{
|
|
id: 'cancel_connecting',
|
|
x: 336,
|
|
y: 367,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.cancel,
|
|
onClick: () => {
|
|
cancelConnecting(this.state)
|
|
},
|
|
},
|
|
]
|
|
|
|
case 'bnet_login':
|
|
return [
|
|
{
|
|
id: 'bnet_login_btn',
|
|
x: 264,
|
|
y: 424,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.logIn,
|
|
onClick: () => {
|
|
submitBattleNetLogin(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'bnet_account_settings',
|
|
x: 264,
|
|
y: 466,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.accountSettings,
|
|
onClick: () => {
|
|
submitBattleNetLogin(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'bnet_create_account',
|
|
x: 264,
|
|
y: 508,
|
|
w: 272,
|
|
h: 35,
|
|
sprite: 'btn-wide.png',
|
|
label: s.createNewAccount,
|
|
onClick: () => {
|
|
submitBattleNetLogin(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'bnet_login_cancel',
|
|
x: 33,
|
|
y: 538,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.cancel,
|
|
onClick: () => {
|
|
this.state.stage = 'main_menu'
|
|
this.state.stageTicks = 0
|
|
this.state.activeInput = null
|
|
},
|
|
},
|
|
]
|
|
|
|
case 'char_create':
|
|
return [
|
|
{
|
|
id: 'char_create_exit',
|
|
x: 33,
|
|
y: 538,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.exit,
|
|
onClick: () => {
|
|
if (this.state.roster.length > 0) {
|
|
this.state.stage = 'char_select'
|
|
this.state.stageTicks = 0
|
|
this.state.activeInput = null
|
|
} else {
|
|
this.state.stage = 'bnet_login'
|
|
this.state.stageTicks = 0
|
|
this.state.activeInput = 'accountName'
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'char_create_ok',
|
|
x: 639,
|
|
y: 538,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.ok,
|
|
disabled: !this.state.selectedClass,
|
|
onClick: () => {
|
|
submitCharacterCreate(this.state)
|
|
},
|
|
},
|
|
]
|
|
|
|
case 'char_select': {
|
|
const hasChars = this.state.roster.length > 0
|
|
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
|
|
return [
|
|
{
|
|
id: 'char_select_create_new',
|
|
x: 33,
|
|
y: 468,
|
|
w: 168,
|
|
h: 60,
|
|
sprite: 'btn-tall.png',
|
|
label: s.createNewCharacter,
|
|
onClick: () => {
|
|
enterCharCreate(this.state)
|
|
},
|
|
},
|
|
{
|
|
id: 'char_select_convert_exp',
|
|
x: 233,
|
|
y: 468,
|
|
w: 168,
|
|
h: 60,
|
|
sprite: 'btn-tall.png',
|
|
label: s.convertToExpansion,
|
|
disabled: !selectedChar || selectedChar.isExpansion,
|
|
onClick: () => {
|
|
if (selectedChar && !selectedChar.isExpansion) {
|
|
selectedChar.isExpansion = true
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'char_select_delete',
|
|
x: 433,
|
|
y: 468,
|
|
w: 168,
|
|
h: 60,
|
|
sprite: 'btn-tall.png',
|
|
label: s.deleteCharacter,
|
|
disabled: !selectedChar,
|
|
onClick: () => {
|
|
if (selectedChar) {
|
|
this.state.roster.splice(this.state.selectedSlotIndex, 1)
|
|
this.state.selectedSlotIndex = Math.max(
|
|
0,
|
|
Math.min(this.state.selectedSlotIndex, this.state.roster.length - 1),
|
|
)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'char_select_exit',
|
|
x: 33,
|
|
y: 538,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.exit,
|
|
onClick: () => {
|
|
this.state.stage = 'main_menu'
|
|
this.state.stageTicks = 0
|
|
this.state.activeInput = null
|
|
},
|
|
},
|
|
{
|
|
id: 'char_select_ok',
|
|
x: 639,
|
|
y: 538,
|
|
w: 128,
|
|
h: 35,
|
|
sprite: 'btn-medium.png',
|
|
label: s.ok,
|
|
disabled: !hasChars,
|
|
onClick: () => {
|
|
confirmCharacterSelect(this.state)
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
case 'bnet_lobby': {
|
|
const L = s.lobby
|
|
return [
|
|
// Left chat utility buttons (`chatleftbuttons.dc6` 78x20 at y=450 & y=470 per D2Multi.dll 0x6f9e9d28..0x6f9e9e20)
|
|
{
|
|
id: 'lobby_send',
|
|
x: 27,
|
|
y: 450,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.send,
|
|
onClick: () => {
|
|
this.sendChatMessage()
|
|
},
|
|
},
|
|
{
|
|
id: 'lobby_whisper',
|
|
x: 107,
|
|
y: 450,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.whisper,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_help',
|
|
x: 187,
|
|
y: 450,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.help,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_squelch',
|
|
x: 27,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.squelch,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_unsquelch',
|
|
x: 107,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.unsquelch,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_emote',
|
|
x: 187,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.emote,
|
|
onClick: () => {},
|
|
},
|
|
// Right Game Command Bar (D2Multi.dll 0x6f9e9e50..0x6f9e9f10)
|
|
{
|
|
id: 'lobby_tab_create',
|
|
x: 533,
|
|
y: 449,
|
|
w: 118,
|
|
h: 20,
|
|
sprite: 'btn-game.png',
|
|
label: L.create,
|
|
onClick: () => {
|
|
this.state.lobbySubPanel = 'create'
|
|
this.state.activeInput = 'gameName'
|
|
},
|
|
},
|
|
{
|
|
id: 'lobby_tab_join',
|
|
x: 653,
|
|
y: 449,
|
|
w: 118,
|
|
h: 20,
|
|
sprite: 'btn-game.png',
|
|
label: L.join,
|
|
onClick: () => {
|
|
this.state.lobbySubPanel = 'create'
|
|
},
|
|
},
|
|
{
|
|
id: 'lobby_tab_channel',
|
|
x: 535,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.channel,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_tab_ladder',
|
|
x: 614,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.ladder,
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'lobby_tab_quit',
|
|
x: 693,
|
|
y: 470,
|
|
w: 78,
|
|
h: 20,
|
|
sprite: 'btn-chatleft.png',
|
|
label: L.quit,
|
|
onClick: () => {
|
|
this.state.stage = 'char_select'
|
|
this.state.stageTicks = 0
|
|
this.state.activeInput = null
|
|
},
|
|
},
|
|
// Create Game sub-panel buttons (`CancelButtonBlank.dc6` 96x32 @ (433,401) & `GameButtonBlank.dc6` 172x32 @ (594,401) per D2Multi.dll 0x6f9eaba0 & 0x6f9eaff0)
|
|
{
|
|
id: 'create_game_cancel',
|
|
x: 433,
|
|
y: 401,
|
|
w: 96,
|
|
h: 32,
|
|
sprite: 'btn-create-cancel.png',
|
|
label: s.cancel,
|
|
onClick: () => {
|
|
this.state.gameName = ''
|
|
this.state.gamePassword = ''
|
|
},
|
|
},
|
|
{
|
|
id: 'create_game_submit',
|
|
x: 594,
|
|
y: 401,
|
|
w: 172,
|
|
h: 32,
|
|
sprite: 'btn-game.png',
|
|
label: L.createGameBtn,
|
|
onClick: () => {
|
|
this.triggerCreateGame()
|
|
},
|
|
},
|
|
]
|
|
}
|
|
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
private triggerCreateGame(): void {
|
|
if (submitCreateGame(this.state)) {
|
|
this.onEnterDoorLoading?.(this.state)
|
|
}
|
|
}
|
|
|
|
private sendChatMessage(): void {
|
|
const trimmed = this.state.chatInput.trim()
|
|
if (!trimmed) return
|
|
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
|
|
const speaker = selectedChar?.name ?? this.state.accountName
|
|
this.state.chatMessages.push(`<${speaker}> ${trimmed}`)
|
|
if (this.state.chatMessages.length > 14) {
|
|
this.state.chatMessages.shift()
|
|
}
|
|
this.state.chatInput = ''
|
|
}
|
|
|
|
/**
|
|
* Render the active pre-game stage onto the 800x600 canvas.
|
|
*/
|
|
render(nowMs = performance.now()): void {
|
|
if (!this.ready || !this.manifest) return
|
|
const ctx = this.ctx
|
|
ctx.clearRect(0, 0, 800, 600)
|
|
|
|
switch (this.state.stage) {
|
|
case 'main_menu':
|
|
this.renderMainMenu(false)
|
|
break
|
|
case 'bnet_connecting':
|
|
this.renderMainMenu(true)
|
|
break
|
|
case 'bnet_login':
|
|
this.renderBnetLogin()
|
|
break
|
|
case 'char_create':
|
|
this.renderCharCreate()
|
|
break
|
|
case 'char_select':
|
|
this.renderCharSelect()
|
|
break
|
|
case 'bnet_lobby':
|
|
this.renderBnetLobby()
|
|
break
|
|
case 'door_loading':
|
|
this.renderDoorLoading()
|
|
break
|
|
case 'in_game':
|
|
break
|
|
}
|
|
|
|
// Draw the authentic Diablo II animated hand cursor on top of all front-end screens
|
|
if (this.state.stage !== 'in_game') {
|
|
this.cursor.render(ctx, this.cursorAtlas, nowMs)
|
|
}
|
|
}
|
|
|
|
private drawBurningMainLogo(): void {
|
|
if (!this.manifest) return
|
|
const tick = this.state.globalTick
|
|
this.drawAnimStripFrame(this.manifest.animStrips['logoMainBlack'], tick, 400, 120, false)
|
|
this.drawAnimStripFrame(this.manifest.animStrips['logoMainFire'], tick, 400, 120, true)
|
|
}
|
|
|
|
private renderMainMenu(showConnectingModal: boolean): void {
|
|
const bg = this.images.get('bg-main-menu.png')
|
|
if (bg) this.ctx.drawImage(bg, 0, 0)
|
|
|
|
this.drawBurningMainLogo()
|
|
|
|
if (!showConnectingModal) {
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
} else {
|
|
// Dim background slightly and render `PopUp_340x224.dc6` at (230, 188)
|
|
const popup = this.images.get('popup-340x224.png')
|
|
if (popup) this.ctx.drawImage(popup, 230, 188)
|
|
|
|
const s = this.manifest!.strings
|
|
this.drawBitmapText('BATTLE.NET', 400, 214, { font: 'font24', color: 4, align: 'center' })
|
|
this.drawBitmapText(s.connectingToFastest, 400, 256, {
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
align: 'center',
|
|
})
|
|
if (this.state.stageTicks >= 6) {
|
|
this.drawBitmapText(s.checkingVersions, 400, 284, {
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
align: 'center',
|
|
})
|
|
}
|
|
if (this.state.stageTicks >= 12) {
|
|
this.drawBitmapText(s.accessingAccount, 400, 312, {
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
align: 'center',
|
|
})
|
|
}
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
}
|
|
|
|
// Version string at bottom-left (`D2Launch.dll` 0x6fa64310: x=0, w=200, y_bottom=599, h=40, align=2, Font16, color=0)
|
|
this.drawBitmapText('v 1.13c', 100, 560, { font: 'font16', color: 0, align: 'center' })
|
|
}
|
|
|
|
private renderBnetLogin(): void {
|
|
const bg = this.images.get('bg-main-menu.png')
|
|
if (bg) this.ctx.drawImage(bg, 0, 0)
|
|
|
|
this.drawBurningMainLogo()
|
|
|
|
const s = this.manifest!.strings
|
|
this.drawBitmapText(
|
|
'Enter your Account Name and Password to log in to Battle.net.',
|
|
400,
|
|
252,
|
|
{ font: 'fontformal12', color: 0, align: 'center' },
|
|
)
|
|
|
|
// Account Name label & editbox (D2Launch.dll 0x6fa61310..0x6fa616d0)
|
|
this.drawBitmapText(s.accountName, 278, 296, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('accountName', 278, 316, 243, 31, this.state.accountName, {
|
|
drawFrameImage: true,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
// Password label & editbox
|
|
this.drawBitmapText(s.password, 278, 356, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('accountPassword', 278, 376, 243, 31, this.state.accountPassword, {
|
|
drawFrameImage: true,
|
|
maskChar: '*',
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
}
|
|
|
|
private renderCharCreate(): void {
|
|
const bg = this.images.get('bg-char-create.png')
|
|
if (bg) this.ctx.drawImage(bg, 0, 0)
|
|
|
|
const s = this.manifest!.strings
|
|
|
|
// Top banner: "Choose a class for your campaign"
|
|
this.drawBitmapText(s.chooseClassTitle, 400, 18, {
|
|
font: 'font30',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
|
|
// Active or hovered class name & lore description under the title banner
|
|
const activeClassId = this.state.hoveredClass ?? this.state.selectedClass
|
|
if (activeClassId) {
|
|
const clsInfo = s.classes[activeClassId]
|
|
this.drawBitmapText(clsInfo.name, 400, 52, {
|
|
font: 'font24',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
this.drawWrappedBitmapText(clsInfo.desc, 400, 80, 420, {
|
|
font: 'font16',
|
|
color: 0,
|
|
align: 'center',
|
|
lineStep: 17,
|
|
})
|
|
}
|
|
|
|
// Draw back-row classes first (those in `nu1` or `nu2`), then campfire flame, then selected/stepping classes (`fw`, `nu3`, `bw`)
|
|
const backClasses: CampfireClassId[] = []
|
|
const frontClasses: CampfireClassId[] = []
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const phase = this.state.campfireClasses[id].phase
|
|
if (phase === 'fw' || phase === 'nu3' || phase === 'bw') {
|
|
frontClasses.push(id)
|
|
} else {
|
|
backClasses.push(id)
|
|
}
|
|
}
|
|
|
|
for (const id of backClasses) {
|
|
this.drawCampfireClass(id)
|
|
}
|
|
|
|
// Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322))
|
|
this.drawAnimStripFrame(
|
|
this.manifest!.animStrips['campfireFlame'],
|
|
this.state.globalTick,
|
|
379,
|
|
322,
|
|
true,
|
|
)
|
|
|
|
for (const id of frontClasses) {
|
|
this.drawCampfireClass(id)
|
|
}
|
|
|
|
// Bottom character name input and checkboxes (visible once a class is selected)
|
|
if (this.state.selectedClass) {
|
|
this.drawBitmapText(s.characterName, 318, 466, {
|
|
font: 'font16',
|
|
color: 4,
|
|
align: 'left',
|
|
})
|
|
this.drawEditBox('charName', 318, 485, 175, 28, this.state.charName, {
|
|
drawFrameImage: true,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
this.drawCheckbox(318, 520, this.state.isExpansion, s.expansionCharacter, 4)
|
|
this.drawCheckbox(318, 540, this.state.isHardcore, s.hardcore, this.state.isHardcore ? 1 : 4)
|
|
this.drawCheckbox(318, 560, this.state.isLadder, s.ladderCharacter, 4)
|
|
}
|
|
|
|
if (this.state.charCreateError) {
|
|
this.drawBitmapText(this.state.charCreateError, 400, 444, {
|
|
font: 'fontformal12',
|
|
color: 1,
|
|
align: 'center',
|
|
})
|
|
}
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
}
|
|
|
|
private drawCampfireClass(id: CampfireClassId): void {
|
|
const slot = this.state.campfireClasses[id]
|
|
const strips = this.manifest?.campfireClasses[id]
|
|
if (!strips) return
|
|
const baseStrip = strips[slot.phase]
|
|
this.drawAnimStripFrame(baseStrip, slot.frameIndex, slot.anchorX, slot.anchorY, false)
|
|
const ovStrip = strips[`${slot.phase}_ov`]
|
|
if (ovStrip) {
|
|
this.drawAnimStripFrame(ovStrip, slot.frameIndex, slot.anchorX, slot.anchorY, true)
|
|
}
|
|
}
|
|
|
|
private drawCheckbox(
|
|
x: number,
|
|
y: number,
|
|
checked: boolean,
|
|
label: string,
|
|
labelColor: FrontendColorIndex = 4,
|
|
): void {
|
|
const cbImg = this.images.get('clickbox.png')
|
|
if (cbImg) {
|
|
const w = Math.floor(cbImg.width / 2)
|
|
const h = cbImg.height
|
|
this.ctx.drawImage(cbImg, checked ? w : 0, 0, w, h, x, y, w, h)
|
|
}
|
|
this.drawBitmapText(label, x + 22, y + 1, {
|
|
font: 'font16',
|
|
color: labelColor,
|
|
align: 'left',
|
|
})
|
|
}
|
|
|
|
private drawRadioButton(x: number, y: number, selected: boolean, label: string): void {
|
|
const rbImg = this.images.get('radiobutton.png')
|
|
if (rbImg) {
|
|
const w = Math.floor(rbImg.width / 2)
|
|
const h = rbImg.height
|
|
this.ctx.drawImage(rbImg, selected ? w : 0, 0, w, h, x, y, w, h)
|
|
}
|
|
this.drawBitmapText(label, x + 22, y + 1, {
|
|
font: 'font16',
|
|
color: 4,
|
|
align: 'left',
|
|
})
|
|
}
|
|
|
|
private renderCharSelect(): void {
|
|
const bg = this.images.get('bg-char-select.png')
|
|
if (bg) this.ctx.drawImage(bg, 0, 0)
|
|
|
|
const s = this.manifest!.strings
|
|
this.drawBitmapText(s.selectCharacterTitle, 400, 22, {
|
|
font: 'font30',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
|
|
// Top-right Realm info
|
|
this.drawBitmapText(`${this.state.gateway} Realm`, 690, 56, {
|
|
font: 'font16',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
|
|
// 8 character slots (2 columns x 4 rows)
|
|
// Col 0: x = 37, Col 1: x = 309
|
|
// Row tops: y = 85, 178, 271, 364 (height = 93 each)
|
|
const boxImg = this.images.get('charselectbox.png')
|
|
for (let i = 0; i < 8; i++) {
|
|
const char = this.state.roster[i]
|
|
if (!char) continue
|
|
const col = i % 2
|
|
const row = Math.floor(i / 2)
|
|
const slotX = col === 0 ? 37 : 309
|
|
const slotY = 85 + row * 93
|
|
|
|
if (i === this.state.selectedSlotIndex && boxImg) {
|
|
this.ctx.drawImage(boxImg, slotX, slotY)
|
|
}
|
|
|
|
// Draw animated Level 1 unequipped Town-Neutral (TN) avatar standing on left side of slot
|
|
const avatarStrip = this.manifest!.charAvatars[char.heroToken]
|
|
this.drawAnimStripFrame(avatarStrip, this.state.globalTick, slotX + 36, slotY + 82, false)
|
|
|
|
// Right side metadata: Name (gold or red if Hardcore), Level & Class, Expansion Character, Expires in 90 days
|
|
const textX = slotX + 78
|
|
this.drawBitmapText(char.name, textX, slotY + 8, {
|
|
font: 'font16',
|
|
color: char.isHardcore ? 1 : 4,
|
|
align: 'left',
|
|
})
|
|
this.drawBitmapText(`Level ${char.level} ${char.className}`, textX, slotY + 26, {
|
|
font: 'font16',
|
|
color: 0,
|
|
align: 'left',
|
|
})
|
|
if (char.isExpansion) {
|
|
this.drawBitmapText(s.expansionCharacter, textX, slotY + 43, {
|
|
font: 'font16',
|
|
color: 2, // Bright Green
|
|
align: 'left',
|
|
})
|
|
}
|
|
this.drawBitmapText(s.expiresIn90Days, textX, slotY + 62, {
|
|
font: 'fontformal10',
|
|
color: 9, // Yellow
|
|
align: 'left',
|
|
})
|
|
}
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
}
|
|
|
|
private renderBnetLobby(): void {
|
|
const bg = this.images.get('bg-waiting-room.png')
|
|
if (bg) this.ctx.drawImage(bg, 0, 0)
|
|
|
|
const s = this.manifest!.strings
|
|
const L = s.lobby
|
|
|
|
// Top-left & Top-right small burning D2 logos (`D2logoBlackBnet.dc6` + `D2logoBnet.dc6` at (84, 52) & (726, 52) per D2Multi.dll 0x6f9ea1b0 & 0x6f9ea240)
|
|
for (const logoX of [84, 726]) {
|
|
this.drawAnimStripFrame(
|
|
this.manifest!.animStrips['logoBnetBlack'],
|
|
this.state.globalTick,
|
|
logoX,
|
|
52,
|
|
false,
|
|
)
|
|
this.drawAnimStripFrame(
|
|
this.manifest!.animStrips['logoBnetFire'],
|
|
this.state.globalTick,
|
|
logoX,
|
|
52,
|
|
true,
|
|
)
|
|
}
|
|
|
|
// Left Chat Window messages (`x = 28..382, y = 112..408` per D2Multi.dll 0x6f9ea120)
|
|
this.drawBitmapText('Diablo II Retail_USA-1', 214, 83, {
|
|
font: 'font16',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
const visibleMessages = this.state.chatMessages.slice(-14)
|
|
for (let i = 0; i < visibleMessages.length; i++) {
|
|
const msg = visibleMessages[i]!
|
|
const isGemMsg = msg === 'Gem Activated' || msg === 'Gem Deactivated'
|
|
this.drawBitmapText(msg, 32, 114 + i * 20, {
|
|
font: 'fontformal11',
|
|
color: isGemMsg ? 3 : 0,
|
|
align: 'left',
|
|
})
|
|
}
|
|
|
|
// Chat Input box (`x = 28, y = 418, w = 335, h = 28` inside braided frame per D2Multi.dll 0x6f9ea150)
|
|
this.drawEditBox('chatInput', 28, 418, 335, 28, this.state.chatInput, {
|
|
drawFrameImage: false,
|
|
font: 'fontformal11',
|
|
color: 0,
|
|
})
|
|
|
|
// Center Chat Gem (`gembutton.dc6` at `(398, 461)` inside the Celtic knot medallion per D2Multi.dll 0x6f9ea270)
|
|
const gemImg = this.images.get('gembutton.png')
|
|
if (gemImg) {
|
|
const gw = Math.floor(gemImg.width / 2)
|
|
const gh = gemImg.height
|
|
this.ctx.drawImage(gemImg, this.state.gemActivated ? gw : 0, 0, gw, gh, 398, 461, gw, gh)
|
|
}
|
|
|
|
// Right-hand `Create Game` sub-panel (`create-game-bg.png` 373x373 at `(415, 74)` per D2Multi.dll 0x6f9ea930 `pos=(415,447)`)
|
|
const cgBg = this.images.get('create-game-bg.png')
|
|
if (cgBg) {
|
|
this.ctx.drawImage(cgBg, 415, 74)
|
|
}
|
|
|
|
this.drawBitmapText(L.createGameTitle, 596, 88, {
|
|
font: 'font24',
|
|
color: 4,
|
|
align: 'center',
|
|
})
|
|
|
|
// 1. Game Name (`x = 427, y = 137, w = 188, h = 25` per D2Multi.dll 0x6f9ea990 & 0x6f9eaab0)
|
|
this.drawBitmapText(L.gameName, 427, 119, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('gameName', 427, 137, 188, 25, this.state.gameName, {
|
|
drawFrameImage: false,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
// 2. Password (`x = 427, y = 191, w = 188, h = 25` per D2Multi.dll 0x6f9ea9c0 & 0x6f9eaae0)
|
|
this.drawBitmapText(L.gamePassword, 427, 173, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('gamePassword', 427, 191, 188, 25, this.state.gamePassword, {
|
|
drawFrameImage: false,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
// 3. Game Description (`x = 427, y = 245, w = 342, h = 25` per D2Multi.dll 0x6f9ea9f0 & 0x6f9eab10)
|
|
this.drawBitmapText(L.gameDescription, 427, 227, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('gameDescription', 427, 245, 342, 25, this.state.gameDescription, {
|
|
drawFrameImage: false,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
// 4. Max Number of Players (`x = 655, y = 278, w = 42, h = 29` per create-game-bg.png box & D2Multi.dll 0x6f9eaa20)
|
|
this.drawBitmapText(L.maxPlayers, 427, 286, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawEditBox('maxPlayers', 655, 278, 42, 29, this.state.maxPlayers, {
|
|
drawFrameImage: false,
|
|
font: 'fontformal12',
|
|
color: 0,
|
|
})
|
|
|
|
// 5. Character Difference (`x = 655, y = 315, w = 42, h = 29` per create-game-bg.png box & D2Multi.dll 0x6f9eaa50)
|
|
this.drawCheckbox(431, 323, this.state.charDifferenceEnabled, L.charDifference, 4)
|
|
this.drawBitmapText(this.state.charDifference, 676, 329, {
|
|
font: 'fontformal12',
|
|
color: this.state.charDifferenceEnabled ? 0 : 5,
|
|
align: 'center',
|
|
vAlign: 'middle',
|
|
})
|
|
|
|
// 6. Select Difficulty (`Normal`, `Nightmare`, `Hell` per D2Multi.dll 0x6f9eac30..0x6f9ead20)
|
|
this.drawBitmapText(L.selectDifficulty, 427, 350, { font: 'font16', color: 4, align: 'left' })
|
|
this.drawRadioButton(430, 368, this.state.difficulty === 'normal', L.normal)
|
|
this.drawRadioButton(555, 368, this.state.difficulty === 'nightmare', L.nightmare)
|
|
this.drawRadioButton(698, 368, this.state.difficulty === 'hell', L.hell)
|
|
|
|
if (this.state.createGameError) {
|
|
this.drawBitmapText(this.state.createGameError, 596, 388, {
|
|
font: 'fontformal10',
|
|
color: 1,
|
|
align: 'center',
|
|
})
|
|
}
|
|
|
|
// Bottom Channel Character Lineup Bar (`y = 500..585` per D2Multi.dll 0x6f9ea0c0)
|
|
const selectedChar = this.state.roster[this.state.selectedSlotIndex]
|
|
if (selectedChar) {
|
|
const avatarStrip = this.manifest!.charAvatars[selectedChar.heroToken]
|
|
this.drawAnimStripFrame(avatarStrip, this.state.globalTick, 64, 578, false)
|
|
this.drawBitmapText(selectedChar.name, 134, 528, {
|
|
font: 'font16',
|
|
color: selectedChar.isHardcore ? 1 : 4,
|
|
align: 'left',
|
|
})
|
|
this.drawBitmapText(
|
|
`Level ${selectedChar.level} ${selectedChar.className}`,
|
|
134,
|
|
548,
|
|
{ font: 'fontformal10', color: 0, align: 'left' },
|
|
)
|
|
}
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
this.drawButton(btn)
|
|
}
|
|
}
|
|
|
|
private renderDoorLoading(): void {
|
|
const ctx = this.ctx
|
|
ctx.fillStyle = '#000000'
|
|
ctx.fillRect(0, 0, 800, 600)
|
|
|
|
const doorStrip = this.manifest?.animStrips['loadingDoor']
|
|
if (!doorStrip) return
|
|
const img = this.images.get(doorStrip.file)
|
|
if (!img) return
|
|
const idx = Math.max(0, Math.min(doorStrip.frames.length - 1, this.state.doorFrame))
|
|
const f = doorStrip.frames[idx]!
|
|
// Center the 640x480 door loading frame in the 800x600 canvas at (80, 60) (or scale 800x600)
|
|
ctx.drawImage(img, f.x, f.y, f.w, f.h, 80, 60, 640, 480)
|
|
}
|
|
|
|
/**
|
|
* Convert a pointer event on `this.canvas` to 800x600 logical coordinates.
|
|
*/
|
|
eventToCanvasCoords(e: MouseEvent): { x: number; y: number } {
|
|
const rect = this.canvas.getBoundingClientRect()
|
|
if (rect.width <= 0 || rect.height <= 0) return { x: 400, y: 300 }
|
|
const x = ((e.clientX - rect.left) / rect.width) * 800
|
|
const y = ((e.clientY - rect.top) / rect.height) * 600
|
|
return {
|
|
x: Math.max(0, Math.min(800, x)),
|
|
y: Math.max(0, Math.min(600, y)),
|
|
}
|
|
}
|
|
|
|
handleMouseMove(x: number, y: number, nowMs = performance.now()): void {
|
|
this.cursor.handleMouseMove(x, y, nowMs)
|
|
if (this.state.stage === 'char_create') {
|
|
let found: CampfireClassId | null = null
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const hb = this.state.campfireClasses[id].hitBox
|
|
if (x >= hb.x && x <= hb.x + hb.w && y >= hb.y && y <= hb.y + hb.h) {
|
|
found = id
|
|
break
|
|
}
|
|
}
|
|
hoverCampfireClass(this.state, found)
|
|
}
|
|
}
|
|
|
|
handleMouseDown(x: number, y: number, nowMs = performance.now()): void {
|
|
this.cursor.handleMouseDown(nowMs)
|
|
this.pressedButtonId = null
|
|
for (const btn of this.getActiveButtons()) {
|
|
if (!btn.disabled && x >= btn.x && x <= btn.x + btn.w && y >= btn.y && y <= btn.y + btn.h) {
|
|
this.pressedButtonId = btn.id
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
handleMouseUp(x: number, y: number, nowMs = performance.now()): void {
|
|
this.cursor.handleMouseUp(nowMs)
|
|
const pressedId = this.pressedButtonId
|
|
this.pressedButtonId = null
|
|
|
|
for (const btn of this.getActiveButtons()) {
|
|
if (
|
|
!btn.disabled &&
|
|
btn.id === pressedId &&
|
|
x >= btn.x &&
|
|
x <= btn.x + btn.w &&
|
|
y >= btn.y &&
|
|
y <= btn.y + btn.h
|
|
) {
|
|
btn.onClick()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Stage-specific non-button interactions (editboxes, campfire heroes, character slots, checkboxes, radio buttons, chat gem)
|
|
if (this.state.stage === 'bnet_login') {
|
|
if (x >= 278 && x <= 521 && y >= 316 && y <= 347) {
|
|
this.state.activeInput = 'accountName'
|
|
} else if (x >= 278 && x <= 521 && y >= 376 && y <= 407) {
|
|
this.state.activeInput = 'accountPassword'
|
|
}
|
|
return
|
|
}
|
|
|
|
if (this.state.stage === 'char_create') {
|
|
// Check if a campfire class was clicked
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const hb = this.state.campfireClasses[id].hitBox
|
|
if (x >= hb.x && x <= hb.x + hb.w && y >= hb.y && y <= hb.y + hb.h) {
|
|
selectCampfireClass(this.state, id)
|
|
return
|
|
}
|
|
}
|
|
if (this.state.selectedClass) {
|
|
if (x >= 318 && x <= 493 && y >= 485 && y <= 513) {
|
|
this.state.activeInput = 'charName'
|
|
return
|
|
}
|
|
if (x >= 318 && x <= 520 && y >= 518 && y <= 536) {
|
|
this.state.isExpansion = !this.state.isExpansion
|
|
return
|
|
}
|
|
if (x >= 318 && x <= 480 && y >= 538 && y <= 556) {
|
|
this.state.isHardcore = !this.state.isHardcore
|
|
return
|
|
}
|
|
if (x >= 318 && x <= 510 && y >= 558 && y <= 576) {
|
|
this.state.isLadder = !this.state.isLadder
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (this.state.stage === 'char_select') {
|
|
for (let i = 0; i < this.state.roster.length; i++) {
|
|
const col = i % 2
|
|
const row = Math.floor(i / 2)
|
|
const slotX = col === 0 ? 37 : 309
|
|
const slotY = 85 + row * 93
|
|
if (x >= slotX && x <= slotX + 272 && y >= slotY && y <= slotY + 93) {
|
|
if (this.lastClickSlotIndex === i && nowMs - this.lastClickSlotTime < 450) {
|
|
this.state.selectedSlotIndex = i
|
|
confirmCharacterSelect(this.state)
|
|
} else {
|
|
this.state.selectedSlotIndex = i
|
|
this.lastClickSlotIndex = i
|
|
this.lastClickSlotTime = nowMs
|
|
}
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (this.state.stage === 'bnet_lobby') {
|
|
// Chat Gem at (398, 461, 20x24)
|
|
if (x >= 392 && x <= 424 && y >= 455 && y <= 485) {
|
|
toggleChatGem(this.state)
|
|
return
|
|
}
|
|
// Editboxes in Create Game panel & Chat Input
|
|
if (x >= 427 && x <= 615 && y >= 137 && y <= 162) {
|
|
this.state.activeInput = 'gameName'
|
|
return
|
|
}
|
|
if (x >= 427 && x <= 615 && y >= 191 && y <= 216) {
|
|
this.state.activeInput = 'gamePassword'
|
|
return
|
|
}
|
|
if (x >= 427 && x <= 769 && y >= 245 && y <= 270) {
|
|
this.state.activeInput = 'gameDescription'
|
|
return
|
|
}
|
|
if (x >= 647 && x <= 680 && y >= 286 && y <= 310) {
|
|
this.state.activeInput = 'maxPlayers'
|
|
return
|
|
}
|
|
if (x >= 28 && x <= 363 && y >= 418 && y <= 446) {
|
|
this.state.activeInput = 'chatInput'
|
|
return
|
|
}
|
|
// Character Difference checkbox
|
|
if (x >= 431 && x <= 620 && y >= 322 && y <= 344) {
|
|
this.state.charDifferenceEnabled = !this.state.charDifferenceEnabled
|
|
return
|
|
}
|
|
// Difficulty radio buttons
|
|
const diffs: { id: GameDifficulty; x: number; w: number }[] = [
|
|
{ id: 'normal', x: 430, w: 95 },
|
|
{ id: 'nightmare', x: 555, w: 125 },
|
|
{ id: 'hell', x: 698, w: 80 },
|
|
]
|
|
for (const d of diffs) {
|
|
if (x >= d.x && x <= d.x + d.w && y >= 364 && y <= 388) {
|
|
this.state.difficulty = d.id
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
handleKeyDown(e: KeyboardEvent): void {
|
|
if (this.state.stage === 'in_game' || this.state.stage === 'door_loading') return
|
|
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
if (this.state.stage === 'bnet_connecting') {
|
|
cancelConnecting(this.state)
|
|
} else if (this.state.stage === 'bnet_login') {
|
|
this.state.stage = 'main_menu'
|
|
this.state.activeInput = null
|
|
} else if (this.state.stage === 'char_create') {
|
|
if (this.state.roster.length > 0) {
|
|
this.state.stage = 'char_select'
|
|
this.state.activeInput = null
|
|
} else {
|
|
this.state.stage = 'bnet_login'
|
|
this.state.activeInput = 'accountName'
|
|
}
|
|
} else if (this.state.stage === 'char_select') {
|
|
this.state.stage = 'main_menu'
|
|
this.state.activeInput = null
|
|
} else if (this.state.stage === 'bnet_lobby') {
|
|
this.state.stage = 'char_select'
|
|
this.state.activeInput = null
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (this.state.stage === 'main_menu') {
|
|
clickBattleNetButton(this.state)
|
|
} else if (this.state.stage === 'bnet_login') {
|
|
submitBattleNetLogin(this.state)
|
|
} else if (this.state.stage === 'char_create') {
|
|
submitCharacterCreate(this.state)
|
|
} else if (this.state.stage === 'char_select') {
|
|
confirmCharacterSelect(this.state)
|
|
} else if (this.state.stage === 'bnet_lobby') {
|
|
if (this.state.activeInput === 'chatInput') {
|
|
this.sendChatMessage()
|
|
} else {
|
|
this.triggerCreateGame()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault()
|
|
if (this.state.stage === 'bnet_login') {
|
|
this.state.activeInput =
|
|
this.state.activeInput === 'accountName' ? 'accountPassword' : 'accountName'
|
|
} else if (this.state.stage === 'bnet_lobby') {
|
|
const order: ActiveInputField[] = [
|
|
'gameName',
|
|
'gamePassword',
|
|
'gameDescription',
|
|
'maxPlayers',
|
|
'chatInput',
|
|
]
|
|
const idx = order.indexOf(this.state.activeInput)
|
|
this.state.activeInput = order[(idx + 1) % order.length]!
|
|
}
|
|
return
|
|
}
|
|
|
|
const field = this.state.activeInput
|
|
if (!field) return
|
|
|
|
if (e.key === 'Backspace') {
|
|
e.preventDefault()
|
|
this.setFieldValue(field, this.getFieldValue(field).slice(0, -1))
|
|
return
|
|
}
|
|
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
const ch = e.key
|
|
const cur = this.getFieldValue(field)
|
|
if (field === 'charName' || field === 'gameName' || field === 'gamePassword' || field === 'accountName') {
|
|
if (/^[A-Za-z0-9_-]$/.test(ch) && cur.length < 15) {
|
|
this.setFieldValue(field, cur + ch)
|
|
}
|
|
} else if (field === 'accountPassword') {
|
|
if (cur.length < 16) {
|
|
this.setFieldValue(field, cur + ch)
|
|
}
|
|
} else if (field === 'maxPlayers') {
|
|
if (/^[1-8]$/.test(ch)) {
|
|
this.setFieldValue(field, ch)
|
|
}
|
|
} else if (field === 'gameDescription' || field === 'chatInput') {
|
|
if (cur.length < 38) {
|
|
this.setFieldValue(field, cur + ch)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private getFieldValue(field: Exclude<ActiveInputField, null>): string {
|
|
switch (field) {
|
|
case 'accountName':
|
|
return this.state.accountName
|
|
case 'accountPassword':
|
|
return this.state.accountPassword
|
|
case 'charName':
|
|
return this.state.charName
|
|
case 'gameName':
|
|
return this.state.gameName
|
|
case 'gamePassword':
|
|
return this.state.gamePassword
|
|
case 'gameDescription':
|
|
return this.state.gameDescription
|
|
case 'maxPlayers':
|
|
return this.state.maxPlayers
|
|
case 'chatInput':
|
|
return this.state.chatInput
|
|
}
|
|
}
|
|
|
|
private setFieldValue(field: Exclude<ActiveInputField, null>, val: string): void {
|
|
switch (field) {
|
|
case 'accountName':
|
|
this.state.accountName = val
|
|
break
|
|
case 'accountPassword':
|
|
this.state.accountPassword = val
|
|
break
|
|
case 'charName':
|
|
this.state.charName = val
|
|
this.state.charCreateError = null
|
|
break
|
|
case 'gameName':
|
|
this.state.gameName = val
|
|
this.state.createGameError = null
|
|
break
|
|
case 'gamePassword':
|
|
this.state.gamePassword = val
|
|
break
|
|
case 'gameDescription':
|
|
this.state.gameDescription = val
|
|
break
|
|
case 'maxPlayers':
|
|
this.state.maxPlayers = val
|
|
break
|
|
case 'chatInput':
|
|
this.state.chatInput = val
|
|
break
|
|
}
|
|
}
|
|
}
|