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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user