feat: add NpcInfoPanel HTML/CSS overlay with EarthBound-style UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 04:35:46 +00:00
parent 1bbbba477b
commit ffdfd1474e
+146
View File
@@ -0,0 +1,146 @@
import type { EntityState } from '@dflike/shared';
const ACTIVITY_LABELS: Record<string, string> = {
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<string, HTMLDivElement> = 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 = `
<div style="font-size:7px;margin-bottom:2px;">${label}</div>
<div style="background:#0d0828;border:2px solid #4040a0;border-radius:4px;height:10px;overflow:hidden;">
<div class="bar-fill" style="height:100%;transition:width 0.3s;border-radius:2px;"></div>
</div>
`;
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();
}
}