diff --git a/ai_animation/src/components/twoPowerConversation.ts b/ai_animation/src/components/twoPowerConversation.ts index 18d9e9f..027d1ee 100644 --- a/ai_animation/src/components/twoPowerConversation.ts +++ b/ai_animation/src/components/twoPowerConversation.ts @@ -2,6 +2,7 @@ import { gameState } from '../gameState'; import { config } from '../config'; import { getPowerDisplayName } from '../utils/powerNames'; import { PowerENUM } from '../types/map'; +import { Moment } from '../types/moments'; interface ConversationMessage { sender: string; @@ -16,6 +17,7 @@ interface TwoPowerDialogueOptions { power2: string; messages?: ConversationMessage[]; title?: string; + moment?: Moment; onClose?: () => void; } @@ -26,7 +28,7 @@ let dialogueOverlay: HTMLElement | null = null; * @param options Configuration for the dialogue display */ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void { - const { power1, power2, messages, title, onClose } = options; + const { power1, power2, messages, title, moment, onClose } = options; // Close any existing dialogue closeTwoPowerConversation(); @@ -43,11 +45,12 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void dialogueOverlay = createDialogueOverlay(); // Create dialogue container - const dialogueContainer = createDialogueContainer(power1, power2, title); + const dialogueContainer = createDialogueContainer(power1, power2, title, moment); - // Create conversation area + // Create conversation area and append to the conversation wrapper + const conversationWrapper = dialogueContainer.querySelector('div[style*="flex: 2"]') as HTMLElement; const conversationArea = createConversationArea(); - dialogueContainer.appendChild(conversationArea); + conversationWrapper.appendChild(conversationArea); // Add close button const closeButton = createCloseButton(); @@ -134,7 +137,7 @@ function createDialogueOverlay(): HTMLElement { /** * Creates the main dialogue container */ -function createDialogueContainer(power1: string, power2: string, title?: string): HTMLElement { +function createDialogueContainer(power1: string, power2: string, title?: string, moment?: Moment): HTMLElement { const container = document.createElement('div'); container.className = 'dialogue-container'; container.style.cssText = ` @@ -142,30 +145,178 @@ function createDialogueContainer(power1: string, power2: string, title?: string) border: 3px solid #4f3b16; border-radius: 8px; box-shadow: 0 0 15px rgba(0,0,0,0.5); - width: 80%; - height: 80%; + width: 90%; + height: 85%; position: relative; padding: 20px; display: flex; flex-direction: column; `; - // Add title + // Create header section with title and moment info + const headerSection = document.createElement('div'); + headerSection.style.cssText = ` + margin-bottom: 15px; + text-align: center; + `; + + // Add main title const titleElement = document.createElement('h2'); titleElement.textContent = title || `Conversation: ${getPowerDisplayName(power1 as PowerENUM)} & ${getPowerDisplayName(power2 as PowerENUM)}`; titleElement.style.cssText = ` - margin: 0 0 20px 0; - text-align: center; + margin: 0 0 10px 0; color: #4f3b16; font-family: 'Times New Roman', serif; font-size: 24px; font-weight: bold; `; - container.appendChild(titleElement); + headerSection.appendChild(titleElement); + + // Add moment type if available + if (moment) { + const momentTypeElement = document.createElement('div'); + momentTypeElement.textContent = `${moment.category} (Interest: ${moment.interest_score}/10)`; + momentTypeElement.style.cssText = ` + background: rgba(75, 59, 22, 0.8); + color: #f7ecd1; + padding: 5px 15px; + border-radius: 15px; + display: inline-block; + font-size: 14px; + font-weight: bold; + margin-bottom: 5px; + `; + headerSection.appendChild(momentTypeElement); + + // Add moment description if available + if (moment.promise_agreement || moment.actual_action || moment.impact) { + const momentDescription = document.createElement('div'); + let description = ''; + if (moment.promise_agreement) description += `Promise: ${moment.promise_agreement}. `; + if (moment.actual_action) description += `Action: ${moment.actual_action}. `; + if (moment.impact) description += `Impact: ${moment.impact}`; + + momentDescription.textContent = description; + momentDescription.style.cssText = ` + font-size: 12px; + color: #5a4b2b; + font-style: italic; + margin: 5px 20px 0 20px; + line-height: 1.4; + `; + headerSection.appendChild(momentDescription); + } + } + + container.appendChild(headerSection); + + // Create main content area with three columns: diary1, conversation, diary2 + const mainContent = document.createElement('div'); + mainContent.style.cssText = ` + flex: 1; + display: flex; + gap: 15px; + height: 100%; + `; + + // Left diary box for power1 + const diary1Box = createDiaryBox(power1 as PowerENUM, moment?.diary_context?.[power1 as PowerENUM] || ''); + mainContent.appendChild(diary1Box); + + // Center conversation area + const conversationWrapper = document.createElement('div'); + conversationWrapper.style.cssText = ` + flex: 2; + display: flex; + flex-direction: column; + `; + mainContent.appendChild(conversationWrapper); + + // Right diary box for power2 + const diary2Box = createDiaryBox(power2 as PowerENUM, moment?.diary_context?.[power2 as PowerENUM] || ''); + mainContent.appendChild(diary2Box); + + container.appendChild(mainContent); return container; } +/** + * Creates a diary box for displaying power-specific thoughts and context + */ +function createDiaryBox(power: PowerENUM, diaryContent: string): HTMLElement { + const diaryBox = document.createElement('div'); + diaryBox.className = `diary-box diary-${power.toLowerCase()}`; + diaryBox.style.cssText = ` + flex: 1; + background: rgba(255, 255, 255, 0.4); + border: 2px solid #8b7355; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); + `; + + // Power name header + const powerHeader = document.createElement('h4'); + powerHeader.textContent = `${getPowerDisplayName(power)} Thoughts`; + powerHeader.className = `power-${power.toLowerCase()}`; + powerHeader.style.cssText = ` + margin: 0 0 10px 0; + font-size: 14px; + font-weight: bold; + text-align: center; + padding: 5px; + background: rgba(75, 59, 22, 0.1); + border-radius: 4px; + border-bottom: 1px solid #8b7355; + `; + diaryBox.appendChild(powerHeader); + + // Diary content area + const contentArea = document.createElement('div'); + contentArea.className = 'diary-content'; + contentArea.style.cssText = ` + flex: 1; + overflow-y: auto; + font-size: 12px; + line-height: 1.5; + color: #4a3b1f; + font-style: italic; + `; + + if (diaryContent.trim()) { + // Split diary content into paragraphs for better readability + const paragraphs = diaryContent.split('\n').filter(p => p.trim()); + paragraphs.forEach((paragraph, index) => { + const p = document.createElement('p'); + p.textContent = paragraph.trim(); + p.style.cssText = ` + margin: 0 0 8px 0; + padding: 4px; + background: ${index % 2 === 0 ? 'rgba(255,255,255,0.2)' : 'transparent'}; + border-radius: 3px; + `; + contentArea.appendChild(p); + }); + } else { + // No diary content available + const noDiaryMsg = document.createElement('div'); + noDiaryMsg.textContent = 'No diary entries available for this moment.'; + noDiaryMsg.style.cssText = ` + color: #8b7355; + font-style: italic; + text-align: center; + padding: 20px; + `; + contentArea.appendChild(noDiaryMsg); + } + + diaryBox.appendChild(contentArea); + return diaryBox; +} + /** * Creates the conversation display area */ diff --git a/ai_animation/src/config.ts b/ai_animation/src/config.ts index 39fc119..0664143 100644 --- a/ai_animation/src/config.ts +++ b/ai_animation/src/config.ts @@ -12,5 +12,8 @@ export const config = { animationDuration: 1500, // How frequently to play sound effects (1 = every message, 3 = every third message) - soundEffectFrequency: 3 + soundEffectFrequency: 3, + + // Whether speech/TTS is enabled (can be toggled via debug menu) + speechEnabled: import.meta.env.VITE_DEBUG_MODE ? false : true } diff --git a/ai_animation/src/debug/debugMenu.ts b/ai_animation/src/debug/debugMenu.ts index 71a47f2..11e7d13 100644 --- a/ai_animation/src/debug/debugMenu.ts +++ b/ai_animation/src/debug/debugMenu.ts @@ -6,6 +6,7 @@ import { updateNextMomentDisplay, initNextMomentTool } from "./nextMoment"; import { initDebugProvinceHighlighting } from "./provinceHighlight"; import { initInstantChatTool } from "./instantChat"; +import { initSpeechToggleTool } from "./speechToggle"; export class DebugMenu { private toggleBtn: HTMLButtonElement; @@ -172,6 +173,7 @@ export class DebugMenu { } private initTools(): void { + initSpeechToggleTool(this); initInstantChatTool(this); initNextMomentTool(this); initDebugProvinceHighlighting() diff --git a/ai_animation/src/debug/speechToggle.ts b/ai_animation/src/debug/speechToggle.ts new file mode 100644 index 0000000..d4959ab --- /dev/null +++ b/ai_animation/src/debug/speechToggle.ts @@ -0,0 +1,37 @@ +/** + * Speech Toggle Debug Tool + * Allows toggling text-to-speech functionality on/off + */ + +import { config } from "../config"; +import type { DebugMenu } from "./debugMenu"; + +/** + * Initializes the speech toggle debug tool + * @param debugMenu - The debug menu instance to add this tool to + */ +export function initSpeechToggleTool(debugMenu: DebugMenu): void { + const content = ` +
+ + + Controls whether phase summaries are spoken aloud + +
+ `; + + debugMenu.addDebugTool('Speech Control', content); + + // Add event listener for the checkbox + const checkbox = document.getElementById('speech-toggle-checkbox') as HTMLInputElement; + if (checkbox) { + checkbox.addEventListener('change', (event) => { + const target = event.target as HTMLInputElement; + config.speechEnabled = target.checked; + console.log(`Speech ${config.speechEnabled ? 'enabled' : 'disabled'}`); + }); + } +} \ No newline at end of file diff --git a/ai_animation/src/gameState.ts b/ai_animation/src/gameState.ts index 658e0ba..a2ffe48 100644 --- a/ai_animation/src/gameState.ts +++ b/ai_animation/src/gameState.ts @@ -22,7 +22,9 @@ enum AvailableMaps { * Return a random power from the PowerENUM for the player to control */ function getRandomPower(): PowerENUM { - const values = Object.values(PowerENUM); + const values = Object.values(PowerENUM).filter(power => + power !== PowerENUM.GLOBAL && power !== PowerENUM.EUROPE + ); const idx = Math.floor(Math.random() * values.length); return values[idx]; } diff --git a/ai_animation/src/phase.ts b/ai_animation/src/phase.ts index b9fbf6f..a82e8ee 100644 --- a/ai_animation/src/phase.ts +++ b/ai_animation/src/phase.ts @@ -63,19 +63,24 @@ function _setPhase(phaseIndex: number) { export function nextPhase() { if (!gameState.isDisplayingMoment && gameState.gameData && gameState.momentsData) { let moment = gameState.checkPhaseHasMoment(gameState.gameData.phases[gameState.phaseIndex].name) - if (moment !== null && moment.interest_score >= MOMENT_THRESHOLD && moment.hasBeenDisplayed === undefined) { + if (moment !== null && moment.interest_score >= MOMENT_THRESHOLD && moment.powers_involved.length >= 2) { gameState.isDisplayingMoment = true moment.hasBeenDisplayed = true - showTwoPowerConversation({ power1: PowerENUM.AUSTRIA, power2: PowerENUM.FRANCE }) + + const power1 = moment.powers_involved[0]; + const power2 = moment.powers_involved[1]; + + showTwoPowerConversation({ + power1: power1, + power2: power2, + moment: moment + }) setTimeout(() => { closeTwoPowerConversation() gameState.isDisplayingMoment = false _setPhase(gameState.phaseIndex + 1) }, MOMENT_DISPLAY_TIMEOUT_MS) - } - - else { - + } else { _setPhase(gameState.phaseIndex + 1) } } diff --git a/ai_animation/src/speech.ts b/ai_animation/src/speech.ts index a1d4d9b..c4ff033 100644 --- a/ai_animation/src/speech.ts +++ b/ai_animation/src/speech.ts @@ -67,6 +67,11 @@ async function testElevenLabsKey() { * @returns Promise that resolves when audio completes or rejects on error */ export async function speakSummary(summaryText: string): Promise { + if (!config.speechEnabled) { + console.log("Speech disabled via config, skipping TTS"); + return; + } + if (!summaryText || summaryText.trim() === '') { console.warn("No summary text provided to speakSummary function"); return; @@ -98,12 +103,11 @@ export async function speakSummary(summaryText: string): Promise { try { // Truncate text to first 100 characters for ElevenLabs - // FIXME: Is this meant to be truncated? Presumably not - //const textForSpeaking = textToSpeak.substring(0, 100); + let textForSpeaking; if (config.isDebugMode) { - const textForSpeaking = textToSpeak.substring(0, 100); + textForSpeaking = textToSpeak.substring(0, 100); } else { - const textForSpeaking = textToSpeak + textForSpeaking = textToSpeak } // Hit ElevenLabs TTS endpoint with the truncated text const headers = {