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:
+236
-10
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user