mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-19 12:58:09 +00:00
WIP: Moving most globals into gamestate
The gamestate object is now where most of the "state" of both the map and the board live. The units load, just working on loading the colors of the map ownership again.
This commit is contained in:
parent
dff01db83f
commit
ae2db39729
13 changed files with 1363 additions and 1322 deletions
615
ai_animation/src/domElements/chatWindows.ts
Normal file
615
ai_animation/src/domElements/chatWindows.ts
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
import * as THREE from "three";
|
||||
import { currentPower } from "../gameState";
|
||||
|
||||
let faceIconCache = {}; // Cache for generated face icons
|
||||
|
||||
// Add a message counter to track sound effect frequency
|
||||
let messageCounter = 0;
|
||||
let chatWindows = {}; // Store chat window elements by power
|
||||
// --- CHAT WINDOW FUNCTIONS ---
|
||||
export function createChatWindows() {
|
||||
// Clear existing chat windows
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
chatContainer.innerHTML = '';
|
||||
chatWindows = {};
|
||||
|
||||
// Create a chat window for each power (except the current power)
|
||||
const powers = ['AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY'];
|
||||
|
||||
// Filter out the current power for chat windows
|
||||
const otherPowers = powers.filter(power => power !== currentPower);
|
||||
|
||||
// Add a GLOBAL chat window first
|
||||
createChatWindow('GLOBAL', true);
|
||||
|
||||
// Create chat windows for each power except the current one
|
||||
otherPowers.forEach(power => {
|
||||
createChatWindow(power);
|
||||
});
|
||||
}
|
||||
// Modified to use 3D face icons properly
|
||||
function createChatWindow(power, isGlobal = false) {
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const chatWindow = document.createElement('div');
|
||||
chatWindow.className = 'chat-window';
|
||||
chatWindow.id = `chat - ${power} `;
|
||||
chatWindow.style.position = 'relative'; // Add relative positioning for absolute child positioning
|
||||
|
||||
// Create a slimmer header with appropriate styling
|
||||
const header = document.createElement('div');
|
||||
header.className = 'chat-header';
|
||||
|
||||
// Adjust header to accommodate larger face icons
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.padding = '4px 8px'; // Reduced vertical padding
|
||||
header.style.height = '24px'; // Explicit smaller height
|
||||
header.style.backgroundColor = 'rgba(78, 62, 41, 0.7)'; // Semi-transparent background
|
||||
header.style.borderBottom = '1px solid rgba(78, 62, 41, 1)'; // Solid bottom border
|
||||
|
||||
// Create the title element
|
||||
const titleElement = document.createElement('span');
|
||||
if (isGlobal) {
|
||||
titleElement.style.color = '#ffffff';
|
||||
titleElement.textContent = 'GLOBAL';
|
||||
} else {
|
||||
titleElement.className = `power - ${power.toLowerCase()} `;
|
||||
titleElement.textContent = power;
|
||||
}
|
||||
titleElement.style.fontWeight = 'bold'; // Make text more prominent
|
||||
titleElement.style.textShadow = '1px 1px 2px rgba(0,0,0,0.7)'; // Add text shadow for better readability
|
||||
header.appendChild(titleElement);
|
||||
|
||||
// Create container for 3D face icon that floats over the header
|
||||
const faceHolder = document.createElement('div');
|
||||
faceHolder.style.width = '64px';
|
||||
faceHolder.style.height = '64px';
|
||||
faceHolder.style.position = 'absolute'; // Position absolutely
|
||||
faceHolder.style.right = '10px'; // From right edge
|
||||
faceHolder.style.top = '0px'; // ADJUSTED: Moved lower to align with the header
|
||||
faceHolder.style.cursor = 'pointer';
|
||||
faceHolder.style.borderRadius = '50%';
|
||||
faceHolder.style.overflow = 'hidden';
|
||||
faceHolder.style.boxShadow = '0 2px 5px rgba(0,0,0,0.5)';
|
||||
faceHolder.style.border = '2px solid #fff';
|
||||
faceHolder.style.zIndex = '10'; // Ensure it's above other elements
|
||||
faceHolder.id = `face - ${power} `;
|
||||
|
||||
// Generate the face icon and add it to the chat window (not header)
|
||||
generateFaceIcon(power).then(dataURL => {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURL;
|
||||
img.style.width = '100%';
|
||||
img.style.height = '100%';
|
||||
img.id = `face - img - ${power} `; // Add ID for animation targeting
|
||||
|
||||
img.id = `face - img - ${power} `; // Add ID for animation targeting
|
||||
|
||||
// Add subtle idle animation
|
||||
setInterval(() => {
|
||||
if (!img.dataset.animating && Math.random() < 0.1) {
|
||||
idleAnimation(img);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
faceHolder.appendChild(img);
|
||||
});
|
||||
|
||||
// Create messages container with extra top padding to avoid overlap with floating head
|
||||
|
||||
header.appendChild(faceHolder);
|
||||
|
||||
// Create messages container
|
||||
const messagesContainer = document.createElement('div');
|
||||
messagesContainer.className = 'chat-messages';
|
||||
messagesContainer.id = `messages - ${power} `;
|
||||
messagesContainer.style.paddingTop = '8px'; // Add padding to prevent content being hidden under face
|
||||
|
||||
// Add toggle functionality
|
||||
header.addEventListener('click', () => {
|
||||
chatWindow.classList.toggle('chat-collapsed');
|
||||
});
|
||||
|
||||
// Assemble chat window - add faceHolder directly to chatWindow, not header
|
||||
chatWindow.appendChild(header);
|
||||
chatWindow.appendChild(faceHolder);
|
||||
chatWindow.appendChild(messagesContainer);
|
||||
|
||||
// Add to container
|
||||
chatContainer.appendChild(chatWindow);
|
||||
|
||||
// Store reference
|
||||
chatWindows[power] = {
|
||||
element: chatWindow,
|
||||
messagesContainer: messagesContainer,
|
||||
isGlobal: isGlobal,
|
||||
seenMessages: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
// Modified to accumulate messages instead of resetting and only animate for new messages
|
||||
function updateChatWindows(phase, stepMessages = false) {
|
||||
if (!phase.messages || !phase.messages.length) {
|
||||
messagesPlaying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const relevantMessages = phase.messages.filter(msg => {
|
||||
return (
|
||||
msg.sender === currentPower ||
|
||||
msg.recipient === currentPower ||
|
||||
msg.recipient === 'GLOBAL'
|
||||
);
|
||||
});
|
||||
relevantMessages.sort((a, b) => a.time_sent - b.time_sent);
|
||||
|
||||
if (!stepMessages) {
|
||||
// Normal: show all at once
|
||||
relevantMessages.forEach(msg => {
|
||||
const isNew = addMessageToChat(msg, phase.name);
|
||||
if (isNew) {
|
||||
// Increment message counter and play sound on every third message
|
||||
messageCounter++;
|
||||
animateHeadNod(msg, (messageCounter % 3 === 0));
|
||||
}
|
||||
});
|
||||
messagesPlaying = false;
|
||||
} else {
|
||||
// Stepwise
|
||||
messagesPlaying = true;
|
||||
let index = 0;
|
||||
|
||||
// Define the showNext function that will be called after each message animation completes
|
||||
const showNext = () => {
|
||||
if (index >= relevantMessages.length) {
|
||||
messagesPlaying = false;
|
||||
if (unitAnimations.length === 0 && isPlaying && !isSpeaking) {
|
||||
// Call the async function without awaiting it here
|
||||
playbackTimer = setTimeout(() => advanceToNextPhase(), playbackSpeed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = relevantMessages[index];
|
||||
index++; // Increment index before adding message so word animation knows the correct next message
|
||||
|
||||
const isNew = addMessageToChat(msg, phase.name, true, showNext); // Pass showNext as callback
|
||||
|
||||
if (isNew && !isDebugMode) {
|
||||
// Increment message counter
|
||||
messageCounter++;
|
||||
|
||||
// Only animate head and play sound for every third message
|
||||
animateHeadNod(msg, (messageCounter % 3 === 0));
|
||||
} else if (isDebugMode) {
|
||||
// In debug mode, immediately call showNext to skip waiting for animation
|
||||
showNext();
|
||||
} else {
|
||||
setTimeout(showNext, playbackSpeed * 3);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the message sequence
|
||||
showNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Modified to support word-by-word animation and callback
|
||||
function addMessageToChat(msg, phaseName, animateWords = false, onComplete = null) {
|
||||
// Determine which chat window to use
|
||||
let targetPower;
|
||||
if (msg.recipient === 'GLOBAL') {
|
||||
targetPower = 'GLOBAL';
|
||||
} else {
|
||||
targetPower = msg.sender === currentPower ? msg.recipient : msg.sender;
|
||||
}
|
||||
if (!chatWindows[targetPower]) return 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');
|
||||
|
||||
// Style based on sender/recipient
|
||||
if (targetPower === 'GLOBAL') {
|
||||
// Global chat shows sender info
|
||||
const senderColor = msg.sender.toLowerCase();
|
||||
messageElement.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 = `${msg.sender}: `;
|
||||
messageElement.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 = phaseName;
|
||||
messageElement.appendChild(timeDiv);
|
||||
} else {
|
||||
// Private chat - outgoing or incoming style
|
||||
const isOutgoing = msg.sender === currentPower;
|
||||
messageElement.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 = phaseName;
|
||||
messageElement.appendChild(timeDiv);
|
||||
}
|
||||
|
||||
// Add to container
|
||||
messagesContainer.appendChild(messageElement);
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
// Show entire message at once
|
||||
const contentSpan = messageElement.querySelector(`#msg-content-${msgId.replace(/[^a-zA-Z0-9]/g, '-')}`);
|
||||
if (contentSpan) {
|
||||
contentSpan.textContent = msg.message;
|
||||
}
|
||||
|
||||
// If there's a completion callback, call it immediately for non-animated messages
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
return true; // This was a new message
|
||||
}
|
||||
|
||||
// New function to animate message words one at a time
|
||||
function animateMessageWords(message, contentSpanId, targetPower, messagesContainer, onComplete) {
|
||||
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 - keep messagesPlaying true until next message starts
|
||||
|
||||
// Add a slight delay after the last word for readability
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(); // Call the completion callback
|
||||
}
|
||||
}, Math.min(playbackSpeed / 3, 150));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add space if not the first word
|
||||
if (wordIndex > 0) {
|
||||
contentSpan.textContent += ' ';
|
||||
}
|
||||
|
||||
// Add the next word
|
||||
contentSpan.textContent += words[wordIndex];
|
||||
wordIndex++;
|
||||
|
||||
// Schedule the next word with a delay based on word length and playback speed
|
||||
const delay = Math.max(30, Math.min(120, playbackSpeed / 10 * (words[wordIndex - 1].length / 4)));
|
||||
setTimeout(addNextWord, delay);
|
||||
|
||||
// Scroll to ensure newest content is visible
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
};
|
||||
|
||||
// Start animation
|
||||
addNextWord();
|
||||
}
|
||||
|
||||
// Modified to support conditional sound effects
|
||||
function animateHeadNod(msg, playSoundEffect = true) {
|
||||
// Determine which chat window's head to animate
|
||||
let targetPower;
|
||||
if (msg.recipient === 'GLOBAL') {
|
||||
targetPower = 'GLOBAL';
|
||||
} else {
|
||||
targetPower = msg.sender === currentPower ? msg.recipient : msg.sender;
|
||||
}
|
||||
|
||||
const chatWindow = chatWindows[targetPower]?.element;
|
||||
if (!chatWindow) return;
|
||||
|
||||
// Find the face image and animate it
|
||||
const img = chatWindow.querySelector(`#face - img - ${targetPower} `);
|
||||
if (!img) return;
|
||||
|
||||
img.dataset.animating = 'true';
|
||||
|
||||
// Choose a random animation type for variety
|
||||
const animationType = Math.floor(Math.random() * 4);
|
||||
|
||||
let animation;
|
||||
|
||||
switch (animationType) {
|
||||
case 0: // Nod animation
|
||||
animation = img.animate([
|
||||
{ transform: 'rotate(0deg) scale(1)' },
|
||||
{ transform: 'rotate(15deg) scale(1.1)' },
|
||||
{ transform: 'rotate(-10deg) scale(1.05)' },
|
||||
{ transform: 'rotate(5deg) scale(1.02)' },
|
||||
{ transform: 'rotate(0deg) scale(1)' }
|
||||
], {
|
||||
duration: 600,
|
||||
easing: 'ease-in-out'
|
||||
});
|
||||
break;
|
||||
|
||||
case 1: // Bounce animation
|
||||
animation = img.animate([
|
||||
{ transform: 'translateY(0) scale(1)' },
|
||||
{ transform: 'translateY(-8px) scale(1.15)' },
|
||||
{ transform: 'translateY(3px) scale(0.95)' },
|
||||
{ transform: 'translateY(-2px) scale(1.05)' },
|
||||
{ transform: 'translateY(0) scale(1)' }
|
||||
], {
|
||||
duration: 700,
|
||||
easing: 'ease-in-out'
|
||||
});
|
||||
break;
|
||||
|
||||
case 2: // Shake animation
|
||||
animation = img.animate([
|
||||
{ transform: 'translate(0, 0) rotate(0deg)' },
|
||||
{ transform: 'translate(-5px, -3px) rotate(-5deg)' },
|
||||
{ transform: 'translate(5px, 2px) rotate(5deg)' },
|
||||
{ transform: 'translate(-5px, 1px) rotate(-3deg)' },
|
||||
{ transform: 'translate(0, 0) rotate(0deg)' }
|
||||
], {
|
||||
duration: 500,
|
||||
easing: 'ease-in-out'
|
||||
});
|
||||
break;
|
||||
|
||||
case 3: // Pulse animation
|
||||
animation = img.animate([
|
||||
{ transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0.7)' },
|
||||
{ transform: 'scale(1.2)', boxShadow: '0 0 0 10px rgba(255,255,255,0)' },
|
||||
{ transform: 'scale(1)', boxShadow: '0 0 0 0 rgba(255,255,255,0)' }
|
||||
], {
|
||||
duration: 800,
|
||||
easing: 'ease-out'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
animation.onfinish = () => {
|
||||
img.dataset.animating = 'false';
|
||||
};
|
||||
|
||||
// Trigger random snippet only if playSoundEffect is true
|
||||
if (playSoundEffect) {
|
||||
playRandomSoundEffect();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a 3D face icon for chat windows with higher contrast
|
||||
async function generateFaceIcon(power) {
|
||||
if (faceIconCache[power]) {
|
||||
return faceIconCache[power];
|
||||
}
|
||||
|
||||
// Even larger renderer size for better quality
|
||||
const offWidth = 192, offHeight = 192; // Increased from 128x128 to 192x192
|
||||
const offRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
offRenderer.setSize(offWidth, offHeight);
|
||||
offRenderer.setPixelRatio(1);
|
||||
|
||||
// Scene
|
||||
const offScene = new THREE.Scene();
|
||||
offScene.background = null;
|
||||
|
||||
// Camera
|
||||
const offCamera = new THREE.PerspectiveCamera(45, offWidth / offHeight, 0.1, 1000);
|
||||
offCamera.position.set(0, 0, 50);
|
||||
|
||||
// Power-specific colors with higher contrast/saturation
|
||||
const colorMap = {
|
||||
'GLOBAL': 0xf5f5f5, // Brighter white
|
||||
'AUSTRIA': 0xff0000, // Brighter red
|
||||
'ENGLAND': 0x0000ff, // Brighter blue
|
||||
'FRANCE': 0x00bfff, // Brighter cyan
|
||||
'GERMANY': 0x1a1a1a, // Darker gray for better contrast
|
||||
'ITALY': 0x00cc00, // Brighter green
|
||||
'RUSSIA': 0xe0e0e0, // Brighter gray
|
||||
'TURKEY': 0xffcc00 // Brighter yellow
|
||||
};
|
||||
const headColor = colorMap[power] || 0x808080;
|
||||
|
||||
// Larger head geometry
|
||||
const headGeom = new THREE.BoxGeometry(20, 20, 20); // Increased from 16x16x16
|
||||
const headMat = new THREE.MeshStandardMaterial({ color: headColor });
|
||||
const headMesh = new THREE.Mesh(headGeom, headMat);
|
||||
offScene.add(headMesh);
|
||||
|
||||
// Create outline for better visibility (a slightly larger black box behind)
|
||||
const outlineGeom = new THREE.BoxGeometry(22, 22, 19);
|
||||
const outlineMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
const outlineMesh = new THREE.Mesh(outlineGeom, outlineMat);
|
||||
outlineMesh.position.z = -2; // Place it behind the head
|
||||
offScene.add(outlineMesh);
|
||||
|
||||
// Larger eyes with better contrast
|
||||
const eyeGeom = new THREE.BoxGeometry(3.5, 3.5, 3.5); // Increased from 2.5x2.5x2.5
|
||||
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x000000 });
|
||||
const leftEye = new THREE.Mesh(eyeGeom, eyeMat);
|
||||
leftEye.position.set(-4.5, 2, 10); // Adjusted position
|
||||
offScene.add(leftEye);
|
||||
const rightEye = new THREE.Mesh(eyeGeom, eyeMat);
|
||||
rightEye.position.set(4.5, 2, 10); // Adjusted position
|
||||
offScene.add(rightEye);
|
||||
|
||||
// Add a simple mouth
|
||||
const mouthGeom = new THREE.BoxGeometry(8, 1.5, 1);
|
||||
const mouthMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
|
||||
const mouth = new THREE.Mesh(mouthGeom, mouthMat);
|
||||
mouth.position.set(0, -3, 10);
|
||||
offScene.add(mouth);
|
||||
|
||||
// Brighter lighting for better contrast
|
||||
const light = new THREE.DirectionalLight(0xffffff, 1.2); // Increased intensity
|
||||
light.position.set(0, 20, 30);
|
||||
offScene.add(light);
|
||||
|
||||
// Add more lights for better definition
|
||||
const fillLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
fillLight.position.set(-20, 0, 20);
|
||||
offScene.add(fillLight);
|
||||
|
||||
offScene.add(new THREE.AmbientLight(0xffffff, 0.4)); // Slightly brighter ambient
|
||||
|
||||
// Slight head rotation
|
||||
headMesh.rotation.y = Math.PI / 6; // More pronounced angle
|
||||
|
||||
// Render to a texture
|
||||
const renderTarget = new THREE.WebGLRenderTarget(offWidth, offHeight);
|
||||
offRenderer.setRenderTarget(renderTarget);
|
||||
offRenderer.render(offScene, offCamera);
|
||||
|
||||
// Get pixels
|
||||
const pixels = new Uint8Array(offWidth * offHeight * 4);
|
||||
offRenderer.readRenderTargetPixels(renderTarget, 0, 0, offWidth, offHeight, pixels);
|
||||
|
||||
// Convert to canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = offWidth;
|
||||
canvas.height = offHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx.createImageData(offWidth, offHeight);
|
||||
imageData.data.set(pixels);
|
||||
|
||||
// Flip image (WebGL coordinate system is inverted)
|
||||
flipImageDataVertically(imageData, offWidth, offHeight);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Get data URL
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
faceIconCache[power] = dataURL;
|
||||
|
||||
// Cleanup
|
||||
offRenderer.dispose();
|
||||
renderTarget.dispose();
|
||||
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
// Add a subtle idle animation for faces
|
||||
function idleAnimation(img) {
|
||||
if (img.dataset.animating === 'true') return;
|
||||
|
||||
img.dataset.animating = 'true';
|
||||
|
||||
const animation = img.animate([
|
||||
{ transform: 'rotate(0deg) scale(1)' },
|
||||
{ transform: 'rotate(-2deg) scale(0.98)' },
|
||||
{ transform: 'rotate(0deg) scale(1)' }
|
||||
], {
|
||||
duration: 1500,
|
||||
easing: 'ease-in-out'
|
||||
});
|
||||
|
||||
animation.onfinish = () => {
|
||||
img.dataset.animating = 'false';
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to flip image data vertically
|
||||
function flipImageDataVertically(imageData, width, height) {
|
||||
const bytesPerRow = width * 4;
|
||||
const temp = new Uint8ClampedArray(bytesPerRow);
|
||||
for (let y = 0; y < height / 2; y++) {
|
||||
const topOffset = y * bytesPerRow;
|
||||
const bottomOffset = (height - y - 1) * bytesPerRow;
|
||||
temp.set(imageData.data.slice(topOffset, topOffset + bytesPerRow));
|
||||
imageData.data.set(imageData.data.slice(bottomOffset, bottomOffset + bytesPerRow), topOffset);
|
||||
imageData.data.set(temp, bottomOffset);
|
||||
}
|
||||
}
|
||||
|
||||
// --- NEW: Function to play a random sound effect ---
|
||||
function playRandomSoundEffect() {
|
||||
// List all the sound snippet filenames in assets/sounds
|
||||
const soundEffects = [
|
||||
'snippet_2.mp3',
|
||||
'snippet_3.mp3',
|
||||
'snippet_4.mp3',
|
||||
'snippet_9.mp3',
|
||||
'snippet_10.mp3',
|
||||
'snippet_11.mp3',
|
||||
'snippet_12.mp3',
|
||||
'snippet_13.mp3',
|
||||
'snippet_14.mp3',
|
||||
'snippet_15.mp3',
|
||||
'snippet_16.mp3',
|
||||
'snippet_17.mp3',
|
||||
];
|
||||
// Pick one at random
|
||||
const chosen = soundEffects[Math.floor(Math.random() * soundEffects.length)];
|
||||
|
||||
// Create an <audio> and play
|
||||
const audio = new Audio(`assets / sounds / ${chosen} `);
|
||||
audio.play().catch(err => {
|
||||
// In case of browser auto-play restrictions, you may see warnings in console
|
||||
console.warn("Audio play was interrupted:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends text to the scrolling news banner.
|
||||
* If the banner is at its default text or empty, replace it entirely.
|
||||
* Otherwise, just append " | " + newText.
|
||||
*/
|
||||
function addToNewsBanner(newText) {
|
||||
const bannerEl = document.getElementById('news-banner-content');
|
||||
if (!bannerEl) return;
|
||||
|
||||
// If the banner only has the default text or is empty, replace it
|
||||
if (
|
||||
bannerEl.textContent.trim() === 'Diplomatic actions unfolding...' ||
|
||||
bannerEl.textContent.trim() === ''
|
||||
) {
|
||||
bannerEl.textContent = newText;
|
||||
} else {
|
||||
// Otherwise append with a separator
|
||||
bannerEl.textContent += ' | ' + newText;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue