Another pass at basic moments

Adding diary, name of powers, name of moment type, and some supporting
thoughts.

Added a debug menu item to disable or enable the eleven labs speech.
Useful for removing it when debugging
This commit is contained in:
Tyler Marques 2025-05-29 16:20:17 -07:00
parent e1309c4012
commit ecf8e1db06
No known key found for this signature in database
GPG key ID: CB99EDCF41D3016F
7 changed files with 227 additions and 23 deletions

View file

@ -2,6 +2,7 @@ import { gameState } from '../gameState';
import { config } from '../config'; import { config } from '../config';
import { getPowerDisplayName } from '../utils/powerNames'; import { getPowerDisplayName } from '../utils/powerNames';
import { PowerENUM } from '../types/map'; import { PowerENUM } from '../types/map';
import { Moment } from '../types/moments';
interface ConversationMessage { interface ConversationMessage {
sender: string; sender: string;
@ -16,6 +17,7 @@ interface TwoPowerDialogueOptions {
power2: string; power2: string;
messages?: ConversationMessage[]; messages?: ConversationMessage[];
title?: string; title?: string;
moment?: Moment;
onClose?: () => void; onClose?: () => void;
} }
@ -26,7 +28,7 @@ let dialogueOverlay: HTMLElement | null = null;
* @param options Configuration for the dialogue display * @param options Configuration for the dialogue display
*/ */
export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void { 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 // Close any existing dialogue
closeTwoPowerConversation(); closeTwoPowerConversation();
@ -43,11 +45,12 @@ export function showTwoPowerConversation(options: TwoPowerDialogueOptions): void
dialogueOverlay = createDialogueOverlay(); dialogueOverlay = createDialogueOverlay();
// Create dialogue container // 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(); const conversationArea = createConversationArea();
dialogueContainer.appendChild(conversationArea); conversationWrapper.appendChild(conversationArea);
// Add close button // Add close button
const closeButton = createCloseButton(); const closeButton = createCloseButton();
@ -134,7 +137,7 @@ function createDialogueOverlay(): HTMLElement {
/** /**
* Creates the main dialogue container * 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'); const container = document.createElement('div');
container.className = 'dialogue-container'; container.className = 'dialogue-container';
container.style.cssText = ` container.style.cssText = `
@ -142,30 +145,178 @@ function createDialogueContainer(power1: string, power2: string, title?: string)
border: 3px solid #4f3b16; border: 3px solid #4f3b16;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 15px rgba(0,0,0,0.5); box-shadow: 0 0 15px rgba(0,0,0,0.5);
width: 80%; width: 90%;
height: 80%; height: 85%;
position: relative; position: relative;
padding: 20px; padding: 20px;
display: flex; display: flex;
flex-direction: column; 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'); const titleElement = document.createElement('h2');
titleElement.textContent = title || `Conversation: ${getPowerDisplayName(power1 as PowerENUM)} & ${getPowerDisplayName(power2 as PowerENUM)}`; titleElement.textContent = title || `Conversation: ${getPowerDisplayName(power1 as PowerENUM)} & ${getPowerDisplayName(power2 as PowerENUM)}`;
titleElement.style.cssText = ` titleElement.style.cssText = `
margin: 0 0 20px 0; margin: 0 0 10px 0;
text-align: center;
color: #4f3b16; color: #4f3b16;
font-family: 'Times New Roman', serif; font-family: 'Times New Roman', serif;
font-size: 24px; font-size: 24px;
font-weight: bold; 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; 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 * Creates the conversation display area
*/ */

View file

@ -12,5 +12,8 @@ export const config = {
animationDuration: 1500, animationDuration: 1500,
// How frequently to play sound effects (1 = every message, 3 = every third message) // 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
} }

View file

@ -6,6 +6,7 @@
import { updateNextMomentDisplay, initNextMomentTool } from "./nextMoment"; import { updateNextMomentDisplay, initNextMomentTool } from "./nextMoment";
import { initDebugProvinceHighlighting } from "./provinceHighlight"; import { initDebugProvinceHighlighting } from "./provinceHighlight";
import { initInstantChatTool } from "./instantChat"; import { initInstantChatTool } from "./instantChat";
import { initSpeechToggleTool } from "./speechToggle";
export class DebugMenu { export class DebugMenu {
private toggleBtn: HTMLButtonElement; private toggleBtn: HTMLButtonElement;
@ -172,6 +173,7 @@ export class DebugMenu {
} }
private initTools(): void { private initTools(): void {
initSpeechToggleTool(this);
initInstantChatTool(this); initInstantChatTool(this);
initNextMomentTool(this); initNextMomentTool(this);
initDebugProvinceHighlighting() initDebugProvinceHighlighting()

View file

@ -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 = `
<div style="margin: 8px 0;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="speech-toggle-checkbox" ${config.speechEnabled ? 'checked' : ''}>
<span>Enable Text-to-Speech</span>
</label>
<small style="color: #666; margin-top: 4px; display: block;">
Controls whether phase summaries are spoken aloud
</small>
</div>
`;
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'}`);
});
}
}

View file

@ -22,7 +22,9 @@ enum AvailableMaps {
* Return a random power from the PowerENUM for the player to control * Return a random power from the PowerENUM for the player to control
*/ */
function getRandomPower(): PowerENUM { 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); const idx = Math.floor(Math.random() * values.length);
return values[idx]; return values[idx];
} }

View file

@ -63,19 +63,24 @@ function _setPhase(phaseIndex: number) {
export function nextPhase() { export function nextPhase() {
if (!gameState.isDisplayingMoment && gameState.gameData && gameState.momentsData) { if (!gameState.isDisplayingMoment && gameState.gameData && gameState.momentsData) {
let moment = gameState.checkPhaseHasMoment(gameState.gameData.phases[gameState.phaseIndex].name) 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 gameState.isDisplayingMoment = true
moment.hasBeenDisplayed = 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(() => { setTimeout(() => {
closeTwoPowerConversation() closeTwoPowerConversation()
gameState.isDisplayingMoment = false gameState.isDisplayingMoment = false
_setPhase(gameState.phaseIndex + 1) _setPhase(gameState.phaseIndex + 1)
}, MOMENT_DISPLAY_TIMEOUT_MS) }, MOMENT_DISPLAY_TIMEOUT_MS)
} } else {
else {
_setPhase(gameState.phaseIndex + 1) _setPhase(gameState.phaseIndex + 1)
} }
} }

View file

@ -67,6 +67,11 @@ async function testElevenLabsKey() {
* @returns Promise that resolves when audio completes or rejects on error * @returns Promise that resolves when audio completes or rejects on error
*/ */
export async function speakSummary(summaryText: string): Promise<void> { export async function speakSummary(summaryText: string): Promise<void> {
if (!config.speechEnabled) {
console.log("Speech disabled via config, skipping TTS");
return;
}
if (!summaryText || summaryText.trim() === '') { if (!summaryText || summaryText.trim() === '') {
console.warn("No summary text provided to speakSummary function"); console.warn("No summary text provided to speakSummary function");
return; return;
@ -98,12 +103,11 @@ export async function speakSummary(summaryText: string): Promise<void> {
try { try {
// Truncate text to first 100 characters for ElevenLabs // Truncate text to first 100 characters for ElevenLabs
// FIXME: Is this meant to be truncated? Presumably not let textForSpeaking;
//const textForSpeaking = textToSpeak.substring(0, 100);
if (config.isDebugMode) { if (config.isDebugMode) {
const textForSpeaking = textToSpeak.substring(0, 100); textForSpeaking = textToSpeak.substring(0, 100);
} else { } else {
const textForSpeaking = textToSpeak textForSpeaking = textToSpeak
} }
// Hit ElevenLabs TTS endpoint with the truncated text // Hit ElevenLabs TTS endpoint with the truncated text
const headers = { const headers = {