Files
dflike/client/src/ui/NpcInfoPanel.ts
T
2026-03-10 20:46:43 +00:00

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;">&#9670; ${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;">&#9675; (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();
}
}