From ffdfd1474e06c600e2c6e6fc487aa9f80c5a5fc0 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 04:35:46 +0000 Subject: [PATCH] feat: add NpcInfoPanel HTML/CSS overlay with EarthBound-style UI Co-Authored-By: Claude Opus 4.6 --- client/src/ui/NpcInfoPanel.ts | 146 ++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 client/src/ui/NpcInfoPanel.ts diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts new file mode 100644 index 0000000..791789b --- /dev/null +++ b/client/src/ui/NpcInfoPanel.ts @@ -0,0 +1,146 @@ +import type { EntityState } from '@dflike/shared'; + +const ACTIVITY_LABELS: Record = { + wander: 'Wandering', + eat: 'Eating', + rest: 'Resting', +}; + +export class NpcInfoPanel { + private container: HTMLDivElement; + private portraitImg: HTMLImageElement; + private nameEl: HTMLDivElement; + private activityEl: HTMLDivElement; + private needsBarsContainer: HTMLDivElement; + private needsBars: Map = new Map(); + private visible = false; + + constructor() { + this.container = document.createElement('div'); + this.container.id = 'npc-info-panel'; + this.applyContainerStyles(); + + // Portrait + this.portraitImg = document.createElement('img'); + this.portraitImg.id = 'npc-portrait'; + this.applyPortraitStyles(); + this.container.appendChild(this.portraitImg); + + // Info section + const infoSection = document.createElement('div'); + infoSection.style.cssText = 'display:flex;flex-direction:column;gap:6px;padding:8px 12px 12px;'; + + this.nameEl = document.createElement('div'); + this.nameEl.id = 'npc-name'; + infoSection.appendChild(this.nameEl); + + this.activityEl = document.createElement('div'); + this.activityEl.id = 'npc-activity'; + infoSection.appendChild(this.activityEl); + + this.needsBarsContainer = document.createElement('div'); + this.needsBarsContainer.id = 'npc-needs'; + this.needsBarsContainer.style.cssText = 'display:flex;flex-direction:column;gap:4px;margin-top:4px;'; + infoSection.appendChild(this.needsBarsContainer); + + this.container.appendChild(infoSection); + document.body.appendChild(this.container); + } + + private applyContainerStyles(): void { + this.container.style.cssText = ` + position: fixed; + top: 16px; + right: 16px; + width: 220px; + background: #1a1040; + border: 4px solid #6060c0; + border-radius: 12px; + box-shadow: 0 0 0 2px #303070, inset 0 0 0 2px #303070; + font-family: 'Press Start 2P', monospace; + color: #e8e8ff; + z-index: 1000; + transform: translateX(300px); + transition: transform 0.3s ease-out; + overflow: hidden; + `; + } + + private applyPortraitStyles(): void { + this.portraitImg.style.cssText = ` + width: 100%; + height: auto; + image-rendering: pixelated; + display: block; + background: #0d0828; + border-bottom: 4px solid #6060c0; + `; + } + + show(entity: EntityState, portraitDataUrl: string): void { + this.portraitImg.src = portraitDataUrl; + this.updateInfo(entity); + this.visible = true; + this.container.style.transform = 'translateX(0)'; + } + + hide(): void { + this.visible = false; + this.container.style.transform = 'translateX(300px)'; + } + + updateInfo(entity: EntityState): void { + this.nameEl.textContent = entity.name ?? `NPC #${entity.id}`; + this.nameEl.style.cssText = 'font-size:10px;font-weight:bold;text-align:center;'; + + const goal = entity.npcBrain?.currentGoal; + this.activityEl.textContent = goal ? (ACTIVITY_LABELS[goal] ?? goal) : 'Idle'; + this.activityEl.style.cssText = 'font-size:8px;text-align:center;color:#a0a0e0;'; + + if (entity.needs) { + this.updateNeeds(entity.needs); + } + } + + updateNeeds(needs: { hunger: number; energy: number }): void { + this.setNeedBar('Hunger', needs.hunger); + this.setNeedBar('Energy', needs.energy); + } + + updatePortrait(portraitDataUrl: string): void { + this.portraitImg.style.opacity = '0'; + setTimeout(() => { + this.portraitImg.src = portraitDataUrl; + this.portraitImg.style.opacity = '1'; + }, 150); + this.portraitImg.style.transition = 'opacity 0.15s'; + } + + private setNeedBar(label: string, value: number): void { + let bar = this.needsBars.get(label); + if (!bar) { + bar = document.createElement('div'); + bar.innerHTML = ` +
${label}
+
+
+
+ `; + this.needsBarsContainer.appendChild(bar); + this.needsBars.set(label, bar); + } + const fill = bar.querySelector('.bar-fill') as HTMLDivElement; + fill.style.width = `${Math.max(0, Math.min(100, value))}%`; + if (value > 60) fill.style.background = '#40c040'; + else if (value > 30) fill.style.background = '#c0c040'; + else fill.style.background = '#c04040'; + } + + isVisible(): boolean { + return this.visible; + } + + destroy(): void { + this.container.remove(); + } +}