From 526bb83d0b39c6fd3024dbec1d805341a92491db Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 13:39:09 +0000 Subject: [PATCH] feat: create EarthBound-styled CommandPanel UI component Co-Authored-By: Claude Opus 4.6 --- client/src/ui/CommandPanel.ts | 170 ++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 client/src/ui/CommandPanel.ts diff --git a/client/src/ui/CommandPanel.ts b/client/src/ui/CommandPanel.ts new file mode 100644 index 0000000..bbdc51c --- /dev/null +++ b/client/src/ui/CommandPanel.ts @@ -0,0 +1,170 @@ +// EarthBound-inspired color palette (matches NpcInfoPanel) +const EB = { + bgDeep: '#0c0824', + bgPanel: '#141038', + borderOuter: '#7878d8', + borderInner: '#5858b8', + borderGap: '#1c1450', + textPrimary: '#f0f0ff', + textSecondary: '#9898d0', + textMuted: '#6868a8', + shine: 'rgba(200,200,255,0.08)', +}; + +interface Command { + key: string; + label: string; +} + +export class CommandPanel { + private outerFrame: HTMLDivElement; + private listContainer: HTMLDivElement; + private visible = false; + + constructor() { + // Outer frame (doubled border, same as NpcInfoPanel) + this.outerFrame = document.createElement('div'); + this.outerFrame.id = 'command-panel'; + this.outerFrame.style.cssText = ` + position: fixed; + bottom: 16px; + right: 16px; + min-width: 160px; + border-radius: 12px; + border: 3px solid ${EB.borderOuter}; + background: ${EB.borderGap}; + z-index: 1000; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + box-shadow: + 0 0 16px rgba(80, 60, 160, 0.3), + 0 4px 20px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(160, 160, 255, 0.15); + `; + + // Inner container + const container = document.createElement('div'); + container.style.cssText = ` + margin: 3px; + border-radius: 8px; + border: 2px solid ${EB.borderInner}; + background: ${EB.bgPanel}; + overflow: hidden; + position: relative; + padding: 8px 12px 10px; + font-family: 'Press Start 2P', monospace; + `; + + // Shine overlay + const shine = document.createElement('div'); + shine.style.cssText = ` + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient( + 135deg, + ${EB.shine} 0%, + transparent 40%, + transparent 60%, + rgba(0,0,0,0.05) 100% + ); + pointer-events: none; + z-index: 1; + `; + container.appendChild(shine); + + // Title + const title = document.createElement('div'); + title.style.cssText = ` + font-size: 6px; + color: ${EB.textMuted}; + text-align: center; + letter-spacing: 4px; + padding-bottom: 6px; + user-select: none; + position: relative; + z-index: 2; + `; + title.textContent = '\u25C6 COMMANDS \u25C6'; + container.appendChild(title); + + // Commands list + this.listContainer = document.createElement('div'); + this.listContainer.style.cssText = ` + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + z-index: 2; + `; + container.appendChild(this.listContainer); + + this.outerFrame.appendChild(container); + document.body.appendChild(this.outerFrame); + + // Add default commands + this.addCommand({ key: '1', label: 'Spawn NPC' }); + } + + private addCommand(cmd: Command): void { + const row = document.createElement('div'); + row.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + `; + + const keyBadge = document.createElement('div'); + keyBadge.style.cssText = ` + font-size: 7px; + color: ${EB.bgDeep}; + background: ${EB.borderOuter}; + border-radius: 3px; + padding: 2px 5px; + min-width: 12px; + text-align: center; + text-shadow: none; + font-weight: bold; + box-shadow: 0 1px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.2); + `; + keyBadge.textContent = cmd.key; + + const label = document.createElement('div'); + label.style.cssText = ` + font-size: 7px; + color: ${EB.textSecondary}; + letter-spacing: 0.5px; + `; + label.textContent = cmd.label; + + row.appendChild(keyBadge); + row.appendChild(label); + this.listContainer.appendChild(row); + } + + show(): void { + if (this.visible) return; + this.visible = true; + this.outerFrame.style.opacity = '1'; + this.outerFrame.style.transform = 'translateY(0)'; + this.outerFrame.style.pointerEvents = 'auto'; + } + + hide(): void { + if (!this.visible) return; + this.visible = false; + this.outerFrame.style.opacity = '0'; + this.outerFrame.style.transform = 'translateY(20px)'; + this.outerFrame.style.pointerEvents = 'none'; + } + + isVisible(): boolean { + return this.visible; + } + + destroy(): void { + this.outerFrame.remove(); + } +}