mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-23 16:59:10 +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,
|
"x": 720,
|
||||||
"y": 651
|
"y": 651
|
||||||
},
|
},
|
||||||
"type": null
|
"type": "Inpassable"
|
||||||
},
|
},
|
||||||
"ADR": {
|
"ADR": {
|
||||||
"label": {
|
"label": {
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
"x": 929.6,
|
"x": 929.6,
|
||||||
"y": 538.9
|
"y": 538.9
|
||||||
},
|
},
|
||||||
"type": null,
|
"type": "Water",
|
||||||
"isSupplyCenter": false
|
"isSupplyCenter": false
|
||||||
},
|
},
|
||||||
"LYO": {
|
"LYO": {
|
||||||
|
|
@ -223,7 +223,7 @@
|
||||||
"x": 525.8,
|
"x": 525.8,
|
||||||
"y": 1107
|
"y": 1107
|
||||||
},
|
},
|
||||||
"type": null,
|
"type": "Water",
|
||||||
"isSupplyCenter": false
|
"isSupplyCenter": false
|
||||||
},
|
},
|
||||||
"HEL": {
|
"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>
|
<body>
|
||||||
<div id="app"></div>
|
<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="container">
|
||||||
<div class="top-controls">
|
<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",
|
"name": "ai_animation",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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": {
|
"devDependencies": {
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
|
|
@ -706,6 +709,12 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
|
|
@ -713,6 +722,44 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
|
||||||
|
|
@ -754,6 +801,12 @@
|
||||||
"@esbuild/win32-x64": "0.25.0"
|
"@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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"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": "^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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.8",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||||
|
|
@ -964,6 +1023,15 @@
|
||||||
"optional": true
|
"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"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"three": "^0.174.0"
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
},
|
"@types/three": "^0.174.0",
|
||||||
"packageManager": "pnpm@9.12.2+sha512.22721b3a11f81661ae1ec68ce1a7b879425a1ca5b991c975b074ac220b187ce56c708fe5db69f4c962c989452eee76c82877f4ee80f474cebd61ee13461b6228"
|
"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",
|
WATER = "Water",
|
||||||
COAST = "Coast",
|
COAST = "Coast",
|
||||||
LAND = "Land",
|
LAND = "Land",
|
||||||
|
INPASSABLE = "Inpassable",
|
||||||
}
|
|
||||||
type Province = {
|
|
||||||
label: {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
type: ProvTypeEnum
|
|
||||||
unit?: {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoordinateData = {
|
export enum PowerENUM {
|
||||||
provinces: Province[]
|
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