diff --git a/play.html b/play.html
index f15f686..4c16fac 100644
--- a/play.html
+++ b/play.html
@@ -46,9 +46,146 @@
#d2-toolbar-container {
flex: 0 0 auto;
+ width: 100%;
z-index: 50;
}
+ #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);
+ 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__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;
+ }
+
#play-workspace {
flex: 1 1 auto;
min-height: 0;
diff --git a/src/client/main.ts b/src/client/main.ts
index 08cb3ed..69becb2 100644
--- a/src/client/main.ts
+++ b/src/client/main.ts
@@ -169,7 +169,39 @@ async function bootPlayApp(): Promise {
const initialSettings = settingsStore.get()
setLang(initialSettings.lang)
+ let sessionRef: OnlineSession | null = null
+
+ const toolbar = new Toolbar({
+ store: settingsStore,
+ container: toolbarContainer,
+ callbacks: {
+ onLangChange: (lang: GameLang) => {
+ setLang(lang)
+ sessionRef?.hudManager.setLang(lang)
+ },
+ onViewportChange: (profile: ViewportProfile) => {
+ applyViewportProfileToDom(profile, {
+ stageEl,
+ glCanvas: viewCanvas,
+ hudCanvas,
+ })
+ sessionRef?.hudManager.resize(profile.width, profile.height)
+ sessionRef?.camera.resize(profile)
+ },
+ onAutomapRevealChange: (mode) => {
+ sessionRef?.automapView.setMode(mode)
+ },
+ onInspectorToggle: (open) => {
+ sessionRef?.packetInspector.setOpen(open)
+ },
+ onServerHostReconnect: (newHost) => {
+ void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
+ },
+ },
+ })
+
const initialCacheStatus = await registerAssetCache()
+ toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes)
const minimap = new Minimap()
const renderer = new SpriteRenderer(viewCanvas)
@@ -183,8 +215,6 @@ async function bootPlayApp(): Promise {
let itemsAtlasHandle: AtlasHandle | undefined
let lastRenderSnapshot: SceneFrameSnapshot | null = null
- let sessionRef: OnlineSession | null = null
-
const hudManager = new HudManager(hudCanvas, {
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
@@ -339,35 +369,6 @@ async function bootPlayApp(): Promise {
}
}
- const toolbar = new Toolbar({
- store: settingsStore,
- container: toolbarContainer,
- callbacks: {
- onLangChange: (lang: GameLang) => {
- setLang(lang)
- session.hudManager.setLang(lang)
- },
- onViewportChange: (profile: ViewportProfile) => {
- applyViewportProfileToDom(profile, {
- stageEl,
- glCanvas: viewCanvas,
- hudCanvas,
- })
- session.hudManager.resize(profile.width, profile.height)
- session.camera.resize(profile)
- },
- onAutomapRevealChange: (mode) => {
- session.automapView.setMode(mode)
- },
- onInspectorToggle: (open) => {
- inspector.setOpen(open)
- },
- onServerHostReconnect: (newHost) => {
- void session.disconnect(`Reconnecting to ${newHost}`)
- },
- },
- })
- toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes)
void assetsReadyPromise.then(async () => {
const updatedStatus = await getAssetCacheStatus()
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
diff --git a/src/client/toolbar/toolbar.css b/src/client/toolbar/toolbar.css
index 6acfa71..50b5cba 100644
--- a/src/client/toolbar/toolbar.css
+++ b/src/client/toolbar/toolbar.css
@@ -5,9 +5,11 @@
position: relative;
z-index: 1000;
display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
- gap: 10px;
+ gap: 12px;
width: 100%;
height: 32px;
min-height: 32px;
@@ -17,15 +19,21 @@
background: linear-gradient(180deg, #1b1712 0%, #0d0b08 100%);
border-bottom: 1px solid #4a3c24;
color: #d6c496;
- font-family: "Exocet", "Cinzel", "Noto Serif SC", "PingFang SC", Georgia, serif;
+ 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;
}
diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts
index 2ca7b32..666eccc 100644
--- a/src/client/toolbar/toolbar.ts
+++ b/src/client/toolbar/toolbar.ts
@@ -82,13 +82,12 @@ export interface ToolbarSnapshot {
}
/**
- * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with 6 primary controls:
+ * 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. Server Host Input (`www.laiseek.xyz` default, bare-IP over HTTPS guard, mid-game reconnect prompt)
- * 3. Live Language Toggle (`中` / `EN`)
- * 4. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
- * 5. Automap Reveal Selector (`off | level | act`)
- * 6. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button
+ * 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 Toggle (`800x600 | 1024x768`) + Asset Cache Clear button
*/
export class Toolbar {
private readonly store: SettingsStore
@@ -112,9 +111,6 @@ export class Toolbar {
private statusDotEl: HTMLElement | null = null
private statusTextEl: HTMLElement | null = null
private rttTextEl: HTMLElement | null = null
- private serverLabelEl: HTMLElement | null = null
- private serverInputEl: HTMLInputElement | null = null
- private serverApplyBtnEl: HTMLButtonElement | null = null
private langBtnZhEl: HTMLButtonElement | null = null
private langBtnEnEl: HTMLButtonElement | null = null
private lightingLabelEl: HTMLElement | null = null
@@ -395,8 +391,146 @@ export class Toolbar {
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'
@@ -430,35 +564,7 @@ export class Toolbar {
statusGroup.appendChild(statusBadge)
statusGroup.appendChild(inspectorBtn)
- // Group 2: Server Host Input
- const serverGroup = doc.createElement('div')
- serverGroup.className = 'd2-toolbar__group'
- const serverLabel = doc.createElement('label')
- serverLabel.className = 'd2-toolbar__label'
- serverLabel.htmlFor = 'd2-toolbar-server-host'
- const serverInput = doc.createElement('input')
- serverInput.type = 'text'
- serverInput.id = 'd2-toolbar-server-host'
- serverInput.className = 'd2-toolbar__input'
- serverInput.value = this.serverInputValue
- serverInput.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter') {
- ev.preventDefault()
- this.submitServerHost(serverInput.value)
- }
- })
- const serverApplyBtn = doc.createElement('button')
- serverApplyBtn.type = 'button'
- serverApplyBtn.id = 'd2-toolbar-server-apply'
- serverApplyBtn.className = 'd2-toolbar__btn'
- serverApplyBtn.addEventListener('click', () => {
- this.submitServerHost(serverInput.value)
- })
- serverGroup.appendChild(serverLabel)
- serverGroup.appendChild(serverInput)
- serverGroup.appendChild(serverApplyBtn)
-
- // Group 3: Language Toggle (中 / EN)
+ // Group 2: Language Toggle (中 / EN)
const langGroup = doc.createElement('div')
langGroup.className = 'd2-toolbar__group'
const langBtnZh = doc.createElement('button')
@@ -478,7 +584,7 @@ export class Toolbar {
langGroup.appendChild(langBtnZh)
langGroup.appendChild(langBtnEn)
- // Group 4: Lighting Selector
+ // Group 3: Lighting Selector
const lightingGroup = doc.createElement('div')
lightingGroup.className = 'd2-toolbar__group'
const lightingLabel = doc.createElement('label')
@@ -493,7 +599,7 @@ export class Toolbar {
lightingGroup.appendChild(lightingLabel)
lightingGroup.appendChild(lightingSelect)
- // Group 5: Automap Reveal Selector (Off / Level / Act)
+ // Group 4: Automap Reveal Selector (Off / Level / Act)
const automapGroup = doc.createElement('div')
automapGroup.className = 'd2-toolbar__group'
const automapLabel = doc.createElement('label')
@@ -508,7 +614,7 @@ export class Toolbar {
automapGroup.appendChild(automapLabel)
automapGroup.appendChild(automapSelect)
- // Group 6: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button
+ // Group 5: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button
const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label')
@@ -545,7 +651,6 @@ export class Toolbar {
diagBanner.className = 'd2-toolbar__diag'
root.appendChild(statusGroup)
- root.appendChild(serverGroup)
root.appendChild(langGroup)
root.appendChild(lightingGroup)
root.appendChild(automapGroup)
@@ -560,9 +665,6 @@ export class Toolbar {
this.statusDotEl = dot
this.statusTextEl = statusText
this.rttTextEl = rttText
- this.serverLabelEl = serverLabel
- this.serverInputEl = serverInput
- this.serverApplyBtnEl = serverApplyBtn
this.langBtnZhEl = langBtnZh
this.langBtnEnEl = langBtnEn
this.lightingLabelEl = lightingLabel
@@ -598,21 +700,6 @@ export class Toolbar {
? 'd2-toolbar__btn d2-toolbar__btn--active'
: 'd2-toolbar__btn'
}
- if (this.serverLabelEl) {
- this.serverLabelEl.textContent = tToolbar('toolbar.server.label', lang)
- }
- if (this.serverInputEl) {
- this.serverInputEl.placeholder = tToolbar('toolbar.server.placeholder', lang)
- if (this.serverInputEl.value !== snap.serverInputValue) {
- this.serverInputEl.value = snap.serverInputValue
- }
- this.serverInputEl.className = snap.serverInputValid
- ? 'd2-toolbar__input'
- : 'd2-toolbar__input d2-toolbar__input--invalid'
- }
- if (this.serverApplyBtnEl) {
- this.serverApplyBtnEl.textContent = tToolbar('toolbar.server.apply', lang)
- }
if (this.langBtnZhEl) {
this.langBtnZhEl.className =
lang === 'zh' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'