diff --git a/src/frontend/flow-state.ts b/src/frontend/flow-state.ts index 00df34e..5f45408 100644 --- a/src/frontend/flow-state.ts +++ b/src/frontend/flow-state.ts @@ -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, diff --git a/src/frontend/frontend-renderer.ts b/src/frontend/frontend-renderer.ts index 6d8b1d4..d3cf1b9 100644 --- a/src/frontend/frontend-renderer.ts +++ b/src/frontend/frontend-renderer.ts @@ -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 {