mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-22 16:49:15 +00:00
Merge pull request #17 from Tylermarques/bugfix/animations-disappearing
Animations now present, they just don't always move to the right space.
This commit is contained in:
commit
502445a0d1
25 changed files with 2287 additions and 2004 deletions
|
|
@ -9,7 +9,7 @@
|
|||
"x": 720,
|
||||
"y": 651
|
||||
},
|
||||
"type": null
|
||||
"type": "Inpassable"
|
||||
},
|
||||
"ADR": {
|
||||
"label": {
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
"x": 929.6,
|
||||
"y": 538.9
|
||||
},
|
||||
"type": null,
|
||||
"type": "Water",
|
||||
"isSupplyCenter": false
|
||||
},
|
||||
"LYO": {
|
||||
|
|
@ -223,7 +223,7 @@
|
|||
"x": 525.8,
|
||||
"y": 1107
|
||||
},
|
||||
"type": null,
|
||||
"type": "Water",
|
||||
"isSupplyCenter": false
|
||||
},
|
||||
"HEL": {
|
||||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
|
@ -1,202 +0,0 @@
|
|||
{
|
||||
"unplayable": {
|
||||
"fill": "#D5D5D5"
|
||||
},
|
||||
"impassable": {
|
||||
"fill": "#353433"
|
||||
},
|
||||
"background": {
|
||||
"fill": "#FFFFFF"
|
||||
},
|
||||
"labeltext": {
|
||||
"font-size": "12px",
|
||||
"fill": "black",
|
||||
"font-family": "Arial"
|
||||
},
|
||||
"currentnoterect": {
|
||||
"fill": "#c5dfea",
|
||||
"stroke-width": "0",
|
||||
"stroke": "black"
|
||||
},
|
||||
"currentnotetext": {
|
||||
"font-family": "serif,sans-serif",
|
||||
"font-size": "12px",
|
||||
"fill": "black",
|
||||
"stroke": "black"
|
||||
},
|
||||
"currentphasetext": {
|
||||
"font-family": "serif,sans-serif",
|
||||
"font-size": "14px",
|
||||
"fill": "black",
|
||||
"stroke": "black"
|
||||
},
|
||||
"invisibleContent": {
|
||||
"stroke": "#000000",
|
||||
"fill": "#000000",
|
||||
"fill-opacity": "0.0",
|
||||
"opacity": "0.0"
|
||||
},
|
||||
"provinceRed": {
|
||||
"fill": "url(#patternRed)",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"provinceBrown": {
|
||||
"fill": "url(#patternBrown)",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"provinceGreen": {
|
||||
"fill": "url(#patternGreen)",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"provinceBlack": {
|
||||
"fill": "url(#patternBlack)",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"provinceBlue": {
|
||||
"fill": "url(#patternBlue)",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"nopower": {
|
||||
"fill": "#ddd2af",
|
||||
"stroke": "black",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"water": {
|
||||
"fill": "#c5dfea",
|
||||
"stroke": "black",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"britain": {
|
||||
"fill": "royalblue",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"egypt": {
|
||||
"fill": "#808000",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"france": {
|
||||
"fill": "#00FFFF",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"germany": {
|
||||
"fill": "darkgrey",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"italy": {
|
||||
"fill": "#80FF80",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"poland": {
|
||||
"fill": "#FF0000",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"russia": {
|
||||
"fill": "#008000",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"spain": {
|
||||
"fill": "#FF8080",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"turkey": {
|
||||
"fill": "#FFFF00",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"ukraine": {
|
||||
"fill": "#FF00FF",
|
||||
"stroke": "black",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"unitbritain": {
|
||||
"fill": "deepskyblue",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitegypt": {
|
||||
"fill": "#808000",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitfrance": {
|
||||
"fill": "#00FFFF",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitgermany": {
|
||||
"fill": "darkgrey",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unititaly": {
|
||||
"fill": "#80FF80",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitpoland": {
|
||||
"fill": "#FF0000",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitrussia": {
|
||||
"fill": "#008000",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitspain": {
|
||||
"fill": "#FF8080",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitturkey": {
|
||||
"fill": "#FFFF00",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"unitukraine": {
|
||||
"fill": "#FF00FF",
|
||||
"stroke": "black",
|
||||
"fill-opacity": "0.90"
|
||||
},
|
||||
"supportorder": {
|
||||
"stroke-width": "2",
|
||||
"fill": "none",
|
||||
"stroke-dasharray": "5,5"
|
||||
},
|
||||
"convoyorder": {
|
||||
"stroke-dasharray": "15,5",
|
||||
"stroke-width": "2",
|
||||
"fill": "none"
|
||||
},
|
||||
"shadowdash": {
|
||||
"stroke-width": "4",
|
||||
"fill": "none",
|
||||
"stroke": "black",
|
||||
"opacity": "0.45"
|
||||
},
|
||||
"varwidthorder": {
|
||||
"fill": "none"
|
||||
},
|
||||
"varwidthshadow": {
|
||||
"fill": "none",
|
||||
"stroke": "black"
|
||||
},
|
||||
"style1": {
|
||||
"fill": "darkGray"
|
||||
}
|
||||
}
|
||||
48
ai_animation/assets/maps/standard/styles.json
Normal file
48
ai_animation/assets/maps/standard/styles.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"unplayable": {
|
||||
"fill": "#D5D5D5"
|
||||
},
|
||||
"impassable": {
|
||||
"fill": "#353433"
|
||||
},
|
||||
"labeltext": {
|
||||
"font-size": "12px",
|
||||
"fill": "black",
|
||||
"font-family": "Arial"
|
||||
},
|
||||
"currentnoterect": {
|
||||
"fill": "#c5dfea",
|
||||
"stroke-width": "0",
|
||||
"stroke": "black"
|
||||
},
|
||||
"currentnotetext": {
|
||||
"font-family": "serif,sans-serif",
|
||||
"font-size": "12px",
|
||||
"fill": "black",
|
||||
"stroke": "black"
|
||||
},
|
||||
"currentphasetext": {
|
||||
"font-family": "serif,sans-serif",
|
||||
"font-size": "14px",
|
||||
"fill": "black",
|
||||
"stroke": "black"
|
||||
},
|
||||
"invisibleContent": {
|
||||
"stroke": "#000000",
|
||||
"fill": "#000000",
|
||||
"fill-opacity": "0.0",
|
||||
"opacity": "0.0"
|
||||
},
|
||||
"nopower": {
|
||||
"fill": "#ddd2af",
|
||||
"stroke": "black",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
},
|
||||
"water": {
|
||||
"fill": "#c5dfea",
|
||||
"stroke": "black",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<div class="container">
|
||||
<div class="top-controls">
|
||||
|
|
|
|||
70
ai_animation/package-lock.json
generated
70
ai_animation/package-lock.json
generated
|
|
@ -8,7 +8,10 @@
|
|||
"name": "ai_animation",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"three": "^0.174.0"
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@types/three": "^0.174.0",
|
||||
"three": "^0.174.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.7.2",
|
||||
|
|
@ -706,6 +709,12 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
|
|
@ -713,6 +722,44 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz",
|
||||
"integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.174.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.174.0.tgz",
|
||||
"integrity": "sha512-De/+vZnfg2aVWNiuy1Ldu+n2ydgw1osinmiZTAn0necE++eOfsygL8JpZgFjR2uHmAPo89MkxBj3JJ+2BMe+Uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/three/node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.21.tgz",
|
||||
"integrity": "sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.55",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.55.tgz",
|
||||
"integrity": "sha512-p97I8XEC1h04esklFqyIH+UhFrUcj8/1/vBWgc6lAK4jMJc+KbhUy8D4dquHYztFj6pHLqGcp/P1xvBBF4r3DA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
|
||||
|
|
@ -754,6 +801,12 @@
|
|||
"@esbuild/win32-x64": "0.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -769,6 +822,12 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
|
|
@ -964,6 +1023,15 @@
|
|||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
"vite": "^6.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.174.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228"
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@types/three": "^0.174.0",
|
||||
"three": "^0.174.0",
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
ai_animation/src/config.ts
Normal file
4
ai_animation/src/config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const config = {
|
||||
playbackSpeed: 500, // Default speed in ms
|
||||
isDebugMode: true
|
||||
}
|
||||
45
ai_animation/src/domElements.ts
Normal file
45
ai_animation/src/domElements.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { gameState } from "./gameState";
|
||||
import { logger } from "./logger";
|
||||
// --- LOADING & DISPLAYING GAME PHASES ---
|
||||
export function loadGameBtnFunction(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
gameState.loadGameData(e.target?.result)
|
||||
};
|
||||
reader.onerror = () => {
|
||||
logger.log("Error reading file.")
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
export const loadBtn = document.getElementById('load-btn');
|
||||
export const fileInput = document.getElementById('file-input');
|
||||
export const prevBtn = document.getElementById('prev-btn');
|
||||
export const nextBtn = document.getElementById('next-btn');
|
||||
export const playBtn = document.getElementById('play-btn');
|
||||
export const speedSelector = document.getElementById('speed-selector');
|
||||
export const phaseDisplay = document.getElementById('phase-display');
|
||||
export const mapView = document.getElementById('map-view');
|
||||
export const leaderboard = document.getElementById('leaderboard');
|
||||
|
||||
// Add roundRect polyfill for browsers that don't support it
|
||||
if (!CanvasRenderingContext2D.prototype.roundRect) {
|
||||
CanvasRenderingContext2D.prototype.roundRect = function (x, y, width, height, radius) {
|
||||
if (typeof radius === 'undefined') {
|
||||
radius = 5;
|
||||
}
|
||||
this.beginPath();
|
||||
this.moveTo(x + radius, y);
|
||||
this.lineTo(x + width - radius, y);
|
||||
this.arcTo(x + width, y, x + width, y + radius, radius);
|
||||
this.lineTo(x + width, y + height - radius);
|
||||
this.arcTo(x + width, y + height, x + width - radius, y + height, radius);
|
||||
this.lineTo(x + radius, y + height);
|
||||
this.arcTo(x, y + height, x, y + height - radius, radius);
|
||||
this.lineTo(x, y + radius);
|
||||
this.arcTo(x, y, x + radius, y, radius);
|
||||
this.closePath();
|
||||
return this;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
616
ai_animation/src/domElements/chatWindows.ts
Normal file
616
ai_animation/src/domElements/chatWindows.ts
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
import * as THREE from "three";
|
||||
import { currentPower, gameState } from "../gameState";
|
||||
import { config } from "../config";
|
||||
|
||||
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
|
||||
export function updateChatWindows(phase, stepMessages = false) {
|
||||
if (!phase.messages || !phase.messages.length) {
|
||||
gameState.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));
|
||||
}
|
||||
});
|
||||
gameState.messagesPlaying = false;
|
||||
} else {
|
||||
// Stepwise
|
||||
gameState.messagesPlaying = true;
|
||||
let index = 0;
|
||||
|
||||
// Define the showNext function that will be called after each message animation completes
|
||||
const showNext = () => {
|
||||
if (index >= relevantMessages.length) {
|
||||
gameState.messagesPlaying = false;
|
||||
if (gameState.isAnimating && gameState.isPlaying && !gameState.isSpeaking) {
|
||||
// Call the async function without awaiting it here
|
||||
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.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 && !config.isDebugMode) {
|
||||
// Increment message counter
|
||||
messageCounter++;
|
||||
|
||||
// Only animate head and play sound for every third message
|
||||
animateHeadNod(msg, (messageCounter % 3 === 0));
|
||||
} else if (config.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(config.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, config.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;
|
||||
}
|
||||
}
|
||||
|
||||
140
ai_animation/src/gameState.ts
Normal file
140
ai_animation/src/gameState.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import * as THREE from "three"
|
||||
import { type Province, type CoordinateData, CoordinateDataSchema, PowerENUM } from "./types/map"
|
||||
import type { GameSchemaType } from "./types/gameState";
|
||||
import { GameSchema } from "./types/gameState";
|
||||
import { prevBtn, nextBtn, playBtn, speedSelector, mapView } from "./domElements";
|
||||
import { createChatWindows } from "./domElements/chatWindows";
|
||||
import { logger } from "./logger";
|
||||
import { OrbitControls } from "three/examples/jsm/Addons.js";
|
||||
import { displayInitialPhase } from "./phase";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
|
||||
//FIXME: This whole file is a mess. Need to organize and format
|
||||
//
|
||||
// NEW: Add a lock for text-to-speech
|
||||
export let isSpeaking = false; // Lock to pause game flow while TTS is active
|
||||
export let currentPhaseIndex = 0;
|
||||
enum AvailableMaps {
|
||||
STANDARD = "standard"
|
||||
}
|
||||
// Move these definitions BEFORE they're used
|
||||
function getRandomPower(): PowerENUM {
|
||||
const values = Object.values(PowerENUM)
|
||||
|
||||
const idx = Math.floor(Math.random() * values.length);
|
||||
return values[idx];
|
||||
}
|
||||
export const currentPower = getRandomPower();
|
||||
|
||||
|
||||
class GameState {
|
||||
boardState: CoordinateData
|
||||
gameData: GameSchemaType | null
|
||||
phaseIndex: number
|
||||
boardName: string
|
||||
|
||||
// state locks
|
||||
messagesPlaying: boolean
|
||||
isPlaying: boolean
|
||||
isSpeaking: boolean
|
||||
isAnimating: boolean
|
||||
|
||||
//Scene for three.js
|
||||
scene: THREE.Scene
|
||||
|
||||
// camera and controls
|
||||
camControls: OrbitControls
|
||||
camera: THREE.PerspectiveCamera
|
||||
renderer: THREE.WebGLRenderer
|
||||
|
||||
unitMeshes: THREE.Group[]
|
||||
|
||||
// Animations needed for this turn
|
||||
unitAnimations: TWEEN.Tween[]
|
||||
|
||||
//
|
||||
playbackTimer: number
|
||||
constructor(boardName: AvailableMaps) {
|
||||
this.phaseIndex = 0
|
||||
this.gameData = null
|
||||
this.boardName = boardName
|
||||
// State locks
|
||||
this.isSpeaking = false
|
||||
this.isPlaying = false
|
||||
this.isAnimating = false
|
||||
this.messagesPlaying = false
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.unitMeshes = []
|
||||
this.unitAnimations = []
|
||||
}
|
||||
|
||||
loadGameData = (gameDataString: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.gameData = GameSchema.parse(JSON.parse(gameDataString));
|
||||
logger.log(`Game data loaded: ${this.gameData.phases?.length || 0} phases found.`)
|
||||
this.phaseIndex = 0;
|
||||
if (this.gameData.phases?.length) {
|
||||
prevBtn.disabled = false;
|
||||
nextBtn.disabled = false;
|
||||
playBtn.disabled = false;
|
||||
speedSelector.disabled = false;
|
||||
|
||||
// Initialize chat windows
|
||||
createChatWindows();
|
||||
displayInitialPhase()
|
||||
resolve()
|
||||
} else {
|
||||
reject()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
loadBoardState = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`./assets/maps/${this.boardName}/coords.json`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load coordinates: ${response.status}`);
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((data) => {
|
||||
this.boardState = CoordinateDataSchema.parse(data)
|
||||
resolve()
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
throw error
|
||||
});
|
||||
})
|
||||
}
|
||||
initScene = () => {
|
||||
this.scene.background = new THREE.Color(0x87CEEB);
|
||||
|
||||
// Camera
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
mapView.clientWidth / mapView.clientHeight,
|
||||
1,
|
||||
3000
|
||||
);
|
||||
this.camera.position.set(0, 800, 900); // MODIFIED: Increased z-value to account for map shift
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setSize(mapView.clientWidth, mapView.clientHeight);
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
mapView.appendChild(this.renderer.domElement);
|
||||
|
||||
// Controls
|
||||
this.camControls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.camControls.enableDamping = true;
|
||||
this.camControls.dampingFactor = 0.05;
|
||||
this.camControls.screenSpacePanning = true;
|
||||
this.camControls.minDistance = 100;
|
||||
this.camControls.maxDistance = 2000;
|
||||
this.camControls.maxPolarAngle = Math.PI / 2; // Limit so you don't flip under the map
|
||||
this.camControls.target.set(0, 0, 100); // ADDED: Set control target to new map center
|
||||
}
|
||||
}
|
||||
|
||||
export let gameState = new GameState(AvailableMaps.STANDARD);
|
||||
43
ai_animation/src/logger.ts
Normal file
43
ai_animation/src/logger.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { gameState, currentPower } from "./gameState";
|
||||
import { currentPhaseIndex } from "./gameState";
|
||||
|
||||
class Logger {
|
||||
get infoPanel() {
|
||||
let _panel = document.getElementById('info-panel');
|
||||
if (_panel === null) {
|
||||
throw new Error("Unable to find the element with id 'info-panel'")
|
||||
}
|
||||
return _panel
|
||||
}
|
||||
log = (msg: string) => {
|
||||
if (typeof msg !== "string") {
|
||||
throw new Error(`Logger messages must be strings, you passed a ${typeof msg}`)
|
||||
}
|
||||
this.infoPanel.textContent = msg;
|
||||
|
||||
console.log(msg)
|
||||
}
|
||||
// New function to update info panel with useful information
|
||||
updateInfoPanel = () => {
|
||||
const totalPhases = gameState.gameData?.phases?.length || 0;
|
||||
const currentPhaseNumber = currentPhaseIndex + 1;
|
||||
const phaseName = gameState.gameData?.phases?.[currentPhaseIndex]?.name || 'Unknown';
|
||||
|
||||
this.infoPanel.innerHTML = `
|
||||
<div><strong>Power:</strong> <span class="power-${currentPower.toLowerCase()}">${currentPower}</span></div>
|
||||
<div><strong>Current Phase:</strong> ${phaseName} (${currentPhaseNumber}/${totalPhases})</div>
|
||||
<hr/>
|
||||
<h4>All-Time Leaderboard</h4>
|
||||
<ul style="list-style:none;padding-left:0;margin:0;">
|
||||
<li>Austria: 0</li>
|
||||
<li>England: 0</li>
|
||||
<li>France: 0</li>
|
||||
<li>Germany: 0</li>
|
||||
<li>Italy: 0</li>
|
||||
<li>Russia: 0</li>
|
||||
<li>Turkey: 0</li>
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
export const logger = new Logger()
|
||||
File diff suppressed because it is too large
Load diff
235
ai_animation/src/main.ts
Normal file
235
ai_animation/src/main.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import * as THREE from "three";
|
||||
import "./style.css"
|
||||
import { initMap } from "./map/create";
|
||||
import { createTweenAnimations } from "./units/animate";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { gameState } from "./gameState";
|
||||
import { logger } from "./logger";
|
||||
import { loadBtn, prevBtn, nextBtn, speedSelector, fileInput, playBtn, mapView, loadGameBtnFunction } from "./domElements";
|
||||
import { updateChatWindows } from "./domElements/chatWindows";
|
||||
import { displayPhaseWithAnimation } from "./phase";
|
||||
import { config } from "./config";
|
||||
|
||||
//TODO: Create a function that finds a suitable unit location within a given polygon, for placing units better
|
||||
// Currently the location for label, unit, and SC are all the same manually picked location
|
||||
|
||||
//const isDebugMode = process.env.NODE_ENV === 'development' || localStorage.getItem('debug') === 'true';
|
||||
const isDebugMode = config.isDebugMode;
|
||||
|
||||
// --- CORE VARIABLES ---
|
||||
|
||||
let cameraPanTime = 0; // Timer that drives the camera panning
|
||||
const cameraPanSpeed = 0.0005; // Smaller = slower
|
||||
|
||||
// --- INITIALIZE SCENE ---
|
||||
function initScene() {
|
||||
gameState.initScene()
|
||||
|
||||
// Lighting (keep it simple)
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
gameState.scene.add(ambientLight);
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
||||
dirLight.position.set(300, 400, 300);
|
||||
gameState.scene.add(dirLight);
|
||||
|
||||
// Load coordinate data, then build the map
|
||||
gameState.loadBoardState().then(() => {
|
||||
initMap(gameState.scene).then(() => {
|
||||
// Load default game file if in debug mode
|
||||
if (isDebugMode) {
|
||||
loadDefaultGameFile();
|
||||
}
|
||||
})
|
||||
}).catch(err => {
|
||||
console.error("Error loading coordinates:", err);
|
||||
logger.log(`Error loading coords: ${err.message}`)
|
||||
});
|
||||
|
||||
// Handle resizing
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
// Kick off animation loop
|
||||
animate();
|
||||
|
||||
// Initialize info panel
|
||||
logger.updateInfoPanel();
|
||||
|
||||
}
|
||||
|
||||
// --- ANIMATION LOOP ---
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
if (gameState.isPlaying) {
|
||||
// Pan camera slowly in playback mode
|
||||
cameraPanTime += cameraPanSpeed;
|
||||
const angle = 0.9 * Math.sin(cameraPanTime) + 1.2;
|
||||
const radius = 1300;
|
||||
gameState.camera.position.set(
|
||||
radius * Math.cos(angle),
|
||||
650 + 80 * Math.sin(cameraPanTime * 0.5),
|
||||
100 + radius * Math.sin(angle)
|
||||
);
|
||||
|
||||
// If messages are done playing but we haven't started unit animations yet
|
||||
if (!gameState.messagesPlaying && !gameState.isSpeaking && gameState.unitAnimations.length === 0 && gameState.isPlaying) {
|
||||
if (gameState.gameData && gameState.gameData.phases) {
|
||||
const prevIndex = gameState.phaseIndex > 0 ? gameState.phaseIndex - 1 : gameState.gameData.phases.length - 1;
|
||||
createTweenAnimations(
|
||||
gameState.gameData.phases[gameState.phaseIndex],
|
||||
gameState.gameData.phases[prevIndex]
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gameState.camControls.update();
|
||||
}
|
||||
|
||||
// Process unit movement animations using TWEEN.js update
|
||||
|
||||
// Check if all animations are complete
|
||||
if (gameState.unitAnimations.length > 0) {
|
||||
// Filter out completed animations
|
||||
gameState.unitAnimations = gameState.unitAnimations.filter(anim => anim.isPlaying());
|
||||
gameState.unitAnimations.forEach((anim) => anim.update())
|
||||
|
||||
// If all animations are complete and we're in playback mode
|
||||
if (gameState.unitAnimations.length === 0 && gameState.isPlaying && !gameState.messagesPlaying) {
|
||||
// Schedule next phase after a pause delay
|
||||
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.playbackSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
// Update any pulsing or wave animations on supply centers or units
|
||||
if (gameState.scene.userData.animatedObjects) {
|
||||
gameState.scene.userData.animatedObjects.forEach(obj => {
|
||||
if (obj.userData.pulseAnimation) {
|
||||
const anim = obj.userData.pulseAnimation;
|
||||
anim.time += anim.speed;
|
||||
if (obj.userData.glowMesh) {
|
||||
const pulseValue = Math.sin(anim.time) * anim.intensity + 0.5;
|
||||
obj.userData.glowMesh.material.opacity = 0.2 + (pulseValue * 0.3);
|
||||
obj.userData.glowMesh.scale.set(
|
||||
1 + (pulseValue * 0.1),
|
||||
1 + (pulseValue * 0.1),
|
||||
1 + (pulseValue * 0.1)
|
||||
);
|
||||
}
|
||||
// Subtle bobbing up/down
|
||||
obj.position.y = 2 + Math.sin(anim.time) * 0.5;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
gameState.camControls.update();
|
||||
gameState.renderer.render(gameState.scene, gameState.camera);
|
||||
}
|
||||
|
||||
|
||||
// --- RESIZE HANDLER ---
|
||||
function onWindowResize() {
|
||||
gameState.camera.aspect = mapView.clientWidth / mapView.clientHeight;
|
||||
gameState.camera.updateProjectionMatrix();
|
||||
gameState.renderer.setSize(mapView.clientWidth, mapView.clientHeight);
|
||||
}
|
||||
|
||||
// Load a default game if we're running debug
|
||||
function loadDefaultGameFile() {
|
||||
console.log("Loading default game file for debug mode...");
|
||||
|
||||
// Path to the default game file
|
||||
const defaultGameFilePath = './assets/default_game.json';
|
||||
|
||||
fetch(defaultGameFilePath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load default game file: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(data => {
|
||||
gameState.loadGameData(data);
|
||||
console.log("Default game file loaded successfully");
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading default game file:", error);
|
||||
logger.log(`Error loading default game: ${error.message}`)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- PLAYBACK CONTROLS ---
|
||||
function togglePlayback() {
|
||||
if (!gameState.gameData || gameState.gameData.phases.length <= 1) return;
|
||||
|
||||
// NEW: If we're speaking, don't allow toggling playback
|
||||
if (gameState.isSpeaking) return;
|
||||
|
||||
gameState.isPlaying = !gameState.isPlaying;
|
||||
|
||||
if (gameState.isPlaying) {
|
||||
playBtn.textContent = "⏸ Pause";
|
||||
prevBtn.disabled = true;
|
||||
nextBtn.disabled = true;
|
||||
|
||||
// First, show the messages of the current phase if it's the initial playback
|
||||
const phase = gameState.gameData.phases[gameState.phaseIndex];
|
||||
if (phase.messages && phase.messages.length) {
|
||||
// Show messages with stepwise animation
|
||||
updateChatWindows(phase, true);
|
||||
} else {
|
||||
// No messages, go straight to unit animations
|
||||
displayPhaseWithAnimation(gameState.phaseIndex);
|
||||
}
|
||||
} else {
|
||||
playBtn.textContent = "▶ Play";
|
||||
if (gameState.playbackTimer) {
|
||||
clearTimeout(gameState.playbackTimer);
|
||||
gameState.playbackTimer = null;
|
||||
}
|
||||
gameState.messagesPlaying = false;
|
||||
prevBtn.disabled = false;
|
||||
nextBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- EVENT HANDLERS ---
|
||||
loadBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', e => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
loadGameBtnFunction(file);
|
||||
}
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (gameState.phaseIndex > 0) {
|
||||
gameState.phaseIndex--;
|
||||
displayPhaseWithAnimation(gameState.phaseIndex);
|
||||
}
|
||||
});
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (gameState.gameData && gameState.phaseIndex < gameState.gameData.phases.length - 1) {
|
||||
gameState.phaseIndex++;
|
||||
displayPhaseWithAnimation(gameState.phaseIndex);
|
||||
}
|
||||
});
|
||||
|
||||
playBtn.addEventListener('click', togglePlayback);
|
||||
|
||||
speedSelector.addEventListener('change', e => {
|
||||
config.playbackSpeed = parseInt(e.target.value);
|
||||
// If we're currently playing, restart the timer with the new speed
|
||||
if (gameState.isPlaying && gameState.playbackTimer) {
|
||||
clearTimeout(gameState.playbackTimer);
|
||||
gameState.playbackTimer = setTimeout(() => advanceToNextPhase(), config.playbackSpeed);
|
||||
}
|
||||
});
|
||||
|
||||
// --- BOOTSTRAP ON PAGE LOAD ---
|
||||
window.addEventListener('load', initScene);
|
||||
|
||||
|
||||
|
||||
109
ai_animation/src/map/create.ts
Normal file
109
ai_animation/src/map/create.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import * as THREE from "three";
|
||||
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
|
||||
import { createLabel } from "./labels"
|
||||
import { gameState } from "../gameState";
|
||||
import { getPowerHexColor } from "../units/create";
|
||||
|
||||
export function initMap(scene): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new SVGLoader();
|
||||
loader.load('assets/maps/standard/map.svg',
|
||||
function (data) {
|
||||
fetch('assets/maps/standard/styles.json')
|
||||
.then(resp => resp.json())
|
||||
.then(map_styles => {
|
||||
const paths = data.paths;
|
||||
const group = new THREE.Group();
|
||||
const textGroup = new THREE.Group();
|
||||
let fillColor;
|
||||
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
fillColor = "";
|
||||
const path = paths[i];
|
||||
// The "standard" map has keys like _mos, so remove that then send them to caps
|
||||
let provinceKey = path.userData.node.id.substring(1).toUpperCase();
|
||||
let nodeClass = path.userData.node.classList[0]
|
||||
|
||||
switch (nodeClass) {
|
||||
case undefined:
|
||||
continue
|
||||
case "water":
|
||||
fillColor = "#c5dfea"
|
||||
break
|
||||
case "nopower":
|
||||
fillColor = getPowerHexColor(undefined)
|
||||
}
|
||||
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: fillColor,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
const shapes = SVGLoader.createShapes(path);
|
||||
|
||||
for (let j = 0; j < shapes.length; j++) {
|
||||
|
||||
const shape = shapes[j];
|
||||
const geometry = new THREE.ShapeGeometry(shape);
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
mesh.rotation.x = Math.PI / 2;
|
||||
if (provinceKey && gameState.boardState.provinces[provinceKey]) {
|
||||
gameState.boardState.provinces[provinceKey].mesh = mesh
|
||||
}
|
||||
|
||||
|
||||
// Create an edges geometry from the shape geometry.
|
||||
const edges = new THREE.EdgesGeometry(geometry);
|
||||
// Create a line material with black color for the border.
|
||||
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 });
|
||||
// Create the line segments object to display the border.
|
||||
const line = new THREE.LineSegments(edges, lineMaterial);
|
||||
// Add the border as a child of the mesh.
|
||||
mesh.add(line);
|
||||
group.add(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all the labels for each map position
|
||||
const fontLoader = new FontLoader();
|
||||
fontLoader.load('assets/fonts/helvetiker_regular.typeface.json', function (font) {
|
||||
for (const [key, value] of Object.entries(gameState.boardState.provinces)) {
|
||||
|
||||
textGroup.add(createLabel(font, key, value))
|
||||
}
|
||||
})
|
||||
// This rotates the SVG the "correct" way round, and scales it down
|
||||
group.scale.set(1, -1, 1)
|
||||
textGroup.rotation.x = Math.PI / 2;
|
||||
textGroup.scale.set(1, -1, 1)
|
||||
|
||||
// After adding all meshes to the group, update its matrix:
|
||||
group.updateMatrixWorld(true);
|
||||
textGroup.updateMatrixWorld(true);
|
||||
|
||||
// Compute the bounding box of the group:
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const center = new THREE.Vector3();
|
||||
box.getCenter(center);
|
||||
gameState.camera.position.set(center.x, center.y + 1100, 1600)
|
||||
gameState.camControls.target = center
|
||||
|
||||
|
||||
scene.add(group);
|
||||
scene.add(textGroup);
|
||||
resolve()
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading map styles:', error);
|
||||
});
|
||||
},
|
||||
// Progress function
|
||||
undefined,
|
||||
function (error) { console.log(error) })
|
||||
})
|
||||
}
|
||||
122
ai_animation/src/map/state.ts
Normal file
122
ai_animation/src/map/state.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { getPowerHexColor } from "../units/create";
|
||||
import { gameState } from "../gameState";
|
||||
import { leaderboard } from "../domElements";
|
||||
import type { GamePhase } from "../types/gameState";
|
||||
import { ProvTypeENUM } from "../types/map";
|
||||
|
||||
|
||||
export function updateSupplyCenterOwnership(centers) {
|
||||
if (!centers) return;
|
||||
const ownershipMap = {};
|
||||
// centers is typically { "AUSTRIA":["VIE","BUD"], "FRANCE":["PAR","MAR"], ... }
|
||||
for (const [power, provinces] of Object.entries(centers)) {
|
||||
provinces.forEach(p => {
|
||||
// No messages, animate units immediately
|
||||
ownershipMap[p.toUpperCase()] = power.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
gameState.unitMeshes.forEach(obj => {
|
||||
if (obj.userData && obj.userData.isSupplyCenter) {
|
||||
const prov = obj.userData.province;
|
||||
const owner = ownershipMap[prov];
|
||||
if (owner) {
|
||||
const c = getPowerHexColor(owner);
|
||||
obj.userData.starMesh.material.color.setHex(c);
|
||||
|
||||
// Add a pulsing animation
|
||||
if (!obj.userData.pulseAnimation) {
|
||||
obj.userData.pulseAnimation = {
|
||||
speed: 0.003 + Math.random() * 0.002,
|
||||
intensity: 0.3,
|
||||
time: Math.random() * Math.PI * 2
|
||||
};
|
||||
if (!gameState.scene.userData.animatedObjects) gameState.scene.userData.animatedObjects = [];
|
||||
gameState.scene.userData.animatedObjects.push(obj);
|
||||
}
|
||||
} else {
|
||||
// Neutral
|
||||
obj.userData.starMesh.material.color.setHex(0xFFD700);
|
||||
// remove pulse
|
||||
obj.userData.pulseAnimation = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLeaderboard(phase) {
|
||||
// Get supply center counts
|
||||
const centerCounts = {};
|
||||
const unitCounts = {};
|
||||
|
||||
// Count supply centers by power
|
||||
if (phase.state?.centers) {
|
||||
for (const [power, provinces] of Object.entries(phase.state.centers)) {
|
||||
centerCounts[power] = provinces.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Count units by power
|
||||
if (phase.state?.units) {
|
||||
for (const [power, units] of Object.entries(phase.state.units)) {
|
||||
unitCounts[power] = units.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all powers from both centers and units
|
||||
const allPowers = new Set([
|
||||
...Object.keys(centerCounts),
|
||||
...Object.keys(unitCounts)
|
||||
]);
|
||||
|
||||
// Sort powers by supply center count (descending)
|
||||
const sortedPowers = Array.from(allPowers).sort((a, b) => {
|
||||
return (centerCounts[b] || 0) - (centerCounts[a] || 0);
|
||||
});
|
||||
|
||||
// Build HTML for leaderboard
|
||||
let html = `<strong>Council Standings</strong><br/>`;
|
||||
|
||||
sortedPowers.forEach(power => {
|
||||
const centers = centerCounts[power] || 0;
|
||||
const units = unitCounts[power] || 0;
|
||||
|
||||
// Use CSS classes instead of inline styles for better contrast
|
||||
html += `<div style="margin: 5px 0; display: flex; justify-content: space-between;">
|
||||
<span class="power-${power.toLowerCase()}">${power}</span>
|
||||
<span>${centers} SCs, ${units} units</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Add victory condition reminder
|
||||
html += `<hr style="border-color: #555; margin: 8px 0;"/>
|
||||
<small>Victory: 18 supply centers</small>`;
|
||||
|
||||
leaderboard.innerHTML = html;
|
||||
}
|
||||
|
||||
export function updateMapOwnership(currentPhase: GamePhase) {
|
||||
//FIXME: This only works in the forward direction, we currently don't update ownership correctly when going to previous phase
|
||||
|
||||
for (const [power, unitArr] of Object.entries(currentPhase.state.units)) {
|
||||
unitArr.forEach(unitStr => {
|
||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||
if (!match) return;
|
||||
const location = match[2];
|
||||
const normalized = location.toUpperCase().replace('/', '_');
|
||||
const base = normalized.split('_')[0];
|
||||
if (gameState.boardState.provinces[base] === undefined) {
|
||||
console.log(base)
|
||||
}
|
||||
gameState.boardState.provinces[base].owner = power
|
||||
})
|
||||
}
|
||||
for (const [key, value] of Object.entries(gameState.boardState.provinces)) {
|
||||
// Update the color of the provinces if needed
|
||||
if (gameState.boardState.provinces[key].owner && gameState.boardState?.provinces[key].type != ProvTypeENUM.WATER) {
|
||||
let powerColor = getPowerHexColor(gameState.boardState.provinces[key].owner)
|
||||
let powerColorHex = parseInt(powerColor.substring(1), 16);
|
||||
gameState.boardState.provinces[key].mesh?.material.color.setHex(powerColorHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
ai_animation/src/map/utils.ts
Normal file
35
ai_animation/src/map/utils.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
|
||||
function hashStringToPosition(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const x = (hash % 800) - 400;
|
||||
const z = ((hash >> 8) % 800) - 400;
|
||||
return { x, y: 0, z };
|
||||
}
|
||||
|
||||
//TODO: Make coordinateData come from gameState
|
||||
export function getProvincePosition(coordinateData, loc) {
|
||||
// Convert e.g. "Spa/sc" to "SPA_SC" if needed
|
||||
const normalized = loc.toUpperCase().replace('/', '_');
|
||||
const base = normalized.split('_')[0];
|
||||
|
||||
if (coordinateData && coordinateData.provinces) {
|
||||
if (coordinateData.provinces[normalized]) {
|
||||
return {
|
||||
"x": coordinateData.provinces[normalized].label.x,
|
||||
"y": 10,
|
||||
"z": coordinateData.provinces[normalized].label.y
|
||||
};
|
||||
}
|
||||
if (coordinateData.provinces[base]) {
|
||||
return coordinateData.provinces[base].label;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback with offset
|
||||
const pos = hashStringToPosition(loc);
|
||||
return { x: pos.x, y: pos.y, z: pos.z + 100 };
|
||||
}
|
||||
122
ai_animation/src/phase.ts
Normal file
122
ai_animation/src/phase.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { gameState } from "./gameState";
|
||||
import { logger } from "./logger";
|
||||
import { phaseDisplay } from "./domElements";
|
||||
import { createSupplyCenters } from "./units/create";
|
||||
import { createUnitMesh } from "./units/create";
|
||||
import { updateSupplyCenterOwnership, updateLeaderboard, updateMapOwnership } from "./map/state";
|
||||
import { updateChatWindows } from "./domElements/chatWindows";
|
||||
import { createTweenAnimations } from "./units/animate";
|
||||
|
||||
// New function to display initial state without messages
|
||||
export function displayInitialPhase() {
|
||||
let index = 0
|
||||
if (!gameState.gameData || !gameState.gameData.phases || index < 0 || index >= gameState.gameData.phases.length) {
|
||||
logger.log("Invalid phase index.")
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing units
|
||||
const supplyCenters = gameState.unitMeshes.filter(m => m.userData && m.userData.isSupplyCenter);
|
||||
const oldUnits = gameState.unitMeshes.filter(m => m.userData && !m.userData.isSupplyCenter);
|
||||
oldUnits.forEach(m => gameState.scene.remove(m));
|
||||
gameState.unitMeshes = supplyCenters;
|
||||
|
||||
const phase = gameState.gameData.phases[index];
|
||||
phaseDisplay.textContent = `Era: ${phase.name || 'Unknown Era'} (${index + 1}/${gameState.gameData.phases.length})`;
|
||||
|
||||
// Show supply centers
|
||||
let newSCs = createSupplyCenters();
|
||||
newSCs.forEach((sc) => gameState.scene.add(sc))
|
||||
if (phase.state?.centers) {
|
||||
updateSupplyCenterOwnership(phase.state.centers);
|
||||
}
|
||||
|
||||
// Show units
|
||||
if (phase.state?.units) {
|
||||
for (const [power, unitArr] of Object.entries(phase.state.units)) {
|
||||
unitArr.forEach(unitStr => {
|
||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||
if (match) {
|
||||
let newUnit = createUnitMesh({
|
||||
power: power.toUpperCase(),
|
||||
type: match[1],
|
||||
province: match[2],
|
||||
});
|
||||
gameState.scene.add(newUnit)
|
||||
gameState.unitMeshes.push(newUnit)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateLeaderboard(phase);
|
||||
updateMapOwnership(phase)
|
||||
|
||||
logger.log(`Phase: ${phase.name}\nSCs: ${phase.state?.centers ? JSON.stringify(phase.state.centers) : 'None'}\nUnits: ${phase.state?.units ? JSON.stringify(phase.state.units) : 'None'}`)
|
||||
|
||||
// Add: Update info panel
|
||||
logger.updateInfoPanel();
|
||||
|
||||
}
|
||||
|
||||
export function displayPhaseWithAnimation(index) {
|
||||
if (!gameState.gameData || !gameState.gameData.phases || index < 0 || index >= gameState.gameData.phases.length) {
|
||||
logger.log("Invalid phase index.")
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIndex = index > 0 ? index - 1 : gameState.gameData.phases.length - 1;
|
||||
const currentPhase = gameState.gameData.phases[index];
|
||||
const previousPhase = gameState.gameData.phases[prevIndex];
|
||||
|
||||
phaseDisplay.textContent = `Era: ${currentPhase.name || 'Unknown Era'} (${index + 1}/${gameState.gameData.phases.length})`;
|
||||
|
||||
// Rebuild supply centers, remove old units
|
||||
|
||||
// First show messages, THEN animate units after
|
||||
// First show messages with stepwise animation
|
||||
updateChatWindows(currentPhase, true);
|
||||
|
||||
|
||||
// Ownership
|
||||
if (currentPhase.state?.centers) {
|
||||
updateSupplyCenterOwnership(currentPhase.state.centers);
|
||||
}
|
||||
|
||||
// Update leaderboard
|
||||
updateLeaderboard(currentPhase);
|
||||
updateMapOwnership(currentPhase)
|
||||
|
||||
createTweenAnimations(currentPhase, previousPhase);
|
||||
let msg = `Phase: ${currentPhase.name}\nSCs: ${JSON.stringify(currentPhase.state.centers)} \nUnits: ${currentPhase.state?.units ? JSON.stringify(currentPhase.state.units) : 'None'} `
|
||||
// Panel
|
||||
|
||||
// Add: Update info panel
|
||||
logger.updateInfoPanel();
|
||||
|
||||
}
|
||||
|
||||
async function advanceToNextPhase() {
|
||||
// Only show a summary if we have at least started the first phase
|
||||
// and only if the just-ended phase has a "summary" property.
|
||||
if (gameState.gameData && gameState.gameData.phases && gameState.phaseIndex >= 0) {
|
||||
const justEndedPhase = gameState.gameData.phases[gameState.phaseIndex];
|
||||
if (justEndedPhase.summary && justEndedPhase.summary.trim() !== '') {
|
||||
// UPDATED: First update the news banner with full summary
|
||||
addToNewsBanner(`(${justEndedPhase.name}) ${justEndedPhase.summary}`);
|
||||
|
||||
// Then speak the summary (will be truncated internally)
|
||||
await speakSummary(justEndedPhase.summary);
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached the end, loop back to the beginning
|
||||
if (gameState.phaseIndex >= gameState.gameData.phases.length - 1) {
|
||||
gameState.phaseIndex = 0;
|
||||
} else {
|
||||
gameState.phaseIndex++;
|
||||
}
|
||||
|
||||
// Display the new phase with animation
|
||||
displayPhaseWithAnimation(gameState.phaseIndex);
|
||||
}
|
||||
73
ai_animation/src/speech.ts
Normal file
73
ai_animation/src/speech.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
|
||||
// --- NEW: ElevenLabs TTS helper function ---
|
||||
const ELEVENLABS_API_KEY = import.meta.env.VITE_ELEVENLABS_API_KEY || "";
|
||||
const VOICE_ID = "onwK4e9ZLuTAKqWW03F9";
|
||||
const MODEL_ID = "eleven_multilingual_v2";
|
||||
/**
|
||||
* Call ElevenLabs TTS to speak out loud. Returns a promise that
|
||||
* resolves only after the audio finishes playing (or fails).
|
||||
* Now accepts only the first 100 characters for brevity.
|
||||
*/
|
||||
async function speakSummary(summaryText) {
|
||||
if (!ELEVENLABS_API_KEY) {
|
||||
console.warn("No ElevenLabs API key found. Skipping TTS.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the speaking flag to block other animations/transitions
|
||||
isSpeaking = true;
|
||||
|
||||
try {
|
||||
// Truncate text to first 100 characters for ElevenLabs
|
||||
const truncatedText = summaryText.substring(0, 100);
|
||||
if (truncatedText.length < summaryText.length) {
|
||||
console.log(`TTS text truncated from ${summaryText.length} to 100 characters`);
|
||||
}
|
||||
|
||||
// Hit ElevenLabs TTS endpoint with the truncated text
|
||||
const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": ELEVENLABS_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/mpeg"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: truncatedText,
|
||||
model_id: MODEL_ID,
|
||||
// Optional fine-tuning parameters
|
||||
// voice_settings: { stability: 0.3, similarity_boost: 0.8 },
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ElevenLabs TTS error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Convert response into a playable blob
|
||||
const audioBlob = await response.blob();
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Play the audio, pause until finished
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play().then(() => {
|
||||
audio.onended = () => {
|
||||
// Clear the speaking flag when audio finishes
|
||||
isSpeaking = false;
|
||||
resolve();
|
||||
};
|
||||
}).catch(err => {
|
||||
console.error("Audio playback error", err);
|
||||
// Make sure to clear the flag even if there's an error
|
||||
isSpeaking = false;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to generate TTS from ElevenLabs:", err);
|
||||
// Make sure to clear the flag if there's any exception
|
||||
isSpeaking = false;
|
||||
}
|
||||
}
|
||||
26
ai_animation/src/types/gameState.ts
Normal file
26
ai_animation/src/types/gameState.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from 'zod';
|
||||
import { PowerENUMSchema } from './map';
|
||||
import { OrderFromString } from './unitOrders';
|
||||
import { ProvinceENUMSchema } from './map';
|
||||
|
||||
|
||||
const PhaseSchema = z.object({
|
||||
messages: z.array(z.any()),
|
||||
name: z.string(),
|
||||
orders: z.record(PowerENUMSchema, z.array(OrderFromString).nullable()),
|
||||
results: z.record(z.string(), z.array(z.any())),
|
||||
state: z.object({
|
||||
units: z.record(PowerENUMSchema, z.array(z.string())),
|
||||
centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema))
|
||||
}),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
export const GameSchema = z.object({
|
||||
map: z.string(),
|
||||
id: z.string(),
|
||||
phases: z.array(PhaseSchema),
|
||||
});
|
||||
|
||||
export type GamePhase = z.infer<typeof PhaseSchema>;
|
||||
export type GameSchemaType = z.infer<typeof GameSchema>;
|
||||
|
|
@ -1,22 +1,134 @@
|
|||
import { z } from "zod";
|
||||
|
||||
enum ProvTypeEnum {
|
||||
export enum ProvTypeENUM {
|
||||
WATER = "Water",
|
||||
COAST = "Coast",
|
||||
LAND = "Land",
|
||||
|
||||
}
|
||||
type Province = {
|
||||
label: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
type: ProvTypeEnum
|
||||
unit?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
INPASSABLE = "Inpassable",
|
||||
}
|
||||
|
||||
type CoordinateData = {
|
||||
provinces: Province[]
|
||||
export enum PowerENUM {
|
||||
ENGLAND = "England",
|
||||
FRANCE = "France",
|
||||
TURKEY = "Turkey",
|
||||
AUSTRIA = "Austria",
|
||||
GERMANY = "Germany",
|
||||
ITALY = "Italy",
|
||||
RUSSIA = "Russia",
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const ProvTypeSchema = z.nativeEnum(ProvTypeENUM);
|
||||
export const PowerENUMSchema = z.preprocess((arg) => {
|
||||
if (typeof arg === "string") {
|
||||
// Normalize the string: "austria" or "AUSTRIA" becomes "Austria"
|
||||
return arg.charAt(0).toUpperCase() + arg.slice(1).toLowerCase();
|
||||
}
|
||||
return arg;
|
||||
}, z.nativeEnum(PowerENUM));
|
||||
|
||||
export const LabelSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
});
|
||||
|
||||
export const UnitSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
});
|
||||
|
||||
export const ProvinceSchema = z.object({
|
||||
label: LabelSchema,
|
||||
type: ProvTypeSchema,
|
||||
unit: UnitSchema.optional(),
|
||||
owner: PowerENUMSchema.optional(),
|
||||
isSupplyCenter: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const CoordinateDataSchema = z.object({
|
||||
provinces: z.record(z.string(), ProvinceSchema),
|
||||
});
|
||||
|
||||
export type Province = z.infer<typeof ProvinceSchema>;
|
||||
export type CoordinateData = z.infer<typeof CoordinateDataSchema>;
|
||||
enum ProvinceENUM {
|
||||
ANK = "ANK",
|
||||
ARM = "ARM",
|
||||
CON = "CON",
|
||||
MOS = "MOS",
|
||||
SEV = "SEV",
|
||||
STP = "STP",
|
||||
SYR = "SYR",
|
||||
UKR = "UKR",
|
||||
LVN = "LVN",
|
||||
WAR = "WAR",
|
||||
PRU = "PRU",
|
||||
SIL = "SIL",
|
||||
BER = "BER",
|
||||
KIE = "KIE",
|
||||
RUH = "RUH",
|
||||
MUN = "MUN",
|
||||
RUM = "RUM",
|
||||
BUL = "BUL",
|
||||
GRE = "GRE",
|
||||
SMY = "SMY",
|
||||
ALB = "ALB",
|
||||
SER = "SER",
|
||||
BUD = "BUD",
|
||||
GAL = "GAL",
|
||||
VIE = "VIE",
|
||||
BOH = "BOH",
|
||||
TYR = "TYR",
|
||||
TRI = "TRI",
|
||||
FIN = "FIN",
|
||||
SWE = "SWE",
|
||||
NWY = "NWY",
|
||||
DEN = "DEN",
|
||||
HOL = "HOL",
|
||||
BEL = "BEL",
|
||||
SWI = "SWI",
|
||||
VEN = "VEN",
|
||||
PIE = "PIE",
|
||||
TUS = "TUS",
|
||||
ROM = "ROM",
|
||||
APU = "APU",
|
||||
NAP = "NAP",
|
||||
BUR = "BUR",
|
||||
MAR = "MAR",
|
||||
GAS = "GAS",
|
||||
PIC = "PIC",
|
||||
PAR = "PAR",
|
||||
BRE = "BRE",
|
||||
SPA = "SPA",
|
||||
POR = "POR",
|
||||
NAF = "NAF",
|
||||
TUN = "TUN",
|
||||
LON = "LON",
|
||||
WAL = "WAL",
|
||||
LVP = "LVP",
|
||||
YOR = "YOR",
|
||||
EDI = "EDI",
|
||||
CLY = "CLY",
|
||||
NAT = "NAT",
|
||||
NRG = "NRG",
|
||||
BAR = "BAR",
|
||||
BOT = "BOT",
|
||||
BAL = "BAL",
|
||||
SKA = "SKA",
|
||||
HEL = "HEL",
|
||||
NTH = "NTH",
|
||||
ENG = "ENG",
|
||||
IRI = "IRI",
|
||||
MID = "MID",
|
||||
WES = "WES",
|
||||
GOL = "GOL",
|
||||
TYN = "TYN",
|
||||
ADR = "ADR",
|
||||
ION = "ION",
|
||||
AEG = "AEG",
|
||||
EAS = "EAS",
|
||||
BLA = "BLA",
|
||||
}
|
||||
|
||||
export const ProvinceENUMSchema = z.nativeEnum(ProvinceENUM)
|
||||
|
|
|
|||
82
ai_animation/src/types/unitOrders.ts
Normal file
82
ai_animation/src/types/unitOrders.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const OrderFromString = z.string().transform((orderStr) => {
|
||||
// Split the order into tokens by whitespace.
|
||||
const tokens = orderStr.trim().split(/\s+/);
|
||||
// The first token is the unit type (A or F)
|
||||
const unitType = tokens[0];
|
||||
// The second token is the origin province.
|
||||
const origin = tokens[1];
|
||||
|
||||
// Check if this order is a support order.
|
||||
if (tokens.includes("S")) {
|
||||
const indexS = tokens.indexOf("S");
|
||||
// The tokens immediately after "S" define the supported unit.
|
||||
const supportedUnitType = tokens[indexS + 1];
|
||||
const supportedOrigin = tokens[indexS + 2];
|
||||
let supportedDestination = null;
|
||||
// If there is a hyphen following, then a destination is specified.
|
||||
if (tokens.length > indexS + 3 && tokens[indexS + 3] === "-") {
|
||||
supportedDestination = tokens[indexS + 4];
|
||||
}
|
||||
return {
|
||||
type: "support",
|
||||
unit: { type: unitType, origin },
|
||||
support: {
|
||||
unit: { type: supportedUnitType, origin: supportedOrigin },
|
||||
// If no destination is given, this means the supported unit is holding.
|
||||
destination: supportedDestination,
|
||||
},
|
||||
raw: orderStr,
|
||||
};
|
||||
}
|
||||
// Check if the order is a hold order.
|
||||
else if (tokens.includes("H")) {
|
||||
return {
|
||||
type: "hold",
|
||||
unit: { type: unitType, origin },
|
||||
raw: orderStr,
|
||||
};
|
||||
}
|
||||
// Check if order is a disband
|
||||
else if (tokens.includes("D")) {
|
||||
return {
|
||||
type: "disband",
|
||||
unit: { type: unitType, origin },
|
||||
raw: orderStr
|
||||
}
|
||||
}
|
||||
// Check if order is Bounce
|
||||
else if (tokens.includes("B")) {
|
||||
return {
|
||||
type: "bounce",
|
||||
unit: { type: unitType, origin },
|
||||
raw: orderStr
|
||||
}
|
||||
}
|
||||
else if (tokens.includes("R")) {
|
||||
return {
|
||||
type: "retreat",
|
||||
unit: { type: unitType, origin },
|
||||
destination: tokens[-1],
|
||||
raw: orderStr
|
||||
}
|
||||
}
|
||||
// Otherwise, assume it's a move order if a hyphen ("-") is present.
|
||||
else if (tokens.includes("-")) {
|
||||
const dashIndex = tokens.indexOf("-");
|
||||
// The token immediately after "-" is the destination.
|
||||
const destination = tokens[dashIndex + 1];
|
||||
return {
|
||||
type: "move",
|
||||
unit: { type: unitType, origin },
|
||||
destination,
|
||||
raw: orderStr,
|
||||
};
|
||||
}
|
||||
// If none of the expected tokens are found, throw an error.
|
||||
else {
|
||||
throw new Error(`Order format not recognized: ${orderStr}`);
|
||||
}
|
||||
});
|
||||
export type UnitOrder = z.infer<typeof OrderFromString>
|
||||
25
ai_animation/src/types/units.ts
Normal file
25
ai_animation/src/types/units.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as THREE from "three"
|
||||
import { PowerENUM } from "./map"
|
||||
|
||||
|
||||
export enum UnitTypeENUM {
|
||||
A = "A",
|
||||
F = "Fleet"
|
||||
}
|
||||
|
||||
export type UnitData = {
|
||||
province: string
|
||||
power: PowerENUM
|
||||
type: UnitTypeENUM
|
||||
}
|
||||
|
||||
export type UnitMesh = {
|
||||
mesh?: THREE.Group
|
||||
userData: {
|
||||
province: string
|
||||
isSupplyCenter: boolean
|
||||
power: PowerENUM
|
||||
starMesh?: THREE.Mesh
|
||||
glowMesh: THREE.Mesh
|
||||
}
|
||||
}
|
||||
210
ai_animation/src/units/animate.ts
Normal file
210
ai_animation/src/units/animate.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import * as THREE from "three";
|
||||
import type { GamePhase } from "../types/gameState";
|
||||
import { createUnitMesh } from "./create";
|
||||
import { UnitMesh } from "../types/units";
|
||||
import { getProvincePosition } from "../map/utils";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { gameState } from "../gameState";
|
||||
import type { UnitOrder } from "../types/unitOrders";
|
||||
|
||||
//FIXME: Move this to a file with all the constants
|
||||
let animationDuration = 1500; // Duration of unit movement animation in ms
|
||||
enum AnimationTypeENUM {
|
||||
CREATE,
|
||||
MOVE,
|
||||
DELETE,
|
||||
}
|
||||
export type UnitAnimation = {
|
||||
duration: number
|
||||
endPos: any
|
||||
startPos: any
|
||||
object: THREE.Group
|
||||
startTime: number
|
||||
animationType?: AnimationTypeENUM
|
||||
}
|
||||
|
||||
function getUnit(unitOrder: UnitOrder) {
|
||||
let posUnits = gameState.unitMeshes.filter((unit) => {
|
||||
return (unit.userData.province === unitOrder.unit.origin && unit.userData.type === unitOrder.unit.type)
|
||||
})
|
||||
// TODO: Need to do something here if we get multiple results
|
||||
return gameState.unitMeshes.indexOf(posUnits[0])
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function createTweenAnimations(currentPhase: GamePhase, previousPhase: GamePhase | null) {
|
||||
for (const [power, orders] of Object.entries(previousPhase.orders)) {
|
||||
for (const order of orders) {
|
||||
let unitIndex = getUnit(order);
|
||||
if (unitIndex === -1) continue; // Skip if unit not found
|
||||
|
||||
switch (order.type) {
|
||||
case "move":
|
||||
let destinationVector = getProvincePosition(gameState.boardState, order.destination);
|
||||
if (!destinationVector) continue; // Skip if destination not found
|
||||
|
||||
// Create a tween for smooth movement
|
||||
let anim = new TWEEN.Tween(gameState.unitMeshes[unitIndex].position)
|
||||
.to({
|
||||
x: destinationVector.x,
|
||||
y: 10, // Keep consistent height
|
||||
z: destinationVector.z
|
||||
}, 1500)
|
||||
.easing(TWEEN.Easing.Quadratic.InOut) // Add easing for smoother motion
|
||||
.onUpdate(() => {
|
||||
// Add a slight bobbing effect during movement
|
||||
gameState.unitMeshes[unitIndex].position.y = 10 + Math.sin(Date.now() * 0.05) * 2;
|
||||
|
||||
// For fleets, add a gentle rocking motion
|
||||
if (gameState.unitMeshes[unitIndex].userData.type === 'F') {
|
||||
gameState.unitMeshes[unitIndex].rotation.z = Math.sin(Date.now() * 0.03) * 0.1;
|
||||
gameState.unitMeshes[unitIndex].rotation.x = Math.sin(Date.now() * 0.02) * 0.1;
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
// Update the unit's province data when animation completes
|
||||
|
||||
// Reset height and rotation
|
||||
gameState.unitMeshes[unitIndex].position.y = 10;
|
||||
if (gameState.unitMeshes[unitIndex].userData.type === 'F') {
|
||||
gameState.unitMeshes[unitIndex].rotation.z = 0;
|
||||
gameState.unitMeshes[unitIndex].rotation.x = 0;
|
||||
}
|
||||
})
|
||||
.start();
|
||||
|
||||
gameState.unitMeshes[unitIndex].userData.province = order.destination;
|
||||
gameState.unitAnimations.push(anim);
|
||||
break;
|
||||
|
||||
case "disband":
|
||||
gameState.scene.remove(gameState.unitMeshes[unitIndex]);
|
||||
// Remove from unitMeshes array
|
||||
gameState.unitMeshes.splice(unitIndex, 1);
|
||||
break;
|
||||
case "bounce":
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export function createAnimationsForPhaseTransition(unitMeshes: UnitMesh[], currentPhase: GamePhase, previousPhase: GamePhase | null): UnitAnimation[] {
|
||||
let unitAnimations: UnitAnimation[] = []
|
||||
// Prepare unit position maps
|
||||
const previousUnitPositions = {};
|
||||
if (previousPhase.state?.units) {
|
||||
for (const [power, unitArr] of Object.entries(previousPhase.state.units)) {
|
||||
unitArr.forEach(unitStr => {
|
||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||
if (match) {
|
||||
const key = `${power} -${match[1]} -${match[2]} `;
|
||||
previousUnitPositions[key] = getProvincePosition(gameState.boardState, match[2]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Animate new units from old positions (or spawn from below)
|
||||
if (currentPhase.state?.units) {
|
||||
for (const [power, unitArr] of Object.entries(currentPhase.state.units)) {
|
||||
unitArr.forEach(unitStr => {
|
||||
// For each unit, create a new mesh
|
||||
const match = unitStr.match(/^([AF])\s+(.+)$/);
|
||||
if (!match) return;
|
||||
const unitType = match[1];
|
||||
const location = match[2];
|
||||
|
||||
|
||||
// Current final
|
||||
const currentPos = getProvincePosition(gameState.boardState, location);
|
||||
|
||||
let startPos;
|
||||
let matchFound = false;
|
||||
for (const prevKey in previousUnitPositions) {
|
||||
if (prevKey.startsWith(`${power} -${unitType} `)) {
|
||||
startPos = previousUnitPositions[prevKey];
|
||||
matchFound = true;
|
||||
delete previousUnitPositions[prevKey];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchFound) {
|
||||
// TODO: Add a spawn animation?
|
||||
//
|
||||
// New spawn
|
||||
startPos = { x: currentPos.x, y: -20, z: currentPos.z };
|
||||
}
|
||||
|
||||
const unitMesh = createUnitMesh({
|
||||
power: power,
|
||||
province: location,
|
||||
type: unitType,
|
||||
});
|
||||
unitMesh.position.set(startPos.x, 10, startPos.z);
|
||||
|
||||
// Animate
|
||||
unitAnimations.push({
|
||||
object: unitMesh,
|
||||
startPos,
|
||||
endPos: currentPos,
|
||||
startTime: Date.now(),
|
||||
duration: animationDuration
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return unitAnimations
|
||||
}
|
||||
// Easing function for smooth animations
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
export function proccessUnitAnimationWithTween(anim: Tween) {
|
||||
anim.update()
|
||||
}
|
||||
|
||||
export function processUnitAnimation(anim: UnitAnimation): boolean {
|
||||
|
||||
const currentTime = Date.now();
|
||||
const elapsed = currentTime - anim.startTime;
|
||||
// Calculate progress (0 to 1)
|
||||
const progress = Math.min(1, elapsed / anim.duration);
|
||||
|
||||
// Apply movement
|
||||
if (progress < 1) {
|
||||
// Apply easing for more natural movement - ease in and out
|
||||
const easedProgress = easeInOutCubic(progress);
|
||||
|
||||
// Update position
|
||||
anim.object.position.x = anim.startPos.x + (anim.endPos.x - anim.startPos.x) * easedProgress;
|
||||
anim.object.position.z = anim.startPos.z + (anim.endPos.z - anim.startPos.z) * easedProgress;
|
||||
|
||||
// Subtle bobbing up and down during movement
|
||||
anim.object.position.y = 10 + Math.sin(progress * Math.PI * 2) * 5;
|
||||
|
||||
// For fleets (ships), add a gentle rocking motion
|
||||
if (anim.object.userData.type === 'F') {
|
||||
anim.object.rotation.z = Math.sin(progress * Math.PI * 3) * 0.05;
|
||||
anim.object.rotation.x = Math.sin(progress * Math.PI * 2) * 0.05;
|
||||
}
|
||||
if (anim.object.position.x == anim.startPos.x) {
|
||||
console.log("We ain't moving")
|
||||
}
|
||||
anim.object.updateMatrix()
|
||||
return false
|
||||
} else {
|
||||
// Set final position
|
||||
anim.object.position.x = anim.endPos.x;
|
||||
anim.object.position.z = anim.endPos.z;
|
||||
anim.object.position.y = 10; // Reset height
|
||||
|
||||
// Reset rotation for ships
|
||||
if (anim.object.userData.type === 'F') {
|
||||
anim.object.rotation.z = 0;
|
||||
anim.object.rotation.x = 0;
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
147
ai_animation/src/units/create.ts
Normal file
147
ai_animation/src/units/create.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import * as THREE from "three";
|
||||
import { UnitData, UnitMesh } from "../types/units";
|
||||
import { PowerENUM } from "../types/map";
|
||||
import { gameState } from "../gameState";
|
||||
import { getProvincePosition } from "../map/utils";
|
||||
|
||||
// Get color for a power
|
||||
export function getPowerHexColor(power: PowerENUM) {
|
||||
let defaultColor = '#ddd2af'
|
||||
if (power === undefined) return defaultColor
|
||||
const powerColors = {
|
||||
'AUSTRIA': '#c40000',
|
||||
'ENGLAND': '#00008B',
|
||||
'FRANCE': '#0fa0d0',
|
||||
'GERMANY': '#666666',
|
||||
'ITALY': '#008000',
|
||||
'RUSSIA': '#cccccc',
|
||||
'TURKEY': '#e0c846',
|
||||
};
|
||||
return powerColors[power.toUpperCase()] || defaultColor; // fallback to neutral
|
||||
}
|
||||
|
||||
function createArmy(color: string): THREE.Group {
|
||||
let group = new THREE.Group();
|
||||
// Army: a block + small head for soldier-like appearance
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(15, 20, 10),
|
||||
new THREE.MeshStandardMaterial({ color })
|
||||
);
|
||||
body.position.y = 10;
|
||||
group.add(body);
|
||||
|
||||
// Head
|
||||
const head = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(4, 12, 12),
|
||||
new THREE.MeshStandardMaterial({ color })
|
||||
);
|
||||
head.position.set(0, 24, 0);
|
||||
group.add(head);
|
||||
return group
|
||||
}
|
||||
|
||||
function createFleet(color: string): THREE.Group {
|
||||
|
||||
let group = new THREE.Group();
|
||||
// Fleet: a rectangle + a mast and sail
|
||||
const hull = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(30, 8, 15),
|
||||
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
|
||||
);
|
||||
hull.position.y = 4;
|
||||
group.add(hull);
|
||||
|
||||
// Mast
|
||||
const mast = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(1, 1, 30, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x000000 })
|
||||
);
|
||||
mast.position.y = 15;
|
||||
group.add(mast);
|
||||
|
||||
// Sail
|
||||
const sail = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 15),
|
||||
new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide })
|
||||
);
|
||||
sail.rotation.y = Math.PI / 2;
|
||||
sail.position.set(0, 15, 0);
|
||||
group.add(sail);
|
||||
return group
|
||||
}
|
||||
|
||||
export function createSupplyCenters(): THREE.Group[] {
|
||||
let supplyCenterMeshes: THREE.Group[] = [];
|
||||
if (!gameState.boardState || !gameState.boardState.provinces) throw new Error("Game not initialized, cannot create SCs");
|
||||
for (const [province, data] of Object.entries(gameState.boardState.provinces)) {
|
||||
if (data.isSupplyCenter && gameState.boardState.provinces[province]) {
|
||||
|
||||
// Build a small pillar + star in 3D
|
||||
const scGroup = new THREE.Group();
|
||||
|
||||
const baseGeom = new THREE.CylinderGeometry(12, 12, 3, 16);
|
||||
const baseMat = new THREE.MeshStandardMaterial({ color: 0x333333 });
|
||||
const base = new THREE.Mesh(baseGeom, baseMat);
|
||||
base.position.y = 1.5;
|
||||
scGroup.add(base);
|
||||
|
||||
const pillarGeom = new THREE.CylinderGeometry(2.5, 2.5, 12, 8);
|
||||
const pillarMat = new THREE.MeshStandardMaterial({ color: 0xcccccc });
|
||||
const pillar = new THREE.Mesh(pillarGeom, pillarMat);
|
||||
pillar.position.y = 7.5;
|
||||
scGroup.add(pillar);
|
||||
|
||||
// We'll just do a cone star for simplicity
|
||||
const starGeom = new THREE.ConeGeometry(6, 10, 5);
|
||||
const starMat = new THREE.MeshStandardMaterial({ color: 0xFFD700 });
|
||||
const starMesh = new THREE.Mesh(starGeom, starMat);
|
||||
starMesh.rotation.x = Math.PI; // point upwards
|
||||
starMesh.position.y = 14;
|
||||
scGroup.add(starMesh);
|
||||
|
||||
// Optionally add a glow disc
|
||||
const glowGeom = new THREE.CircleGeometry(15, 32);
|
||||
const glowMat = new THREE.MeshBasicMaterial({ color: 0xFFFFAA, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
|
||||
const glowMesh = new THREE.Mesh(glowGeom, glowMat);
|
||||
glowMesh.rotation.x = -Math.PI / 2;
|
||||
glowMesh.position.y = 2;
|
||||
scGroup.add(glowMesh);
|
||||
|
||||
// Store userData for ownership changes
|
||||
scGroup.userData = {
|
||||
province,
|
||||
isSupplyCenter: true,
|
||||
owner: null,
|
||||
starMesh,
|
||||
glowMesh
|
||||
};
|
||||
|
||||
const pos = getProvincePosition(gameState.boardState, province);
|
||||
scGroup.position.set(pos.x, 2, pos.z);
|
||||
supplyCenterMeshes.push(scGroup)
|
||||
}
|
||||
}
|
||||
return supplyCenterMeshes
|
||||
}
|
||||
export function createUnitMesh(unitData: UnitData): THREE.Group {
|
||||
const color = getPowerHexColor(unitData.power);
|
||||
let group: THREE.Group | null;
|
||||
|
||||
// Minimal shape difference for armies vs fleets
|
||||
if (unitData.type === 'A') {
|
||||
group = createArmy(color)
|
||||
} else {
|
||||
group = createFleet(color)
|
||||
}
|
||||
let pos = getProvincePosition(gameState.boardState, unitData.province)
|
||||
group.position.set(pos.x, pos.y, pos.z)
|
||||
|
||||
// Store metadata
|
||||
group.userData = {
|
||||
power: unitData.power,
|
||||
type: unitData.type,
|
||||
province: unitData.province
|
||||
};
|
||||
|
||||
return group;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue