feat(thought): wire client socket, GameScene thought handling, emoji animation, and info panel display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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<number, { text: string; emoji: string }> = 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<void> {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, { wrapper: HTMLDivElement; fill: HTMLDivElement; valueEl: HTMLDivElement }> = 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(() => {
|
||||
|
||||
Reference in New Issue
Block a user