mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
WIP: Queue works properly, fills from beginning to end of game and plays/pauses correctly
Just need to fix the issues with the momentModal not displaying correctly.
This commit is contained in:
parent
73d195304a
commit
79a2eceef4
8 changed files with 230 additions and 292 deletions
72
ai_animation/src/components/chatBubble.ts
Normal file
72
ai_animation/src/components/chatBubble.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { MessageSchemaType } from "../types/gameState";
|
||||
import { config } from "../config";
|
||||
|
||||
|
||||
export function createChatBubble(message: MessageSchemaType): HTMLElement {
|
||||
|
||||
const messageElement = document.createElement('div');
|
||||
return messageElement
|
||||
}
|
||||
|
||||
|
||||
// New function to animate message words one at a time
|
||||
/**
|
||||
* Animates message text one word at a time
|
||||
* @param message The full message text to animate
|
||||
* @param contentSpanId The ID of the span element to animate within
|
||||
* @param messagesContainer The container holding the messages
|
||||
* @param onComplete Callback function to run when animation completes
|
||||
*/
|
||||
export function animateMessageWords(message: string, contentSpan: HTMLElement, messagesContainer: HTMLElement, onComplete: Function | null) {
|
||||
if (!(typeof message === "string")) {
|
||||
throw new Error("Message must be a string")
|
||||
|
||||
}
|
||||
const words = message.split(/\s+/);
|
||||
if (!contentSpan) {
|
||||
throw new Error("Couldn't find text bubble to fill")
|
||||
}
|
||||
|
||||
// Clear any existing content
|
||||
contentSpan.textContent = '';
|
||||
let wordIndex = 0;
|
||||
|
||||
// Function to add the next word
|
||||
const addNextWord = () => {
|
||||
if (wordIndex >= words.length) {
|
||||
// All words added - message is complete
|
||||
console.log(`Finished animating message with ${words.length} words in chat`);
|
||||
// Call completion callback after all words are displayed
|
||||
if (onComplete) {
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, config.messageCompletionDelay || 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add space if not the first word
|
||||
if (wordIndex > 0) {
|
||||
contentSpan.textContent += ' ';
|
||||
}
|
||||
|
||||
// Add the next word
|
||||
contentSpan.textContent += words[wordIndex];
|
||||
wordIndex++;
|
||||
|
||||
// Calculate delay based on word length and playback speed
|
||||
// Longer words get slightly longer display time
|
||||
const wordLength = words[wordIndex - 1].length;
|
||||
const delay = Math.max(config.messageWordDelay || 50, Math.min(200, (config.messageWordDelay || 50) * (wordLength / 4)));
|
||||
|
||||
// Scroll to ensure newest content is visible
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
// Schedule next word
|
||||
setTimeout(addNextWord, delay);
|
||||
};
|
||||
|
||||
// Start animation
|
||||
addNextWord();
|
||||
}
|
||||
|
||||
|
|
@ -5,6 +5,7 @@ import { PowerENUM } from '../types/map';
|
|||
import { Moment } from '../types/moments';
|
||||
import { MessageSchemaType } from '../types/gameState';
|
||||
import { ScheduledEvent } from '../events.ts'
|
||||
import { animateMessageWords, createChatBubble } from './chatBubble.ts';
|
||||
|
||||
interface MomentDialogueOptions {
|
||||
moment: Moment;
|
||||
|
|
@ -33,14 +34,26 @@ export function showMomentModal(options: MomentDialogueOptions): void {
|
|||
`This indicates a data quality issue - moments should only be created when there are actual conversations to display.`
|
||||
);
|
||||
}
|
||||
if (moment.powers_involved.length < 2) {
|
||||
console.warn("Attempted to show moment with only one power involved")
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (moment.raw_messages.length > config.convertsationModalMaxMessages) {
|
||||
moment.raw_messages = moment.raw_messages.slice(moment.raw_messages.length - config.convertsationModalMaxMessages, moment.raw_messages.length)
|
||||
}
|
||||
showConversationModalSequence(title, moment, onClose);
|
||||
}
|
||||
|
||||
export function createMomentEvent(moment: Moment): ScheduledEvent {
|
||||
return new ScheduledEvent(
|
||||
`moment-${moment.phase}`,
|
||||
() => showMomentModal({ moment }))
|
||||
() => new Promise<void>((resolve) => {
|
||||
showMomentModal({
|
||||
moment,
|
||||
onClose: () => resolve()
|
||||
});
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -78,59 +91,56 @@ function showConversationModalSequence(
|
|||
dialogueOverlay.appendChild(dialogueContainer);
|
||||
document.body.appendChild(dialogueOverlay);
|
||||
|
||||
// Set up event listeners for close functionality
|
||||
setupEventListeners(onClose);
|
||||
|
||||
dialogueOverlay!.style.opacity = '1'
|
||||
|
||||
// Schedule messages to be displayed sequentially through event queue
|
||||
console.log(`Starting two-power conversation with ${moment.raw_messages.length} messages`);
|
||||
scheduleMessageSequence(conversationArea, moment.raw_messages, power1, power2, onClose);
|
||||
scheduleMessageSequence(conversationArea, moment.raw_messages, power1, power2).then(() => {
|
||||
closeMomentModal();
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules all messages to be displayed sequentially through the event queue
|
||||
* Schedules all messages to be displayed sequentially using promises
|
||||
*/
|
||||
function scheduleMessageSequence(
|
||||
async function scheduleMessageSequence(
|
||||
container: HTMLElement,
|
||||
messages: MessageSchemaType[],
|
||||
power1: string,
|
||||
power2: string,
|
||||
callbackOnClose?: () => void
|
||||
): void {
|
||||
let messageIndex = 0;
|
||||
): Promise<void> {
|
||||
// Chain message animations using promises to ensure sequential display
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const messageObj = messages[i];
|
||||
|
||||
// Function to show the next message
|
||||
const showNext = () => {
|
||||
// All messages have been displayed
|
||||
if (messageIndex >= messages.length) {
|
||||
console.log(`All ${messages.length} conversation messages displayed, scheduling close in ${config.conversationFinalDelay}ms`);
|
||||
// Schedule conversation close after all messages are shown
|
||||
gameState.eventQueue.scheduleDelay(config.conversationFinalDelay, () => {
|
||||
console.log('Closing two-power conversation and calling onClose callback');
|
||||
closeMomentModal();
|
||||
if (callbackOnClose) callbackOnClose();
|
||||
}, `close-conversation-after-messages-${Date.now()}`);
|
||||
return;
|
||||
// Create the message element
|
||||
const messageElement = createMessageElement(messageObj, power1, power2);
|
||||
container.appendChild(messageElement);
|
||||
|
||||
// Animate message appearance
|
||||
messageElement.style.opacity = '0';
|
||||
messageElement.style.transform = 'translateY(20px)';
|
||||
messageElement.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
||||
messageElement.style.opacity = '1';
|
||||
messageElement.style.transform = 'translateY(0)';
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Wait for word-by-word animation to complete
|
||||
const messageBubble = messageElement.querySelector('.message-bubble') as HTMLElement;
|
||||
if (messageBubble) {
|
||||
await new Promise<void>((resolve) => {
|
||||
animateMessageWords(messageObj.message, messageBubble, container, resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get the next message
|
||||
const message = messages[messageIndex];
|
||||
|
||||
// Function to call after message animation completes
|
||||
const onMessageComplete = () => {
|
||||
messageIndex++;
|
||||
console.log(`Conversation message ${messageIndex} of ${messages.length} completed`);
|
||||
// Schedule next message with proper delay
|
||||
gameState.eventQueue.scheduleDelay(config.messageBetweenDelay, showNext, `show-next-conversation-message-${messageIndex}-${Date.now()}`);
|
||||
};
|
||||
|
||||
// Display the message with word-by-word animation
|
||||
displaySingleMessage(container, message, power1, power2, onMessageComplete);
|
||||
};
|
||||
|
||||
// Start the message sequence with initial delay
|
||||
gameState.eventQueue.scheduleDelay(config.conversationModalDelay, showNext, `start-conversation-sequence-${Date.now()}`);
|
||||
if (callbackOnClose) {
|
||||
callbackOnClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -210,7 +220,8 @@ function createDialogueOverlay(): HTMLElement {
|
|||
`;
|
||||
|
||||
// Trigger fade in
|
||||
gameState.eventQueue.scheduleDelay(10, () => overlay.style.opacity = '1', `fade-in-overlay-${Date.now()}`);
|
||||
// Trigger fade in with a small timeout
|
||||
setTimeout(() => overlay.style.opacity = '1', 10);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
|
@ -551,55 +562,3 @@ function createMessageElement(message: MessageSchemaType, power1: string, power2
|
|||
return messageDiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates message text one word at a time (adapted from chatWindows.ts)
|
||||
*/
|
||||
function animateMessageWords(
|
||||
message: string,
|
||||
contentElement: HTMLElement,
|
||||
container: HTMLElement,
|
||||
onComplete: (() => void) | null
|
||||
): void {
|
||||
const words = message.split(/\s+/);
|
||||
|
||||
// Clear any existing content
|
||||
contentElement.textContent = '';
|
||||
let wordIndex = 0;
|
||||
|
||||
// Function to add the next word
|
||||
const addNextWord = () => {
|
||||
if (wordIndex >= words.length) {
|
||||
// All words added - message is complete
|
||||
console.log(`Finished animating conversation message with ${words.length} words`);
|
||||
|
||||
// Add a slight delay after the last word for readability
|
||||
gameState.eventQueue.scheduleDelay(config.messageCompletionDelay, () => {
|
||||
if (onComplete) {
|
||||
onComplete(); // Call the completion callback
|
||||
}
|
||||
}, `conversation-message-complete-${Date.now()}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add space if not the first word
|
||||
if (wordIndex > 0) {
|
||||
contentElement.textContent += ' ';
|
||||
}
|
||||
|
||||
// Add the next word
|
||||
contentElement.textContent += words[wordIndex];
|
||||
wordIndex++;
|
||||
|
||||
// Calculate delay based on word length and playback speed
|
||||
const wordLength = words[wordIndex - 1].length;
|
||||
const delay = Math.max(config.messageWordDelay, Math.min(200, config.messageWordDelay * (wordLength / 4)));
|
||||
gameState.eventQueue.scheduleDelay(delay, addNextWord, `add-conversation-word-${wordIndex}-${Date.now()}`);
|
||||
|
||||
// Scroll to ensure newest content is visible
|
||||
container.scrollTop = container.scrollHeight;
|
||||
};
|
||||
|
||||
// Start animation
|
||||
addNextWord();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export const config = {
|
|||
get conversationModalDelay(): number {
|
||||
return this.isInstantMode ? 50 : 500; // Initial delay before showing messages
|
||||
},
|
||||
convertsationModalMaxMessages: 20,
|
||||
|
||||
/**
|
||||
* Phase and moment timing
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class EventQueueDisplay {
|
|||
/* Wrapper for debug menu and event queue */
|
||||
#debug-wrapper {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
top: 200px;
|
||||
right: 20px;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ import { GamePhase, MessageSchemaType } from "../types/gameState";
|
|||
import { getPowerDisplayName } from '../utils/powerNames';
|
||||
import { PowerENUM } from '../types/map';
|
||||
import { ScheduledEvent } from "../events";
|
||||
import { createChatBubble, animateMessageWords } from "../components/chatBubble";
|
||||
|
||||
|
||||
//TODO: Sometimes the LLMs use lists, and they don't work in the chats. The just appear as bullets within a single line.
|
||||
//
|
||||
//TODO: We are getting a mixing of chats from different phases. In game 0, F1902M starts using chat before S1902M finishes
|
||||
let faceIconCache = {}; // Cache for generated face icons
|
||||
|
||||
// Add a message counter to track sound effect frequency
|
||||
let messageCounter = 0;
|
||||
type chatWindowMap = { [key in PowerENUM]: {
|
||||
element: HTMLHtmlElement,
|
||||
messagesContainer: HTMLHtmlElement,
|
||||
|
|
@ -163,94 +161,21 @@ export function createMessageEvents(phase: GamePhase): ScheduledEvent[] {
|
|||
let messageEvents: ScheduledEvent[] = []
|
||||
|
||||
// Only show messages relevant to the current player (sent by them, to them, or global)
|
||||
const relevantMessages = phase.messages.filter(msg => {
|
||||
return (
|
||||
msg.sender === gameState.currentPower ||
|
||||
msg.recipient === gameState.currentPower ||
|
||||
msg.recipient === 'GLOBAL'
|
||||
);
|
||||
});
|
||||
// Sort messages by time sent
|
||||
relevantMessages.sort((a, b) => a.time_sent - b.time_sent);
|
||||
const relevantMessages = fiterAndSortChatMessagesForPhase(phase)
|
||||
for (let [idx, msg] of relevantMessages.entries()) {
|
||||
messageEvents.push(new ScheduledEvent(
|
||||
`message-${phase.name}-${msg.sender}`,
|
||||
() => new Promise<void>((resolve) => {
|
||||
addMessageToChat(msg, !config.isInstantMode, () => resolve());
|
||||
animateHeadNod(msg, (idx % config.soundEffectFrequency === 0));
|
||||
})
|
||||
))
|
||||
|
||||
for (let msg of relevantMessages) {
|
||||
messageEvents.push(new ScheduledEvent(`message-${phase.name}-${msg.sender}`, () => addMessageToChat(msg, !config.isInstantMode)))
|
||||
}
|
||||
return messageEvents
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Modified to accumulate messages instead of resetting and only animate for new messages
|
||||
/**
|
||||
* Updates chat windows with messages for the current phase
|
||||
* @param phase The current game phase containing messages
|
||||
* @param stepMessages Whether to animate messages one-by-word (true) or show all at once (false)
|
||||
*/
|
||||
function updateChatWindows(stepMessages = false, callback?: () => void) {
|
||||
// Exit early if no messages
|
||||
if (!gameState.currentPhase.messages || !gameState.currentPhase.messages.length) {
|
||||
console.log("No messages to display for this phase");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Log message count but only in debug mode to reduce noise
|
||||
if (config.isDebugMode) {
|
||||
console.log(`Found ${relevantMessages.length} messages for player ${gameState.currentPower} in phase ${gameState.currentPhase.name}`);
|
||||
}
|
||||
|
||||
// Stepwise mode: show one message at a time, animating word-by-word
|
||||
let index = 0;
|
||||
|
||||
// Store the start time for debugging
|
||||
const messageStartTime = Date.now();
|
||||
|
||||
// Function to process the next message
|
||||
const showNext = () => {
|
||||
// If we're not playing or user has manually advanced, stop message animation
|
||||
if (!gameState.isPlaying && !config.isDebugMode) {
|
||||
console.log("Playback stopped, halting message animations");
|
||||
return;
|
||||
}
|
||||
|
||||
// All messages have been displayed
|
||||
if (index >= relevantMessages.length) {
|
||||
if (config.isDebugMode) {
|
||||
console.log(`All messages displayed in ${Date.now() - messageStartTime}ms`);
|
||||
}
|
||||
console.log("Messages complete, triggering next phase");
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the next message
|
||||
const msg = relevantMessages[index];
|
||||
|
||||
// Only log in debug mode to reduce console noise
|
||||
if (config.isDebugMode) {
|
||||
console.log(`Displaying message ${index + 1}/${relevantMessages.length}: ${msg.sender} to ${msg.recipient}`);
|
||||
}
|
||||
|
||||
// Function to call after message animation completes
|
||||
const onMessageComplete = () => {
|
||||
index++; // Only increment after animation completes
|
||||
showNext()
|
||||
};
|
||||
|
||||
// Add the message with word animation
|
||||
addMessageToChat(msg, true, onMessageComplete);
|
||||
|
||||
// Animate head and play sound for new messages (not just when not in debug mode)
|
||||
messageCounter++;
|
||||
animateHeadNod(msg, (messageCounter % config.soundEffectFrequency === 0));
|
||||
};
|
||||
|
||||
// Start the message sequence with initial delay
|
||||
gameState.eventQueue.scheduleDelay(50, showNext, `start-message-sequence-${Date.now()}`);
|
||||
}
|
||||
|
||||
// Modified to support word-by-word animation and callback
|
||||
function addMessageToChat(msg: MessageSchemaType, animateWords: boolean = false, onComplete: Function | null = null) {
|
||||
// Determine which chat window to use
|
||||
|
|
@ -265,70 +190,52 @@ function addMessageToChat(msg: MessageSchemaType, animateWords: boolean = false,
|
|||
// Create a unique ID for this message to avoid duplication
|
||||
const msgId = `${msg.sender}-${msg.recipient}-${msg.time_sent}-${msg.message}`;
|
||||
|
||||
// Skip if we've already shown this message
|
||||
if (chatWindows[targetPower].seenMessages.has(msgId)) {
|
||||
return false; // Not a new message
|
||||
}
|
||||
|
||||
// Mark as seen
|
||||
chatWindows[targetPower].seenMessages.add(msgId);
|
||||
|
||||
const messagesContainer = chatWindows[targetPower].messagesContainer;
|
||||
const messageElement = document.createElement('div');
|
||||
const chatBubble = createChatBubble(msg)
|
||||
|
||||
// Style based on sender/recipient
|
||||
if (targetPower === 'GLOBAL') {
|
||||
// Global chat shows sender info
|
||||
const senderColor = msg.sender.toLowerCase();
|
||||
messageElement.className = 'chat-message message-incoming';
|
||||
chatBubble.className = 'chat-message message-incoming';
|
||||
|
||||
// Add the header with the sender name immediately
|
||||
const headerSpan = document.createElement('span');
|
||||
headerSpan.style.fontWeight = 'bold';
|
||||
headerSpan.className = `power-${senderColor}`;
|
||||
headerSpan.textContent = `${getPowerDisplayName(msg.sender as PowerENUM)}: `;
|
||||
messageElement.appendChild(headerSpan);
|
||||
chatBubble.appendChild(headerSpan);
|
||||
|
||||
// Create a span for the message content that will be filled word by word
|
||||
const contentSpan = document.createElement('span');
|
||||
contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
messageElement.appendChild(contentSpan);
|
||||
|
||||
// Add timestamp
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'message-time';
|
||||
timeDiv.textContent = gameState.currentPhase.name;
|
||||
messageElement.appendChild(timeDiv);
|
||||
} else {
|
||||
// Private chat - outgoing or incoming style
|
||||
const isOutgoing = msg.sender === gameState.currentPower;
|
||||
messageElement.className = `chat-message ${isOutgoing ? 'message-outgoing' : 'message-incoming'}`;
|
||||
chatBubble.className = `chat-message ${isOutgoing ? 'message-outgoing' : 'message-incoming'}`;
|
||||
|
||||
// Create content span
|
||||
const contentSpan = document.createElement('span');
|
||||
contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
messageElement.appendChild(contentSpan);
|
||||
|
||||
// Add timestamp
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'message-time';
|
||||
timeDiv.textContent = gameState.currentPhase.name;
|
||||
messageElement.appendChild(timeDiv);
|
||||
}
|
||||
const contentSpan = document.createElement('span');
|
||||
contentSpan.id = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
chatBubble.appendChild(contentSpan);
|
||||
|
||||
// Add timestamp
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'message-time';
|
||||
timeDiv.textContent = gameState.currentPhase.name;
|
||||
chatBubble.appendChild(timeDiv);
|
||||
// Add to container
|
||||
messagesContainer.appendChild(messageElement);
|
||||
messagesContainer.appendChild(chatBubble);
|
||||
|
||||
// Scroll to bottom
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
|
||||
if (animateWords) {
|
||||
// Start word-by-word animation
|
||||
const contentSpanId = `msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
animateMessageWords(msg.message, contentSpanId, targetPower, messagesContainer, onComplete);
|
||||
animateMessageWords(msg.message, contentSpan, messagesContainer, onComplete);
|
||||
} else {
|
||||
// Show entire message at once
|
||||
const contentSpan = messageElement.querySelector(`#msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
||||
const contentSpan = chatBubble.querySelector(`#msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
||||
if (contentSpan) {
|
||||
contentSpan.textContent = msg.message;
|
||||
}
|
||||
|
|
@ -342,59 +249,6 @@ function addMessageToChat(msg: MessageSchemaType, animateWords: boolean = false,
|
|||
return true; // This was a new message
|
||||
}
|
||||
|
||||
// New function to animate message words one at a time
|
||||
/**
|
||||
* Animates message text one word at a time
|
||||
* @param message The full message text to animate
|
||||
* @param contentSpanId The ID of the span element to animate within
|
||||
* @param targetPower The power the message is displayed for
|
||||
* @param messagesContainer The container holding the messages
|
||||
* @param onComplete Callback function to run when animation completes
|
||||
*/
|
||||
function animateMessageWords(message: string, contentSpanId: string, targetPower: string,
|
||||
messagesContainer: HTMLElement, onComplete: (() => void) | null) {
|
||||
const words = message.split(/\s+/);
|
||||
const contentSpan = document.getElementById(contentSpanId);
|
||||
if (!contentSpan) {
|
||||
// If span not found, still call onComplete to avoid breaking the game flow
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing content
|
||||
contentSpan.textContent = '';
|
||||
let wordIndex = 0;
|
||||
|
||||
// Function to add the next word
|
||||
const addNextWord = () => {
|
||||
if (wordIndex >= words.length) {
|
||||
// All words added - message is complete
|
||||
console.log(`Finished animating message with ${words.length} words in ${targetPower} chat`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add space if not the first word
|
||||
if (wordIndex > 0) {
|
||||
contentSpan.textContent += ' ';
|
||||
}
|
||||
|
||||
// Add the next word
|
||||
contentSpan.textContent += words[wordIndex];
|
||||
wordIndex++;
|
||||
|
||||
// Calculate delay based on word length and playback speed
|
||||
// Longer words get slightly longer display time
|
||||
const wordLength = words[wordIndex - 1].length;
|
||||
// Use consistent word delay from config
|
||||
|
||||
// Scroll to ensure newest content is visible
|
||||
// Use requestAnimationFrame to batch DOM updates in streaming mode
|
||||
addNextWord()
|
||||
};
|
||||
|
||||
// Start animation
|
||||
addNextWord();
|
||||
}
|
||||
|
||||
// Modified to support conditional sound effects
|
||||
function animateHeadNod(msg, playSoundEffect = true) {
|
||||
|
|
@ -671,4 +525,3 @@ function playRandomSoundEffect() {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,30 +4,32 @@
|
|||
|
||||
import { config } from "./config";
|
||||
import { debugMenuInstance } from "./debug/debugMenu";
|
||||
import { toggleEventQueueDisplayState } from "./debug/eventQueueDisplay";
|
||||
import { debugMenu } from "./domElements";
|
||||
|
||||
export class ScheduledEvent {
|
||||
id: string;
|
||||
callback: () => void;
|
||||
callback: () => void | Promise<void>;
|
||||
resolved?: boolean;
|
||||
running!: boolean;
|
||||
error?: Error; // If the event caused an error, store it here.
|
||||
constructor(id: string, callback: () => void, resolved?: boolean) {
|
||||
promise?: Promise<void>;
|
||||
|
||||
constructor(id: string, callback: () => void | Promise<void>, resolved?: boolean) {
|
||||
this.id = id
|
||||
this.callback = callback
|
||||
this.resolved = resolved ? true : resolved
|
||||
}
|
||||
|
||||
run = () => {
|
||||
this.running = true
|
||||
try {
|
||||
this.callback();
|
||||
} catch (e) {
|
||||
this.error = e
|
||||
} finally {
|
||||
|
||||
this.resolved = true
|
||||
}
|
||||
// Store the promise so we can track it
|
||||
this.promise = Promise.resolve(this.callback())
|
||||
.then(() => {
|
||||
this.resolved = true;
|
||||
})
|
||||
.catch((e) => {
|
||||
this.error = e;
|
||||
this.resolved = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,13 +37,16 @@ export class EventQueue {
|
|||
private events: ScheduledEvent[] = [];
|
||||
private startTime: number = 0;
|
||||
private isRunning: boolean = false;
|
||||
private currentEventPromise?: Promise<void>;
|
||||
|
||||
/**
|
||||
* Start the event queue with current time as reference
|
||||
*/
|
||||
start(): void {
|
||||
this.isRunning = true;
|
||||
this.events[0].run()
|
||||
if (this.events.length > 0) {
|
||||
this.events[0].run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +63,7 @@ export class EventQueue {
|
|||
reset(resetCallback?: () => void): void {
|
||||
this.events = [];
|
||||
this.isRunning = false;
|
||||
this.currentEventPromise = undefined;
|
||||
if (resetCallback) {
|
||||
resetCallback();
|
||||
}
|
||||
|
|
@ -86,15 +92,26 @@ export class EventQueue {
|
|||
if (!this.isRunning) return;
|
||||
if (this.events.length < 1) return;
|
||||
|
||||
if (this.events[0].resolved) {
|
||||
if (this.events[0].error) {
|
||||
console.error(this.events[0].error)
|
||||
const currentEvent = this.events[0];
|
||||
|
||||
// Start the event if not started
|
||||
if (!currentEvent.running && !currentEvent.resolved) {
|
||||
currentEvent.run();
|
||||
this.currentEventPromise = currentEvent.promise;
|
||||
}
|
||||
|
||||
// Check if current event is complete
|
||||
if (currentEvent.resolved) {
|
||||
if (currentEvent.error) {
|
||||
console.error(currentEvent.error)
|
||||
}
|
||||
if (config.isDebugMode) {
|
||||
debugMenuInstance.updateTools()
|
||||
}
|
||||
this.events.shift()
|
||||
this.events[0].run()
|
||||
if (this.events.length > 0) {
|
||||
this.events[0].run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as THREE from "three"
|
||||
import { type CoordinateData, CoordinateDataSchema, PowerENUM } from "./types/map"
|
||||
import type { GameSchemaType, MessageSchemaType } from "./types/gameState";
|
||||
import type { GamePhase, GameSchemaType, MessageSchemaType } from "./types/gameState";
|
||||
import { GameSchema } from "./types/gameState";
|
||||
import { debugMenuInstance } from "./debug/debugMenu.ts"
|
||||
import { config } from "./config.ts"
|
||||
|
|
@ -18,6 +18,7 @@ import { EventQueue, ScheduledEvent } from "./events.ts";
|
|||
import { createAnimateUnitsEvent, createAnimationsForNextPhase } from "./units/animate.ts";
|
||||
import { createUpdateNewsBannerEvent } from "./components/newsBanner.ts";
|
||||
import { createMomentEvent } from "./components/momentModal.ts";
|
||||
import { updateMapOwnership } from "./map/state.ts";
|
||||
|
||||
//FIXME: This whole file is a mess. Need to organize and format
|
||||
|
||||
|
|
@ -112,6 +113,16 @@ class GameAudio {
|
|||
|
||||
constructor() {
|
||||
this.players = [{ Name: "background_music", track: initializeBackgroundAudio() }]
|
||||
}
|
||||
getNarratorPlayer = (): Audio | null => {
|
||||
let player = this.players.filter((player) => player.Name.includes("Narrator"))
|
||||
if (player.length === 0) {
|
||||
return null
|
||||
} else {
|
||||
return player[0].track
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
pause = (track_idx?: number | undefined) => {
|
||||
if (!track_idx) {
|
||||
|
|
@ -196,7 +207,7 @@ class GameState {
|
|||
}
|
||||
|
||||
_fillEventQueue = (gameData: GameSchemaType) => {
|
||||
for (let phase of gameData.phases) {
|
||||
for (let [phaseIdx, phase] of gameData.phases.entries()) {
|
||||
// Update Phase Display
|
||||
let updateUIEvent = createUpdateUIEvent(phase)
|
||||
this.eventQueue.schedule(updateUIEvent)
|
||||
|
|
@ -216,13 +227,37 @@ class GameState {
|
|||
if (phaseMoment) {
|
||||
this.eventQueue.schedule(createMomentEvent(phaseMoment))
|
||||
}
|
||||
let animationEvents = createAnimateUnitsEvent(phase)
|
||||
this.eventQueue.schedule(animationEvents)
|
||||
if (!(phaseIdx === 0)) {
|
||||
|
||||
let animationEvents = createAnimateUnitsEvent(phase, phaseIdx)
|
||||
this.eventQueue.schedule(animationEvents)
|
||||
}
|
||||
|
||||
// Lastly, update the gamePhase id
|
||||
this.eventQueue.schedule(new ScheduledEvent(`phaseidUpdate-${phase.name}`, () => { this.phaseIndex = gameData.phases.indexOf(phase) }))
|
||||
this.eventQueue.schedule(this.createNextPhaseEvent(phase, phaseIdx))
|
||||
}
|
||||
}
|
||||
createNextPhaseEvent = (phase: GamePhase, phaseIdx: number): ScheduledEvent => {
|
||||
return new ScheduledEvent(
|
||||
`movePhase-${phase.name}`,
|
||||
async () => {
|
||||
while (true) {
|
||||
let narrator = this.audio.getNarratorPlayer()
|
||||
|
||||
let narratorFinished = (narrator === null) || narrator.ended
|
||||
if (this.unitAnimations.length === 0 && narratorFinished) {
|
||||
this.phaseIndex = phaseIdx
|
||||
updateMapOwnership()
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait 500ms before checking again
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load game data from a JSON string and initialize the game state
|
||||
|
|
|
|||
|
|
@ -264,16 +264,17 @@ function createHoldAnimation(unitMesh: THREE.Group): Tween {
|
|||
return growTween;
|
||||
}
|
||||
|
||||
export function createAnimateUnitsEvent(phase: GamePhase): ScheduledEvent {
|
||||
return new ScheduledEvent(`createAnimations-${phase.name}`, () => createAnimationsForNextPhase())
|
||||
export function createAnimateUnitsEvent(phase: GamePhase, phaseIdx: number): ScheduledEvent {
|
||||
return new ScheduledEvent(`createAnimations-${phase.name}`, () => createAnimationsForNextPhase(phaseIdx))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates animations for unit movements based on orders from the previous phase
|
||||
*
|
||||
**/
|
||||
export function createAnimationsForNextPhase() {
|
||||
let previousPhase = gameState.gameData?.phases[gameState.phaseIndex == 0 ? 0 : gameState.phaseIndex - 1]
|
||||
export function createAnimationsForNextPhase(phaseIdx: number) {
|
||||
if (phaseIdx === 0) { throw new Error("Cannot create animations for phase 0, must start on 1 or higher") }
|
||||
let previousPhase = gameState.gameData?.phases[phaseIdx - 1]
|
||||
// const sequence = ["build", "disband", "hold", "move", "bounce", "retreat"]
|
||||
// Safety check - if no previous phase or no orders, return
|
||||
if (!previousPhase) {
|
||||
|
|
@ -305,7 +306,7 @@ export function createAnimationsForNextPhase() {
|
|||
order.type = "bounce"
|
||||
}
|
||||
// If the result is void, that means the move was not valid?
|
||||
if (result === "void") continue;
|
||||
if (result === "void" || result === "no convoy") continue;
|
||||
let unitIndex = -1
|
||||
|
||||
unitIndex = getUnit(order, power);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue