fix(frontend): align button fonts (FontExocet10/FontRidiculous) and multiply engraving blend with D2Win.dll & D2Launch.dll

This commit is contained in:
troytt 2026-09-28 08:28:56 +00:00
parent a100885552
commit 298c6f6b39
2 changed files with 75 additions and 90 deletions

View File

@ -228,7 +228,7 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B
return { return {
stage: 'main_menu', stage: 'main_menu',
gateway: 'U.S. West', gateway: 'U.S. East',
stageTicks: 0, stageTicks: 0,
globalTick: 0, globalTick: 0,

View File

@ -332,6 +332,7 @@ export class FrontendRenderer {
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
vAlign?: 'top' | 'middle' vAlign?: 'top' | 'middle'
lineGap?: number lineGap?: number
blendMode?: GlobalCompositeOperation
} = {}, } = {},
): void { ): void {
const font = options.font ?? 'font16' const font = options.font ?? 'font16'
@ -344,10 +345,15 @@ export class FrontendRenderer {
const lines = text.split('\n') const lines = text.split('\n')
const frameH = meta.glyphs[65]?.frameH || meta.lineHeight const frameH = meta.glyphs[65]?.frameH || meta.lineHeight
const stepY = (options.lineGap ?? Math.max(meta.lineHeight, Math.round(frameH * 0.78))) const stepY = options.lineGap ?? Math.max(meta.lineHeight, Math.round(frameH * 0.78))
const totalBlockH = frameH + (lines.length - 1) * stepY const totalBlockH = frameH + (lines.length - 1) * stepY
let curY = vAlign === 'middle' ? Math.round(y - totalBlockH / 2) : Math.round(y) 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) { for (const line of lines) {
const lineW = this.measureBitmapText(line, font) const lineW = this.measureBitmapText(line, font)
let curX = Math.round(x) let curX = Math.round(x)
@ -365,6 +371,10 @@ export class FrontendRenderer {
} }
curY += stepY curY += stepY
} }
if (options.blendMode) {
this.ctx.restore()
}
} }
/** /**
@ -434,7 +444,12 @@ export class FrontendRenderer {
} }
/** /**
* Draw a 2-state button (`state = 0` Up, `state = 1` Down) with `(-2, +2)` pressed label offset. * 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 { private drawButton(btn: UiButtonDef): void {
const img = this.images.get(btn.sprite) const img = this.images.get(btn.sprite)
@ -443,21 +458,56 @@ export class FrontendRenderer {
const stateW = Math.floor(img.width / 2) const stateW = Math.floor(img.width / 2)
const stateH = img.height const stateH = img.height
const sx = isDown ? stateW : 0 const sx = isDown ? stateW : 0
this.ctx.drawImage(img, sx, 0, stateW, stateH, btn.x, btn.y, btn.w, btn.h) 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) { if (btn.label) {
const dx = isDown ? -2 : 0 const dx = isDown ? -2 : 0
const dy = isDown ? 2 : 0 const dy = isDown ? 2 : 0
let font = btn.font ?? 'font24' const font: FrontendFontName = btn.font ?? (btn.h >= 35 ? 'fontexocet10' : 'fontridiculous')
if (font === 'font30' && this.measureBitmapText(btn.label, 'font30') > btn.w - 30) { const yAdjust = btn.h >= 35 ? 4 : btn.h === 32 ? 3 : btn.h > 20 ? 2 : 1
font = 'font24' 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',
})
} }
this.drawBitmapText(btn.label, btn.x + Math.floor(btn.w / 2) + dx, btn.y + Math.floor(btn.h / 2) + dy, {
font,
color: btn.disabled ? 5 : (btn.color ?? 4),
align: 'center',
vAlign: 'middle',
})
} }
} }
@ -512,17 +562,16 @@ export class FrontendRenderer {
switch (this.state.stage) { switch (this.state.stage) {
case 'main_menu': case 'main_menu':
// Exact Expansion Main Menu coordinates from D2Launch.dll (0x6fa61190..0x6fa612d0)
return [ return [
{ {
id: 'single_player', id: 'single_player',
x: 264, x: 264,
y: 290, y: 289,
w: 272, w: 272,
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.singlePlayer, label: s.singlePlayer,
font: 'font30',
color: 4,
onClick: () => { onClick: () => {
clickBattleNetButton(this.state) clickBattleNetButton(this.state)
}, },
@ -530,13 +579,11 @@ export class FrontendRenderer {
{ {
id: 'battle_net', id: 'battle_net',
x: 264, x: 264,
y: 332, y: 331,
w: 272, w: 272,
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.battleNet, label: s.battleNet,
font: 'font30',
color: 4,
onClick: () => { onClick: () => {
clickBattleNetButton(this.state) clickBattleNetButton(this.state)
}, },
@ -544,15 +591,13 @@ export class FrontendRenderer {
{ {
id: 'gateway', id: 'gateway',
x: 264, x: 264,
y: 369, y: 366,
w: 272, w: 272,
h: 25, h: 25,
sprite: 'btn-wide02.png', sprite: 'btn-wide02.png',
label: `${s.gatewayPrefix}${this.state.gateway.toUpperCase()}`, label: `${s.gatewayPrefix}${this.state.gateway.toUpperCase()}`,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
const gateways = ['U.S. West', 'U.S. East', 'Asia', 'Europe'] const gateways = ['U.S. East', 'U.S. West', 'Asia', 'Europe']
const idx = gateways.indexOf(this.state.gateway) const idx = gateways.indexOf(this.state.gateway)
this.state.gateway = gateways[(idx + 1) % gateways.length]! this.state.gateway = gateways[(idx + 1) % gateways.length]!
}, },
@ -560,13 +605,11 @@ export class FrontendRenderer {
{ {
id: 'other_multiplayer', id: 'other_multiplayer',
x: 264, x: 264,
y: 400, y: 398,
w: 272, w: 272,
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.otherMultiplayer, label: s.otherMultiplayer,
font: 'font30',
color: 4,
onClick: () => { onClick: () => {
clickBattleNetButton(this.state) clickBattleNetButton(this.state)
}, },
@ -574,37 +617,31 @@ export class FrontendRenderer {
{ {
id: 'credits', id: 'credits',
x: 264, x: 264,
y: 505, y: 503,
w: 135, w: 135,
h: 25, h: 25,
sprite: 'btn-short.png', sprite: 'btn-short.png',
label: s.credits, label: s.credits,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
id: 'cinematics', id: 'cinematics',
x: 401, x: 402,
y: 505, y: 503,
w: 135, w: 135,
h: 25, h: 25,
sprite: 'btn-short.png', sprite: 'btn-short.png',
label: s.cinematics, label: s.cinematics,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
id: 'exit_d2', id: 'exit_d2',
x: 264, x: 264,
y: 535, y: 533,
w: 272, w: 272,
h: 35, h: 35,
sprite: 'btn-3wide.png', sprite: 'btn-3wide.png',
label: s.exitDiablo2, label: s.exitDiablo2,
font: 'font30',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
] ]
@ -619,8 +656,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.cancel, label: s.cancel,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
cancelConnecting(this.state) cancelConnecting(this.state)
}, },
@ -637,8 +672,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.logIn, label: s.logIn,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
submitBattleNetLogin(this.state) submitBattleNetLogin(this.state)
}, },
@ -651,8 +684,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.accountSettings, label: s.accountSettings,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
submitBattleNetLogin(this.state) submitBattleNetLogin(this.state)
}, },
@ -665,8 +696,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-wide.png', sprite: 'btn-wide.png',
label: s.createNewAccount, label: s.createNewAccount,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
submitBattleNetLogin(this.state) submitBattleNetLogin(this.state)
}, },
@ -679,8 +708,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.cancel, label: s.cancel,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
this.state.stage = 'main_menu' this.state.stage = 'main_menu'
this.state.stageTicks = 0 this.state.stageTicks = 0
@ -699,8 +726,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.exit, label: s.exit,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
if (this.state.roster.length > 0) { if (this.state.roster.length > 0) {
this.state.stage = 'char_select' this.state.stage = 'char_select'
@ -721,8 +746,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.ok, label: s.ok,
font: 'font24',
color: 4,
disabled: !this.state.selectedClass, disabled: !this.state.selectedClass,
onClick: () => { onClick: () => {
submitCharacterCreate(this.state) submitCharacterCreate(this.state)
@ -742,8 +765,6 @@ export class FrontendRenderer {
h: 60, h: 60,
sprite: 'btn-tall.png', sprite: 'btn-tall.png',
label: s.createNewCharacter, label: s.createNewCharacter,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
enterCharCreate(this.state) enterCharCreate(this.state)
}, },
@ -756,8 +777,6 @@ export class FrontendRenderer {
h: 60, h: 60,
sprite: 'btn-tall.png', sprite: 'btn-tall.png',
label: s.convertToExpansion, label: s.convertToExpansion,
font: 'font16',
color: 4,
disabled: !selectedChar || selectedChar.isExpansion, disabled: !selectedChar || selectedChar.isExpansion,
onClick: () => { onClick: () => {
if (selectedChar && !selectedChar.isExpansion) { if (selectedChar && !selectedChar.isExpansion) {
@ -773,8 +792,6 @@ export class FrontendRenderer {
h: 60, h: 60,
sprite: 'btn-tall.png', sprite: 'btn-tall.png',
label: s.deleteCharacter, label: s.deleteCharacter,
font: 'font16',
color: 4,
disabled: !selectedChar, disabled: !selectedChar,
onClick: () => { onClick: () => {
if (selectedChar) { if (selectedChar) {
@ -794,8 +811,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.exit, label: s.exit,
font: 'font24',
color: 4,
onClick: () => { onClick: () => {
this.state.stage = 'main_menu' this.state.stage = 'main_menu'
this.state.stageTicks = 0 this.state.stageTicks = 0
@ -810,8 +825,6 @@ export class FrontendRenderer {
h: 35, h: 35,
sprite: 'btn-medium.png', sprite: 'btn-medium.png',
label: s.ok, label: s.ok,
font: 'font24',
color: 4,
disabled: !hasChars, disabled: !hasChars,
onClick: () => { onClick: () => {
confirmCharacterSelect(this.state) confirmCharacterSelect(this.state)
@ -832,8 +845,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.send, label: L.send,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.sendChatMessage() this.sendChatMessage()
}, },
@ -846,8 +857,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.whisper, label: L.whisper,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -858,8 +867,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.help, label: L.help,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -870,8 +877,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.squelch, label: L.squelch,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -882,8 +887,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.unsquelch, label: L.unsquelch,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -894,8 +897,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.emote, label: L.emote,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
// Right Game Command Bar (D2Multi.dll 0x6f9e9e50..0x6f9e9f10) // Right Game Command Bar (D2Multi.dll 0x6f9e9e50..0x6f9e9f10)
@ -907,8 +908,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-game.png', sprite: 'btn-game.png',
label: L.create, label: L.create,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.state.lobbySubPanel = 'create' this.state.lobbySubPanel = 'create'
this.state.activeInput = 'gameName' this.state.activeInput = 'gameName'
@ -922,8 +921,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-game.png', sprite: 'btn-game.png',
label: L.join, label: L.join,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.state.lobbySubPanel = 'create' this.state.lobbySubPanel = 'create'
}, },
@ -936,8 +933,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.channel, label: L.channel,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -948,8 +943,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.ladder, label: L.ladder,
font: 'font16',
color: 4,
onClick: () => {}, onClick: () => {},
}, },
{ {
@ -960,8 +953,6 @@ export class FrontendRenderer {
h: 20, h: 20,
sprite: 'btn-chatleft.png', sprite: 'btn-chatleft.png',
label: L.quit, label: L.quit,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.state.stage = 'char_select' this.state.stage = 'char_select'
this.state.stageTicks = 0 this.state.stageTicks = 0
@ -977,8 +968,6 @@ export class FrontendRenderer {
h: 32, h: 32,
sprite: 'btn-create-cancel.png', sprite: 'btn-create-cancel.png',
label: s.cancel, label: s.cancel,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.state.gameName = '' this.state.gameName = ''
this.state.gamePassword = '' this.state.gamePassword = ''
@ -992,8 +981,6 @@ export class FrontendRenderer {
h: 32, h: 32,
sprite: 'btn-game.png', sprite: 'btn-game.png',
label: L.createGameBtn, label: L.createGameBtn,
font: 'font16',
color: 4,
onClick: () => { onClick: () => {
this.triggerCreateGame() this.triggerCreateGame()
}, },
@ -1113,8 +1100,8 @@ export class FrontendRenderer {
} }
} }
// Version string at bottom-right // 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', 748, 578, { font: 'fontformal10', color: 0, align: 'left' }) this.drawBitmapText('v 1.13c', 100, 560, { font: 'font16', color: 0, align: 'center' })
} }
private renderBnetLogin(): void { private renderBnetLogin(): void {
@ -1151,8 +1138,6 @@ export class FrontendRenderer {
for (const btn of this.getActiveButtons()) { for (const btn of this.getActiveButtons()) {
this.drawButton(btn) this.drawButton(btn)
} }
this.drawBitmapText('v 1.13c', 748, 578, { font: 'fontformal10', color: 0, align: 'left' })
} }
private renderCharCreate(): void { private renderCharCreate(): void {