From 3af1e59232c8f17827f24a6b6dbbfa752621658e Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 15:38:30 +0000 Subject: [PATCH] feat: add relationships tab to NPC info panel with tier-grouped sliders Co-Authored-By: Claude Opus 4.6 --- client/src/ui/NpcInfoPanel.ts | 246 ++++++++++++++++++++++++++++++++-- 1 file changed, 236 insertions(+), 10 deletions(-) diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index 46fd932..48d5e4b 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -37,6 +37,11 @@ export class NpcInfoPanel { private needsBars: Map = new Map(); private statsContainer: HTMLDivElement; private statElements: Map = 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): 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(); + 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 = { + '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; }