feat: add relationships tab to NPC info panel with tier-grouped sliders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 15:38:30 +00:00
parent 97c2176b75
commit 3af1e59232
+236 -10
View File
@@ -37,6 +37,11 @@ export class NpcInfoPanel {
private needsBars: Map<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = new Map();
private statsContainer: HTMLDivElement;
private statElements: Map<string, HTMLDivElement> = new Map();
private statusTab!: HTMLDivElement;
private relationshipsTab!: HTMLDivElement;
private statusContent!: HTMLDivElement;
private relationshipsContent!: HTMLDivElement;
private activeTab: 'status' | 'relationships' = 'status';
private visible = false;
constructor() {
@@ -134,9 +139,51 @@ export class NpcInfoPanel {
portraitWell.appendChild(portraitFrame);
this.container.appendChild(portraitWell);
// Info section
const infoSection = document.createElement('div');
infoSection.style.cssText = `
// Tab bar
const tabBar = document.createElement('div');
tabBar.style.cssText = `
display: flex;
border-bottom: 2px solid ${EB.borderInner};
font-family: 'Press Start 2P', monospace;
`;
this.statusTab = document.createElement('div');
this.statusTab.textContent = 'Status';
this.statusTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 9px;
cursor: pointer;
color: ${EB.textPrimary};
border-bottom: 2px solid ${EB.borderOuter};
user-select: none;
`;
this.relationshipsTab = document.createElement('div');
this.relationshipsTab.textContent = 'Relations';
this.relationshipsTab.style.cssText = `
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 9px;
cursor: pointer;
color: ${EB.textMuted};
user-select: none;
`;
this.statusTab.addEventListener('click', () => this.switchTab('status'));
this.relationshipsTab.addEventListener('click', () => this.switchTab('relationships'));
tabBar.appendChild(this.statusTab);
tabBar.appendChild(this.relationshipsTab);
this.container.appendChild(tabBar);
// Content wrapper
const contentWrapper = document.createElement('div');
// Status content (existing info section)
this.statusContent = document.createElement('div');
this.statusContent.style.cssText = `
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
@@ -156,7 +203,7 @@ export class NpcInfoPanel {
text-shadow: 1px 1px 0 rgba(0,0,0,0.5);
letter-spacing: 0.5px;
`;
infoSection.appendChild(this.nameEl);
this.statusContent.appendChild(this.nameEl);
// Activity
this.activityEl = document.createElement('div');
@@ -166,7 +213,7 @@ export class NpcInfoPanel {
text-align: center;
padding-bottom: 2px;
`;
infoSection.appendChild(this.activityEl);
this.statusContent.appendChild(this.activityEl);
// Decorative separator (EarthBound diamond pattern)
this.separatorEl = document.createElement('div');
@@ -179,7 +226,7 @@ export class NpcInfoPanel {
user-select: none;
`;
this.separatorEl.textContent = '\u25C6\u25C6\u25C6';
infoSection.appendChild(this.separatorEl);
this.statusContent.appendChild(this.separatorEl);
// Needs bars container
this.needsBarsContainer = document.createElement('div');
@@ -188,7 +235,7 @@ export class NpcInfoPanel {
flex-direction: column;
gap: 6px;
`;
infoSection.appendChild(this.needsBarsContainer);
this.statusContent.appendChild(this.needsBarsContainer);
// Stats separator
const statsSeparator = document.createElement('div');
@@ -201,7 +248,7 @@ export class NpcInfoPanel {
user-select: none;
`;
statsSeparator.textContent = '\u25C6\u25C6\u25C6';
infoSection.appendChild(statsSeparator);
this.statusContent.appendChild(statsSeparator);
// Stats container - two columns
this.statsContainer = document.createElement('div');
@@ -211,9 +258,21 @@ export class NpcInfoPanel {
gap: 3px 8px;
font-family: 'Press Start 2P', monospace;
`;
infoSection.appendChild(this.statsContainer);
this.statusContent.appendChild(this.statsContainer);
this.container.appendChild(infoSection);
contentWrapper.appendChild(this.statusContent);
this.relationshipsContent = document.createElement('div');
this.relationshipsContent.style.cssText = `
display: none;
padding: 8px 12px;
font-family: 'Press Start 2P', monospace;
max-height: 300px;
overflow-y: auto;
`;
contentWrapper.appendChild(this.relationshipsContent);
this.container.appendChild(contentWrapper);
this.outerFrame.appendChild(this.container);
document.body.appendChild(this.outerFrame);
}
@@ -248,6 +307,10 @@ export class NpcInfoPanel {
if (entity.stats) {
this.updateStats(entity.stats);
}
if (entity.relationships) {
this.updateRelationships(entity.relationships);
}
}
updateNeeds(needs: { hunger: number; energy: number }): void {
@@ -390,6 +453,169 @@ export class NpcInfoPanel {
valueEl.textContent = String(value);
}
private switchTab(tab: 'status' | 'relationships'): void {
this.activeTab = tab;
if (tab === 'status') {
this.statusContent.style.display = '';
this.relationshipsContent.style.display = 'none';
this.statusTab.style.color = EB.textPrimary;
this.statusTab.style.borderBottom = `2px solid ${EB.borderOuter}`;
this.relationshipsTab.style.color = EB.textMuted;
this.relationshipsTab.style.borderBottom = 'none';
} else {
this.statusContent.style.display = 'none';
this.relationshipsContent.style.display = '';
this.relationshipsTab.style.color = EB.textPrimary;
this.relationshipsTab.style.borderBottom = `2px solid ${EB.borderOuter}`;
this.statusTab.style.color = EB.textMuted;
this.statusTab.style.borderBottom = 'none';
}
}
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: 9px; padding: 16px 0;`;
empty.textContent = 'No relationships yet';
this.relationshipsContent.appendChild(empty);
return;
}
const tierOrder = ['Partner', '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', 'Close Friend': '\u2605', 'Friend': '\u25C6',
'Acquaintance': '\u25CB', 'Stranger': '\u00B7',
'Wary': '\u25B2', 'Rival': '\u2716', 'Enemy': '\u2620', 'Nemesis': '\u2620',
};
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 remaining = group.length - 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: 8px; color: ${EB.textSecondary}; margin-bottom: 4px;`;
tierLabel.textContent = `${icon} ${tier}${remaining > 0 ? ` (${remaining} more...)` : ''}`;
tierEl.appendChild(tierLabel);
for (const rel of shown) {
tierEl.appendChild(this.createRelSlider(rel.name, rel.value, tier));
}
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: 8px; color: ${EB.textMuted}; margin-bottom: 4px;`;
memLabel.textContent = '\u25CB Memories';
memEl.appendChild(memLabel);
for (const rel of memories.slice(0, 2)) {
memEl.appendChild(this.createRelSlider(rel.name + ' \u2020', rel.value, 'memory'));
}
this.relationshipsContent.appendChild(memEl);
}
}
private createRelSlider(name: string, value: number, tier: string): HTMLDivElement {
const row = document.createElement('div');
row.style.cssText = `
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
font-size: 8px;
`;
const nameEl = document.createElement('span');
nameEl.style.cssText = `color: ${EB.textPrimary}; min-width: 70px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`;
nameEl.textContent = name;
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 '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';
}
}
isVisible(): boolean {
return this.visible;
}