diff --git a/client/src/network/SocketClient.ts b/client/src/network/SocketClient.ts index 4f919d4..6d3e123 100644 --- a/client/src/network/SocketClient.ts +++ b/client/src/network/SocketClient.ts @@ -17,6 +17,7 @@ export class SocketClient { onNarrationEvent: ((data: NarrationEvent) => void) | null = null; onNarrationUpdate: ((data: { id: number; narration: string }) => void) | null = null; onNarrationHistory: ((data: NarrationEvent[]) => void) | null = null; + onNpcThought: ((data: { entityId: number; text: string; emoji: string }) => void) | null = null; constructor(url: string) { this.socket = io(url); @@ -48,6 +49,7 @@ export class SocketClient { this.socket.on('narration-event', (data) => this.onNarrationEvent?.(data)); this.socket.on('narration-update', (data) => this.onNarrationUpdate?.(data)); this.socket.on('narration-history', (data) => this.onNarrationHistory?.(data)); + this.socket.on('npc-thought', (data) => this.onNpcThought?.(data)); } get playerId(): string | null { return this._playerId; } diff --git a/client/src/scenes/GameScene.ts b/client/src/scenes/GameScene.ts index fea229a..8e3003b 100644 --- a/client/src/scenes/GameScene.ts +++ b/client/src/scenes/GameScene.ts @@ -41,6 +41,7 @@ export class GameScene extends Phaser.Scene { private highlightedSpriteId: number | null = null; private dayNightOverlay!: Phaser.GameObjects.Graphics; private narrationEvents: NarrationEvent[] = []; + private npcThoughts: Map = new Map(); private currentGameTime = 0; private targetGameTime = 0; private lastPhaseKey = ''; @@ -201,6 +202,12 @@ export class GameScene extends Phaser.Scene { this.refreshRecentEvents(); }; + this.client.onNpcThought = (data) => { + this.npcThoughts.set(data.entityId, { text: data.text, emoji: data.emoji }); + this.refreshThought(); + this.showThoughtEmoji(data.entityId, data.emoji); + }; + document.addEventListener('narration-follow', ((e: CustomEvent) => { const { entityId } = e.detail; const npcIds = this.getNpcIds(); @@ -733,6 +740,8 @@ export class GameScene extends Phaser.Scene { const portraitUrl = await this.portraitCompositor.compositePortrait(es.lastState.appearance); this.npcInfoPanel.show(es.lastState, portraitUrl); this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId)); + const thought = this.npcThoughts.get(targetId); + this.npcInfoPanel.updateThought(thought?.text ?? null); } private async updateFollowPanel(): Promise { @@ -746,6 +755,8 @@ export class GameScene extends Phaser.Scene { this.npcInfoPanel.updatePortrait(portraitUrl); this.npcInfoPanel.updateInfo(es.lastState); this.npcInfoPanel.updateRecentEvents(this.getEventsForEntity(targetId)); + const thought = this.npcThoughts.get(targetId); + this.npcInfoPanel.updateThought(thought?.text ?? null); } private getNpcIds(): number[] { @@ -801,6 +812,52 @@ export class GameScene extends Phaser.Scene { } } + private refreshThought(): void { + if (this.mode === 'follow' && this.npcInfoPanel.isVisible()) { + const npcIds = this.getNpcIds(); + const followedId = npcIds[this.followTargetIndex]; + if (followedId != null) { + const thought = this.npcThoughts.get(followedId); + this.npcInfoPanel.updateThought(thought?.text ?? null); + } + } + } + + private showThoughtEmoji(entityId: number, emoji: string): void { + const es = this.entitySprites.get(entityId); + if (!es) return; + + const x = es.sprite.x; + const y = es.sprite.y - 30; + + const text = this.add.text(x, y, emoji, { fontSize: '16px' }) + .setOrigin(0.5) + .setDepth(1000) + .setAlpha(0); + + // Fade in + this.tweens.add({ + targets: text, + alpha: 1, + y: y - 8, + duration: 500, + ease: 'Power2', + onComplete: () => { + // Hold, then fade out + this.time.delayedCall(4000, () => { + this.tweens.add({ + targets: text, + alpha: 0, + y: y - 16, + duration: 500, + ease: 'Power2', + onComplete: () => text.destroy(), + }); + }); + }, + }); + } + private getDayNightPhase(gameTime: number): { phase: 'day' | 'sunset' | 'night' | 'sunrise'; progress: number } { const hour = gameTime * TOTAL_HOURS; diff --git a/client/src/ui/NpcInfoPanel.ts b/client/src/ui/NpcInfoPanel.ts index c20f4b5..2865639 100644 --- a/client/src/ui/NpcInfoPanel.ts +++ b/client/src/ui/NpcInfoPanel.ts @@ -33,6 +33,7 @@ export class NpcInfoPanel { private nameEl: HTMLDivElement; private activityEl: HTMLDivElement; private recentEventsEl: HTMLDivElement; + private thoughtEl: HTMLDivElement; private separatorEl: HTMLDivElement; private needsBarsContainer: HTMLDivElement; private needsBars: Map = new Map(); @@ -60,6 +61,9 @@ export class NpcInfoPanel { border: 3px solid ${EB.borderOuter}; background: ${EB.borderGap}; z-index: 1000; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; transform: translateX(410px); transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); box-shadow: @@ -78,6 +82,9 @@ export class NpcInfoPanel { background: ${EB.bgPanel}; overflow: hidden; position: relative; + display: flex; + flex-direction: column; + min-height: 0; `; // Subtle diagonal shine overlay @@ -106,6 +113,7 @@ export class NpcInfoPanel { display: flex; justify-content: center; position: relative; + flex-shrink: 0; `; // Inner portrait border (another doubled frame, just for the portrait) @@ -148,6 +156,7 @@ export class NpcInfoPanel { display: flex; border-bottom: 2px solid ${EB.borderInner}; font-family: 'Press Start 2P', monospace; + flex-shrink: 0; `; this.statusTab = document.createElement('div'); @@ -181,8 +190,15 @@ export class NpcInfoPanel { tabBar.appendChild(this.relationshipsTab); this.container.appendChild(tabBar); - // Content wrapper + // Content wrapper (scrollable area below portrait + tabs) 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'); @@ -234,6 +250,20 @@ export class NpcInfoPanel { `; 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 = ` @@ -294,8 +324,6 @@ export class NpcInfoPanel { display: none; padding: 8px 12px; font-family: 'Press Start 2P', monospace; - max-height: 300px; - overflow-y: auto; `; contentWrapper.appendChild(this.relationshipsContent); @@ -394,6 +422,16 @@ export class NpcInfoPanel { } } + updateThought(text: string | null): void { + if (!text) { + this.thoughtEl.style.display = 'none'; + return; + } + this.thoughtEl.style.display = ''; + this.thoughtEl.textContent = `"${text}"`; + this.thoughtEl.title = text; + } + updatePortrait(portraitDataUrl: string): void { this.portraitImg.style.opacity = '0'; setTimeout(() => {