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 {
stage: 'main_menu',
gateway: 'U.S. West',
gateway: 'U.S. East',
stageTicks: 0,
globalTick: 0,

View File

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