768 lines
25 KiB
TypeScript
768 lines
25 KiB
TypeScript
import {
|
|
getGameLang,
|
|
onLangChange,
|
|
setLang,
|
|
tToolbar,
|
|
type GameLang,
|
|
} from '../i18n/lang.ts'
|
|
import {
|
|
CLIENT_LIGHTING_PRESET_ORDER,
|
|
LIGHTING_PRESETS,
|
|
} from '../scene/lighting-presets.ts'
|
|
import {
|
|
VALID_AUTOMAP_REVEAL_MODES,
|
|
VALID_VIEWPORT_RESOLUTIONS,
|
|
validateServerHost,
|
|
type AutomapRevealMode,
|
|
type ClientLightingPreset,
|
|
type ClientSettings,
|
|
type ServerHostValidationResult,
|
|
type ViewportResolution,
|
|
} from '../settings/client-settings.ts'
|
|
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts'
|
|
import {
|
|
clearClientAssetCache,
|
|
formatCacheBytesMb,
|
|
} from '../sw/register.ts'
|
|
import {
|
|
formatViewportLabel,
|
|
getViewportProfile,
|
|
type ViewportProfile,
|
|
} from '../view/viewport-profile.ts'
|
|
|
|
export type ToolbarConnectionPhase =
|
|
| 'disconnected'
|
|
| 'connecting'
|
|
| 'bncs'
|
|
| 'mcp'
|
|
| 'd2gs'
|
|
| 'error'
|
|
|
|
export type ToolbarDiagnosticKind =
|
|
| 'none'
|
|
| 'quota_exceeded'
|
|
| 'tls_error'
|
|
| 'bare_ip_on_https'
|
|
| 'invalid_host'
|
|
| 'custom'
|
|
|
|
export interface ToolbarConnectionState {
|
|
readonly phase: ToolbarConnectionPhase
|
|
readonly rttMs: number | null
|
|
readonly diagnosticKind?: ToolbarDiagnosticKind | undefined
|
|
readonly diagnosticMessage?: string | undefined
|
|
}
|
|
|
|
export interface ToolbarCallbacks {
|
|
readonly onServerHostReconnect?: ((newHost: string, prevHost: string) => void) | undefined
|
|
readonly onLangChange?: ((lang: GameLang) => void) | undefined
|
|
readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined
|
|
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
|
|
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
|
|
readonly onInspectorToggle?: ((open: boolean) => void) | undefined
|
|
readonly onClearCache?: (() => void | Promise<void>) | undefined
|
|
}
|
|
|
|
export interface ToolbarOptions {
|
|
readonly store?: SettingsStore | undefined
|
|
readonly container?: HTMLElement | null | undefined
|
|
readonly pageProtocol?: string | undefined
|
|
readonly confirmReconnect?: ((message: string) => boolean) | undefined
|
|
readonly callbacks?: ToolbarCallbacks | undefined
|
|
}
|
|
|
|
export interface ToolbarSnapshot {
|
|
readonly connection: ToolbarConnectionState
|
|
readonly settings: ClientSettings
|
|
readonly viewportProfile: ViewportProfile
|
|
readonly statusLabel: string
|
|
readonly rttLabel: string
|
|
readonly diagnosticBannerText: string | null
|
|
readonly serverInputValue: string
|
|
readonly serverInputValid: boolean
|
|
}
|
|
|
|
/**
|
|
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 5 primary controls:
|
|
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle
|
|
* 2. Live Language Toggle (`中` / `EN`)
|
|
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
|
|
* 4. Automap Reveal Selector (`off | level | act`)
|
|
* 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + Asset Cache Clear button
|
|
*/
|
|
export class Toolbar {
|
|
private readonly store: SettingsStore
|
|
private readonly pageProtocol: string
|
|
private readonly confirmReconnectFn: (message: string) => boolean
|
|
private readonly callbacks: ToolbarCallbacks
|
|
private connection: ToolbarConnectionState = {
|
|
phase: 'disconnected',
|
|
rttMs: null,
|
|
diagnosticKind: 'none',
|
|
}
|
|
private serverInputValue: string
|
|
private serverInputValid = true
|
|
private cacheUsageBytes = 0
|
|
private cacheClearedFlash = false
|
|
private unsubscribeStore: (() => void) | null = null
|
|
private unsubscribeLang: (() => void) | null = null
|
|
|
|
// Optional real DOM elements when mounted in browser
|
|
private rootEl: HTMLElement | null = null
|
|
private statusDotEl: HTMLElement | null = null
|
|
private statusTextEl: HTMLElement | null = null
|
|
private rttTextEl: HTMLElement | null = null
|
|
private langBtnZhEl: HTMLButtonElement | null = null
|
|
private langBtnEnEl: HTMLButtonElement | null = null
|
|
private lightingLabelEl: HTMLElement | null = null
|
|
private lightingSelectEl: HTMLSelectElement | null = null
|
|
private automapLabelEl: HTMLElement | null = null
|
|
private automapSelectEl: HTMLSelectElement | null = null
|
|
private viewportLabelEl: HTMLElement | null = null
|
|
private viewportSelectEl: HTMLSelectElement | null = null
|
|
private inspectorBtnEl: HTMLButtonElement | null = null
|
|
private cacheClearBtnEl: HTMLButtonElement | null = null
|
|
private diagBannerEl: HTMLElement | null = null
|
|
|
|
constructor(options?: ToolbarOptions) {
|
|
this.store = options?.store ?? getGlobalSettingsStore()
|
|
this.pageProtocol =
|
|
options?.pageProtocol ??
|
|
(typeof window !== 'undefined' && window.location ? window.location.protocol : 'https:')
|
|
this.confirmReconnectFn =
|
|
options?.confirmReconnect ??
|
|
((msg: string) => (typeof window !== 'undefined' && typeof window.confirm === 'function' ? window.confirm(msg) : true))
|
|
this.callbacks = options?.callbacks ?? {}
|
|
this.serverInputValue = this.store.get().serverHost
|
|
|
|
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
|
this.mountDom(options?.container ?? null)
|
|
}
|
|
|
|
this.unsubscribeStore = this.store.subscribe((next, prev, changedKeys) => {
|
|
if (changedKeys.includes('serverHost')) {
|
|
this.serverInputValue = next.serverHost
|
|
this.serverInputValid = true
|
|
}
|
|
if (changedKeys.includes('lang') && next.lang !== prev.lang) {
|
|
this.callbacks.onLangChange?.(next.lang)
|
|
}
|
|
if (changedKeys.includes('lightingPreset') && next.lightingPreset !== prev.lightingPreset) {
|
|
this.callbacks.onLightingChange?.(next.lightingPreset)
|
|
}
|
|
if (changedKeys.includes('automapReveal') && next.automapReveal !== prev.automapReveal) {
|
|
this.callbacks.onAutomapRevealChange?.(next.automapReveal)
|
|
}
|
|
if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) {
|
|
this.callbacks.onViewportChange?.(getViewportProfile(next.viewport))
|
|
}
|
|
if (changedKeys.includes('inspectorOpen') && next.inspectorOpen !== prev.inspectorOpen) {
|
|
this.callbacks.onInspectorToggle?.(next.inspectorOpen)
|
|
}
|
|
this.syncDom()
|
|
})
|
|
|
|
this.unsubscribeLang = onLangChange(() => {
|
|
this.syncDom()
|
|
})
|
|
}
|
|
|
|
getElement(): HTMLElement | null {
|
|
return this.rootEl
|
|
}
|
|
|
|
getSettings(): ClientSettings {
|
|
return this.store.get()
|
|
}
|
|
|
|
getConnectionState(): ToolbarConnectionState {
|
|
return this.connection
|
|
}
|
|
|
|
isActiveSession(): boolean {
|
|
const p = this.connection.phase
|
|
return p === 'connecting' || p === 'bncs' || p === 'mcp' || p === 'd2gs'
|
|
}
|
|
|
|
setConnectionState(next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase }): void {
|
|
this.connection = {
|
|
phase: next.phase,
|
|
rttMs: next.rttMs !== undefined ? next.rttMs : this.connection.rttMs,
|
|
diagnosticKind: next.diagnosticKind ?? (next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'),
|
|
diagnosticMessage: next.diagnosticMessage,
|
|
}
|
|
this.syncDom()
|
|
}
|
|
|
|
setRttMs(rttMs: number | null): void {
|
|
this.connection = {
|
|
...this.connection,
|
|
rttMs: rttMs !== null && Number.isFinite(rttMs) ? Math.max(0, Math.round(rttMs)) : null,
|
|
}
|
|
this.syncDom()
|
|
}
|
|
|
|
reportDiagnostic(kind: ToolbarDiagnosticKind, customMessage?: string): void {
|
|
this.connection = {
|
|
...this.connection,
|
|
phase: kind === 'none' ? this.connection.phase : 'error',
|
|
diagnosticKind: kind,
|
|
diagnosticMessage: customMessage,
|
|
}
|
|
this.syncDom()
|
|
}
|
|
|
|
clearDiagnostic(): void {
|
|
this.connection = {
|
|
...this.connection,
|
|
diagnosticKind: 'none',
|
|
diagnosticMessage: undefined,
|
|
}
|
|
this.syncDom()
|
|
}
|
|
|
|
/**
|
|
* Attempt to apply a new `serverHost`.
|
|
* - Validates against `validateServerHost` (rejects bare IP on `https:`).
|
|
* - If currently in an active session (`connecting | bncs | mcp | d2gs`), asks for confirmation before disconnecting & reconnecting.
|
|
*/
|
|
submitServerHost(rawHost: string): ServerHostValidationResult & { readonly applied: boolean } {
|
|
this.serverInputValue = rawHost
|
|
const check = validateServerHost(rawHost, this.pageProtocol)
|
|
if (!check.valid) {
|
|
this.serverInputValid = false
|
|
const lang = this.store.get().lang
|
|
const msg = lang === 'en' ? check.errorMessage : (check.errorMessageZh ?? check.errorMessage)
|
|
this.connection = {
|
|
...this.connection,
|
|
diagnosticKind: check.reason === 'bare_ip_on_https' ? 'bare_ip_on_https' : 'invalid_host',
|
|
diagnosticMessage: msg,
|
|
}
|
|
this.syncDom()
|
|
return { ...check, applied: false }
|
|
}
|
|
|
|
const prevHost = this.store.get().serverHost
|
|
if (check.normalizedHost === prevHost) {
|
|
this.serverInputValue = check.normalizedHost
|
|
this.serverInputValid = true
|
|
if (
|
|
this.connection.diagnosticKind === 'bare_ip_on_https' ||
|
|
this.connection.diagnosticKind === 'invalid_host'
|
|
) {
|
|
this.clearDiagnostic()
|
|
} else {
|
|
this.syncDom()
|
|
}
|
|
return { ...check, applied: true }
|
|
}
|
|
|
|
if (this.isActiveSession()) {
|
|
const confirmMsg = tToolbar('toolbar.server.confirmReconnect', this.store.get().lang)
|
|
if (!this.confirmReconnectFn(confirmMsg)) {
|
|
this.serverInputValue = prevHost
|
|
this.serverInputValid = true
|
|
this.syncDom()
|
|
return { ...check, applied: false }
|
|
}
|
|
}
|
|
|
|
this.serverInputValid = true
|
|
this.serverInputValue = check.normalizedHost
|
|
if (
|
|
this.connection.diagnosticKind === 'bare_ip_on_https' ||
|
|
this.connection.diagnosticKind === 'invalid_host'
|
|
) {
|
|
this.connection = {
|
|
...this.connection,
|
|
diagnosticKind: 'none',
|
|
diagnosticMessage: undefined,
|
|
}
|
|
}
|
|
this.store.setServerHost(check.normalizedHost, this.pageProtocol)
|
|
this.callbacks.onServerHostReconnect?.(check.normalizedHost, prevHost)
|
|
this.syncDom()
|
|
return { ...check, applied: true }
|
|
}
|
|
|
|
setLanguage(lang: GameLang): void {
|
|
setLang(lang)
|
|
this.store.set({ lang })
|
|
this.syncDom()
|
|
}
|
|
|
|
toggleLanguage(): GameLang {
|
|
const next: GameLang = this.store.get().lang === 'en' ? 'zh' : 'en'
|
|
this.setLanguage(next)
|
|
return next
|
|
}
|
|
|
|
setLightingPreset(preset: ClientLightingPreset): void {
|
|
this.store.set({ lightingPreset: preset })
|
|
this.syncDom()
|
|
}
|
|
|
|
setAutomapReveal(mode: AutomapRevealMode): void {
|
|
this.store.set({ automapReveal: mode })
|
|
this.syncDom()
|
|
}
|
|
|
|
setViewport(viewport: ViewportResolution): ViewportProfile {
|
|
this.store.set({ viewport })
|
|
this.syncDom()
|
|
return getViewportProfile(viewport)
|
|
}
|
|
|
|
/** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */
|
|
toggleViewport(): ViewportProfile {
|
|
const order = VALID_VIEWPORT_RESOLUTIONS
|
|
const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length]
|
|
return this.setViewport(next)
|
|
}
|
|
|
|
setInspectorOpen(open: boolean): void {
|
|
this.store.set({ inspectorOpen: open })
|
|
this.syncDom()
|
|
}
|
|
|
|
toggleInspector(): boolean {
|
|
const next = !this.store.get().inspectorOpen
|
|
this.setInspectorOpen(next)
|
|
return next
|
|
}
|
|
|
|
setAssetCacheUsageBytes(bytes: number): void {
|
|
this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0
|
|
this.syncDom()
|
|
}
|
|
|
|
async clearAssetCache(): Promise<number> {
|
|
const res = await clearClientAssetCache()
|
|
this.cacheUsageBytes = 0
|
|
this.cacheClearedFlash = true
|
|
this.syncDom()
|
|
await this.callbacks.onClearCache?.()
|
|
setTimeout(() => {
|
|
this.cacheClearedFlash = false
|
|
this.syncDom()
|
|
}, 1500)
|
|
return res.deletedEntries
|
|
}
|
|
|
|
getSnapshot(): ToolbarSnapshot {
|
|
const settings = this.store.get()
|
|
const lang = settings.lang || getGameLang()
|
|
const statusKey = `toolbar.status.${this.connection.phase}` as const
|
|
const statusLabel = tToolbar(statusKey, lang)
|
|
const rttLabel =
|
|
this.connection.rttMs !== null ? `${this.connection.rttMs} ms` : '-- ms'
|
|
|
|
let diagnosticBannerText: string | null = null
|
|
const kind = this.connection.diagnosticKind ?? 'none'
|
|
if (kind === 'quota_exceeded') {
|
|
diagnosticBannerText = tToolbar('toolbar.diag.quotaExceeded', lang)
|
|
} else if (kind === 'tls_error') {
|
|
diagnosticBannerText = tToolbar('toolbar.diag.tlsError', lang)
|
|
} else if (kind === 'bare_ip_on_https') {
|
|
diagnosticBannerText =
|
|
this.connection.diagnosticMessage ?? tToolbar('toolbar.diag.bareIpHttps', lang)
|
|
} else if (kind === 'invalid_host' || kind === 'custom') {
|
|
diagnosticBannerText = this.connection.diagnosticMessage ?? null
|
|
}
|
|
|
|
return {
|
|
connection: this.connection,
|
|
settings,
|
|
viewportProfile: getViewportProfile(settings.viewport),
|
|
statusLabel,
|
|
rttLabel,
|
|
diagnosticBannerText,
|
|
serverInputValue: this.serverInputValue,
|
|
serverInputValid: this.serverInputValid,
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
this.unsubscribeStore?.()
|
|
this.unsubscribeStore = null
|
|
this.unsubscribeLang?.()
|
|
this.unsubscribeLang = null
|
|
if (this.rootEl && this.rootEl.parentNode) {
|
|
this.rootEl.parentNode.removeChild(this.rootEl)
|
|
}
|
|
this.rootEl = null
|
|
}
|
|
|
|
private ensureStyles(doc: Document): void {
|
|
if (!doc.head || doc.getElementById('d2-toolbar-styles')) return
|
|
const styleEl = doc.createElement('style')
|
|
styleEl.id = 'd2-toolbar-styles'
|
|
styleEl.textContent = `
|
|
#d2-toolbar,
|
|
.d2-toolbar {
|
|
position: relative;
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
width: 100%;
|
|
height: 32px;
|
|
min-height: 32px;
|
|
max-height: 32px;
|
|
padding: 0 12px;
|
|
box-sizing: border-box;
|
|
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
|
|
border-bottom: 1px solid #4a3c24;
|
|
color: #d6c496;
|
|
font-family: var(--font-display, "D2Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif);
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
user-select: none;
|
|
}
|
|
.d2-toolbar__group {
|
|
display: inline-flex;
|
|
flex-direction: row;
|
|
flex-wrap: nowrap;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
gap: 6px;
|
|
white-space: nowrap;
|
|
}
|
|
.d2-toolbar__divider {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: #3a2f1d;
|
|
}
|
|
.d2-toolbar__status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 3px 8px;
|
|
border-radius: 3px;
|
|
background: #14110d;
|
|
border: 1px solid #352b1b;
|
|
}
|
|
.d2-toolbar__status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
background: #6b6b6b;
|
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
|
}
|
|
.d2-toolbar__status-dot--disconnected { background: #767676; }
|
|
.d2-toolbar__status-dot--connecting { background: #e6a822; box-shadow: 0 0 6px #e6a822; }
|
|
.d2-toolbar__status-dot--bncs { background: #4495e8; box-shadow: 0 0 6px #4495e8; }
|
|
.d2-toolbar__status-dot--mcp { background: #36c5d6; box-shadow: 0 0 6px #36c5d6; }
|
|
.d2-toolbar__status-dot--d2gs { background: #2ecc71; box-shadow: 0 0 6px #2ecc71; }
|
|
.d2-toolbar__status-dot--error { background: #e74c3c; box-shadow: 0 0 6px #e74c3c; }
|
|
.d2-toolbar__rtt {
|
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
|
font-size: 11px;
|
|
color: #a89974;
|
|
}
|
|
.d2-toolbar__label {
|
|
color: #b59e68;
|
|
font-size: 11px;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.d2-toolbar__select {
|
|
height: 22px;
|
|
padding: 0 6px;
|
|
background: #120f0b;
|
|
border: 1px solid #4a3c24;
|
|
border-radius: 2px;
|
|
color: #e2d1a0;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
.d2-toolbar__select:hover,
|
|
.d2-toolbar__btn:hover {
|
|
border-color: #c7b377;
|
|
color: #fff2c6;
|
|
}
|
|
.d2-toolbar__btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 22px;
|
|
padding: 0 8px;
|
|
background: linear-gradient(180deg, #2b2316 0%, #17120b 100%);
|
|
border: 1px solid #574629;
|
|
border-radius: 2px;
|
|
color: #dfc991;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.d2-toolbar__btn--active {
|
|
background: linear-gradient(180deg, #524020 0%, #2e2310 100%);
|
|
border-color: #d4b46a;
|
|
color: #fff5d6;
|
|
}
|
|
.d2-toolbar__diag {
|
|
position: absolute;
|
|
top: 32px;
|
|
left: 12px;
|
|
right: 12px;
|
|
z-index: 1001;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 10px;
|
|
background: rgba(38, 12, 10, 0.96);
|
|
border: 1px solid #a93226;
|
|
border-top: none;
|
|
border-radius: 0 0 4px 4px;
|
|
color: #ffd6d1;
|
|
font-size: 12px;
|
|
}
|
|
.d2-toolbar__diag--visible {
|
|
display: flex;
|
|
}
|
|
`
|
|
doc.head.appendChild(styleEl)
|
|
}
|
|
|
|
private mountDom(container: HTMLElement | null): void {
|
|
const doc = document
|
|
this.ensureStyles(doc)
|
|
const root = doc.createElement('header')
|
|
root.id = 'd2-toolbar'
|
|
root.className = 'd2-toolbar'
|
|
|
|
// Group 1: Connection Status + RTT + Packet Inspector button
|
|
const statusGroup = doc.createElement('div')
|
|
statusGroup.className = 'd2-toolbar__group'
|
|
|
|
const statusBadge = doc.createElement('div')
|
|
statusBadge.className = 'd2-toolbar__status'
|
|
const dot = doc.createElement('span')
|
|
dot.id = 'd2-toolbar-status-dot'
|
|
dot.className = 'd2-toolbar__status-dot d2-toolbar__status-dot--disconnected'
|
|
const statusText = doc.createElement('span')
|
|
statusText.id = 'd2-toolbar-status-text'
|
|
const rttText = doc.createElement('span')
|
|
rttText.id = 'd2-toolbar-rtt'
|
|
rttText.className = 'd2-toolbar__rtt'
|
|
statusBadge.appendChild(dot)
|
|
statusBadge.appendChild(statusText)
|
|
statusBadge.appendChild(rttText)
|
|
|
|
const inspectorBtn = doc.createElement('button')
|
|
inspectorBtn.type = 'button'
|
|
inspectorBtn.id = 'd2-toolbar-inspector-btn'
|
|
inspectorBtn.className = 'd2-toolbar__btn'
|
|
inspectorBtn.addEventListener('click', () => {
|
|
this.toggleInspector()
|
|
})
|
|
|
|
statusGroup.appendChild(statusBadge)
|
|
statusGroup.appendChild(inspectorBtn)
|
|
|
|
// Group 2: Language Toggle (中 / EN)
|
|
const langGroup = doc.createElement('div')
|
|
langGroup.className = 'd2-toolbar__group'
|
|
const langBtnZh = doc.createElement('button')
|
|
langBtnZh.type = 'button'
|
|
langBtnZh.id = 'd2-toolbar-lang-zh'
|
|
langBtnZh.className = 'd2-toolbar__btn'
|
|
langBtnZh.textContent = '中'
|
|
langBtnZh.addEventListener('click', () => this.setLanguage('zh'))
|
|
|
|
const langBtnEn = doc.createElement('button')
|
|
langBtnEn.type = 'button'
|
|
langBtnEn.id = 'd2-toolbar-lang-en'
|
|
langBtnEn.className = 'd2-toolbar__btn'
|
|
langBtnEn.textContent = 'EN'
|
|
langBtnEn.addEventListener('click', () => this.setLanguage('en'))
|
|
|
|
langGroup.appendChild(langBtnZh)
|
|
langGroup.appendChild(langBtnEn)
|
|
|
|
// Group 3: Lighting Selector
|
|
const lightingGroup = doc.createElement('div')
|
|
lightingGroup.className = 'd2-toolbar__group'
|
|
const lightingLabel = doc.createElement('label')
|
|
lightingLabel.className = 'd2-toolbar__label'
|
|
lightingLabel.htmlFor = 'd2-toolbar-lighting-select'
|
|
const lightingSelect = doc.createElement('select')
|
|
lightingSelect.id = 'd2-toolbar-lighting-select'
|
|
lightingSelect.className = 'd2-toolbar__select'
|
|
lightingSelect.addEventListener('change', () => {
|
|
this.setLightingPreset(lightingSelect.value as ClientLightingPreset)
|
|
})
|
|
lightingGroup.appendChild(lightingLabel)
|
|
lightingGroup.appendChild(lightingSelect)
|
|
|
|
// Group 4: Automap Reveal Selector (Off / Level / Act)
|
|
const automapGroup = doc.createElement('div')
|
|
automapGroup.className = 'd2-toolbar__group'
|
|
const automapLabel = doc.createElement('label')
|
|
automapLabel.className = 'd2-toolbar__label'
|
|
automapLabel.htmlFor = 'd2-toolbar-automap-select'
|
|
const automapSelect = doc.createElement('select')
|
|
automapSelect.id = 'd2-toolbar-automap-select'
|
|
automapSelect.className = 'd2-toolbar__select'
|
|
automapSelect.addEventListener('change', () => {
|
|
this.setAutomapReveal(automapSelect.value as AutomapRevealMode)
|
|
})
|
|
automapGroup.appendChild(automapLabel)
|
|
automapGroup.appendChild(automapSelect)
|
|
|
|
// Group 5: Viewport Size Selector (every VALID_VIEWPORT_RESOLUTIONS entry) + Asset Cache Clear button
|
|
const viewportGroup = doc.createElement('div')
|
|
viewportGroup.className = 'd2-toolbar__group'
|
|
const viewportLabel = doc.createElement('label')
|
|
viewportLabel.className = 'd2-toolbar__label'
|
|
viewportLabel.htmlFor = 'd2-toolbar-viewport-select'
|
|
const viewportSelect = doc.createElement('select')
|
|
viewportSelect.id = 'd2-toolbar-viewport-select'
|
|
viewportSelect.className = 'd2-toolbar__select'
|
|
for (const vp of VALID_VIEWPORT_RESOLUTIONS) {
|
|
const opt = doc.createElement('option')
|
|
opt.value = vp
|
|
opt.textContent = formatViewportLabel(vp)
|
|
viewportSelect.appendChild(opt)
|
|
}
|
|
viewportSelect.addEventListener('change', () => {
|
|
this.setViewport(viewportSelect.value as ViewportResolution)
|
|
})
|
|
|
|
const cacheClearBtn = doc.createElement('button')
|
|
cacheClearBtn.type = 'button'
|
|
cacheClearBtn.id = 'd2-toolbar-cache-clear-btn'
|
|
cacheClearBtn.className = 'd2-toolbar__btn'
|
|
cacheClearBtn.addEventListener('click', () => {
|
|
void this.clearAssetCache()
|
|
})
|
|
|
|
viewportGroup.appendChild(viewportLabel)
|
|
viewportGroup.appendChild(viewportSelect)
|
|
viewportGroup.appendChild(cacheClearBtn)
|
|
|
|
// Diagnostic Banner
|
|
const diagBanner = doc.createElement('div')
|
|
diagBanner.id = 'd2-toolbar-diag-banner'
|
|
diagBanner.className = 'd2-toolbar__diag'
|
|
|
|
root.appendChild(statusGroup)
|
|
root.appendChild(langGroup)
|
|
root.appendChild(lightingGroup)
|
|
root.appendChild(automapGroup)
|
|
root.appendChild(viewportGroup)
|
|
root.appendChild(diagBanner)
|
|
|
|
if (container) {
|
|
container.appendChild(root)
|
|
}
|
|
|
|
this.rootEl = root
|
|
this.statusDotEl = dot
|
|
this.statusTextEl = statusText
|
|
this.rttTextEl = rttText
|
|
this.langBtnZhEl = langBtnZh
|
|
this.langBtnEnEl = langBtnEn
|
|
this.lightingLabelEl = lightingLabel
|
|
this.lightingSelectEl = lightingSelect
|
|
this.automapLabelEl = automapLabel
|
|
this.automapSelectEl = automapSelect
|
|
this.viewportLabelEl = viewportLabel
|
|
this.viewportSelectEl = viewportSelect
|
|
this.inspectorBtnEl = inspectorBtn
|
|
this.cacheClearBtnEl = cacheClearBtn
|
|
this.diagBannerEl = diagBanner
|
|
|
|
this.syncDom()
|
|
}
|
|
|
|
private syncDom(): void {
|
|
if (!this.rootEl) return
|
|
const snap = this.getSnapshot()
|
|
const lang = snap.settings.lang
|
|
|
|
if (this.statusDotEl) {
|
|
this.statusDotEl.className = `d2-toolbar__status-dot d2-toolbar__status-dot--${snap.connection.phase}`
|
|
}
|
|
if (this.statusTextEl) {
|
|
this.statusTextEl.textContent = snap.statusLabel
|
|
}
|
|
if (this.rttTextEl) {
|
|
this.rttTextEl.textContent = snap.rttLabel
|
|
}
|
|
if (this.inspectorBtnEl) {
|
|
this.inspectorBtnEl.textContent = tToolbar('toolbar.inspector.toggle', lang)
|
|
this.inspectorBtnEl.className = snap.settings.inspectorOpen
|
|
? 'd2-toolbar__btn d2-toolbar__btn--active'
|
|
: 'd2-toolbar__btn'
|
|
}
|
|
if (this.langBtnZhEl) {
|
|
this.langBtnZhEl.className =
|
|
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
|
|
}
|
|
if (this.langBtnEnEl) {
|
|
this.langBtnEnEl.className =
|
|
lang === 'en' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
|
|
}
|
|
if (this.lightingLabelEl) {
|
|
this.lightingLabelEl.textContent = tToolbar('toolbar.lighting.label', lang)
|
|
}
|
|
if (this.lightingSelectEl && typeof document !== 'undefined') {
|
|
this.lightingSelectEl.innerHTML = ''
|
|
for (const presetId of CLIENT_LIGHTING_PRESET_ORDER) {
|
|
const cfg = LIGHTING_PRESETS[presetId]
|
|
const opt = document.createElement('option')
|
|
opt.value = presetId
|
|
opt.textContent = lang === 'en' ? cfg.labelEn : cfg.labelZh
|
|
this.lightingSelectEl.appendChild(opt)
|
|
}
|
|
this.lightingSelectEl.value = snap.settings.lightingPreset
|
|
}
|
|
if (this.automapLabelEl) {
|
|
this.automapLabelEl.textContent = tToolbar('toolbar.automap.label', lang)
|
|
}
|
|
if (this.automapSelectEl && typeof document !== 'undefined') {
|
|
this.automapSelectEl.innerHTML = ''
|
|
for (const mode of VALID_AUTOMAP_REVEAL_MODES) {
|
|
const opt = document.createElement('option')
|
|
opt.value = mode
|
|
opt.textContent = tToolbar(`toolbar.automap.${mode}` as const, lang)
|
|
this.automapSelectEl.appendChild(opt)
|
|
}
|
|
this.automapSelectEl.value = snap.settings.automapReveal
|
|
}
|
|
if (this.viewportLabelEl) {
|
|
this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang)
|
|
}
|
|
if (this.viewportSelectEl) {
|
|
this.viewportSelectEl.value = snap.settings.viewport
|
|
}
|
|
if (this.cacheClearBtnEl) {
|
|
if (this.cacheClearedFlash) {
|
|
this.cacheClearBtnEl.textContent = tToolbar('toolbar.cache.cleared', lang)
|
|
} else {
|
|
const baseLabel = tToolbar('toolbar.cache.clear', lang)
|
|
this.cacheClearBtnEl.textContent =
|
|
this.cacheUsageBytes > 0
|
|
? `${baseLabel} (${formatCacheBytesMb(this.cacheUsageBytes)})`
|
|
: baseLabel
|
|
}
|
|
}
|
|
if (this.diagBannerEl) {
|
|
if (snap.diagnosticBannerText) {
|
|
this.diagBannerEl.textContent = snap.diagnosticBannerText
|
|
this.diagBannerEl.className = 'd2-toolbar__diag d2-toolbar__diag--visible'
|
|
} else {
|
|
this.diagBannerEl.textContent = ''
|
|
this.diagBannerEl.className = 'd2-toolbar__diag'
|
|
}
|
|
}
|
|
}
|
|
}
|