1cb0570a49
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1053 lines
34 KiB
TypeScript
1053 lines
34 KiB
TypeScript
import type { EntityState, NarrationEvent, MemoryEvent, Stats, StatName, DesireCategory } from '@dflike/shared';
|
|
import { attachTooltip } from './tooltip.js';
|
|
|
|
const ACTIVITY_LABELS: Record<string, string> = {
|
|
wander: 'Wandering',
|
|
eat: 'Eating',
|
|
sleep: 'Sleeping',
|
|
gather: 'Gathering',
|
|
craft: 'Crafting',
|
|
build: 'Building',
|
|
dropoff: 'Dropping off',
|
|
pickup: 'Picking up',
|
|
};
|
|
|
|
// EarthBound-inspired color palette
|
|
const EB = {
|
|
bgDeep: '#0c0824', // Deepest background (portrait well)
|
|
bgPanel: '#141038', // Panel body
|
|
bgBar: '#0a0620', // Bar trough
|
|
borderOuter: '#7878d8', // Outer border highlight
|
|
borderInner: '#5858b8', // Inner border
|
|
borderGap: '#1c1450', // Gap between double borders
|
|
textPrimary: '#f0f0ff', // Name, labels
|
|
textSecondary: '#9898d0', // Activity, sublabels
|
|
textMuted: '#6868a8', // Decorative
|
|
barGreen: '#58d858', // Healthy
|
|
barYellow: '#d8d858', // Mid
|
|
barRed: '#d85858', // Critical
|
|
barGreenDim: '#284828', // Bar bg tint when green
|
|
barYellowDim: '#484828', // Bar bg tint when yellow
|
|
barRedDim: '#482828', // Bar bg tint when red
|
|
shine: 'rgba(200,200,255,0.08)', // Subtle window shine
|
|
};
|
|
|
|
function desireCategoryColor(category: string): string {
|
|
switch (category) {
|
|
case 'material': return '#d4a574';
|
|
case 'shelter': return '#c4956a';
|
|
case 'comfort': return '#e8b87a';
|
|
case 'social': return '#7ab8e8';
|
|
case 'community': return '#7ae8a0';
|
|
case 'creative': return '#c87ae8';
|
|
default: return '#9898d0';
|
|
}
|
|
}
|
|
|
|
const STAT_FULL_NAMES: Record<string, string> = {
|
|
STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', PER: 'Perception',
|
|
SOC: 'Sociability', COU: 'Courage', CUR: 'Curiosity', EMP: 'Empathy', TMP: 'Temperament',
|
|
};
|
|
|
|
export class NpcInfoPanel {
|
|
private outerFrame: HTMLDivElement;
|
|
private portraitImg: HTMLImageElement;
|
|
private nameEl: HTMLDivElement;
|
|
private activityEl: HTMLDivElement;
|
|
private recentEventsEl: HTMLDivElement;
|
|
private thoughtEl: HTMLDivElement;
|
|
private separatorEl: HTMLDivElement;
|
|
private needsBarsContainer: HTMLDivElement;
|
|
private needsBars: Map<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = new Map();
|
|
private backstoryEl: HTMLDivElement;
|
|
private desiresEl: HTMLDivElement;
|
|
private statsContainer: HTMLDivElement;
|
|
private statElements: Map<string, HTMLDivElement> = new Map();
|
|
private statusTab!: HTMLDivElement;
|
|
private desiresTab!: HTMLDivElement;
|
|
private relationshipsTab!: HTMLDivElement;
|
|
private historyTab!: HTMLDivElement;
|
|
private inventoryTab!: HTMLDivElement;
|
|
private statusContent!: HTMLDivElement;
|
|
private desiresContent!: HTMLDivElement;
|
|
private relationshipsContent!: HTMLDivElement;
|
|
private historyContent!: HTMLDivElement;
|
|
private inventoryContent!: HTMLDivElement;
|
|
private activeTab: 'status' | 'desires' | 'relationships' | 'history' | 'inventory' = 'status';
|
|
private expandedTiers: Set<string> = new Set();
|
|
private tooltipCleanups: (() => void)[] = [];
|
|
private currentName = '';
|
|
private visible = false;
|
|
|
|
constructor() {
|
|
// Outer frame (flex row: tabColumn + panelBody)
|
|
this.outerFrame = document.createElement('div');
|
|
this.outerFrame.id = 'npc-info-panel';
|
|
this.outerFrame.style.cssText = `
|
|
position: fixed;
|
|
top: 16px;
|
|
right: 16px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
z-index: 1000;
|
|
max-height: calc(100vh - 32px);
|
|
transform: translateX(410px);
|
|
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
|
`;
|
|
|
|
// Vertical tab column (left edge)
|
|
const tabColumn = document.createElement('div');
|
|
tabColumn.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-self: center;
|
|
gap: 2px;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
this.statusTab = this.createTab('S');
|
|
this.desiresTab = this.createTab('D');
|
|
this.relationshipsTab = this.createTab('R');
|
|
this.historyTab = this.createTab('H');
|
|
this.inventoryTab = this.createTab('I');
|
|
|
|
this.statusTab.dataset.active = 'true';
|
|
this.statusTab.style.background = '#2a2a4e';
|
|
|
|
this.statusTab.addEventListener('click', () => this.switchTab('status'));
|
|
this.desiresTab.addEventListener('click', () => this.switchTab('desires'));
|
|
this.relationshipsTab.addEventListener('click', () => this.switchTab('relationships'));
|
|
this.historyTab.addEventListener('click', () => this.switchTab('history'));
|
|
this.inventoryTab.addEventListener('click', () => this.switchTab('inventory'));
|
|
|
|
this.tooltipCleanups.push(attachTooltip(this.statusTab, 'Status', 'left'));
|
|
this.tooltipCleanups.push(attachTooltip(this.desiresTab, 'Desires', 'left'));
|
|
this.tooltipCleanups.push(attachTooltip(this.relationshipsTab, 'Relationships', 'left'));
|
|
this.tooltipCleanups.push(attachTooltip(this.historyTab, 'History', 'left'));
|
|
this.tooltipCleanups.push(attachTooltip(this.inventoryTab, 'Inventory', 'left'));
|
|
|
|
tabColumn.appendChild(this.statusTab);
|
|
tabColumn.appendChild(this.desiresTab);
|
|
tabColumn.appendChild(this.relationshipsTab);
|
|
tabColumn.appendChild(this.historyTab);
|
|
tabColumn.appendChild(this.inventoryTab);
|
|
|
|
// Panel body (has border/shadow, wraps inner container)
|
|
const panelBody = document.createElement('div');
|
|
panelBody.style.cssText = `
|
|
width: 340px;
|
|
border-radius: 16px;
|
|
border: 3px solid ${EB.borderOuter};
|
|
background: ${EB.borderGap};
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow:
|
|
0 0 20px rgba(80, 60, 160, 0.4),
|
|
0 8px 32px rgba(0, 0, 0, 0.6),
|
|
inset 0 1px 0 rgba(160, 160, 255, 0.15);
|
|
`;
|
|
|
|
// Inner container (creates the gap effect)
|
|
const innerContainer = document.createElement('div');
|
|
innerContainer.style.cssText = `
|
|
margin: 3px;
|
|
border-radius: 12px;
|
|
border: 2px solid ${EB.borderInner};
|
|
background: ${EB.bgPanel};
|
|
overflow: hidden;
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
`;
|
|
|
|
// Subtle diagonal 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;
|
|
`;
|
|
innerContainer.appendChild(shine);
|
|
|
|
// Portrait well
|
|
const portraitWell = document.createElement('div');
|
|
portraitWell.style.cssText = `
|
|
background: ${EB.bgDeep};
|
|
border-bottom: 2px solid ${EB.borderInner};
|
|
padding: 8px;
|
|
display: flex;
|
|
justify-content: center;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
// Inner portrait border (another doubled frame, just for the portrait)
|
|
const portraitFrame = document.createElement('div');
|
|
portraitFrame.style.cssText = `
|
|
border: 2px solid ${EB.borderOuter};
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
background: ${EB.borderGap};
|
|
line-height: 0;
|
|
`;
|
|
|
|
const portraitInner = document.createElement('div');
|
|
portraitInner.style.cssText = `
|
|
border: 1px solid ${EB.borderInner};
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
line-height: 0;
|
|
background: ${EB.bgDeep};
|
|
`;
|
|
|
|
this.portraitImg = document.createElement('img');
|
|
this.portraitImg.style.cssText = `
|
|
width: 192px;
|
|
height: 192px;
|
|
image-rendering: pixelated;
|
|
display: block;
|
|
opacity: 1;
|
|
transition: opacity 0.15s ease;
|
|
`;
|
|
|
|
portraitInner.appendChild(this.portraitImg);
|
|
portraitFrame.appendChild(portraitInner);
|
|
portraitWell.appendChild(portraitFrame);
|
|
innerContainer.appendChild(portraitWell);
|
|
|
|
// Content wrapper (scrollable area below portrait)
|
|
const contentWrapper = document.createElement('div');
|
|
contentWrapper.style.cssText = `
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
flex: 1;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: ${EB.borderInner} ${EB.bgDeep};
|
|
`;
|
|
|
|
// Status content (existing info section)
|
|
this.statusContent = document.createElement('div');
|
|
this.statusContent.style.cssText = `
|
|
padding: 10px 12px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
position: relative;
|
|
z-index: 2;
|
|
`;
|
|
|
|
// Name
|
|
this.nameEl = document.createElement('div');
|
|
this.nameEl.style.cssText = `
|
|
font-size: 20px;
|
|
color: ${EB.textPrimary};
|
|
text-align: center;
|
|
padding: 2px 0 4px;
|
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
|
letter-spacing: 0.5px;
|
|
`;
|
|
this.statusContent.appendChild(this.nameEl);
|
|
|
|
// Activity
|
|
this.activityEl = document.createElement('div');
|
|
this.activityEl.style.cssText = `
|
|
font-size: 16px;
|
|
color: ${EB.textSecondary};
|
|
text-align: center;
|
|
padding-bottom: 2px;
|
|
`;
|
|
this.statusContent.appendChild(this.activityEl);
|
|
|
|
// Backstory text
|
|
this.backstoryEl = document.createElement('div');
|
|
this.backstoryEl.style.cssText = `
|
|
font-size: 11px;
|
|
color: ${EB.textSecondary};
|
|
text-align: center;
|
|
padding: 2px 8px 4px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
line-height: 1.6;
|
|
font-style: italic;
|
|
min-height: 0;
|
|
transition: min-height 0.3s ease, opacity 0.3s ease;
|
|
opacity: 0;
|
|
`;
|
|
this.statusContent.appendChild(this.backstoryEl);
|
|
|
|
// Inner thought (italic first-person, shown above recent events)
|
|
this.thoughtEl = document.createElement('div');
|
|
this.thoughtEl.style.cssText = `
|
|
display: none;
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 10px;
|
|
color: ${EB.textSecondary};
|
|
text-align: center;
|
|
padding: 4px 8px;
|
|
font-style: italic;
|
|
line-height: 1.6;
|
|
`;
|
|
this.statusContent.appendChild(this.thoughtEl);
|
|
|
|
// Recent events section (hidden by default)
|
|
this.recentEventsEl = document.createElement('div');
|
|
this.recentEventsEl.style.cssText = `
|
|
display: none;
|
|
padding: 2px 0;
|
|
`;
|
|
this.statusContent.appendChild(this.recentEventsEl);
|
|
|
|
// Decorative separator (EarthBound diamond pattern)
|
|
this.separatorEl = document.createElement('div');
|
|
this.separatorEl.style.cssText = `
|
|
text-align: center;
|
|
font-size: 11px;
|
|
color: ${EB.textMuted};
|
|
padding: 4px 0;
|
|
letter-spacing: 6px;
|
|
user-select: none;
|
|
`;
|
|
this.separatorEl.textContent = '\u25C6\u25C6\u25C6';
|
|
this.statusContent.appendChild(this.separatorEl);
|
|
|
|
// Needs bars container
|
|
this.needsBarsContainer = document.createElement('div');
|
|
this.needsBarsContainer.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
`;
|
|
this.statusContent.appendChild(this.needsBarsContainer);
|
|
|
|
// Stats separator
|
|
const statsSeparator = document.createElement('div');
|
|
statsSeparator.style.cssText = `
|
|
text-align: center;
|
|
font-size: 11px;
|
|
color: ${EB.textMuted};
|
|
padding: 4px 0;
|
|
letter-spacing: 6px;
|
|
user-select: none;
|
|
`;
|
|
statsSeparator.textContent = '\u25C6\u25C6\u25C6';
|
|
this.statusContent.appendChild(statsSeparator);
|
|
|
|
// Stats container - two columns
|
|
this.statsContainer = document.createElement('div');
|
|
this.statsContainer.style.cssText = `
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 3px 8px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
this.statusContent.appendChild(this.statsContainer);
|
|
|
|
contentWrapper.appendChild(this.statusContent);
|
|
|
|
this.desiresContent = document.createElement('div');
|
|
this.desiresContent.style.cssText = `
|
|
display: none;
|
|
padding: 10px 12px 12px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
this.desiresEl = document.createElement('div');
|
|
this.desiresEl.style.cssText = `
|
|
padding: 4px 8px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 10px;
|
|
line-height: 1.8;
|
|
`;
|
|
this.desiresContent.appendChild(this.desiresEl);
|
|
contentWrapper.appendChild(this.desiresContent);
|
|
|
|
this.relationshipsContent = document.createElement('div');
|
|
this.relationshipsContent.style.cssText = `
|
|
display: none;
|
|
padding: 8px 12px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
contentWrapper.appendChild(this.relationshipsContent);
|
|
|
|
this.historyContent = document.createElement('div');
|
|
this.historyContent.style.cssText = `
|
|
display: none;
|
|
padding: 8px 12px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
contentWrapper.appendChild(this.historyContent);
|
|
|
|
this.inventoryContent = document.createElement('div');
|
|
this.inventoryContent.style.cssText = `
|
|
display: none;
|
|
padding: 10px 12px 12px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
contentWrapper.appendChild(this.inventoryContent);
|
|
|
|
innerContainer.appendChild(contentWrapper);
|
|
panelBody.appendChild(innerContainer);
|
|
|
|
this.outerFrame.appendChild(tabColumn);
|
|
this.outerFrame.appendChild(panelBody);
|
|
document.body.appendChild(this.outerFrame);
|
|
}
|
|
|
|
private createTab(label: string): HTMLDivElement {
|
|
const tab = document.createElement('div');
|
|
tab.style.cssText = `
|
|
width: 32px;
|
|
height: 56px;
|
|
background: #1a1a2e;
|
|
border: 3px solid ${EB.borderOuter};
|
|
border-right: none;
|
|
border-radius: 6px 0 0 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background 0.2s;
|
|
`;
|
|
|
|
const tabInner = document.createElement('div');
|
|
tabInner.style.cssText = `
|
|
color: #e0d0b0;
|
|
font-size: 20px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
`;
|
|
tabInner.textContent = label;
|
|
tab.appendChild(tabInner);
|
|
|
|
tab.addEventListener('mouseenter', () => {
|
|
if (tab.dataset.active !== 'true') {
|
|
tab.style.background = '#2a2a4e';
|
|
}
|
|
});
|
|
tab.addEventListener('mouseleave', () => {
|
|
if (tab.dataset.active !== 'true') {
|
|
tab.style.background = '#1a1a2e';
|
|
}
|
|
});
|
|
|
|
return tab;
|
|
}
|
|
|
|
show(entity: EntityState, portraitDataUrl: string): void {
|
|
this.portraitImg.src = portraitDataUrl;
|
|
this.updateInfo(entity);
|
|
this.visible = true;
|
|
this.outerFrame.style.transform = 'translateX(0)';
|
|
}
|
|
|
|
hide(): void {
|
|
this.visible = false;
|
|
this.outerFrame.style.transform = 'translateX(410px)';
|
|
}
|
|
|
|
updateInfo(entity: EntityState): void {
|
|
const name = entity.name ?? `NPC #${entity.id}`;
|
|
this.currentName = name;
|
|
this.nameEl.textContent = name;
|
|
|
|
// Show social state if interacting
|
|
if (entity.socialState && entity.socialState.phase !== 'none') {
|
|
this.activityEl.textContent = 'Socializing';
|
|
} else {
|
|
const goal = entity.npcBrain?.currentGoal;
|
|
this.activityEl.textContent = goal ? (ACTIVITY_LABELS[goal] ?? goal) : 'Idle';
|
|
}
|
|
|
|
// Update backstory
|
|
const backstory = entity.backstory;
|
|
if (backstory && backstory.length > 0) {
|
|
this.backstoryEl.textContent = backstory;
|
|
this.backstoryEl.style.opacity = '1';
|
|
this.backstoryEl.style.minHeight = '40px';
|
|
} else {
|
|
this.backstoryEl.textContent = '';
|
|
this.backstoryEl.style.opacity = '0';
|
|
this.backstoryEl.style.minHeight = '0';
|
|
}
|
|
|
|
// Update desires
|
|
const desires = entity.desires;
|
|
if (desires && desires.length > 0) {
|
|
let html = `<div style="color: ${EB.textMuted}; font-size: 9px; margin-bottom: 2px;">Desires</div>`;
|
|
for (const desire of desires) {
|
|
const color = desireCategoryColor(desire.category);
|
|
html += `<div style="color: ${color}; padding: 1px 0;">◆ ${desire.description}</div>`;
|
|
}
|
|
const empty = 3 - desires.length;
|
|
for (let i = 0; i < empty; i++) {
|
|
html += `<div style="color: ${EB.textMuted}; font-style: italic; padding: 1px 0;">○ (daydreaming...)</div>`;
|
|
}
|
|
this.desiresEl.innerHTML = html;
|
|
this.desiresEl.style.display = 'block';
|
|
} else {
|
|
this.desiresEl.innerHTML = `<div style="color: ${EB.textMuted}; font-size: 9px;">Content for now.</div>`;
|
|
this.desiresEl.style.display = 'block';
|
|
}
|
|
|
|
if (entity.needs) {
|
|
this.updateNeeds(entity.needs);
|
|
}
|
|
|
|
if (entity.stats) {
|
|
this.updateStats(entity.stats);
|
|
}
|
|
|
|
if (entity.relationships) {
|
|
this.updateRelationships(entity.relationships);
|
|
}
|
|
|
|
this.updateInventory(entity.inventory);
|
|
}
|
|
|
|
updateNeeds(needs: { hunger: number; thirst: number; energy: number; productivity: number }): void {
|
|
this.setNeedBar('Hunger', needs.hunger);
|
|
this.setNeedBar('Thirst', needs.thirst);
|
|
this.setNeedBar('Energy', needs.energy);
|
|
this.setNeedBar('Productivity', needs.productivity);
|
|
}
|
|
|
|
private updateInventory(inventory?: Record<string, number>): void {
|
|
this.inventoryContent.innerHTML = '';
|
|
const items = inventory ? Object.entries(inventory).filter(([, qty]) => qty > 0) : [];
|
|
if (items.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 14px; padding: 16px 0;`;
|
|
empty.textContent = 'No items';
|
|
this.inventoryContent.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const grid = document.createElement('div');
|
|
grid.style.cssText = `
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 3px 8px;
|
|
`;
|
|
|
|
for (const [itemId, qty] of items) {
|
|
const el = document.createElement('div');
|
|
el.style.cssText = `
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 14px;
|
|
`;
|
|
const nameEl = document.createElement('span');
|
|
nameEl.style.cssText = `color: ${EB.textSecondary}; text-transform: capitalize;`;
|
|
nameEl.textContent = itemId.replace(/_/g, ' ');
|
|
const qtyEl = document.createElement('span');
|
|
qtyEl.style.cssText = `color: ${EB.textPrimary}; text-shadow: 1px 1px 0 rgba(0,0,0,0.5);`;
|
|
qtyEl.textContent = String(qty);
|
|
el.appendChild(nameEl);
|
|
el.appendChild(qtyEl);
|
|
grid.appendChild(el);
|
|
}
|
|
this.inventoryContent.appendChild(grid);
|
|
}
|
|
|
|
updateRecentEvents(events: NarrationEvent[]): void {
|
|
if (events.length === 0) {
|
|
this.recentEventsEl.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
this.recentEventsEl.style.display = '';
|
|
this.recentEventsEl.innerHTML = '';
|
|
|
|
const label = document.createElement('div');
|
|
label.style.cssText = `
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 8px;
|
|
color: ${EB.textMuted};
|
|
margin-bottom: 3px;
|
|
`;
|
|
label.textContent = 'Recent:';
|
|
this.recentEventsEl.appendChild(label);
|
|
|
|
const recent = events.slice(-3);
|
|
for (const event of recent) {
|
|
const line = document.createElement('div');
|
|
line.style.cssText = `
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 10px;
|
|
color: ${EB.textSecondary};
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
line-height: 1.6;
|
|
`;
|
|
line.textContent = event.narration;
|
|
line.title = event.narration;
|
|
this.recentEventsEl.appendChild(line);
|
|
}
|
|
}
|
|
|
|
updateThought(text: string | null): void {
|
|
if (!text) {
|
|
this.thoughtEl.style.display = 'none';
|
|
return;
|
|
}
|
|
let cleaned = text;
|
|
if (this.currentName && cleaned.startsWith(this.currentName + ': ')) {
|
|
cleaned = cleaned.slice(this.currentName.length + 2);
|
|
}
|
|
this.thoughtEl.style.display = '';
|
|
this.thoughtEl.textContent = `"${cleaned}"`;
|
|
this.thoughtEl.title = cleaned;
|
|
}
|
|
|
|
updatePortrait(portraitDataUrl: string): void {
|
|
this.portraitImg.style.opacity = '0';
|
|
setTimeout(() => {
|
|
this.portraitImg.src = portraitDataUrl;
|
|
this.portraitImg.style.opacity = '1';
|
|
}, 150);
|
|
}
|
|
|
|
private setNeedBar(label: string, value: number): void {
|
|
const clamped = Math.max(0, Math.min(100, value));
|
|
let entry = this.needsBars.get(label);
|
|
|
|
if (!entry) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.cssText = 'font-family: "Press Start 2P", monospace;';
|
|
|
|
// Label row: label on left, numeric value on right
|
|
const labelRow = document.createElement('div');
|
|
labelRow.style.cssText = `
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
margin-bottom: 3px;
|
|
`;
|
|
|
|
const labelEl = document.createElement('div');
|
|
labelEl.style.cssText = `
|
|
font-size: 14px;
|
|
color: ${EB.textSecondary};
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
`;
|
|
labelEl.textContent = label;
|
|
|
|
const valueEl = document.createElement('div');
|
|
valueEl.style.cssText = `
|
|
font-size: 16px;
|
|
color: ${EB.textPrimary};
|
|
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
|
|
`;
|
|
|
|
labelRow.appendChild(labelEl);
|
|
labelRow.appendChild(valueEl);
|
|
wrapper.appendChild(labelRow);
|
|
|
|
// Bar trough with doubled border
|
|
const barOuter = document.createElement('div');
|
|
barOuter.style.cssText = `
|
|
border: 1px solid ${EB.borderInner};
|
|
border-radius: 4px;
|
|
padding: 1px;
|
|
background: ${EB.borderGap};
|
|
`;
|
|
|
|
const barTrough = document.createElement('div');
|
|
barTrough.style.cssText = `
|
|
background: ${EB.bgBar};
|
|
border-radius: 3px;
|
|
height: 8px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
border: 1px solid rgba(0,0,0,0.3);
|
|
`;
|
|
|
|
const fill = document.createElement('div');
|
|
fill.style.cssText = `
|
|
height: 100%;
|
|
border-radius: 2px;
|
|
transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s ease;
|
|
position: relative;
|
|
box-shadow: inset 0 -2px 0 rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.15);
|
|
`;
|
|
|
|
barTrough.appendChild(fill);
|
|
barOuter.appendChild(barTrough);
|
|
wrapper.appendChild(barOuter);
|
|
|
|
this.needsBarsContainer.appendChild(wrapper);
|
|
entry = { wrapper, fill, valueEl };
|
|
this.needsBars.set(label, entry);
|
|
}
|
|
|
|
// Update value text
|
|
entry.valueEl.textContent = `${Math.round(clamped)}`;
|
|
|
|
// Update fill width and color
|
|
entry.fill.style.width = `${clamped}%`;
|
|
if (clamped > 60) {
|
|
entry.fill.style.background = `linear-gradient(180deg, ${EB.barGreen} 0%, #40b840 100%)`;
|
|
} else if (clamped > 30) {
|
|
entry.fill.style.background = `linear-gradient(180deg, ${EB.barYellow} 0%, #b8b840 100%)`;
|
|
} else {
|
|
entry.fill.style.background = `linear-gradient(180deg, ${EB.barRed} 0%, #b84040 100%)`;
|
|
}
|
|
}
|
|
|
|
private updateStats(stats: Stats): void {
|
|
const physicalKeys = ['STR', 'DEX', 'CON', 'INT', 'PER'];
|
|
const personalityKeys = ['SOC', 'COU', 'CUR', 'EMP', 'TMP'];
|
|
const mapping: Record<string, StatName> = {
|
|
STR: 'strength', DEX: 'dexterity', CON: 'constitution', INT: 'intelligence', PER: 'perception',
|
|
SOC: 'sociability', COU: 'courage', CUR: 'curiosity', EMP: 'empathy', TMP: 'temperament',
|
|
};
|
|
for (const key of [...physicalKeys, ...personalityKeys]) {
|
|
const value = stats[mapping[key]];
|
|
if (value !== undefined) {
|
|
this.setStatDisplay(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
private setStatDisplay(label: string, value: number): void {
|
|
let el = this.statElements.get(label);
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.style.cssText = `
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 14px;
|
|
`;
|
|
const labelEl = document.createElement('span');
|
|
labelEl.style.cssText = `color: ${EB.textSecondary}; text-transform: uppercase; letter-spacing: 0.5px;`;
|
|
labelEl.textContent = label;
|
|
const valueEl = document.createElement('span');
|
|
valueEl.style.cssText = `color: ${EB.textPrimary}; text-shadow: 1px 1px 0 rgba(0,0,0,0.5);`;
|
|
valueEl.dataset.role = 'value';
|
|
el.appendChild(labelEl);
|
|
el.appendChild(valueEl);
|
|
this.statsContainer.appendChild(el);
|
|
this.statElements.set(label, el);
|
|
if (STAT_FULL_NAMES[label]) {
|
|
this.tooltipCleanups.push(attachTooltip(el, STAT_FULL_NAMES[label], 'above'));
|
|
}
|
|
}
|
|
const valueEl = el.querySelector('[data-role="value"]') as HTMLSpanElement;
|
|
valueEl.textContent = String(value);
|
|
}
|
|
|
|
private switchTab(tab: 'status' | 'desires' | 'relationships' | 'history' | 'inventory'): void {
|
|
this.activeTab = tab;
|
|
const tabs = [
|
|
{ key: 'status' as const, tabEl: this.statusTab, contentEl: this.statusContent },
|
|
{ key: 'desires' as const, tabEl: this.desiresTab, contentEl: this.desiresContent },
|
|
{ key: 'relationships' as const, tabEl: this.relationshipsTab, contentEl: this.relationshipsContent },
|
|
{ key: 'history' as const, tabEl: this.historyTab, contentEl: this.historyContent },
|
|
{ key: 'inventory' as const, tabEl: this.inventoryTab, contentEl: this.inventoryContent },
|
|
];
|
|
|
|
for (const t of tabs) {
|
|
if (t.key === tab) {
|
|
t.contentEl.style.display = '';
|
|
t.tabEl.dataset.active = 'true';
|
|
t.tabEl.style.background = '#2a2a4e';
|
|
} else {
|
|
t.contentEl.style.display = 'none';
|
|
t.tabEl.dataset.active = 'false';
|
|
t.tabEl.style.background = '#1a1a2e';
|
|
}
|
|
}
|
|
}
|
|
|
|
updateRelationships(relationships: NonNullable<EntityState['relationships']>): void {
|
|
if (relationships.length === 0) {
|
|
this.relationshipsContent.innerHTML = '';
|
|
const empty = document.createElement('div');
|
|
empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 14px; padding: 16px 0;`;
|
|
empty.textContent = 'No relationships yet';
|
|
this.relationshipsContent.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const tierOrder = ['Partner', 'Devoted', 'Close Friend', 'Friend', 'Acquaintance', 'Stranger', 'Wary', 'Rival', 'Enemy', 'Nemesis'];
|
|
const groups = new Map<string, typeof relationships>();
|
|
const memories: typeof relationships = [];
|
|
|
|
for (const rel of relationships) {
|
|
if (rel.status === 'memory') {
|
|
memories.push(rel);
|
|
continue;
|
|
}
|
|
const group = groups.get(rel.classification) ?? [];
|
|
group.push(rel);
|
|
groups.set(rel.classification, group);
|
|
}
|
|
|
|
this.relationshipsContent.innerHTML = '';
|
|
|
|
const tierIcons: Record<string, string> = {
|
|
'Partner': '\u2665', 'Devoted': '\u2764', 'Close Friend': '\u2605', 'Friend': '\u25C6',
|
|
'Acquaintance': '\u25CB', 'Stranger': '\u00B7',
|
|
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2920', 'Nemesis': '\u2920',
|
|
};
|
|
|
|
for (const tier of tierOrder) {
|
|
const group = groups.get(tier);
|
|
if (!group || group.length === 0) continue;
|
|
|
|
group.sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
|
|
|
|
const showCount = 2;
|
|
const shown = group.slice(0, showCount);
|
|
const hidden = group.slice(showCount);
|
|
|
|
const tierEl = document.createElement('div');
|
|
tierEl.style.cssText = 'margin-bottom: 8px;';
|
|
|
|
const tierLabel = document.createElement('div');
|
|
const icon = tierIcons[tier] ?? '';
|
|
tierLabel.style.cssText = `font-size: 13px; color: ${EB.textSecondary}; margin-bottom: 4px;`;
|
|
tierLabel.textContent = `${icon} ${tier}`;
|
|
tierEl.appendChild(tierLabel);
|
|
|
|
for (const rel of shown) {
|
|
tierEl.appendChild(this.createRelSlider(rel.name, rel.value, tier, rel.bond));
|
|
}
|
|
|
|
if (hidden.length > 0) {
|
|
const isExpanded = this.expandedTiers.has(tier);
|
|
const overflow = document.createElement('div');
|
|
overflow.style.cssText = isExpanded ? '' : 'display: none;';
|
|
for (const rel of hidden) {
|
|
overflow.appendChild(this.createRelSlider(rel.name, rel.value, tier, rel.bond));
|
|
}
|
|
tierEl.appendChild(overflow);
|
|
|
|
const toggle = document.createElement('div');
|
|
toggle.style.cssText = `
|
|
font-size: 11px;
|
|
color: ${EB.borderOuter};
|
|
cursor: pointer;
|
|
user-select: none;
|
|
padding: 2px 0;
|
|
text-align: center;
|
|
`;
|
|
toggle.textContent = isExpanded
|
|
? '\u25B2 show less'
|
|
: `\u25BC ${hidden.length} more...`;
|
|
toggle.addEventListener('click', () => {
|
|
const wasExpanded = this.expandedTiers.has(tier);
|
|
if (wasExpanded) {
|
|
this.expandedTiers.delete(tier);
|
|
} else {
|
|
this.expandedTiers.add(tier);
|
|
}
|
|
overflow.style.display = wasExpanded ? 'none' : '';
|
|
toggle.textContent = wasExpanded
|
|
? `\u25BC ${hidden.length} more...`
|
|
: '\u25B2 show less';
|
|
});
|
|
tierEl.appendChild(toggle);
|
|
}
|
|
|
|
this.relationshipsContent.appendChild(tierEl);
|
|
}
|
|
|
|
if (memories.length > 0) {
|
|
const memEl = document.createElement('div');
|
|
memEl.style.cssText = 'margin-bottom: 8px;';
|
|
const memLabel = document.createElement('div');
|
|
memLabel.style.cssText = `font-size: 13px; color: ${EB.textMuted}; margin-bottom: 4px;`;
|
|
memLabel.textContent = '\u25CB Memories';
|
|
memEl.appendChild(memLabel);
|
|
|
|
const memShown = memories.slice(0, 2);
|
|
const memHidden = memories.slice(2);
|
|
|
|
for (const rel of memShown) {
|
|
memEl.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory', rel.bond));
|
|
}
|
|
|
|
if (memHidden.length > 0) {
|
|
const memKey = '__memories__';
|
|
const isExpanded = this.expandedTiers.has(memKey);
|
|
const overflow = document.createElement('div');
|
|
overflow.style.cssText = isExpanded ? '' : 'display: none;';
|
|
for (const rel of memHidden) {
|
|
overflow.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory', rel.bond));
|
|
}
|
|
memEl.appendChild(overflow);
|
|
|
|
const toggle = document.createElement('div');
|
|
toggle.style.cssText = `
|
|
font-size: 11px;
|
|
color: ${EB.borderOuter};
|
|
cursor: pointer;
|
|
user-select: none;
|
|
padding: 2px 0;
|
|
text-align: center;
|
|
`;
|
|
toggle.textContent = isExpanded
|
|
? '\u25B2 show less'
|
|
: `\u25BC ${memHidden.length} more...`;
|
|
toggle.addEventListener('click', () => {
|
|
const wasExpanded = this.expandedTiers.has(memKey);
|
|
if (wasExpanded) {
|
|
this.expandedTiers.delete(memKey);
|
|
} else {
|
|
this.expandedTiers.add(memKey);
|
|
}
|
|
overflow.style.display = wasExpanded ? 'none' : '';
|
|
toggle.textContent = wasExpanded
|
|
? `\u25BC ${memHidden.length} more...`
|
|
: '\u25B2 show less';
|
|
});
|
|
memEl.appendChild(toggle);
|
|
}
|
|
|
|
this.relationshipsContent.appendChild(memEl);
|
|
}
|
|
}
|
|
|
|
private createRelSlider(name: string, value: number, tier: string, bond?: string | null): HTMLDivElement {
|
|
const row = document.createElement('div');
|
|
row.style.cssText = `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-bottom: 3px;
|
|
font-size: 13px;
|
|
`;
|
|
|
|
const nameEl = document.createElement('span');
|
|
nameEl.style.cssText = `color: ${EB.textPrimary}; min-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`;
|
|
const bondIcon = bond === 'partner' ? ' \u{1F48D}' : bond === 'former_partner' ? ' \u{1F48D}\u{FE0E}' : '';
|
|
nameEl.textContent = name + bondIcon;
|
|
|
|
const slider = document.createElement('div');
|
|
slider.style.cssText = `
|
|
flex: 1;
|
|
height: 6px;
|
|
background: ${EB.bgBar};
|
|
border-radius: 3px;
|
|
position: relative;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
`;
|
|
|
|
const center = document.createElement('div');
|
|
center.style.cssText = `
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 1px;
|
|
background: ${EB.textMuted};
|
|
`;
|
|
slider.appendChild(center);
|
|
|
|
const marker = document.createElement('div');
|
|
const pct = ((value + 100) / 200) * 100;
|
|
const color = this.getRelColor(tier);
|
|
marker.style.cssText = `
|
|
position: absolute;
|
|
left: ${pct}%;
|
|
top: -1px;
|
|
width: 5px;
|
|
height: 8px;
|
|
background: ${color};
|
|
border-radius: 2px;
|
|
transform: translateX(-50%);
|
|
box-shadow: 0 0 3px ${color};
|
|
`;
|
|
slider.appendChild(marker);
|
|
|
|
row.appendChild(nameEl);
|
|
row.appendChild(slider);
|
|
return row;
|
|
}
|
|
|
|
private getRelColor(tier: string): string {
|
|
switch (tier) {
|
|
case 'Partner': return '#ff69b4';
|
|
case 'Devoted': return '#ff69b4';
|
|
case 'Close Friend': return '#58d858';
|
|
case 'Friend': return '#40b840';
|
|
case 'Acquaintance': return '#6868a8';
|
|
case 'Stranger': return '#6868a8';
|
|
case 'Wary': return '#d8d858';
|
|
case 'Rival': return '#d89858';
|
|
case 'Enemy': return '#d85858';
|
|
case 'Nemesis': return '#ff2020';
|
|
case 'memory': return '#6868a8';
|
|
default: return '#6868a8';
|
|
}
|
|
}
|
|
|
|
updateHistory(events: MemoryEvent[]): void {
|
|
this.historyContent.innerHTML = '';
|
|
|
|
if (events.length === 0) {
|
|
const empty = document.createElement('div');
|
|
empty.style.cssText = `text-align: center; color: ${EB.textMuted}; font-size: 14px; padding: 16px 0;`;
|
|
empty.textContent = 'No events yet';
|
|
this.historyContent.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const sorted = [...events].reverse();
|
|
for (const event of sorted) {
|
|
const row = document.createElement('div');
|
|
row.style.cssText = `
|
|
font-size: 10px;
|
|
color: ${EB.textSecondary};
|
|
padding: 3px 0;
|
|
border-bottom: 1px solid ${EB.borderGap};
|
|
line-height: 1.6;
|
|
`;
|
|
|
|
const icon = this.getEventIcon(event.type);
|
|
row.textContent = `${icon} ${event.detail}`;
|
|
row.title = event.detail;
|
|
this.historyContent.appendChild(row);
|
|
}
|
|
}
|
|
|
|
private getEventIcon(type: string): string {
|
|
switch (type) {
|
|
case 'social_positive': return '\u{1F91D}';
|
|
case 'social_negative': return '\u{1F4A2}';
|
|
case 'proposal_accepted': return '\u{1F48D}';
|
|
case 'proposal_rejected': return '\u{1F494}';
|
|
case 'tier_change': return '\u{2B50}';
|
|
case 'need_crisis': return '\u{26A0}';
|
|
case 'need_recovery': return '\u{2705}';
|
|
case 'goal_change': return '\u{27A1}';
|
|
case 'bond_formed': return '\u{2764}';
|
|
case 'bond_dissolved': return '\u{1F525}';
|
|
case 'spawned': return '\u{1F331}';
|
|
case 'invention': return '\u{1F4A1}';
|
|
default: return '\u{25CF}';
|
|
}
|
|
}
|
|
|
|
isVisible(): boolean {
|
|
return this.visible;
|
|
}
|
|
|
|
destroy(): void {
|
|
this.tooltipCleanups.forEach(fn => fn());
|
|
this.outerFrame.remove();
|
|
}
|
|
}
|