mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-10 06:08:41 -06:00
Update source files
This commit is contained in:
5
frontend/.prettierrc
Normal file
5
frontend/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"printWidth": 135,
|
||||
"useTabs": false
|
||||
}
|
||||
73
frontend/index.html
Normal file
73
frontend/index.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Iron Borders</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
|
||||
font-family: Arial, sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
filter: blur(4px) brightness(0.85);
|
||||
transform: scale(1.05);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(1px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Style the canvas that Bevy uses */
|
||||
#bevy-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: block;
|
||||
background: #000;
|
||||
z-index: 0; /* Behind React UI elements which have z-index: 1+ */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
// Use the __DESKTOP__ define to load the correct entry point
|
||||
if (typeof __DESKTOP__ !== "undefined" && __DESKTOP__) {
|
||||
import("/src/desktop/main.tsx");
|
||||
} else {
|
||||
import("/src/browser/main.tsx");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "iron-borders",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit --project tsconfig.browser.json",
|
||||
"dev": "vite",
|
||||
"dev:browser": "vite --mode browser",
|
||||
"build": "tsc --project tsconfig.browser.json && vite build",
|
||||
"build:desktop": "tsc --project tsconfig.desktop.json && vite build --mode desktop",
|
||||
"build:browser": "tsc --project tsconfig.browser.json && vite build --mode browser",
|
||||
"preview": "vite preview",
|
||||
"preview:browser": "vite preview --mode browser",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/inter-tight": "^5.2.7",
|
||||
"@fontsource-variable/oswald": "^5.2.8",
|
||||
"@fontsource/dm-serif-text": "^5.2.8",
|
||||
"@fontsource/source-serif-pro": "^5.2.5",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"overlayscrollbars": "^2.12.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.1.9",
|
||||
"vite-imagetools": "^9.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 versions and >0.2% and not dead",
|
||||
"Firefox ESR"
|
||||
]
|
||||
}
|
||||
2153
frontend/pnpm-lock.yaml
generated
Normal file
2153
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
frontend/pnpm-workspace.yaml
Normal file
2
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
BIN
frontend/src/assets/multiplayer.png
Normal file
BIN
frontend/src/assets/multiplayer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
frontend/src/assets/multiplayer.psd
Normal file
BIN
frontend/src/assets/multiplayer.psd
Normal file
Binary file not shown.
BIN
frontend/src/assets/screenshot.png
Normal file
BIN
frontend/src/assets/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
BIN
frontend/src/assets/settings.png
Normal file
BIN
frontend/src/assets/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/src/assets/settings.psd
Normal file
BIN
frontend/src/assets/settings.psd
Normal file
Binary file not shown.
BIN
frontend/src/assets/singleplayer.png
Normal file
BIN
frontend/src/assets/singleplayer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/singleplayer.psd
Normal file
BIN
frontend/src/assets/singleplayer.psd
Normal file
Binary file not shown.
5
frontend/src/browser/analytics/index.ts
Normal file
5
frontend/src/browser/analytics/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WasmAnalytics } from "./wasmAnalytics";
|
||||
import { wasmBridge } from "@/browser/api/wasmBridge";
|
||||
|
||||
// Create singleton instance using the worker from wasmBridge
|
||||
export const wasmAnalytics = new WasmAnalytics(wasmBridge.getWorker());
|
||||
30
frontend/src/browser/analytics/wasmAnalytics.ts
Normal file
30
frontend/src/browser/analytics/wasmAnalytics.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AnalyticsAPI } from "@/shared/analytics/AnalyticsAPI";
|
||||
|
||||
/**
|
||||
* Browser/WASM implementation of AnalyticsAPI.
|
||||
*
|
||||
* Events are sent through the WASM worker which handles batching and
|
||||
* sending to PostHog.
|
||||
*/
|
||||
export class WasmAnalytics implements AnalyticsAPI {
|
||||
private worker: Worker | null = null;
|
||||
|
||||
constructor(worker: Worker) {
|
||||
this.worker = worker;
|
||||
}
|
||||
|
||||
track(event: string, properties?: Record<string, any>): void {
|
||||
if (!this.worker) {
|
||||
console.error("Worker not set for analytics");
|
||||
return;
|
||||
}
|
||||
|
||||
this.worker.postMessage({
|
||||
type: "ANALYTICS_EVENT",
|
||||
payload: {
|
||||
event,
|
||||
properties: properties || {},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
322
frontend/src/browser/api/wasmBridge.ts
Normal file
322
frontend/src/browser/api/wasmBridge.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { GameAPI } from "@/shared/api/GameAPI";
|
||||
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
|
||||
import { decodeTerritorySnapshot } from "@/shared/utils/binaryDecoding";
|
||||
|
||||
/**
|
||||
* Browser implementation of GameAPI using WASM bindings in a Web Worker.
|
||||
*
|
||||
* This wraps the WASM-exported functions from borders-wasm to match
|
||||
* the unified GameAPI interface using the new UI event system.
|
||||
* The WASM module runs in a dedicated worker to avoid blocking the main thread.
|
||||
*/
|
||||
export class WasmBridge implements GameAPI {
|
||||
private worker: Worker;
|
||||
private initialized: boolean = false;
|
||||
private messageQueue: Array<any> = []; // Queue for messages sent before worker is ready
|
||||
private renderer: any = null; // GameRenderer instance
|
||||
private snapshotCallbacks: Array<(data: LeaderboardSnapshot) => void> = [];
|
||||
private gameEndCallbacks: Array<(outcome: GameOutcome) => void> = [];
|
||||
private attacksUpdateCallbacks: Array<(data: AttacksUpdatePayload) => void> = [];
|
||||
private spawnPhaseUpdateCallbacks: Array<(update: SpawnPhaseUpdate) => void> = [];
|
||||
private spawnPhaseEndedCallbacks: Array<() => void> = [];
|
||||
private queuedRenderMessages: Array<any> = []; // Queue for messages received before renderer is ready
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL("../workers/game.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
this.worker.addEventListener("message", (e) => {
|
||||
const { type, payload } = e.data;
|
||||
|
||||
if (type === "INIT_COMPLETE") {
|
||||
this.initialized = true;
|
||||
console.log("WASM worker initialized, processing queued messages");
|
||||
|
||||
// Process any queued messages
|
||||
while (this.messageQueue.length > 0) {
|
||||
const queuedMessage = this.messageQueue.shift();
|
||||
this.worker.postMessage(queuedMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "BACKEND_MESSAGE") {
|
||||
this.handleBackendMessage(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "ERROR") {
|
||||
console.error("Worker error:", payload);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the worker
|
||||
this.worker.postMessage({ type: "INIT" });
|
||||
}
|
||||
|
||||
private handleBackendMessage(message: any) {
|
||||
// Messages use msg_type tag from serde
|
||||
switch (message.msg_type) {
|
||||
case "LeaderboardSnapshot":
|
||||
this.snapshotCallbacks.forEach((callback) => callback(message as LeaderboardSnapshot));
|
||||
break;
|
||||
|
||||
case "AttacksUpdate":
|
||||
this.attacksUpdateCallbacks.forEach((callback) => callback(message as AttacksUpdatePayload));
|
||||
break;
|
||||
|
||||
case "GameEnded":
|
||||
this.gameEndCallbacks.forEach((callback) => callback(message.outcome as GameOutcome));
|
||||
break;
|
||||
|
||||
case "SpawnPhaseUpdate":
|
||||
const update: SpawnPhaseUpdate = {
|
||||
countdown: message.countdown
|
||||
? {
|
||||
startedAtMs: message.countdown.started_at_ms,
|
||||
durationSecs: message.countdown.duration_secs,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
this.spawnPhaseUpdateCallbacks.forEach((callback) => callback(update));
|
||||
break;
|
||||
|
||||
case "SpawnPhaseEnded":
|
||||
this.spawnPhaseEndedCallbacks.forEach((callback) => callback());
|
||||
break;
|
||||
|
||||
case "HighlightNation":
|
||||
this.renderer?.setHighlightedNation(message.nation_id ?? null);
|
||||
break;
|
||||
|
||||
default:
|
||||
// All other messages are render messages
|
||||
this.handleRenderMessage(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sendWorkerMessage(message: any): void {
|
||||
if (this.initialized) {
|
||||
this.worker.postMessage(message);
|
||||
} else {
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
startGame(): void {
|
||||
this.sendWorkerMessage({
|
||||
type: "FRONTEND_MESSAGE",
|
||||
payload: { msg_type: "StartGame" },
|
||||
});
|
||||
}
|
||||
|
||||
quitGame(): void {
|
||||
this.sendWorkerMessage({
|
||||
type: "FRONTEND_MESSAGE",
|
||||
payload: { msg_type: "QuitGame" },
|
||||
});
|
||||
}
|
||||
|
||||
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.snapshotCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.snapshotCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.snapshotCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.gameEndCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.gameEndCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.gameEndCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.attacksUpdateCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.attacksUpdateCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.attacksUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.spawnPhaseUpdateCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.spawnPhaseUpdateCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.spawnPhaseUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.spawnPhaseEndedCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.spawnPhaseEndedCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.spawnPhaseEndedCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Rendering integration methods
|
||||
private handleRenderMessage(message: any) {
|
||||
if (!this.renderer) {
|
||||
this.queuedRenderMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle flattened serde format: {msg_type: 'RenderInit', ...data}
|
||||
if (message.msg_type === "RenderInit") {
|
||||
// Apply all initialization data atomically
|
||||
this.renderer.setTerrainPalette(message.terrain_palette);
|
||||
this.renderer.initTerrain(message.terrain);
|
||||
this.renderer.initPalette(message.palette);
|
||||
|
||||
// Decode sparse binary territory snapshot
|
||||
const sparseTiles = decodeTerritorySnapshot(message.initial_territories.data);
|
||||
if (sparseTiles) {
|
||||
this.renderer.applyTerritorySnapshot(message.initial_territories.turn, sparseTiles);
|
||||
} else {
|
||||
console.error("Failed to decode territory snapshot");
|
||||
}
|
||||
} else if (message.msg_type === "TerritoryDelta") {
|
||||
this.renderer.updateTerritoryDelta(message.turn, message.changes);
|
||||
} else if (message.msg_type === "CameraCommand") {
|
||||
// Handle camera commands from backend
|
||||
if (message.type === "CenterOnTile") {
|
||||
this.renderer.centerOnTile(message.tile_index, message.animate);
|
||||
} else if (message.type === "SetZoom") {
|
||||
this.renderer.setZoom(message.zoom, message.animate);
|
||||
} else if (message.type === "PanBy") {
|
||||
this.renderer.panBy(message.dx, message.dy, message.animate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRenderer(renderer: any): void {
|
||||
this.renderer = renderer;
|
||||
|
||||
// Replay any queued messages that arrived before renderer was ready
|
||||
if (this.queuedRenderMessages.length > 0) {
|
||||
for (const message of this.queuedRenderMessages) {
|
||||
this.handleRenderMessage(message);
|
||||
}
|
||||
this.queuedRenderMessages = []; // Clear queue after replay
|
||||
}
|
||||
}
|
||||
|
||||
sendCameraUpdate(state: { x: number; y: number; zoom: number }): void {
|
||||
try {
|
||||
this.sendWorkerMessage({
|
||||
type: "CAMERA_UPDATE",
|
||||
payload: state,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send camera update:", e);
|
||||
}
|
||||
}
|
||||
|
||||
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
|
||||
try {
|
||||
this.sendWorkerMessage({
|
||||
type: "RENDER_INPUT",
|
||||
payload: {
|
||||
type: "MapClick",
|
||||
tile_index: data.tile_index,
|
||||
world_x: data.world_x,
|
||||
world_y: data.world_y,
|
||||
button: data.button,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send map click:", e);
|
||||
}
|
||||
}
|
||||
|
||||
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
|
||||
try {
|
||||
this.sendWorkerMessage({
|
||||
type: "RENDER_INPUT",
|
||||
payload: {
|
||||
type: "MapHover",
|
||||
tile_index: data.tile_index,
|
||||
world_x: data.world_x,
|
||||
world_y: data.world_y,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send map hover:", e);
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyPress(data: { key: string; pressed: boolean }): void {
|
||||
try {
|
||||
this.sendWorkerMessage({
|
||||
type: "RENDER_INPUT",
|
||||
payload: {
|
||||
type: "KeyPress",
|
||||
key: data.key,
|
||||
pressed: data.pressed,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send key press:", e);
|
||||
}
|
||||
}
|
||||
|
||||
sendAttackRatio(ratio: number): void {
|
||||
try {
|
||||
this.sendWorkerMessage({
|
||||
type: "FRONTEND_MESSAGE",
|
||||
payload: {
|
||||
msg_type: "SetAttackRatio",
|
||||
ratio: ratio,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send attack ratio:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async getGameState(): Promise<any | null> {
|
||||
// WASM doesn't persist state across reloads - always start fresh
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the worker instance (for analytics or other purposes).
|
||||
*/
|
||||
getWorker(): Worker {
|
||||
return this.worker;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wasmBridge = new WasmBridge();
|
||||
29
frontend/src/browser/main.tsx
Normal file
29
frontend/src/browser/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "@/shared/components/App";
|
||||
import { GameAPIProvider } from "@/shared/api/GameAPIContext";
|
||||
import { wasmBridge } from "@/browser/api/wasmBridge";
|
||||
import { AnalyticsProvider } from "@/shared/analytics";
|
||||
import { wasmAnalytics } from "@/browser/analytics";
|
||||
|
||||
function BrowserApp() {
|
||||
return (
|
||||
<AnalyticsProvider api={wasmAnalytics}>
|
||||
<GameAPIProvider api={wasmBridge}>
|
||||
<App />
|
||||
</GameAPIProvider>
|
||||
</AnalyticsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Disable context menu to prevent interference with right-click controls
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
// Create canvas element for Bevy (worker will use it via OffscreenCanvas or leave it for future use)
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "bevy-canvas";
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<BrowserApp />);
|
||||
84
frontend/src/browser/workers/game.worker.ts
Normal file
84
frontend/src/browser/workers/game.worker.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import init, {
|
||||
register_backend_message_callback,
|
||||
send_frontend_message,
|
||||
handle_camera_update,
|
||||
handle_render_input,
|
||||
track_analytics_event,
|
||||
} from "../../../pkg/borders";
|
||||
|
||||
let wasmInitialized = false;
|
||||
|
||||
type WorkerMessage =
|
||||
| { type: "INIT" }
|
||||
| { type: "FRONTEND_MESSAGE"; payload: any }
|
||||
| { type: "CAMERA_UPDATE"; payload: any }
|
||||
| { type: "RENDER_INPUT"; payload: any }
|
||||
| { type: "ANALYTICS_EVENT"; payload: any };
|
||||
|
||||
self.addEventListener("message", async (e: MessageEvent<WorkerMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "INIT": {
|
||||
await init();
|
||||
wasmInitialized = true;
|
||||
|
||||
// Register callback that posts backend messages back to main thread
|
||||
register_backend_message_callback((backendMessage: any) => {
|
||||
self.postMessage({ type: "BACKEND_MESSAGE", payload: backendMessage });
|
||||
});
|
||||
|
||||
self.postMessage({ type: "INIT_COMPLETE" });
|
||||
console.log("WASM initialized successfully in worker");
|
||||
break;
|
||||
}
|
||||
|
||||
case "FRONTEND_MESSAGE": {
|
||||
if (!wasmInitialized) {
|
||||
console.error("WASM module not initialized");
|
||||
return;
|
||||
}
|
||||
send_frontend_message(message.payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case "CAMERA_UPDATE": {
|
||||
if (!wasmInitialized) {
|
||||
console.error("WASM module not initialized");
|
||||
return;
|
||||
}
|
||||
handle_camera_update(message.payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case "RENDER_INPUT": {
|
||||
if (!wasmInitialized) {
|
||||
console.error("WASM module not initialized");
|
||||
return;
|
||||
}
|
||||
handle_render_input(message.payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case "ANALYTICS_EVENT": {
|
||||
if (!wasmInitialized) {
|
||||
console.error("WASM module not initialized");
|
||||
return;
|
||||
}
|
||||
track_analytics_event(message.payload);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.warn("Unknown worker message type:", (message as any).type);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Worker error:", error);
|
||||
self.postMessage({
|
||||
type: "ERROR",
|
||||
payload: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
});
|
||||
32
frontend/src/desktop/analytics/tauriAnalytics.ts
Normal file
32
frontend/src/desktop/analytics/tauriAnalytics.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AnalyticsAPI } from "@/shared/analytics/AnalyticsAPI";
|
||||
|
||||
/**
|
||||
* Desktop implementation of AnalyticsAPI using Tauri commands.
|
||||
*
|
||||
* Events are sent to the Rust backend which handles batching and
|
||||
* sending to PostHog.
|
||||
*/
|
||||
export class TauriAnalytics implements AnalyticsAPI {
|
||||
track(event: string, properties?: Record<string, any>): void {
|
||||
invoke("track_analytics_event", {
|
||||
payload: {
|
||||
event,
|
||||
properties: properties || {},
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("Failed to track analytics event:", err);
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
try {
|
||||
await invoke("flush_analytics");
|
||||
} catch (err) {
|
||||
console.error("Failed to flush analytics:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tauriAnalytics = new TauriAnalytics();
|
||||
398
frontend/src/desktop/api/tauriAPI.ts
Normal file
398
frontend/src/desktop/api/tauriAPI.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { GameAPI } from "@/shared/api/GameAPI";
|
||||
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
|
||||
import { GameRenderer } from "@/shared/render/GameRenderer";
|
||||
import { decodeTerritorySnapshot } from "@/shared/utils/binaryDecoding";
|
||||
|
||||
/**
|
||||
* Desktop implementation of GameAPI using Tauri's unified UI event system.
|
||||
*
|
||||
* This listens to the "ui:event" channel which carries all UiEvent enum variants
|
||||
* from the game core.
|
||||
*/
|
||||
export class TauriAPI implements GameAPI {
|
||||
private snapshotCallbacks: Array<(data: LeaderboardSnapshot) => void> = [];
|
||||
private gameEndCallbacks: Array<(outcome: GameOutcome) => void> = [];
|
||||
private attacksUpdateCallbacks: Array<(data: AttacksUpdatePayload) => void> = [];
|
||||
private spawnPhaseUpdateCallbacks: Array<(update: SpawnPhaseUpdate) => void> = [];
|
||||
private spawnPhaseEndCallbacks: Array<() => void> = [];
|
||||
private renderer: GameRenderer | null = null;
|
||||
private messageBuffer: any[] = [];
|
||||
private initReceived: boolean = false;
|
||||
private pendingDeltas: any[] = [];
|
||||
|
||||
// Store unsubscribe functions for event listeners
|
||||
private uiEventUnsubscribe?: () => void;
|
||||
private pixelStreamUnsubscribe?: () => void;
|
||||
|
||||
constructor() {
|
||||
// Start listening to UI events immediately
|
||||
this.setupEventListener();
|
||||
this.setupRenderListeners();
|
||||
}
|
||||
|
||||
private async setupEventListener() {
|
||||
this.uiEventUnsubscribe = await listen<any>("backend:message", (event) => {
|
||||
this.handleBackendMessage(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
private handleBackendMessage(message: any) {
|
||||
// Messages use msg_type tag from serde
|
||||
switch (message.msg_type) {
|
||||
case "LeaderboardSnapshot":
|
||||
this.snapshotCallbacks.forEach((callback) => callback(message as LeaderboardSnapshot));
|
||||
break;
|
||||
|
||||
case "AttacksUpdate":
|
||||
this.attacksUpdateCallbacks.forEach((callback) => callback(message as AttacksUpdatePayload));
|
||||
break;
|
||||
|
||||
case "GameEnded":
|
||||
console.log("Game ended:", message.outcome);
|
||||
this.gameEndCallbacks.forEach((callback) => callback(message.outcome as GameOutcome));
|
||||
break;
|
||||
|
||||
case "SpawnPhaseUpdate":
|
||||
const update: SpawnPhaseUpdate = {
|
||||
countdown: message.countdown
|
||||
? {
|
||||
startedAtMs: message.countdown.started_at_ms,
|
||||
durationSecs: message.countdown.duration_secs,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
this.spawnPhaseUpdateCallbacks.forEach((callback) => callback(update));
|
||||
break;
|
||||
|
||||
case "SpawnPhaseEnded":
|
||||
this.spawnPhaseEndCallbacks.forEach((callback) => callback());
|
||||
break;
|
||||
|
||||
case "HighlightNation":
|
||||
this.renderer?.setHighlightedNation(message.nation_id ?? null);
|
||||
break;
|
||||
|
||||
default:
|
||||
// All other messages are render messages
|
||||
this.handleRenderMessage(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
startGame(): void {
|
||||
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||
invoke("send_frontend_message", { message: { msg_type: "StartGame" } });
|
||||
});
|
||||
}
|
||||
|
||||
quitGame(): void {
|
||||
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||
invoke("send_frontend_message", { message: { msg_type: "QuitGame" } });
|
||||
});
|
||||
}
|
||||
|
||||
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.snapshotCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.snapshotCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.snapshotCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.gameEndCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.gameEndCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.gameEndCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
|
||||
// Add callback to the list
|
||||
this.attacksUpdateCallbacks.push(callback);
|
||||
|
||||
// Return cleanup function that removes this specific callback
|
||||
return () => {
|
||||
const index = this.attacksUpdateCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.attacksUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
|
||||
this.spawnPhaseUpdateCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.spawnPhaseUpdateCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.spawnPhaseUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
|
||||
this.spawnPhaseEndCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.spawnPhaseEndCallbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.spawnPhaseEndCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Rendering integration methods
|
||||
private async setupRenderListeners() {
|
||||
// Listen for binary pixel stream (territory deltas)
|
||||
// Note: Regular render messages now come through backend:message
|
||||
this.pixelStreamUnsubscribe = await listen<number[]>("render:pixel_stream", (event) => {
|
||||
if (this.renderer) {
|
||||
const uint8Array = new Uint8Array(event.payload);
|
||||
this.renderer.updateBinaryDelta(uint8Array);
|
||||
} else {
|
||||
console.warn("Received pixel stream but renderer not set");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRenderMessage(message: any): Promise<void> {
|
||||
if (!this.renderer) {
|
||||
console.warn("Renderer not set yet, buffering message:", message.msg_type);
|
||||
this.messageBuffer.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.processRenderMessage(message);
|
||||
}
|
||||
|
||||
private async processRenderMessage(message: any): Promise<void> {
|
||||
// The message structure from Rust is flat with msg_type field
|
||||
|
||||
// Defensive check: ensure renderer is still valid
|
||||
if (!this.renderer) {
|
||||
console.warn("Cannot process render message - renderer not available, buffering:", message.msg_type);
|
||||
// Re-buffer the message if renderer is not available
|
||||
this.messageBuffer.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle atomic RenderInit message first
|
||||
if (message.msg_type === "RenderInit") {
|
||||
const rendererAtStart = this.renderer;
|
||||
|
||||
try {
|
||||
// Process terrain palette, terrain, player palette, and initial territories atomically
|
||||
rendererAtStart.setTerrainPalette(message.terrain_palette);
|
||||
|
||||
// Check if renderer changed (keep check for safety)
|
||||
if (this.renderer !== rendererAtStart) {
|
||||
console.warn("Renderer changed during RenderInit processing - re-buffering message");
|
||||
this.messageBuffer.push(message);
|
||||
this.initReceived = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderer.initTerrain(message.terrain);
|
||||
|
||||
// Check if renderer changed (keep check for safety)
|
||||
if (this.renderer !== rendererAtStart) {
|
||||
console.warn("Renderer changed during RenderInit processing - re-buffering message");
|
||||
this.messageBuffer.push(message);
|
||||
this.initReceived = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderer.initPalette(message.palette);
|
||||
|
||||
// Decode sparse binary territory snapshot
|
||||
const sparseTiles = decodeTerritorySnapshot(message.initial_territories.data);
|
||||
if (sparseTiles) {
|
||||
this.renderer.applyTerritorySnapshot(message.initial_territories.turn, sparseTiles);
|
||||
} else {
|
||||
console.error("Failed to decode territory snapshot");
|
||||
}
|
||||
|
||||
this.initReceived = true;
|
||||
} catch (error) {
|
||||
console.error("FATAL: Failed to process RenderInit:", error);
|
||||
throw error; // Re-throw so it's visible
|
||||
}
|
||||
|
||||
// Process any deltas that arrived before initialization
|
||||
if (this.pendingDeltas.length > 0) {
|
||||
for (const pendingDelta of this.pendingDeltas) {
|
||||
await this.processRenderMessage(pendingDelta);
|
||||
}
|
||||
this.pendingDeltas = [];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer deltas if initialization not received yet
|
||||
if (!this.initReceived && message.msg_type === "TerritoryDelta") {
|
||||
this.pendingDeltas.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process other messages normally
|
||||
switch (message.msg_type) {
|
||||
case "TerrainInit":
|
||||
console.warn("Received legacy TerrainInit - should use RenderInit instead");
|
||||
this.renderer!.initTerrain(message);
|
||||
break;
|
||||
case "PaletteInit":
|
||||
console.warn("Received legacy PaletteInit - should use RenderInit instead");
|
||||
this.renderer!.initPalette(message);
|
||||
break;
|
||||
case "TerritoryDelta":
|
||||
this.renderer!.updateTerritoryDelta(message.turn, message.changes);
|
||||
break;
|
||||
default:
|
||||
console.warn("Unknown render message type:", message.msg_type);
|
||||
}
|
||||
}
|
||||
|
||||
async setRenderer(renderer: GameRenderer | null): Promise<void> {
|
||||
if (renderer === null) {
|
||||
console.log("Renderer unregistered from TauriAPI - cleaning up");
|
||||
this.renderer = null;
|
||||
this.initReceived = false;
|
||||
this.pendingDeltas = [];
|
||||
this.messageBuffer = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderer = renderer;
|
||||
console.log("Renderer registered with TauriAPI");
|
||||
|
||||
// Process buffered messages asynchronously to avoid blocking UI during HMR
|
||||
if (this.messageBuffer.length > 0) {
|
||||
const bufferedMessages = [...this.messageBuffer];
|
||||
this.messageBuffer = [];
|
||||
|
||||
// Process messages in next tick to avoid blocking
|
||||
setTimeout(async () => {
|
||||
for (const message of bufferedMessages) {
|
||||
await this.processRenderMessage(message);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Unsubscribe from all Tauri event listeners
|
||||
if (this.uiEventUnsubscribe) {
|
||||
this.uiEventUnsubscribe();
|
||||
this.uiEventUnsubscribe = undefined;
|
||||
}
|
||||
if (this.pixelStreamUnsubscribe) {
|
||||
this.pixelStreamUnsubscribe();
|
||||
this.pixelStreamUnsubscribe = undefined;
|
||||
}
|
||||
|
||||
// Clear all callbacks
|
||||
this.snapshotCallbacks = [];
|
||||
this.gameEndCallbacks = [];
|
||||
this.attacksUpdateCallbacks = [];
|
||||
this.spawnPhaseUpdateCallbacks = [];
|
||||
this.spawnPhaseEndCallbacks = [];
|
||||
|
||||
// Clear state
|
||||
this.renderer = null;
|
||||
this.messageBuffer = [];
|
||||
this.initReceived = false;
|
||||
this.pendingDeltas = [];
|
||||
}
|
||||
|
||||
sendCameraUpdate(state: { x: number; y: number; zoom: number }): void {
|
||||
invoke("handle_camera_update", { update: state }).catch((err) => {
|
||||
console.error("Failed to send camera update:", err);
|
||||
});
|
||||
}
|
||||
|
||||
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
|
||||
invoke("handle_render_input", {
|
||||
event: {
|
||||
type: "MapClick",
|
||||
tile_index: data.tile_index,
|
||||
world_x: data.world_x,
|
||||
world_y: data.world_y,
|
||||
button: data.button,
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("Failed to send map click:", err);
|
||||
});
|
||||
}
|
||||
|
||||
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
|
||||
invoke("handle_render_input", {
|
||||
event: {
|
||||
type: "MapHover",
|
||||
tile_index: data.tile_index,
|
||||
world_x: data.world_x,
|
||||
world_y: data.world_y,
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("Failed to send map hover:", err);
|
||||
});
|
||||
}
|
||||
|
||||
sendKeyPress(data: { key: string; pressed: boolean }): void {
|
||||
invoke("handle_render_input", {
|
||||
event: {
|
||||
type: "KeyPress",
|
||||
key: data.key,
|
||||
pressed: data.pressed,
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("Failed to send key press:", err);
|
||||
});
|
||||
}
|
||||
|
||||
sendAttackRatio(ratio: number): void {
|
||||
invoke("send_frontend_message", {
|
||||
message: {
|
||||
msg_type: "SetAttackRatio",
|
||||
ratio: ratio,
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error("Failed to send attack ratio:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current game state for recovery after reload.
|
||||
* Returns render and leaderboard state if a game is running, null otherwise.
|
||||
* ONLY called on frontend init/reload - not streamed.
|
||||
*/
|
||||
async getGameState(): Promise<{
|
||||
render_init: any | null;
|
||||
leaderboard_snapshot: LeaderboardSnapshot | null;
|
||||
} | null> {
|
||||
try {
|
||||
const state = await invoke<{
|
||||
render_init: any | null;
|
||||
leaderboard_snapshot: LeaderboardSnapshot | null;
|
||||
}>("get_game_state");
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.error("Failed to get game state:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tauriAPI = new TauriAPI();
|
||||
14
frontend/src/desktop/main.tsx
Normal file
14
frontend/src/desktop/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "@/shared/components/App";
|
||||
import { GameAPIProvider } from "@/shared/api/GameAPIContext";
|
||||
import { tauriAPI } from "@/desktop/api/tauriAPI";
|
||||
import { AnalyticsProvider } from "@/shared/analytics";
|
||||
import { tauriAnalytics } from "@/desktop/analytics/tauriAnalytics";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<AnalyticsProvider api={tauriAnalytics}>
|
||||
<GameAPIProvider api={tauriAPI}>
|
||||
<App />
|
||||
</GameAPIProvider>
|
||||
</AnalyticsProvider>,
|
||||
);
|
||||
24
frontend/src/shared/analytics/AnalyticsAPI.ts
Normal file
24
frontend/src/shared/analytics/AnalyticsAPI.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Unified analytics API interface for both desktop and browser builds.
|
||||
*
|
||||
* Desktop implementation uses Tauri commands to send events to Rust backend.
|
||||
* Browser implementation sends events through the WASM worker to Rust backend.
|
||||
*/
|
||||
export interface AnalyticsAPI {
|
||||
/**
|
||||
* Track an analytics event.
|
||||
* @param event - Event name (e.g., "app_started", "game_ended")
|
||||
* @param properties - Optional properties to attach to the event
|
||||
*/
|
||||
track(event: string, properties?: Record<string, any>): void;
|
||||
|
||||
/**
|
||||
* Initialize the analytics client (called once at app startup).
|
||||
*/
|
||||
init?(): void;
|
||||
|
||||
/**
|
||||
* Flush any pending events (useful for cleanup).
|
||||
*/
|
||||
flush?(): Promise<void>;
|
||||
}
|
||||
16
frontend/src/shared/analytics/AnalyticsContext.tsx
Normal file
16
frontend/src/shared/analytics/AnalyticsContext.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { AnalyticsAPI } from "./AnalyticsAPI";
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsAPI | null>(null);
|
||||
|
||||
export function AnalyticsProvider({ api, children }: { api: AnalyticsAPI; children: ReactNode }) {
|
||||
return <AnalyticsContext.Provider value={api}>{children}</AnalyticsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAnalytics(): AnalyticsAPI {
|
||||
const api = useContext(AnalyticsContext);
|
||||
if (!api) {
|
||||
throw new Error("useAnalytics must be used within an AnalyticsProvider");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
2
frontend/src/shared/analytics/index.ts
Normal file
2
frontend/src/shared/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { AnalyticsAPI } from "./AnalyticsAPI";
|
||||
export { AnalyticsProvider, useAnalytics } from "./AnalyticsContext";
|
||||
94
frontend/src/shared/api/GameAPI.ts
Normal file
94
frontend/src/shared/api/GameAPI.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
|
||||
|
||||
// TODO: GameRenderer has been removed - needs refactoring
|
||||
type GameRenderer = any;
|
||||
|
||||
/**
|
||||
* Unified game API interface for both desktop and browser builds.
|
||||
*
|
||||
* Desktop implementation uses Tauri's invoke/listen.
|
||||
* Browser implementation uses WASM bindings with JS callbacks.
|
||||
*/
|
||||
export interface GameAPI {
|
||||
/**
|
||||
* Start a new game.
|
||||
*/
|
||||
startGame(): void;
|
||||
|
||||
/**
|
||||
* Quit the current game.
|
||||
*/
|
||||
quitGame(): void;
|
||||
|
||||
/**
|
||||
* Subscribe to leaderboard snapshots (includes names, colors, and stats).
|
||||
* Returns an unsubscribe function to clean up the listener.
|
||||
*/
|
||||
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn;
|
||||
|
||||
/**
|
||||
* Subscribe to game end events (victory/defeat).
|
||||
* Returns an unsubscribe function to clean up the listener.
|
||||
*/
|
||||
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn;
|
||||
|
||||
/**
|
||||
* Subscribe to dynamic attacks updates.
|
||||
* Returns an unsubscribe function to clean up the listener.
|
||||
*/
|
||||
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn;
|
||||
|
||||
/**
|
||||
* Subscribe to spawn phase updates.
|
||||
* - countdown: null = phase active, waiting for first spawn
|
||||
* - countdown: non-null = countdown in progress with epoch timestamp
|
||||
*/
|
||||
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn;
|
||||
|
||||
/**
|
||||
* Subscribe to spawn phase end event (game has started).
|
||||
*/
|
||||
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn;
|
||||
|
||||
// Rendering integration methods
|
||||
/**
|
||||
* Register the PixiJS renderer to receive render updates from backend.
|
||||
*/
|
||||
setRenderer?(renderer: GameRenderer): void;
|
||||
|
||||
/**
|
||||
* Send camera state updates to the backend.
|
||||
*/
|
||||
sendCameraUpdate?(state: { x: number; y: number; zoom: number }): void;
|
||||
|
||||
/**
|
||||
* Send map click events to the backend.
|
||||
*/
|
||||
sendMapClick?(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void;
|
||||
|
||||
/**
|
||||
* Send map hover events to the backend.
|
||||
*/
|
||||
sendMapHover?(data: { tile_index: number | null; world_x: number; world_y: number }): void;
|
||||
|
||||
/**
|
||||
* Send keyboard input to the backend.
|
||||
*/
|
||||
sendKeyPress?(data: { key: string; pressed: boolean }): void;
|
||||
|
||||
/**
|
||||
* Set attack ratio (percentage of troops to use when attacking).
|
||||
* @param ratio - Attack ratio as a decimal (0.01 to 1.0, representing 1% to 100%)
|
||||
*/
|
||||
sendAttackRatio?(ratio: number): void;
|
||||
|
||||
/**
|
||||
* Get current game state for recovery after reload (desktop only).
|
||||
* Returns render and leaderboard state if a game is running, null otherwise.
|
||||
* ONLY called on frontend init/reload - not streamed.
|
||||
*/
|
||||
getGameState?(): Promise<{
|
||||
render_init: any | null;
|
||||
leaderboard_snapshot: LeaderboardSnapshot | null;
|
||||
} | null>;
|
||||
}
|
||||
32
frontend/src/shared/api/GameAPIContext.tsx
Normal file
32
frontend/src/shared/api/GameAPIContext.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { GameAPI } from "@/shared/api/GameAPI";
|
||||
|
||||
/**
|
||||
* React context for the game API.
|
||||
* Platform-specific implementations (TauriAPI or WasmBridge) are injected at the root.
|
||||
*/
|
||||
const GameAPIContext = createContext<GameAPI | null>(null);
|
||||
|
||||
export interface GameAPIProviderProps {
|
||||
api: GameAPI;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that injects the platform-specific GameAPI implementation.
|
||||
*/
|
||||
export function GameAPIProvider({ api, children }: GameAPIProviderProps) {
|
||||
return <GameAPIContext.Provider value={api}>{children}</GameAPIContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the game API from any component.
|
||||
* Throws an error if used outside of a GameAPIProvider.
|
||||
*/
|
||||
export function useGameAPI(): GameAPI {
|
||||
const api = useContext(GameAPIContext);
|
||||
if (!api) {
|
||||
throw new Error("useGameAPI must be used within a GameAPIProvider");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
4
frontend/src/shared/api/index.ts
Normal file
4
frontend/src/shared/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Re-export all API types and interfaces for convenient imports
|
||||
export * from "./types";
|
||||
export * from "./GameAPI";
|
||||
export * from "./GameAPIContext";
|
||||
49
frontend/src/shared/api/types.ts
Normal file
49
frontend/src/shared/api/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Shared type definitions for game API
|
||||
// These mirror the Rust types used in both desktop and WASM builds
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string; // Hex color without alpha, e.g. "0A44FF"
|
||||
tile_count: number;
|
||||
troops: number;
|
||||
territory_percent: number;
|
||||
};
|
||||
|
||||
export type LeaderboardSnapshot = {
|
||||
turn: number;
|
||||
total_land_tiles: number;
|
||||
entries: LeaderboardEntry[];
|
||||
client_player_id: number;
|
||||
};
|
||||
|
||||
export type GameOutcome = "Victory" | "Defeat";
|
||||
|
||||
export type AttackEntry = {
|
||||
attacker_id: number;
|
||||
target_id: number | null; // null for unclaimed territory
|
||||
troops: number;
|
||||
start_turn: number;
|
||||
is_outgoing: boolean;
|
||||
};
|
||||
|
||||
export type AttacksUpdatePayload = {
|
||||
turn: number;
|
||||
entries: AttackEntry[];
|
||||
};
|
||||
|
||||
export type SpawnCountdown = {
|
||||
startedAtMs: number; // Unix epoch milliseconds
|
||||
durationSecs: number;
|
||||
};
|
||||
|
||||
export type SpawnPhaseUpdate = {
|
||||
countdown: SpawnCountdown | null; // null = waiting, non-null = countdown active
|
||||
};
|
||||
|
||||
export type HighlightNationMessage = {
|
||||
msg_type: "HighlightNation";
|
||||
nation_id: number | null;
|
||||
};
|
||||
|
||||
export type UnsubscribeFn = () => void;
|
||||
164
frontend/src/shared/components/AlphaWarningModal.css
Normal file
164
frontend/src/shared/components/AlphaWarningModal.css
Normal file
@@ -0,0 +1,164 @@
|
||||
.alpha-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.alpha-modal-overlay[data-state="open"] {
|
||||
animation: overlayShow 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.alpha-modal-overlay[data-state="closed"] {
|
||||
animation: overlayHide 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.alpha-modal-content {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.alpha-modal-content[data-state="open"] {
|
||||
animation: contentShow 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.alpha-modal-content[data-state="closed"] {
|
||||
animation: contentHide 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.alpha-modal-header {
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
right: 3rem;
|
||||
}
|
||||
|
||||
.alpha-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #ffffff;
|
||||
opacity: 0.6;
|
||||
mix-blend-mode: screen;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.alpha-modal-close:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.alpha-modal-close:active {
|
||||
transform: scale(0.9);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.alpha-modal-close:focus {
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.alpha-modal-close:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.alpha-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.alpha-modal-title {
|
||||
font-family: "Oswald Variable", sans-serif;
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.alpha-modal-description {
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.alpha-modal-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.alpha-modal-description {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.alpha-modal-header {
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
}
|
||||
42
frontend/src/shared/components/AlphaWarningModal.tsx
Normal file
42
frontend/src/shared/components/AlphaWarningModal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { X } from "lucide-react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import "@fontsource-variable/oswald";
|
||||
import "./AlphaWarningModal.css";
|
||||
|
||||
interface AlphaWarningModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AlphaWarningModal({ open, onOpenChange }: AlphaWarningModalProps) {
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="alpha-modal-overlay" />
|
||||
<Dialog.Content className="alpha-modal-content" onPointerDown={handleBackdropClick}>
|
||||
<div className="alpha-modal-header">
|
||||
<Dialog.Close asChild>
|
||||
<button className="alpha-modal-close" aria-label="Close">
|
||||
<X size={64} strokeWidth={2.5} />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="alpha-modal-body">
|
||||
<Dialog.Title className="alpha-modal-title">Alpha Software</Dialog.Title>
|
||||
<Dialog.Description className="alpha-modal-description">
|
||||
This application is in early development. Expect bugs, missing features, and breaking changes. Your
|
||||
feedback is appreciated as we continue building.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
532
frontend/src/shared/components/App.css
Normal file
532
frontend/src/shared/components/App.css
Normal file
@@ -0,0 +1,532 @@
|
||||
:root {
|
||||
font-family: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #1e293b;
|
||||
background-color: transparent; /* Transparent to show game canvas */
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input blocking system for desktop mode:
|
||||
*
|
||||
* In desktop mode, the InputForwarder component captures all input events
|
||||
* (mouse, keyboard, wheel) from the window and forwards them to the Bevy game engine.
|
||||
*
|
||||
* To prevent UI elements (menus, overlays, etc.) from accidentally forwarding clicks
|
||||
* to the game, those elements should be tagged with the 'block-game-input' class.
|
||||
*
|
||||
* The InputForwarder checks `event.target.closest('.block-game-input')` and skips
|
||||
* forwarding if the event originated from within a blocking element.
|
||||
*
|
||||
* For overlays that SHOULD allow clicks through (like spawn phase instructions),
|
||||
* simply don't add the 'block-game-input' class.
|
||||
*/
|
||||
.block-game-input {
|
||||
/* This class serves as a marker - no styles needed */
|
||||
}
|
||||
|
||||
/* Make the entire React overlay transparent to pointer events */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: transparent; /* Transparent to show game canvas */
|
||||
z-index: 1; /* Above canvas (z-index: 0) */
|
||||
}
|
||||
|
||||
/* In browser mode, enable pointer events on the Bevy canvas */
|
||||
#bevy-canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
pointer-events: none; /* Let clicks pass through to interaction layer */
|
||||
position: relative;
|
||||
z-index: 1; /* Above interaction layer */
|
||||
}
|
||||
|
||||
/* Game canvas - PixiJS renderer layer */
|
||||
.game-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: auto; /* Capture input events */
|
||||
z-index: 0; /* Behind all UI elements */
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none; /* Let clicks pass through to interaction layer */
|
||||
z-index: 1; /* Above interaction layer and canvas */
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
pointer-events: auto; /* Make text selectable if needed */
|
||||
}
|
||||
|
||||
p {
|
||||
pointer-events: auto; /* Make text selectable */
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
text-decoration: inherit;
|
||||
pointer-events: auto; /* Make links clickable */
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
form {
|
||||
pointer-events: auto; /* Make form interactive */
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
pointer-events: auto; /* Re-enable clicks on interactive elements */
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto; /* Re-enable clicks on buttons */
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
button:active {
|
||||
border-color: #3b82f6;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Leaderboard styles */
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
opacity: 0.6;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 13px; /* Base font size for scaling everything */
|
||||
}
|
||||
|
||||
.leaderboard:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leaderboard__header {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: white;
|
||||
padding: 0.5em 0.83em; /* 6px 10px scaled to em */
|
||||
border-radius: 0 0 0 0;
|
||||
font-weight: 500;
|
||||
font-size: 1.25em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.leaderboard__header--collapsed {
|
||||
border-radius: 0 0 0.2em 0.2em;
|
||||
}
|
||||
|
||||
.leaderboard__body {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 0 0 0.5em 0.5em; /* 6px scaled to em */
|
||||
}
|
||||
|
||||
.leaderboard__table {
|
||||
width: 23.5em; /* Increased to accommodate position column */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.leaderboard__row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 1.1em;
|
||||
|
||||
> td {
|
||||
padding-top: 0.25em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.leaderboard__row:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.leaderboard__row--player {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.leaderboard__row--eliminated {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.leaderboard__row--eliminated:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.leaderboard__row--highlighted {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
.leaderboard__position {
|
||||
padding-left: 0.67em;
|
||||
padding-right: 0.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 3em;
|
||||
min-width: 3em;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: -0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.leaderboard__name {
|
||||
padding: 0 0.67em 0 0; /* Reduced left padding since position column has right padding */
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 55%; /* Reduced to make room for position column */
|
||||
}
|
||||
|
||||
.leaderboard__name-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.leaderboard__color-circle {
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 0 0.15em rgba(0, 0, 0, 0.4),
|
||||
0 0 0.3em rgba(0, 0, 0, 0.2),
|
||||
0 0 0.5em rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 0.1em rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.leaderboard__percent {
|
||||
padding: 0 0.67em; /* 6px 8px scaled to em */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 20%; /* Fixed width for percentage column */
|
||||
}
|
||||
|
||||
.leaderboard__troops {
|
||||
padding-left: 0.45em;
|
||||
padding-right: 0.86em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 20%; /* Fixed width for troops column */
|
||||
min-width: 5em; /* 60px scaled to em (60/12) */
|
||||
}
|
||||
|
||||
.leaderboard__placeholder {
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive font sizing based on window size */
|
||||
@media (max-width: 1200px) {
|
||||
.leaderboard {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.leaderboard {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.leaderboard {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.leaderboard {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attacks styles */
|
||||
.attacks {
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 13px; /* Base font size for scaling everything */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.attacks:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attacks__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 0.83em;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
font-size: 1.1em;
|
||||
font-family: inherit;
|
||||
width: 21.67em;
|
||||
border-radius: 0.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attacks__row:hover {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
.attacks__background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.attacks__nation {
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
padding-right: 1em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.attacks__troops {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: 5em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.attacks {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.attacks {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.attacks {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.attacks {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attack Controls styles */
|
||||
.attack-controls {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
opacity: 0.95;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 15.6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attack-controls:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attack-controls__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attack-controls__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
min-width: 26em;
|
||||
}
|
||||
|
||||
.attack-controls__label {
|
||||
font-size: 0.7em;
|
||||
color: white;
|
||||
opacity: 0.6;
|
||||
padding-bottom: 0.15em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.8),
|
||||
0 2px 4px rgba(0, 0, 0, 0.6),
|
||||
1px 0 2px rgba(0, 0, 0, 0.8),
|
||||
-1px 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.attack-controls__attacks {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.attack-controls__slider-wrapper {
|
||||
width: 26em;
|
||||
}
|
||||
|
||||
.attack-controls__slider {
|
||||
height: 2.75em;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 0.4em;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.attack-controls__slider-fill {
|
||||
height: 100%;
|
||||
background: rgba(59, 130, 246, 0.6);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 0.15em;
|
||||
}
|
||||
|
||||
.attack-controls__percentage {
|
||||
position: absolute;
|
||||
left: 0.8em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.15em;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.85;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.4),
|
||||
1px 0 2px rgba(0, 0, 0, 0.4),
|
||||
-1px 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.attack-controls {
|
||||
font-size: 14.4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.attack-controls {
|
||||
font-size: 14.4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.attack-controls {
|
||||
font-size: 13.8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.attack-controls {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
184
frontend/src/shared/components/App.tsx
Normal file
184
frontend/src/shared/components/App.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import "./App.css";
|
||||
import { Attacks } from "@/shared/components/Attacks";
|
||||
import { AttackControls } from "@/shared/components/AttackControls";
|
||||
import { Leaderboard } from "@/shared/components/Leaderboard";
|
||||
import { MenuScreen } from "@/shared/components/MenuScreen";
|
||||
import { GameEndOverlay } from "@/shared/components/GameEndOverlay";
|
||||
import { GameMenu } from "@/shared/components/GameMenu";
|
||||
import { SpawnPhaseOverlay } from "@/shared/components/SpawnPhaseOverlay";
|
||||
import { GameCanvas } from "@/shared/components/GameCanvas";
|
||||
import { useGameAPI } from "@/shared/api/GameAPIContext";
|
||||
import { useAnalytics } from "@/shared/analytics";
|
||||
import type { GameOutcome, LeaderboardSnapshot } from "@/shared/api/types";
|
||||
import type { GameRenderer } from "@/shared/render/GameRenderer";
|
||||
|
||||
function App() {
|
||||
const [showMenu, setShowMenu] = useState(true);
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
const [gameOutcome, setGameOutcome] = useState<GameOutcome | null>(null);
|
||||
const [spawnPhaseActive, setSpawnPhaseActive] = useState(false);
|
||||
const [spawnCountdown, setSpawnCountdown] = useState<{
|
||||
startedAtMs: number;
|
||||
durationSecs: number;
|
||||
} | null>(null);
|
||||
const [initialGameState, setInitialGameState] = useState<any | null>(null);
|
||||
const [initialLeaderboard, setInitialLeaderboard] = useState<LeaderboardSnapshot | null>(null);
|
||||
const [renderer, setRenderer] = useState<GameRenderer | null>(null);
|
||||
const [highlightedNation, setHighlightedNation] = useState<number | null>(null);
|
||||
const api = useGameAPI();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// Track app started on mount
|
||||
useEffect(() => {
|
||||
analytics.track("app_started", {
|
||||
platform: __DESKTOP__ ? "desktop" : "browser",
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
// Check for existing game state on mount (for reload recovery)
|
||||
useEffect(() => {
|
||||
if (!api || typeof api.getGameState !== "function") return;
|
||||
|
||||
api.getGameState().then((state) => {
|
||||
// Only recover if we actually have render data (indicates a running game)
|
||||
if (state && state.render_init) {
|
||||
console.log("Recovered game state after reload:", state);
|
||||
setInitialGameState(state.render_init);
|
||||
setInitialLeaderboard(state.leaderboard_snapshot);
|
||||
setGameStarted(true);
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to spawn phase events
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const unsubUpdate = api.onSpawnPhaseUpdate((update) => {
|
||||
setSpawnPhaseActive(true);
|
||||
setSpawnCountdown(update.countdown);
|
||||
});
|
||||
|
||||
const unsubEnd = api.onSpawnPhaseEnded(() => {
|
||||
setSpawnPhaseActive(false);
|
||||
setSpawnCountdown(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubUpdate();
|
||||
unsubEnd();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to game end events
|
||||
useEffect(() => {
|
||||
if (!gameStarted || !api) return;
|
||||
|
||||
const unsubscribe = api.onGameEnded((outcome) => {
|
||||
console.log("Game outcome received:", outcome);
|
||||
setGameOutcome(outcome);
|
||||
setSpawnPhaseActive(false); // Hide spawn overlay on game end
|
||||
// Track game ended
|
||||
analytics.track("game_ended", {
|
||||
outcome: outcome.toString().toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [gameStarted, api, analytics]);
|
||||
|
||||
// Sync highlighted nation with renderer
|
||||
useEffect(() => {
|
||||
if (renderer) {
|
||||
renderer.setHighlightedNation(highlightedNation);
|
||||
}
|
||||
}, [highlightedNation, renderer]);
|
||||
|
||||
const handleStartSingleplayer = () => {
|
||||
setShowMenu(false);
|
||||
setGameStarted(true);
|
||||
// Start the game in the backend
|
||||
if (api) {
|
||||
api.startGame();
|
||||
}
|
||||
// Track game started
|
||||
analytics.track("game_started", {
|
||||
mode: "singleplayer",
|
||||
});
|
||||
};
|
||||
|
||||
const handleExit = async () => {
|
||||
if (__DESKTOP__) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("request_exit").catch((err) => {
|
||||
console.error("Failed to request exit:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Game Canvas - always rendered at root, hidden under menu initially */}
|
||||
<GameCanvas
|
||||
className="game-canvas"
|
||||
initialState={initialGameState}
|
||||
onRendererReady={setRenderer}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
|
||||
{/* Menu Screen - covers everything when visible */}
|
||||
<MenuScreen onStartSingleplayer={handleStartSingleplayer} onExit={handleExit} isVisible={showMenu} />
|
||||
|
||||
{/* Game UI - only visible when game is started */}
|
||||
{gameStarted && (
|
||||
<div className="game-ui">
|
||||
{/* Spawn Phase Overlay */}
|
||||
<SpawnPhaseOverlay isVisible={spawnPhaseActive} countdown={spawnCountdown} />
|
||||
|
||||
<Leaderboard
|
||||
topN={8}
|
||||
initialSnapshot={initialLeaderboard}
|
||||
highlightedNation={highlightedNation}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
<AttackControls>
|
||||
<Attacks onNationHover={setHighlightedNation} />
|
||||
</AttackControls>
|
||||
<GameMenu
|
||||
onExit={() => {
|
||||
if (api) {
|
||||
api.quitGame();
|
||||
}
|
||||
setGameOutcome(null);
|
||||
setGameStarted(false);
|
||||
setShowMenu(true);
|
||||
}}
|
||||
onSettings={() => {
|
||||
// TODO: Implement settings
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Game End Overlay */}
|
||||
{gameOutcome && (
|
||||
<GameEndOverlay
|
||||
outcome={gameOutcome}
|
||||
onSpectate={() => setGameOutcome(null)}
|
||||
onExit={() => {
|
||||
if (api) {
|
||||
api.quitGame();
|
||||
}
|
||||
setGameOutcome(null);
|
||||
setGameStarted(false);
|
||||
setShowMenu(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
157
frontend/src/shared/components/AttackControls.tsx
Normal file
157
frontend/src/shared/components/AttackControls.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState, ReactNode } from "react";
|
||||
import { useGameAPI } from "@/shared/api/GameAPIContext";
|
||||
import { useThrottledCallback } from "@/shared/hooks";
|
||||
|
||||
interface AttackControlsProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function AttackControls({ children }: AttackControlsProps) {
|
||||
const api = useGameAPI();
|
||||
const [percentage, setPercentage] = useState(50);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const updateAttackRatioThrottled = useThrottledCallback((percent: number) => {
|
||||
if (api && typeof api.sendAttackRatio === "function") {
|
||||
api.sendAttackRatio(percent / 100);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
const handlePercentageChange = (newPercent: number) => {
|
||||
const clampedPercent = Math.max(1, Math.min(100, Math.round(newPercent)));
|
||||
setPercentage(clampedPercent);
|
||||
updateAttackRatioThrottled(clampedPercent);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
updateSliderFromMouse(e);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
updateSliderFromTouch(e);
|
||||
};
|
||||
|
||||
const updateSliderFromMouse = (e: React.MouseEvent<HTMLDivElement> | MouseEvent) => {
|
||||
if (!sliderRef.current) return;
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const rawPercent = (x / width) * 100;
|
||||
handlePercentageChange(rawPercent);
|
||||
};
|
||||
|
||||
const updateSliderFromTouch = (e: React.TouchEvent<HTMLDivElement> | TouchEvent) => {
|
||||
if (!sliderRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = touch.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const rawPercent = (x / width) * 100;
|
||||
handlePercentageChange(rawPercent);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDraggingRef.current) {
|
||||
updateSliderFromMouse(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (isDraggingRef.current) {
|
||||
e.preventDefault();
|
||||
updateSliderFromTouch(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
const handleGlobalWheel = (e: WheelEvent) => {
|
||||
if (!e.shiftKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const delta = e.deltaY > 0 ? -5 : 5;
|
||||
|
||||
let newPercent: number;
|
||||
if (percentage === 1 && delta > 0) {
|
||||
newPercent = 5;
|
||||
} else {
|
||||
newPercent = percentage + delta;
|
||||
}
|
||||
|
||||
handlePercentageChange(newPercent);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", handleTouchEnd);
|
||||
window.addEventListener("wheel", handleGlobalWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", handleTouchEnd);
|
||||
window.removeEventListener("wheel", handleGlobalWheel);
|
||||
};
|
||||
}, [percentage, handlePercentageChange]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!e.shiftKey) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const delta = e.deltaY > 0 ? -5 : 5;
|
||||
|
||||
let newPercent: number;
|
||||
if (percentage === 1 && delta > 0) {
|
||||
newPercent = 5;
|
||||
} else {
|
||||
newPercent = percentage + delta;
|
||||
}
|
||||
|
||||
handlePercentageChange(newPercent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="attack-controls no-drag block-game-input" onWheelCapture={handleWheel}>
|
||||
<div className="attack-controls__content">
|
||||
<div className="attack-controls__header">
|
||||
<div className="attack-controls__label">Troops</div>
|
||||
<div className="attack-controls__attacks">{children}</div>
|
||||
</div>
|
||||
<div className="attack-controls__slider-wrapper">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className="attack-controls__slider"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="attack-controls__slider-fill" style={{ width: `${percentage}%` }} />
|
||||
<div className="attack-controls__percentage">{percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/shared/components/AttackRow.tsx
Normal file
73
frontend/src/shared/components/AttackRow.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { AttackEntry, LeaderboardEntry } from "@/shared/api/types";
|
||||
|
||||
// Format troop count for human-readable display
|
||||
// 100 => "100", 12,493 => "12.4k", 980,455 => "980k"
|
||||
export function formatTroopCount(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) {
|
||||
const k = count / 1000;
|
||||
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
const m = count / 1000000;
|
||||
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`;
|
||||
}
|
||||
|
||||
// Calculate the background width percentage based on troop count
|
||||
// Uses power scale (x^0.4): 100 troops = 5%, 1k = 7.9%, 10k = 15.3%, 100k = 33.7%, 1M = 80%
|
||||
function calculateBackgroundWidth(troops: number): number {
|
||||
const minTroops = 100;
|
||||
const maxTroops = 1000000;
|
||||
const minWidth = 5;
|
||||
const maxWidth = 80;
|
||||
|
||||
// Clamp troops to range
|
||||
const clampedTroops = Math.max(minTroops, Math.min(maxTroops, troops));
|
||||
|
||||
// Power scale with exponent 0.4 provides gentler progression than logarithmic
|
||||
const powerMin = Math.pow(minTroops, 0.4);
|
||||
const powerMax = Math.pow(maxTroops, 0.4);
|
||||
const powerTroops = Math.pow(clampedTroops, 0.4);
|
||||
|
||||
// Map to 5-80% range
|
||||
const normalized = (powerTroops - powerMin) / (powerMax - powerMin);
|
||||
return minWidth + normalized * (maxWidth - minWidth);
|
||||
}
|
||||
|
||||
interface AttackRowProps {
|
||||
attack: AttackEntry;
|
||||
playerMap: Map<number, LeaderboardEntry>;
|
||||
onNationHover?: (nationId: number | null) => void;
|
||||
}
|
||||
|
||||
export function AttackRow({ attack, playerMap, onNationHover }: AttackRowProps) {
|
||||
// For outgoing attacks, show target's name (who we're attacking)
|
||||
// For incoming attacks, show attacker's name (who is attacking us)
|
||||
const displayPlayerId = attack.is_outgoing ? attack.target_id : attack.attacker_id;
|
||||
|
||||
const displayPlayer = displayPlayerId !== null ? playerMap.get(displayPlayerId) : null;
|
||||
const displayName = displayPlayer?.name || "Unclaimed Territory";
|
||||
|
||||
const backgroundWidth = calculateBackgroundWidth(attack.troops);
|
||||
const backgroundColor = attack.is_outgoing
|
||||
? "rgba(59, 130, 246, 0.3)" // Blue with 30% opacity
|
||||
: "rgba(239, 68, 68, 0.3)"; // Red with 30% opacity
|
||||
|
||||
return (
|
||||
<button
|
||||
className="attacks__row"
|
||||
onClick={() => console.log("Attack clicked:", attack)}
|
||||
onMouseEnter={() => displayPlayerId !== null && onNationHover?.(displayPlayerId)}
|
||||
onMouseLeave={() => onNationHover?.(null)}
|
||||
>
|
||||
<div
|
||||
className="attacks__background"
|
||||
style={{
|
||||
width: `${backgroundWidth}%`,
|
||||
backgroundColor,
|
||||
}}
|
||||
/>
|
||||
<div className="attacks__nation">{displayName}</div>
|
||||
<div className="attacks__troops">{formatTroopCount(attack.troops)}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
72
frontend/src/shared/components/Attacks.tsx
Normal file
72
frontend/src/shared/components/Attacks.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameAPI } from "@/shared/api/GameAPIContext";
|
||||
import type { AttacksUpdatePayload, LeaderboardEntry, UnsubscribeFn } from "@/shared/api/types";
|
||||
import { AttackRow } from "@/shared/components/AttackRow";
|
||||
|
||||
export function Attacks({
|
||||
onNationHover
|
||||
}: {
|
||||
onNationHover: (nationId: number | null) => void;
|
||||
}) {
|
||||
const gameAPI = useGameAPI();
|
||||
const [attacksData, setAttacksData] = useState<AttacksUpdatePayload | null>(null);
|
||||
const [playerMap, setPlayerMap] = useState<Map<number, LeaderboardEntry>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribeAttacks: UnsubscribeFn = () => {};
|
||||
let unsubscribeLeaderboard: UnsubscribeFn = () => {};
|
||||
|
||||
// Subscribe to leaderboard snapshots to get player names/colors
|
||||
unsubscribeLeaderboard = gameAPI.onLeaderboardSnapshot((snapshot) => {
|
||||
const newMap = new Map<number, LeaderboardEntry>();
|
||||
snapshot.entries.forEach((entry) => {
|
||||
newMap.set(entry.id, entry);
|
||||
});
|
||||
|
||||
// Only update state if the map actually changed (prevents unnecessary re-renders)
|
||||
setPlayerMap((prevMap) => {
|
||||
// Check if sizes differ
|
||||
if (prevMap.size !== newMap.size) {
|
||||
return newMap;
|
||||
}
|
||||
// Check if any entries changed
|
||||
for (const [id, entry] of newMap) {
|
||||
const prevEntry = prevMap.get(id);
|
||||
if (!prevEntry || prevEntry.name !== entry.name || prevEntry.color !== entry.color) {
|
||||
return newMap;
|
||||
}
|
||||
}
|
||||
// No changes - return previous map to avoid re-render
|
||||
return prevMap;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to attacks updates
|
||||
unsubscribeAttacks = gameAPI.onAttacksUpdate((payload) => {
|
||||
setAttacksData(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeAttacks();
|
||||
unsubscribeLeaderboard();
|
||||
};
|
||||
}, [gameAPI]);
|
||||
|
||||
// Don't render if no attacks
|
||||
if (!attacksData || attacksData.entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="attacks no-drag block-game-input" onMouseLeave={() => onNationHover(null)}>
|
||||
{attacksData.entries.map((attack, index) => (
|
||||
<AttackRow
|
||||
key={`${attack.attacker_id}-${attack.target_id}-${attack.start_turn}-${index}`}
|
||||
attack={attack}
|
||||
playerMap={playerMap}
|
||||
onNationHover={onNationHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
frontend/src/shared/components/GameCanvas.tsx
Normal file
228
frontend/src/shared/components/GameCanvas.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { CameraStateListener, GameRenderer } from "@/shared/render";
|
||||
import { useGameAPI } from "@/shared/api";
|
||||
import { useThrottledCallback } from "@/shared/hooks";
|
||||
|
||||
interface GameCanvasProps {
|
||||
className?: string;
|
||||
initialState?: any | null;
|
||||
onRendererReady?: (renderer: GameRenderer | null) => void;
|
||||
onNationHover?: (nationId: number | null) => void;
|
||||
}
|
||||
|
||||
export const GameCanvas: React.FC<GameCanvasProps> = ({ className, initialState, onRendererReady, onNationHover }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rendererRef = useRef<GameRenderer | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const lastHoveredNationRef = useRef<number | null>(null);
|
||||
const api = useGameAPI();
|
||||
|
||||
// Apply initial state when provided (for reload recovery)
|
||||
useEffect(() => {
|
||||
if (!initialState || !rendererRef.current || !isInitialized) return;
|
||||
|
||||
console.log("Applying initial game state to renderer:", initialState);
|
||||
const renderer = rendererRef.current;
|
||||
|
||||
try {
|
||||
// Apply RenderInit data: terrain palette, terrain, player palette, territories
|
||||
renderer.setTerrainPalette(initialState.terrain_palette);
|
||||
renderer.initTerrain(initialState.terrain);
|
||||
renderer.initPalette(initialState.palette);
|
||||
renderer.applyTerritorySnapshot(initialState.initial_territories.turn, initialState.initial_territories.territories);
|
||||
console.log("Initial state applied successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to apply initial state:", err);
|
||||
}
|
||||
}, [initialState, isInitialized]);
|
||||
|
||||
// Initialize renderer once on mount
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || rendererRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const renderer = new GameRenderer();
|
||||
rendererRef.current = renderer;
|
||||
|
||||
// Initialize renderer
|
||||
renderer
|
||||
.init(canvasRef.current)
|
||||
.then(async () => {
|
||||
if (cancelled) {
|
||||
renderer.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
// Set up camera listener
|
||||
const cameraListener: CameraStateListener = {
|
||||
onCameraUpdate: (x, y, zoom) => {
|
||||
if (api && typeof api.sendCameraUpdate === "function") {
|
||||
api.sendCameraUpdate({ x, y, zoom });
|
||||
}
|
||||
},
|
||||
};
|
||||
renderer.setCameraListener(cameraListener);
|
||||
|
||||
// Register renderer with API for receiving updates
|
||||
if (api && typeof api.setRenderer === "function") {
|
||||
await api.setRenderer(renderer);
|
||||
}
|
||||
|
||||
// Notify parent that renderer is ready
|
||||
onRendererReady?.(renderer);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to initialize GameRenderer:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rendererRef.current) {
|
||||
// Unregister renderer from API before destroying
|
||||
if (api && typeof api.setRenderer === "function") {
|
||||
api.setRenderer(null);
|
||||
}
|
||||
rendererRef.current.destroy();
|
||||
rendererRef.current = null;
|
||||
onRendererReady?.(null);
|
||||
}
|
||||
setIsInitialized(false);
|
||||
};
|
||||
}, [api, onRendererReady]);
|
||||
|
||||
// Handle canvas clicks using native event listener (after renderer initialization)
|
||||
// React onClick doesn't work reliably when native listeners are attached to canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !rendererRef.current || !api) return;
|
||||
|
||||
const handleCanvasClick = (e: MouseEvent) => {
|
||||
if (!rendererRef.current || !api) return;
|
||||
|
||||
// Ignore clicks that were camera drags
|
||||
if (rendererRef.current.hadCameraInteraction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
|
||||
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
|
||||
|
||||
if (typeof api.sendMapClick === "function") {
|
||||
const worldPos = rendererRef.current.tileToWorld(tileIndex || 0);
|
||||
api.sendMapClick({
|
||||
tile_index: tileIndex,
|
||||
world_x: worldPos.x,
|
||||
world_y: worldPos.y,
|
||||
button: e.button,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener("click", handleCanvasClick);
|
||||
return () => canvas.removeEventListener("click", handleCanvasClick);
|
||||
}, [api, isInitialized]);
|
||||
|
||||
// Handle mouse move (for hover) - throttled to reduce IPC overhead
|
||||
const handleMouseMove = useThrottledCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!rendererRef.current) return;
|
||||
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
|
||||
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
|
||||
if (tileIndex === null) return;
|
||||
|
||||
// Handle nation hover detection
|
||||
if (onNationHover) {
|
||||
const nationId = rendererRef.current.getNationAtTile(tileIndex);
|
||||
if (nationId !== lastHoveredNationRef.current) {
|
||||
lastHoveredNationRef.current = nationId;
|
||||
onNationHover(nationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Send map hover to API
|
||||
if (api && typeof api.sendMapHover === "function") {
|
||||
const worldPos = rendererRef.current.tileToWorld(tileIndex);
|
||||
// Only send if we have valid coordinates
|
||||
if (isFinite(worldPos.x) && isFinite(worldPos.y)) {
|
||||
api.sendMapHover({
|
||||
tile_index: tileIndex,
|
||||
world_x: worldPos.x,
|
||||
world_y: worldPos.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Handle keyboard input
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (typeof api.sendKeyPress === "function") {
|
||||
api.sendKeyPress({
|
||||
key: e.code,
|
||||
pressed: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (typeof api.sendKeyPress === "function") {
|
||||
api.sendKeyPress({
|
||||
key: e.code,
|
||||
pressed: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div className={className} style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: isInitialized ? undefined : "wait",
|
||||
}}
|
||||
/>
|
||||
{!isInitialized && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
color: "white",
|
||||
fontSize: "18px",
|
||||
fontFamily: "Inter, sans-serif",
|
||||
}}
|
||||
>
|
||||
Initializing renderer...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
132
frontend/src/shared/components/GameEndOverlay.tsx
Normal file
132
frontend/src/shared/components/GameEndOverlay.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { GameOutcome } from "@/shared/api/types";
|
||||
import "@fontsource-variable/oswald";
|
||||
|
||||
interface GameEndOverlayProps {
|
||||
outcome: GameOutcome;
|
||||
onSpectate: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function GameEndOverlay({ outcome, onSpectate, onExit }: GameEndOverlayProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="game-end-card no-drag block-game-input"
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
userSelect: "none",
|
||||
width: "300px",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{/* Victory/Defeat text */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(15, 23, 42, 0.75)",
|
||||
color: "white",
|
||||
padding: "1.5em 2em",
|
||||
textAlign: "center",
|
||||
borderRadius: "0.5em 0.5em 0 0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: '"Oswald Variable", sans-serif',
|
||||
fontSize: "2.85em",
|
||||
fontWeight: "400",
|
||||
marginBottom: "0.25em",
|
||||
}}
|
||||
>
|
||||
{outcome === "Victory" ? "Victory" : "Defeat"}
|
||||
</div>
|
||||
{/* TODO: Make subtitle dynamic based on win/loss condition:
|
||||
- Victory by elimination: "You destroyed all other Nations."
|
||||
- Victory by occupation: "You reached 80% occupation of the map."
|
||||
- Defeat by elimination: "Your nation was eradicated."
|
||||
- Defeat by enemy occupation: "{PlayerName} reached 80% occupation of the map."
|
||||
*/}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: '"Inter Variable", sans-serif',
|
||||
fontSize: "1.15em",
|
||||
fontWeight: "400",
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{outcome === "Victory" ? "You conquered the map." : "Your nation fell."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "rgba(15, 23, 42, 0.6)",
|
||||
borderRadius: "0 0 0.5em 0.5em",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRight: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
padding: "0.75em",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.15em",
|
||||
fontWeight: "500",
|
||||
fontFamily: '"Inter Variable", sans-serif',
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onClick={onSpectate}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255, 255, 255, 0.06)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
Spectate
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "0.75em",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.15em",
|
||||
fontWeight: "500",
|
||||
fontFamily: '"Inter Variable", sans-serif',
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onClick={onExit}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255, 255, 255, 0.06)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
frontend/src/shared/components/GameMenu.css
Normal file
176
frontend/src/shared/components/GameMenu.css
Normal file
@@ -0,0 +1,176 @@
|
||||
.game-menu {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.game-menu .menu-toggle {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.game-menu .menu-toggle:hover {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.game-menu .menu-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border-radius: 0.375rem;
|
||||
min-width: 140px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-menu .menu-dropdown-button {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.game-menu .menu-dropdown-button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.game-menu .menu-dropdown-button.with-border {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.game-menu .confirmation-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.game-menu .confirmation-overlay.closing {
|
||||
animation: fadeOut 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-dialog {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.game-menu .confirmation-overlay.closing .confirmation-dialog {
|
||||
animation: slideDown 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-title {
|
||||
margin: 0 0 1rem 0;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-message {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.95rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-button {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
padding: 0.625rem 1.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-button.cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.game-menu .confirmation-button.cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.game-menu .confirmation-button.confirm {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.game-menu .confirmation-button.confirm:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
75
frontend/src/shared/components/GameMenu.tsx
Normal file
75
frontend/src/shared/components/GameMenu.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import "./GameMenu.css";
|
||||
|
||||
interface GameMenuProps {
|
||||
onExit: () => void;
|
||||
onSettings?: () => void;
|
||||
}
|
||||
|
||||
export function GameMenu({ onExit, onSettings }: GameMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const closeConfirmation = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false);
|
||||
setIsClosing(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="game-menu no-drag block-game-input">
|
||||
{/* Hamburger/Close Button */}
|
||||
<button className="menu-toggle" onClick={() => setIsOpen((prev) => !prev)}>
|
||||
{isOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="menu-dropdown">
|
||||
<button
|
||||
className={`menu-dropdown-button${onSettings ? " with-border" : ""}`}
|
||||
onClick={() => {
|
||||
setShowConfirmation(true);
|
||||
setIsClosing(false);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirmation && (
|
||||
<div className={`confirmation-overlay${isClosing ? " closing" : ""}`} onClick={closeConfirmation}>
|
||||
<div className="confirmation-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="confirmation-title">Are you sure?</h3>
|
||||
<p className="confirmation-message">You will not be able to return to this game after exiting.</p>
|
||||
<div className="confirmation-actions">
|
||||
<button className="confirmation-button cancel" onClick={closeConfirmation}>
|
||||
Nevermind
|
||||
</button>
|
||||
<button
|
||||
className="confirmation-button confirm"
|
||||
onClick={() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false);
|
||||
setIsClosing(false);
|
||||
onExit();
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
I'm sure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/shared/components/Leaderboard.tsx
Normal file
143
frontend/src/shared/components/Leaderboard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { useGameAPI } from "@/shared/api/GameAPIContext";
|
||||
import type { LeaderboardSnapshot, UnsubscribeFn } from "@/shared/api/types";
|
||||
|
||||
// Smart precision algorithm for percentage display
|
||||
function calculatePrecision(percentages: number[]): number {
|
||||
if (percentages.length === 0) return 0;
|
||||
|
||||
// Find the minimum non-zero difference between consecutive percentages
|
||||
const sorted = [...percentages].sort((a, b) => b - a);
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const diff = sorted[i] - sorted[i + 1];
|
||||
if (diff > 0) {
|
||||
minDiff = Math.min(minDiff, diff);
|
||||
}
|
||||
}
|
||||
|
||||
// If all percentages are the same, use 0 decimal places
|
||||
if (minDiff === Infinity) return 0;
|
||||
|
||||
// Determine precision based on the minimum difference
|
||||
if (minDiff >= 0.1) return 0; // 0.1% or more difference -> 0 decimals
|
||||
if (minDiff >= 0.01) return 1; // 0.01% or more difference -> 1 decimal
|
||||
return 2; // 0.001% or more difference -> 2 decimals (max precision)
|
||||
}
|
||||
|
||||
export function Leaderboard({
|
||||
topN = 8,
|
||||
initialSnapshot,
|
||||
highlightedNation,
|
||||
onNationHover,
|
||||
}: {
|
||||
topN?: number;
|
||||
initialSnapshot?: LeaderboardSnapshot | null;
|
||||
highlightedNation: number | null;
|
||||
onNationHover: (nationId: number | null) => void;
|
||||
}) {
|
||||
const gameAPI = useGameAPI();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<LeaderboardSnapshot | null>(initialSnapshot || null);
|
||||
const [status, setStatus] = useState<"loading" | "waiting" | "ready" | "error">(initialSnapshot ? "ready" : "waiting");
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: UnsubscribeFn = () => {};
|
||||
|
||||
// Subscribe to leaderboard snapshots
|
||||
try {
|
||||
unsubscribe = gameAPI.onLeaderboardSnapshot((snapshotData) => {
|
||||
setSnapshot(snapshotData);
|
||||
setStatus("ready");
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to subscribe to leaderboard snapshots:", error);
|
||||
setStatus("error");
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [gameAPI]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!snapshot) return [];
|
||||
|
||||
const topEntries = snapshot.entries.slice(0, topN);
|
||||
|
||||
// Check if local player is in top N
|
||||
const playerEntry = snapshot.entries.find(e => e.id === snapshot.client_player_id);
|
||||
const playerInTopN = playerEntry && topEntries.some(e => e.id === playerEntry.id);
|
||||
|
||||
// If player exists but not in top N, add them at the end
|
||||
if (playerEntry && !playerInTopN) {
|
||||
const playerPosition = snapshot.entries.findIndex(e => e.id === playerEntry.id);
|
||||
return [...topEntries, { ...playerEntry, position: playerPosition + 1 }];
|
||||
}
|
||||
|
||||
return topEntries;
|
||||
}, [snapshot, topN]);
|
||||
|
||||
const precision = useMemo(() => {
|
||||
if (!snapshot || rows.length === 0) return 0;
|
||||
const percentages = rows.map((r) => r.territory_percent);
|
||||
return calculatePrecision(percentages);
|
||||
}, [snapshot, rows]);
|
||||
|
||||
return (
|
||||
<div className="leaderboard no-drag block-game-input" onMouseLeave={() => onNationHover(null)}>
|
||||
<div className={`leaderboard__header ${collapsed ? 'leaderboard__header--collapsed' : ''}`} onClick={() => setCollapsed((c) => !c)}>
|
||||
<span>Leaderboard</span>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="leaderboard__body">
|
||||
{status === "ready" && snapshot ? (
|
||||
<table className="leaderboard__table">
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const position = "position" in r ? r.position : snapshot.entries.findIndex(e => e.id === r.id) + 1;
|
||||
const isPlayer = r.id === snapshot.client_player_id;
|
||||
const isEliminated = r.territory_percent === 0;
|
||||
const isHighlighted = highlightedNation === r.id;
|
||||
return (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`leaderboard__row ${isPlayer ? 'leaderboard__row--player' : ''} ${isEliminated ? 'leaderboard__row--eliminated' : ''} ${isHighlighted ? 'leaderboard__row--highlighted' : ''}`}
|
||||
onClick={() => !isEliminated && console.log("select", r.id)}
|
||||
onMouseEnter={() => !isEliminated && onNationHover(r.id)}
|
||||
onMouseLeave={() => !isEliminated && onNationHover(null)}
|
||||
>
|
||||
<td className="leaderboard__position">{isEliminated ? '' : position}</td>
|
||||
<td className="leaderboard__name">
|
||||
<div className="leaderboard__name-content">
|
||||
<div
|
||||
className="leaderboard__color-circle"
|
||||
style={{
|
||||
backgroundColor: `#${r.color}`,
|
||||
}}
|
||||
/>
|
||||
<span>{r.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="leaderboard__percent">{isEliminated ? '—' : `${(r.territory_percent * 100).toFixed(precision)}%`}</td>
|
||||
<td className="leaderboard__troops">{isEliminated ? '—' : r.troops.toLocaleString()}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="leaderboard__placeholder">
|
||||
{status === "loading" && "Loading leaderboard…"}
|
||||
{status === "waiting" && "Waiting for updates…"}
|
||||
{status === "error" && "Error loading leaderboard"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
480
frontend/src/shared/components/MenuScreen.css
Normal file
480
frontend/src/shared/components/MenuScreen.css
Normal file
@@ -0,0 +1,480 @@
|
||||
.menu-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.5s ease-out;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menu-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(1px) brightness(0.65) sepia(0.3) contrast(1.5);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: auto;
|
||||
animation: fadeIn 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 2rem 0;
|
||||
margin-top: 2rem;
|
||||
background: rgba(20, 20, 20, 0.45);
|
||||
backdrop-filter: blur(8px) contrast(1.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-family: "Oswald Variable", sans-serif;
|
||||
text-transform: uppercase;
|
||||
font-size: 6rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
line-height: 0.8;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.game-subtitle {
|
||||
padding-top: 0.5rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
font-size: 1.3rem;
|
||||
color: #7e7e7e;
|
||||
display: none;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.menu-islands {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 1.05rem;
|
||||
width: 700px;
|
||||
max-width: 90%;
|
||||
margin: 3rem auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.island-button {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
padding: 0.5rem 1rem;
|
||||
text-align: left;
|
||||
|
||||
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.175);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
&.island-local,
|
||||
&.island-multiplayer {
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.island-button:hover:not(:disabled) {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.27);
|
||||
}
|
||||
|
||||
.island-button:active:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.island-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.island-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.island-title {
|
||||
font-family: "Oswald Variable", sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.2rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.island-description {
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.island-local {
|
||||
background: #c77a06;
|
||||
color: #ffffff;
|
||||
aspect-ratio: 1 / 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.island-local:active {
|
||||
background: #a66305;
|
||||
}
|
||||
|
||||
.island-local .island-background-image {
|
||||
position: absolute;
|
||||
bottom: 0%;
|
||||
left: 0%;
|
||||
transform: scale(1) rotate(-0deg);
|
||||
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.island-right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.05rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.island-multiplayer {
|
||||
background: #4591c0;
|
||||
color: #ffffff;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.island-multiplayer:active {
|
||||
background: #3c7397;
|
||||
}
|
||||
|
||||
.island-multiplayer .island-background-image {
|
||||
position: absolute;
|
||||
bottom: 0%;
|
||||
right: 0%;
|
||||
transform: scale(1) rotate(-0deg);
|
||||
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.island-settings {
|
||||
background: #f1ebdb;
|
||||
color: #424c4a;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.island-settings:active {
|
||||
background: #d9d1bb;
|
||||
}
|
||||
|
||||
.island-settings .island-background-image {
|
||||
position: absolute;
|
||||
bottom: 0%;
|
||||
right: 0%;
|
||||
transform: scale(1) rotate(-0deg);
|
||||
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.island-downloads {
|
||||
background: #f1ebdb;
|
||||
color: #424c4a;
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.island-downloads:active {
|
||||
background: #d9d1bb;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
100% {
|
||||
color: #ffffff;
|
||||
text-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 0 10px rgba(30, 41, 59, 0.2);
|
||||
}
|
||||
50% {
|
||||
color: #ececec;
|
||||
text-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 0 15px rgba(51, 65, 85, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.game-title {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.game-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.menu-islands {
|
||||
grid-template-columns: 1fr;
|
||||
width: 95%;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.island-local {
|
||||
grid-row: auto;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.island-button {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.island-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.island-description {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
position: fixed;
|
||||
bottom: 1.6rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 0 3rem 0 2rem;
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.exit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #ffffff;
|
||||
opacity: 0.85;
|
||||
mix-blend-mode: screen;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.exit-button:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
background: transparent;
|
||||
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.exit-button:active {
|
||||
transform: scale(0.9);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.exit-button:focus {
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.exit-button:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 1rem;
|
||||
color: #e2e8f0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.copyright a {
|
||||
color: #f1f5f9;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.copyright a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.version-warning-button {
|
||||
font-family: "Oswald Variable", sans-serif;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
|
||||
&:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&,
|
||||
span {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.version-warning-button-asterisk {
|
||||
color: #ffc56d;
|
||||
position: absolute;
|
||||
right: -0.42em;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version-warning-button svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-warning-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.version-warning-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu-footer {
|
||||
padding: 0 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.version-warning-button {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.version-warning-button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
117
frontend/src/shared/components/MenuScreen.tsx
Normal file
117
frontend/src/shared/components/MenuScreen.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import "@fontsource/source-serif-pro/400.css";
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource-variable/oswald";
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import "./MenuScreen.css";
|
||||
import screenshotUrl from "@/assets/screenshot.png?w=250&imagetools";
|
||||
import singleplayerImage from "@/assets/singleplayer.png";
|
||||
import multiplayerImage from "@/assets/multiplayer.png";
|
||||
import settingsImage from "@/assets/settings.png";
|
||||
import { AlphaWarningModal } from "./AlphaWarningModal";
|
||||
import { Power } from "lucide-react";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
|
||||
interface MenuScreenProps {
|
||||
onStartSingleplayer: () => void;
|
||||
onExit: () => void;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function MenuScreen({ onStartSingleplayer, onExit, isVisible }: MenuScreenProps) {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Reset transition state when menu becomes visible
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setIsTransitioning(false);
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
const handleSingleplayerClick = () => {
|
||||
setIsTransitioning(true);
|
||||
// Start the fade out animation
|
||||
setTimeout(() => {
|
||||
onStartSingleplayer();
|
||||
}, 500); // Match the CSS transition duration
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className={`menu-screen block-game-input ${isTransitioning ? "fade-out" : ""}`}>
|
||||
<div className="menu-background">
|
||||
<img src={screenshotUrl} alt="Menu background" className="background-image" />
|
||||
<div className="background-overlay"></div>
|
||||
</div>
|
||||
|
||||
<OverlayScrollbarsComponent className="menu-content" defer options={{ scrollbars: { autoHide: "scroll" } }}>
|
||||
<div className="title-section">
|
||||
<h1 className="game-title">Iron Borders</h1>
|
||||
<p className="game-subtitle">Strategic Territory Control</p>
|
||||
</div>
|
||||
|
||||
<div className="menu-islands">
|
||||
<button className="island-button island-local" onClick={handleSingleplayerClick} disabled={isTransitioning}>
|
||||
<img src={singleplayerImage} alt="" className="island-background-image" />
|
||||
<div className="island-content">
|
||||
<h2 className="island-title">Local</h2>
|
||||
<p className="island-description">Battle against AI opponents in strategic territorial warfare</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="island-right-column">
|
||||
<button className="island-button island-multiplayer" disabled={true}>
|
||||
<img src={multiplayerImage} alt="" className="island-background-image" />
|
||||
<div className="island-content">
|
||||
<h2 className="island-title">Multiplayer</h2>
|
||||
<p className="island-description">Challenge other players in real-time matches</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="island-button island-settings" disabled={true}>
|
||||
<img src={settingsImage} alt="" className="island-background-image" />
|
||||
<div className="island-content">
|
||||
<h2 className="island-title">Settings</h2>
|
||||
<p className="island-description">Customize your experience</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!__DESKTOP__ && (
|
||||
<button className="island-button island-downloads" disabled={true}>
|
||||
<div className="island-content">
|
||||
<h2 className="island-title">Downloads</h2>
|
||||
<p className="island-description">Get standalone versions for Windows, Mac, and Linux</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
<div className="menu-footer">
|
||||
<div className="footer-left">
|
||||
{__DESKTOP__ && (
|
||||
<button className="exit-button" onClick={onExit}>
|
||||
<Power size={40} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<button className="version-warning-button" onClick={() => setIsModalOpen(true)}>
|
||||
<span>{__APP_VERSION__ === "0.0.0" ? "Unknown Version" : `v${__APP_VERSION__.trimEnd()}`}</span>
|
||||
<span className="version-warning-button-asterisk">*</span>
|
||||
</button>
|
||||
<div className="copyright">
|
||||
<a href="https://github.com/Xevion" target="_blank">
|
||||
© 2025 Ryan Walters
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlphaWarningModal open={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/shared/components/SpawnPhaseOverlay.css
Normal file
67
frontend/src/shared/components/SpawnPhaseOverlay.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.spawn-phase-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Timeout bar - centered, shrinks from both edges */
|
||||
.spawn-timeout-bar-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 87.5%;
|
||||
max-width: 900px;
|
||||
height: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 0 0 4px 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spawn-timeout-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #8bc34a);
|
||||
border-radius: 0 0 4px 4px;
|
||||
margin: 0 auto; /* Center the fill so it shrinks from both edges */
|
||||
}
|
||||
|
||||
/* Instructions - top center */
|
||||
.spawn-instructions {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spawn-title {
|
||||
margin: 0;
|
||||
font-family: "Oswald Variable", sans-serif;
|
||||
font-size: 48px;
|
||||
line-height: 0.85;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-shadow:
|
||||
2px 2px 5px rgba(0, 0, 0, 0.6),
|
||||
0 0 20px rgba(0, 0, 0, 0.4);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.spawn-subtitle {
|
||||
font-family: "Inter Variable", sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 12px 0 0;
|
||||
font-size: 18px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
text-shadow:
|
||||
1px 1px 1px rgba(0, 0, 0, 0.6),
|
||||
0 0 12px rgba(0, 0, 0, 0.4);
|
||||
max-width: 600px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
58
frontend/src/shared/components/SpawnPhaseOverlay.tsx
Normal file
58
frontend/src/shared/components/SpawnPhaseOverlay.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import "@fontsource-variable/oswald";
|
||||
import "./SpawnPhaseOverlay.css";
|
||||
|
||||
interface SpawnPhaseOverlayProps {
|
||||
isVisible: boolean;
|
||||
countdown: { startedAtMs: number; durationSecs: number } | null;
|
||||
}
|
||||
|
||||
export function SpawnPhaseOverlay({ isVisible, countdown }: SpawnPhaseOverlayProps) {
|
||||
const [progress, setProgress] = useState(1.0); // 1.0 = full, 0.0 = empty
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !countdown) {
|
||||
setProgress(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const nowMs = Date.now();
|
||||
const elapsedMs = nowMs - countdown.startedAtMs;
|
||||
const elapsedSecs = elapsedMs / 1000;
|
||||
const remaining = Math.max(0, countdown.durationSecs - elapsedSecs);
|
||||
const newProgress = remaining / countdown.durationSecs;
|
||||
|
||||
setProgress(newProgress);
|
||||
|
||||
if (newProgress > 0) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const frameId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [isVisible, countdown]);
|
||||
|
||||
// Hide overlay when explicitly hidden or when countdown reaches 0
|
||||
if (!isVisible || progress <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="spawn-phase-overlay">
|
||||
{/* Timeout bar at top */}
|
||||
<div className="spawn-timeout-bar-container">
|
||||
<div className="spawn-timeout-bar-fill" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="spawn-instructions">
|
||||
<h1 className="spawn-title">Pick Your Spawn</h1>
|
||||
<p className="spawn-subtitle">
|
||||
Click anywhere on the map to place your starting territory.
|
||||
<br />
|
||||
You can change your mind before the timer expires.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/shared/hooks/index.ts
Normal file
1
frontend/src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useThrottledCallback } from "./useThrottledCallback";
|
||||
75
frontend/src/shared/hooks/useThrottledCallback.ts
Normal file
75
frontend/src/shared/hooks/useThrottledCallback.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Creates a throttled callback with trailing edge behavior.
|
||||
* Ensures the last call is executed after the throttle period elapses.
|
||||
*
|
||||
* @param callback - The function to throttle
|
||||
* @param delay - The throttle delay in milliseconds
|
||||
* @returns A throttled version of the callback
|
||||
*/
|
||||
export function useThrottledCallback<T extends (...args: any[]) => void>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const lastCallTimeRef = useRef<number>(0);
|
||||
const pendingTimeoutRef = useRef<number | null>(null);
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
// Keep callback ref updated
|
||||
callbackRef.current = callback;
|
||||
|
||||
const throttledCallback = useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCall = now - lastCallTimeRef.current;
|
||||
|
||||
const executeCallback = () => {
|
||||
callbackRef.current(...args);
|
||||
};
|
||||
|
||||
if (timeSinceLastCall >= delay) {
|
||||
// Execute immediately if throttle period has elapsed
|
||||
executeCallback();
|
||||
lastCallTimeRef.current = now;
|
||||
|
||||
// Clear any pending timeout since we just executed
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Schedule execution after the remaining throttle period
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
clearTimeout(pendingTimeoutRef.current);
|
||||
}
|
||||
|
||||
const remainingTime = delay - timeSinceLastCall;
|
||||
pendingTimeoutRef.current = window.setTimeout(() => {
|
||||
executeCallback();
|
||||
lastCallTimeRef.current = Date.now();
|
||||
pendingTimeoutRef.current = null;
|
||||
}, remainingTime);
|
||||
}
|
||||
},
|
||||
[delay]
|
||||
) as T;
|
||||
|
||||
// Cleanup on unmount
|
||||
const cleanupRef = useRef(() => {
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Update cleanup function
|
||||
cleanupRef.current = () => {
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return throttledCallback;
|
||||
}
|
||||
377
frontend/src/shared/render/CameraController.ts
Normal file
377
frontend/src/shared/render/CameraController.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { Container } from "pixi.js";
|
||||
|
||||
export interface CameraStateListener {
|
||||
onCameraUpdate(x: number, y: number, zoom: number): void;
|
||||
}
|
||||
|
||||
export class CameraController {
|
||||
private container: Container;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private isDragging = false;
|
||||
private lastMouseX = 0;
|
||||
private lastMouseY = 0;
|
||||
private mouseDownX = 0;
|
||||
private mouseDownY = 0;
|
||||
private hasDragged = false;
|
||||
private scale = 1;
|
||||
private minScale = 0.5;
|
||||
private maxScale = 5;
|
||||
private listener?: CameraStateListener;
|
||||
|
||||
// Zoom sensitivity configuration
|
||||
private readonly BASE_ZOOM_SENSITIVITY = 0.15; // Base: 10% zoom per scroll
|
||||
private readonly ZOOM_IN_SENSITIVITY = 1.1; // 90% of base (10% less sensitive)
|
||||
private readonly ZOOM_OUT_SENSITIVITY = 0.9; // 120% of base (20% more sensitive)
|
||||
|
||||
// Smooth interpolation state
|
||||
private targetX = 0;
|
||||
private targetY = 0;
|
||||
private targetScale = 1;
|
||||
private lerpFactor = 0.15; // Smoothness factor (0.1-0.2 is good range)
|
||||
private isAnimating = false;
|
||||
|
||||
// Animation loop control
|
||||
private animationFrameId: number | null = null;
|
||||
|
||||
// Event listener references for cleanup
|
||||
private wheelHandler?: (e: WheelEvent) => void;
|
||||
private mouseDownHandler?: (e: MouseEvent) => void;
|
||||
private mouseMoveHandler?: (e: MouseEvent) => void;
|
||||
private mouseUpHandler?: () => void;
|
||||
|
||||
// Track if window listeners are attached (during drag)
|
||||
private windowListenersAttached = false;
|
||||
|
||||
// Throttle camera state updates to reduce IPC overhead
|
||||
private lastListenerNotifyTime = 0;
|
||||
private readonly LISTENER_NOTIFY_THROTTLE_MS = 100; // Max 10 updates/second
|
||||
|
||||
constructor(container: Container, canvas: HTMLCanvasElement) {
|
||||
this.container = container;
|
||||
this.canvas = canvas;
|
||||
this.setupEventListeners(canvas);
|
||||
this.startAnimationLoop();
|
||||
}
|
||||
|
||||
setStateListener(listener: CameraStateListener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
private notifyListener(force: boolean = false) {
|
||||
if (!this.listener) return;
|
||||
|
||||
// Throttle notifications to reduce IPC overhead (unless forced)
|
||||
const now = Date.now();
|
||||
if (!force && now - this.lastListenerNotifyTime < this.LISTENER_NOTIFY_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
this.lastListenerNotifyTime = now;
|
||||
|
||||
this.listener.onCameraUpdate(this.container.x, this.container.y, this.scale);
|
||||
}
|
||||
|
||||
private startAnimationLoop() {
|
||||
// Animation loop is started on demand, not continuously
|
||||
// This is handled by updateSmooth() which requests next frame if needed
|
||||
}
|
||||
|
||||
private lerp(current: number, target: number, factor: number): number {
|
||||
return current + (target - current) * factor;
|
||||
}
|
||||
|
||||
private updateSmooth() {
|
||||
const threshold = 0.01; // Stop animating when close enough
|
||||
|
||||
// Interpolate position
|
||||
const oldX = this.container.x;
|
||||
const oldY = this.container.y;
|
||||
this.container.x = this.lerp(this.container.x, this.targetX, this.lerpFactor);
|
||||
this.container.y = this.lerp(this.container.y, this.targetY, this.lerpFactor);
|
||||
|
||||
// Interpolate scale
|
||||
const oldScale = this.scale;
|
||||
this.scale = this.lerp(this.scale, this.targetScale, this.lerpFactor);
|
||||
this.container.scale.set(this.scale);
|
||||
|
||||
// Check if we're close enough to stop animating
|
||||
const posChanged = Math.abs(this.container.x - oldX) > threshold || Math.abs(this.container.y - oldY) > threshold;
|
||||
const scaleChanged = Math.abs(this.scale - oldScale) > threshold;
|
||||
|
||||
if (posChanged || scaleChanged) {
|
||||
if (!this.isAnimating) {
|
||||
this.isAnimating = true;
|
||||
}
|
||||
this.notifyListener();
|
||||
// Continue animation
|
||||
this.animationFrameId = requestAnimationFrame(() => this.updateSmooth());
|
||||
} else if (this.isAnimating) {
|
||||
this.isAnimating = false;
|
||||
// Snap to final position
|
||||
this.container.x = this.targetX;
|
||||
this.container.y = this.targetY;
|
||||
this.scale = this.targetScale;
|
||||
this.container.scale.set(this.scale);
|
||||
this.notifyListener(true); // Force final update
|
||||
// Animation stopped - no need to request next frame
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAnimationRunning() {
|
||||
// Start animation loop if not already running
|
||||
if (this.animationFrameId === null) {
|
||||
this.animationFrameId = requestAnimationFrame(() => this.updateSmooth());
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners(canvas: HTMLCanvasElement) {
|
||||
// Mouse wheel zoom
|
||||
this.wheelHandler = (e: WheelEvent) => {
|
||||
// Skip zoom when shift is pressed (reserved for attack controls)
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const delta = -e.deltaY;
|
||||
const scaleFactor =
|
||||
delta > 0
|
||||
? 1 + this.BASE_ZOOM_SENSITIVITY * this.ZOOM_IN_SENSITIVITY // Zooming in: base * 0.9
|
||||
: 1 - this.BASE_ZOOM_SENSITIVITY * this.ZOOM_OUT_SENSITIVITY; // Zooming out: base * 1.2
|
||||
const newScale = this.targetScale * scaleFactor;
|
||||
|
||||
if (newScale >= this.minScale && newScale <= this.maxScale) {
|
||||
// Get mouse position relative to canvas
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Calculate world position before zoom using TARGET scale
|
||||
const worldX = (mouseX - this.targetX) / this.targetScale;
|
||||
const worldY = (mouseY - this.targetY) / this.targetScale;
|
||||
|
||||
// Update target scale
|
||||
this.targetScale = newScale;
|
||||
|
||||
// Adjust target position to keep mouse point stable
|
||||
this.targetX = mouseX - worldX * this.targetScale;
|
||||
this.targetY = mouseY - worldY * this.targetScale;
|
||||
|
||||
// Ensure animation loop is running
|
||||
this.ensureAnimationRunning();
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse down - prepare for potential drag
|
||||
this.mouseDownHandler = (e: MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
// Left mouse button only
|
||||
this.isDragging = true;
|
||||
this.hasDragged = false;
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
this.mouseDownX = e.clientX;
|
||||
this.mouseDownY = e.clientY;
|
||||
|
||||
// Attach window-level listeners to track mouse even over UI elements
|
||||
this.attachWindowListeners();
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse move - pan
|
||||
this.mouseMoveHandler = (e: MouseEvent) => {
|
||||
if (this.isDragging) {
|
||||
const dx = e.clientX - this.lastMouseX;
|
||||
const dy = e.clientY - this.lastMouseY;
|
||||
|
||||
// Check if user has moved enough to count as a drag (3px threshold)
|
||||
const totalMoved = Math.abs(e.clientX - this.mouseDownX) + Math.abs(e.clientY - this.mouseDownY);
|
||||
if (totalMoved > 3 && !this.hasDragged) {
|
||||
this.hasDragged = true;
|
||||
canvas.style.cursor = "grabbing";
|
||||
}
|
||||
|
||||
// Update target position for smooth dragging
|
||||
this.targetX += dx;
|
||||
this.targetY += dy;
|
||||
|
||||
// Also update current position directly for responsive feel during drag
|
||||
this.container.x += dx;
|
||||
this.container.y += dy;
|
||||
|
||||
this.lastMouseX = e.clientX;
|
||||
this.lastMouseY = e.clientY;
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse up - stop dragging
|
||||
const stopDragging = () => {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
canvas.style.cursor = "default";
|
||||
this.notifyListener(true); // Force update when user stops dragging
|
||||
|
||||
// Remove window-level listeners
|
||||
this.detachWindowListeners();
|
||||
}
|
||||
};
|
||||
|
||||
this.mouseUpHandler = stopDragging;
|
||||
|
||||
canvas.addEventListener("wheel", this.wheelHandler);
|
||||
canvas.addEventListener("mousedown", this.mouseDownHandler);
|
||||
}
|
||||
|
||||
// Attach mousemove and mouseup to window during drag
|
||||
private attachWindowListeners() {
|
||||
if (this.windowListenersAttached) return;
|
||||
|
||||
if (this.mouseMoveHandler) {
|
||||
window.addEventListener("mousemove", this.mouseMoveHandler);
|
||||
}
|
||||
if (this.mouseUpHandler) {
|
||||
window.addEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
|
||||
this.windowListenersAttached = true;
|
||||
}
|
||||
|
||||
// Detach window listeners when drag ends
|
||||
private detachWindowListeners() {
|
||||
if (!this.windowListenersAttached) return;
|
||||
|
||||
if (this.mouseMoveHandler) {
|
||||
window.removeEventListener("mousemove", this.mouseMoveHandler);
|
||||
}
|
||||
if (this.mouseUpHandler) {
|
||||
window.removeEventListener("mouseup", this.mouseUpHandler);
|
||||
}
|
||||
|
||||
this.windowListenersAttached = false;
|
||||
}
|
||||
|
||||
// Check if the last interaction was a drag (for click filtering)
|
||||
public hadCameraInteraction(): boolean {
|
||||
return this.hasDragged;
|
||||
}
|
||||
|
||||
// Public methods for backend control
|
||||
centerOnTile(tileX: number, tileY: number, tileSize: number, animate: boolean = false) {
|
||||
// Convert tile coordinates to world position (center of tile)
|
||||
const worldX = (tileX + 0.5) * tileSize;
|
||||
const worldY = (tileY + 0.5) * tileSize;
|
||||
|
||||
// Center the camera on the world position
|
||||
const newX = this.canvas.width / 2 - worldX * this.scale;
|
||||
const newY = this.canvas.height / 2 - worldY * this.scale;
|
||||
|
||||
if (animate) {
|
||||
// Smooth animation - set target
|
||||
this.targetX = newX;
|
||||
this.targetY = newY;
|
||||
this.ensureAnimationRunning();
|
||||
} else {
|
||||
// Instant - set both current and target
|
||||
this.container.x = newX;
|
||||
this.container.y = newY;
|
||||
this.targetX = newX;
|
||||
this.targetY = newY;
|
||||
this.notifyListener(true); // Force update for programmatic camera control
|
||||
}
|
||||
}
|
||||
|
||||
setZoom(zoom: number, animate: boolean = false) {
|
||||
const newScale = Math.max(this.minScale, Math.min(this.maxScale, zoom));
|
||||
|
||||
if (animate) {
|
||||
// Smooth animation - set target
|
||||
this.targetScale = newScale;
|
||||
this.ensureAnimationRunning();
|
||||
} else {
|
||||
// Instant - set both current and target
|
||||
this.scale = newScale;
|
||||
this.targetScale = newScale;
|
||||
this.container.scale.set(this.scale);
|
||||
this.notifyListener(true); // Force update for programmatic camera control
|
||||
}
|
||||
}
|
||||
|
||||
panBy(dx: number, dy: number, animate: boolean = false) {
|
||||
if (animate) {
|
||||
// Smooth animation - add to target
|
||||
this.targetX += dx;
|
||||
this.targetY += dy;
|
||||
this.ensureAnimationRunning();
|
||||
} else {
|
||||
// Instant - add to both current and target
|
||||
this.container.x += dx;
|
||||
this.container.y += dy;
|
||||
this.targetX += dx;
|
||||
this.targetY += dy;
|
||||
this.notifyListener(true); // Force update for programmatic camera control
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.scale = 1;
|
||||
this.targetScale = 1;
|
||||
this.container.scale.set(1);
|
||||
this.container.x = 0;
|
||||
this.container.y = 0;
|
||||
this.targetX = 0;
|
||||
this.targetY = 0;
|
||||
this.notifyListener(true); // Force update on reset
|
||||
}
|
||||
|
||||
// Convert screen coordinates to world coordinates
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
||||
const worldX = (screenX - this.container.x) / this.scale;
|
||||
const worldY = (screenY - this.container.y) / this.scale;
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
|
||||
// Convert world coordinates to screen coordinates
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
|
||||
const screenX = worldX * this.scale + this.container.x;
|
||||
const screenY = worldY * this.scale + this.container.y;
|
||||
return { x: screenX, y: screenY };
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
x: this.container.x,
|
||||
y: this.container.y,
|
||||
zoom: this.scale,
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Reset cursor
|
||||
this.canvas.style.cursor = "default";
|
||||
|
||||
// Stop animation loop
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
// Remove window listeners if attached
|
||||
this.detachWindowListeners();
|
||||
|
||||
// Remove canvas event listeners
|
||||
if (this.wheelHandler) {
|
||||
this.canvas.removeEventListener("wheel", this.wheelHandler);
|
||||
}
|
||||
if (this.mouseDownHandler) {
|
||||
this.canvas.removeEventListener("mousedown", this.mouseDownHandler);
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.wheelHandler = undefined;
|
||||
this.mouseDownHandler = undefined;
|
||||
this.mouseMoveHandler = undefined;
|
||||
this.mouseUpHandler = undefined;
|
||||
this.listener = undefined;
|
||||
}
|
||||
}
|
||||
393
frontend/src/shared/render/GameRenderer.ts
Normal file
393
frontend/src/shared/render/GameRenderer.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Application, Container, Sprite, Texture, BufferImageSource } from "pixi.js";
|
||||
import { CameraController, CameraStateListener } from "@/shared/render/CameraController";
|
||||
import { TerritoryLayer } from "@/shared/render/TerritoryLayer";
|
||||
|
||||
export interface RendererConfig {
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
tileSize: number;
|
||||
}
|
||||
|
||||
export interface TileChange {
|
||||
index: number;
|
||||
owner_id: number;
|
||||
}
|
||||
|
||||
export interface TerrainData {
|
||||
width: number;
|
||||
height: number;
|
||||
terrain_data: Uint8Array | number[]; // Tile type IDs (u8 values from backend)
|
||||
}
|
||||
|
||||
export interface PaletteData {
|
||||
colors: Array<{ r: number; g: number; b: number }>;
|
||||
}
|
||||
|
||||
export interface TerrainPaletteData {
|
||||
colors: Array<{ r: number; g: number; b: number }>;
|
||||
}
|
||||
|
||||
export enum TerrainType {
|
||||
Water = 0,
|
||||
Land = 1,
|
||||
Mountain = 2,
|
||||
}
|
||||
|
||||
export class GameRenderer {
|
||||
private app!: Application;
|
||||
private mainContainer!: Container;
|
||||
private layersContainer!: Container;
|
||||
|
||||
private terrainLayer!: Container;
|
||||
private territoryLayer?: TerritoryLayer;
|
||||
|
||||
// Camera
|
||||
private cameraController!: CameraController;
|
||||
|
||||
// Map dimensions (set when terrain data is loaded)
|
||||
private mapWidth: number = 0;
|
||||
private mapHeight: number = 0;
|
||||
private tileSize: number = 1;
|
||||
|
||||
private isInitialized = false;
|
||||
|
||||
// Terrain data
|
||||
private terrainTexture?: Texture;
|
||||
private terrainSprite?: Sprite;
|
||||
private terrainPalette: Array<{ r: number; g: number; b: number }> = [];
|
||||
|
||||
// Window resize handler reference
|
||||
private resizeHandler?: () => void;
|
||||
|
||||
constructor() {
|
||||
// No config needed - dimensions will be set from terrain data
|
||||
}
|
||||
|
||||
async init(canvas: HTMLCanvasElement) {
|
||||
// Validate canvas dimensions
|
||||
const width = canvas.clientWidth || canvas.width || 800;
|
||||
const height = canvas.clientHeight || canvas.height || 600;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
throw new Error(
|
||||
`Canvas has invalid dimensions: ${width}x${height}. ` + `Ensure the canvas element and its parent have explicit dimensions.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Pixi application
|
||||
this.app = new Application();
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
backgroundColor: 0x1a1a2e, // Dark blue-grey background
|
||||
antialias: false,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
// Verify initialization succeeded
|
||||
if (!this.app.stage) {
|
||||
throw new Error("PixiJS Application failed to initialize - stage is null");
|
||||
}
|
||||
|
||||
// Create main container
|
||||
this.mainContainer = new Container();
|
||||
this.app.stage.addChild(this.mainContainer);
|
||||
|
||||
// Create layers container (this gets transformed by camera)
|
||||
this.layersContainer = new Container();
|
||||
this.layersContainer.sortableChildren = true; // Enable z-index sorting
|
||||
this.mainContainer.addChild(this.layersContainer);
|
||||
|
||||
// 1. Terrain layer (water/land/mountains) - created on init
|
||||
this.terrainLayer = new Container();
|
||||
this.terrainLayer.zIndex = 0; // Bottom layer
|
||||
this.layersContainer.addChild(this.terrainLayer);
|
||||
|
||||
// 2. Territory colors layer - will be created when terrain data arrives
|
||||
// (will be added with zIndex = 1 when created)
|
||||
|
||||
// Initialize camera controller
|
||||
this.cameraController = new CameraController(this.layersContainer, canvas);
|
||||
|
||||
// Handle window resize
|
||||
this.resizeHandler = () => this.handleResize();
|
||||
window.addEventListener("resize", this.resizeHandler);
|
||||
|
||||
// Mark as initialized
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
const canvas = this.app.canvas as HTMLCanvasElement;
|
||||
const parent = canvas.parentElement;
|
||||
if (parent) {
|
||||
const width = parent.clientWidth;
|
||||
const height = parent.clientHeight;
|
||||
|
||||
this.app.renderer.resize(width, height);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// Set terrain palette colors
|
||||
setTerrainPalette(palette: TerrainPaletteData): void {
|
||||
this.terrainPalette = palette.colors;
|
||||
}
|
||||
|
||||
// Initialize terrain from backend data
|
||||
initTerrain(data: TerrainData): void {
|
||||
if (!this.isInitialized) {
|
||||
console.error("FATAL: Renderer not initialized - aborting terrain init");
|
||||
throw new Error("Renderer not initialized");
|
||||
}
|
||||
|
||||
if (!this.app) {
|
||||
console.error("FATAL: PixiJS app is null");
|
||||
throw new Error("PixiJS app is null");
|
||||
}
|
||||
|
||||
if (!this.app.renderer) {
|
||||
console.error("FATAL: PixiJS renderer is null");
|
||||
throw new Error("PixiJS renderer is null");
|
||||
}
|
||||
|
||||
if (this.terrainPalette.length === 0) {
|
||||
console.error("FATAL: Terrain palette not set - call setTerrainPalette first");
|
||||
throw new Error("Terrain palette not set");
|
||||
}
|
||||
|
||||
const { width, height, terrain_data } = data;
|
||||
|
||||
// Set map dimensions from terrain data
|
||||
this.mapWidth = width;
|
||||
this.mapHeight = height;
|
||||
|
||||
// Normalize terrain_data to Uint8Array for consistent handling
|
||||
const normalizedTerrainData =
|
||||
terrain_data instanceof Uint8Array ? terrain_data : new Uint8Array(terrain_data);
|
||||
|
||||
// Create territory layer if it doesn't exist
|
||||
if (!this.territoryLayer) {
|
||||
this.territoryLayer = new TerritoryLayer(this.mapWidth, this.mapHeight, this.tileSize);
|
||||
this.territoryLayer.container.zIndex = 1; // Above terrain layer
|
||||
this.layersContainer.addChild(this.territoryLayer.container);
|
||||
}
|
||||
|
||||
// Create terrain texture from tile type IDs
|
||||
const imageData = new Uint8Array(width * height * 4); // RGBA
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const tileTypeId = normalizedTerrainData[i];
|
||||
|
||||
// Get color from terrain palette using tile type ID as index
|
||||
const color = this.terrainPalette[tileTypeId] || {
|
||||
r: 100,
|
||||
g: 100,
|
||||
b: 100,
|
||||
};
|
||||
|
||||
const pixelIndex = i * 4;
|
||||
imageData[pixelIndex] = color.r;
|
||||
imageData[pixelIndex + 1] = color.g;
|
||||
imageData[pixelIndex + 2] = color.b;
|
||||
imageData[pixelIndex + 3] = 255; // Alpha
|
||||
}
|
||||
|
||||
// Clean up old terrain texture if it exists
|
||||
if (this.terrainTexture) {
|
||||
this.terrainTexture.destroy(true); // Destroy texture and base texture
|
||||
this.terrainTexture = undefined;
|
||||
}
|
||||
|
||||
// Clean up old terrain sprite if it exists
|
||||
if (this.terrainSprite) {
|
||||
this.terrainSprite.destroy();
|
||||
this.terrainSprite = undefined;
|
||||
}
|
||||
|
||||
// Clear terrain layer to prevent duplicate sprites
|
||||
this.terrainLayer.removeChildren();
|
||||
|
||||
const bufferSource = new BufferImageSource({
|
||||
resource: imageData,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Create the texture
|
||||
this.terrainTexture = new Texture({ source: bufferSource });
|
||||
|
||||
// Use nearest neighbor filtering for crisp pixel edges
|
||||
this.terrainTexture.source.scaleMode = "nearest";
|
||||
|
||||
// Mark source as needing update
|
||||
bufferSource.update();
|
||||
|
||||
// Create sprite with the texture (now guaranteed to be uploaded)
|
||||
this.terrainSprite = new Sprite(this.terrainTexture);
|
||||
|
||||
// Set dimensions explicitly (don't use width/height setters which can cause issues)
|
||||
this.terrainSprite.scale.set(
|
||||
(width * this.tileSize) / this.terrainTexture.width,
|
||||
(height * this.tileSize) / this.terrainTexture.height,
|
||||
);
|
||||
|
||||
// Center the terrain
|
||||
this.terrainSprite.anchor.set(0.5, 0.5);
|
||||
this.terrainSprite.x = 0;
|
||||
this.terrainSprite.y = 0;
|
||||
|
||||
// Ensure sprite is visible and rendered
|
||||
this.terrainSprite.alpha = 1.0;
|
||||
this.terrainSprite.visible = true;
|
||||
this.terrainSprite.renderable = true;
|
||||
|
||||
// Disable culling to prevent disappearing at certain zoom levels
|
||||
this.terrainSprite.cullable = false;
|
||||
|
||||
this.terrainLayer.addChild(this.terrainSprite);
|
||||
|
||||
// Force an immediate render to ensure texture is processed
|
||||
if (this.app.renderer) {
|
||||
this.app.renderer.render(this.app.stage);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize palette for territory colors
|
||||
initPalette(data: PaletteData) {
|
||||
if (!this.territoryLayer) {
|
||||
console.error("Cannot initialize palette - territory layer not created yet");
|
||||
throw new Error("Territory layer not initialized");
|
||||
}
|
||||
this.territoryLayer.setPalette(data.colors);
|
||||
}
|
||||
|
||||
// Apply full territory snapshot (initial state - sparse format)
|
||||
applyTerritorySnapshot(_turn: number, territories: Array<{ index: number; owner_id: number }>) {
|
||||
if (!this.territoryLayer) {
|
||||
console.error("Cannot apply territory snapshot - territory layer not created yet");
|
||||
return;
|
||||
}
|
||||
this.territoryLayer.applySnapshot(territories);
|
||||
}
|
||||
|
||||
// Update territory ownership
|
||||
updateTerritoryDelta(_turn: number, changes: TileChange[]) {
|
||||
if (!this.territoryLayer) {
|
||||
console.error("Cannot update territory delta - territory layer not created yet");
|
||||
return;
|
||||
}
|
||||
this.territoryLayer.applyDelta(changes);
|
||||
}
|
||||
|
||||
// Handle binary territory delta (for Tauri)
|
||||
updateBinaryDelta(data: Uint8Array) {
|
||||
// Decode binary format: [turn:8][count:4][changes...]
|
||||
if (data.length < 12) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const turn = view.getBigUint64(0, true);
|
||||
const count = view.getUint32(8, true);
|
||||
|
||||
const expectedSize = 12 + count * 6;
|
||||
if (data.length !== expectedSize) {
|
||||
console.error(`Invalid binary delta size: expected ${expectedSize}, got ${data.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const changes: TileChange[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = 12 + i * 6;
|
||||
const index = view.getUint32(offset, true);
|
||||
const owner_id = view.getUint16(offset + 4, true);
|
||||
changes.push({ index, owner_id });
|
||||
}
|
||||
|
||||
this.updateTerritoryDelta(Number(turn), changes);
|
||||
}
|
||||
|
||||
// Camera control methods
|
||||
setCameraListener(listener: CameraStateListener) {
|
||||
this.cameraController.setStateListener(listener);
|
||||
}
|
||||
|
||||
centerOnTile(tileIndex: number, animate: boolean = false) {
|
||||
const x = tileIndex % this.mapWidth;
|
||||
const y = Math.floor(tileIndex / this.mapWidth);
|
||||
this.cameraController.centerOnTile(x, y, this.tileSize, animate);
|
||||
}
|
||||
|
||||
setZoom(zoom: number, animate: boolean = false) {
|
||||
this.cameraController.setZoom(zoom, animate);
|
||||
}
|
||||
|
||||
panBy(dx: number, dy: number, animate: boolean = false) {
|
||||
this.cameraController.panBy(dx, dy, animate);
|
||||
}
|
||||
|
||||
getCameraState() {
|
||||
return this.cameraController.getState();
|
||||
}
|
||||
|
||||
// Check if the last interaction was a camera drag
|
||||
hadCameraInteraction(): boolean {
|
||||
return this.cameraController.hadCameraInteraction();
|
||||
}
|
||||
|
||||
// Convert screen position to tile index
|
||||
screenToTile(screenX: number, screenY: number): number | null {
|
||||
const worldPos = this.cameraController.screenToWorld(screenX, screenY);
|
||||
|
||||
// Adjust for centered map
|
||||
const adjustedX = worldPos.x + (this.mapWidth * this.tileSize) / 2;
|
||||
const adjustedY = worldPos.y + (this.mapHeight * this.tileSize) / 2;
|
||||
|
||||
const tileX = Math.floor(adjustedX / this.tileSize);
|
||||
const tileY = Math.floor(adjustedY / this.tileSize);
|
||||
|
||||
if (tileX >= 0 && tileX < this.mapWidth && tileY >= 0 && tileY < this.mapHeight) {
|
||||
return tileY * this.mapWidth + tileX;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert tile index to world position
|
||||
tileToWorld(tileIndex: number): { x: number; y: number } {
|
||||
const x = tileIndex % this.mapWidth;
|
||||
const y = Math.floor(tileIndex / this.mapWidth);
|
||||
|
||||
// Center of tile in world coordinates
|
||||
const worldX = (x + 0.5) * this.tileSize - (this.mapWidth * this.tileSize) / 2;
|
||||
const worldY = (y + 0.5) * this.tileSize - (this.mapHeight * this.tileSize) / 2;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
|
||||
// Get nation ID at a tile index
|
||||
getNationAtTile(tileIndex: number | null): number | null {
|
||||
if (tileIndex === null || !this.territoryLayer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tileX = tileIndex % this.mapWidth;
|
||||
const tileY = Math.floor(tileIndex / this.mapWidth);
|
||||
const nationId = this.territoryLayer.getOwnerAt(tileX, tileY);
|
||||
|
||||
// Nation IDs: 0-65533 (valid), 65534 (unclaimed), 65535 (water)
|
||||
return nationId < 65534 ? nationId : null;
|
||||
}
|
||||
|
||||
// Set highlighted nation
|
||||
setHighlightedNation(nationId: number | null) {
|
||||
this.territoryLayer?.setHighlightedNation(nationId);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
destroy() {}
|
||||
}
|
||||
407
frontend/src/shared/render/TerritoryLayer.ts
Normal file
407
frontend/src/shared/render/TerritoryLayer.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Container, Sprite, Texture, BufferImageSource, Filter, GlProgram } from "pixi.js";
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
// Vertex shader - PixiJS v8 standard filter vertex shader (GLSL 100 ES)
|
||||
const TERRITORY_VERTEX_SHADER = `
|
||||
attribute vec2 aPosition;
|
||||
|
||||
varying vec2 vTextureCoord;
|
||||
varying vec2 vFilterCoord;
|
||||
|
||||
uniform vec4 uInputSize;
|
||||
uniform vec4 uOutputFrame;
|
||||
uniform vec4 uOutputTexture;
|
||||
|
||||
vec4 filterVertexPosition() {
|
||||
vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
|
||||
position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
|
||||
position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z;
|
||||
return vec4(position, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec2 filterTextureCoord() {
|
||||
return aPosition * (uOutputFrame.zw * uInputSize.zw);
|
||||
}
|
||||
|
||||
void main() {
|
||||
gl_Position = filterVertexPosition();
|
||||
vTextureCoord = filterTextureCoord();
|
||||
vFilterCoord = vTextureCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
const TERRITORY_FRAGMENT_SHADER = `
|
||||
precision mediump float;
|
||||
|
||||
varying vec2 vTextureCoord;
|
||||
|
||||
uniform sampler2D uTexture;
|
||||
uniform vec2 uMapSize;
|
||||
uniform sampler2D uPalette;
|
||||
uniform float uHighlightedNation;
|
||||
|
||||
// Decode u16 from RG8 (R=low byte, G=high byte)
|
||||
float decodeU16(vec2 rg) {
|
||||
return rg.r * 255.0 + rg.g * 65280.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 center = texture2D(uTexture, vTextureCoord);
|
||||
float centerOwner = decodeU16(center.rg);
|
||||
|
||||
// Unclaimed = 65534, Water = 65535
|
||||
if (centerOwner >= 65534.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec2 texelSize = 1.0 / uMapSize;
|
||||
|
||||
bool isBorder = false;
|
||||
for (float dist = 1.0; dist <= 2.0; dist += 1.0) {
|
||||
vec2 offset = texelSize * dist;
|
||||
|
||||
float left = decodeU16(texture2D(uTexture, vTextureCoord + vec2(-offset.x, 0.0)).rg);
|
||||
float right = decodeU16(texture2D(uTexture, vTextureCoord + vec2(offset.x, 0.0)).rg);
|
||||
float top = decodeU16(texture2D(uTexture, vTextureCoord + vec2(0.0, -offset.y)).rg);
|
||||
float bottom = decodeU16(texture2D(uTexture, vTextureCoord + vec2(0.0, offset.y)).rg);
|
||||
|
||||
if (centerOwner != left || centerOwner != right ||
|
||||
centerOwner != top || centerOwner != bottom) {
|
||||
isBorder = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Modulo wraps nation IDs into palette size
|
||||
float paletteIndex = mod(centerOwner, 256.0) / 256.0;
|
||||
vec4 territoryColor = texture2D(uPalette, vec2(paletteIndex, 0.5));
|
||||
|
||||
// Highlighting logic
|
||||
bool isHighlighting = uHighlightedNation >= 0.0;
|
||||
bool isHighlighted = (isHighlighting && centerOwner == uHighlightedNation);
|
||||
|
||||
float alpha = isBorder ? 0.85 : 0.60;
|
||||
|
||||
if (isBorder) {
|
||||
if (isHighlighted) {
|
||||
territoryColor.rgb = vec3(255, 255, 255);
|
||||
} else {
|
||||
territoryColor.rgb *= 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(territoryColor.rgb, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export class TerritoryLayer {
|
||||
public container: Container;
|
||||
|
||||
private sprite!: Sprite;
|
||||
private texture!: Texture;
|
||||
private bufferSource!: BufferImageSource;
|
||||
|
||||
private ownerData: Uint16Array;
|
||||
private textureData: Uint8Array;
|
||||
|
||||
private paletteTexture?: Texture;
|
||||
private paletteSource?: BufferImageSource;
|
||||
private paletteData: Uint8Array;
|
||||
|
||||
// Shader/filter
|
||||
private territoryFilter?: Filter;
|
||||
|
||||
// Dirty region tracking for partial updates
|
||||
private isDirty: boolean = false;
|
||||
private dirtyMinX: number = 0;
|
||||
private dirtyMinY: number = 0;
|
||||
private dirtyMaxX: number = 0;
|
||||
private dirtyMaxY: number = 0;
|
||||
|
||||
// Batching state
|
||||
private pendingChanges: Array<{ index: number; owner_id: number }> = [];
|
||||
private updateScheduled: boolean = false;
|
||||
|
||||
private width: number;
|
||||
private height: number;
|
||||
private tileSize: number;
|
||||
|
||||
constructor(width: number, height: number, tileSize: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.tileSize = tileSize;
|
||||
|
||||
this.container = new Container();
|
||||
|
||||
this.ownerData = new Uint16Array(width * height);
|
||||
this.textureData = new Uint8Array(width * height * 2);
|
||||
this.paletteData = new Uint8Array(256 * 4);
|
||||
|
||||
this.initTexture();
|
||||
}
|
||||
|
||||
private initTexture() {
|
||||
// RG8: R=low byte, G=high byte
|
||||
this.textureData.fill(0);
|
||||
|
||||
this.bufferSource = new BufferImageSource({
|
||||
resource: this.textureData,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
format: "rg8unorm",
|
||||
});
|
||||
|
||||
this.texture = new Texture({ source: this.bufferSource });
|
||||
this.texture.source.scaleMode = "nearest";
|
||||
this.texture.source.update();
|
||||
|
||||
this.sprite = new Sprite(this.texture);
|
||||
this.sprite.scale.set((this.width * this.tileSize) / this.texture.width, (this.height * this.tileSize) / this.texture.height);
|
||||
this.sprite.anchor.set(0.5, 0.5);
|
||||
this.sprite.x = 0;
|
||||
this.sprite.y = 0;
|
||||
this.sprite.alpha = 1.0;
|
||||
this.sprite.visible = true;
|
||||
this.sprite.renderable = true;
|
||||
this.sprite.cullable = false;
|
||||
this.sprite.blendMode = "screen";
|
||||
|
||||
this.container.addChild(this.sprite);
|
||||
}
|
||||
|
||||
setPalette(colors: Array<RgbColor>) {
|
||||
for (let i = 0; i < Math.min(colors.length, 256); i++) {
|
||||
const color = colors[i];
|
||||
const pixelIndex = i * 4;
|
||||
this.paletteData[pixelIndex] = color.r;
|
||||
this.paletteData[pixelIndex + 1] = color.g;
|
||||
this.paletteData[pixelIndex + 2] = color.b;
|
||||
this.paletteData[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
if (!this.paletteSource) {
|
||||
this.paletteSource = new BufferImageSource({
|
||||
resource: this.paletteData,
|
||||
width: 256,
|
||||
height: 1,
|
||||
});
|
||||
this.paletteTexture = new Texture({ source: this.paletteSource });
|
||||
} else {
|
||||
this.paletteSource.update();
|
||||
}
|
||||
|
||||
this.setupShader();
|
||||
}
|
||||
|
||||
applySnapshot(territories: Array<{ index: number; owner_id: number }>) {
|
||||
// Clear all tiles to unclaimed (0)
|
||||
this.ownerData.fill(0);
|
||||
this.textureData.fill(0);
|
||||
|
||||
// Apply claimed tiles from sparse snapshot
|
||||
for (const tile of territories) {
|
||||
const { index, owner_id } = tile;
|
||||
|
||||
if (index >= 0 && index < this.ownerData.length) {
|
||||
this.ownerData[index] = owner_id;
|
||||
|
||||
const texIndex = index * 2;
|
||||
this.textureData[texIndex] = owner_id & 0xff;
|
||||
this.textureData[texIndex + 1] = (owner_id >> 8) & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
this.isDirty = true;
|
||||
this.dirtyMinX = 0;
|
||||
this.dirtyMinY = 0;
|
||||
this.dirtyMaxX = this.width - 1;
|
||||
this.dirtyMaxY = this.height - 1;
|
||||
|
||||
this.updateTexture();
|
||||
}
|
||||
|
||||
applyDelta(changes: Array<{ index: number; owner_id: number }>) {
|
||||
if (changes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingChanges.push(...changes);
|
||||
|
||||
if (!this.updateScheduled) {
|
||||
this.updateScheduled = true;
|
||||
requestAnimationFrame(() => this.processPendingChanges());
|
||||
}
|
||||
}
|
||||
|
||||
private processPendingChanges() {
|
||||
this.updateScheduled = false;
|
||||
|
||||
if (this.pendingChanges.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const OWNER_WATER = 65535;
|
||||
|
||||
let minX = this.width;
|
||||
let minY = this.height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
for (const change of this.pendingChanges) {
|
||||
if (change.index >= 0 && change.index < this.ownerData.length) {
|
||||
const value = change.owner_id;
|
||||
|
||||
if (value === OWNER_WATER) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.ownerData[change.index] = value;
|
||||
|
||||
const texIndex = change.index * 2;
|
||||
this.textureData[texIndex] = value & 0xff;
|
||||
this.textureData[texIndex + 1] = (value >> 8) & 0xff;
|
||||
|
||||
const x = change.index % this.width;
|
||||
const y = Math.floor(change.index / this.width);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingChanges = [];
|
||||
|
||||
if (maxX >= minX && maxY >= minY) {
|
||||
this.isDirty = true;
|
||||
this.dirtyMinX = minX;
|
||||
this.dirtyMinY = minY;
|
||||
this.dirtyMaxX = maxX;
|
||||
this.dirtyMaxY = maxY;
|
||||
this.updateTexture();
|
||||
}
|
||||
}
|
||||
|
||||
private updateTexture() {
|
||||
if (!this.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = this.bufferSource.resource as Uint8Array;
|
||||
const gl = (this.texture.source as any)._glTextures?.[0]?.gl;
|
||||
const glTexture = (this.texture.source as any)._glTextures?.[0]?.texture;
|
||||
|
||||
// If GL context not ready, fall back to full upload
|
||||
if (!gl || !glTexture) {
|
||||
this.bufferSource.update();
|
||||
this.isDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate dirty region dimensions
|
||||
const regionWidth = this.dirtyMaxX - this.dirtyMinX + 1;
|
||||
const regionHeight = this.dirtyMaxY - this.dirtyMinY + 1;
|
||||
const totalPixels = this.width * this.height;
|
||||
const dirtyPixels = regionWidth * regionHeight;
|
||||
|
||||
// Use partial update if dirty region is small (< 25% of texture)
|
||||
if (dirtyPixels < totalPixels * 0.25) {
|
||||
// Extract dirty region into contiguous buffer (R8 format)
|
||||
const regionData = new Uint8Array(regionWidth * regionHeight);
|
||||
|
||||
for (let y = 0; y < regionHeight; y++) {
|
||||
const srcY = this.dirtyMinY + y;
|
||||
const srcOffset = srcY * this.width + this.dirtyMinX;
|
||||
const dstOffset = y * regionWidth;
|
||||
|
||||
// Copy one row at a time (single channel)
|
||||
regionData.set(imageData.subarray(srcOffset, srcOffset + regionWidth), dstOffset);
|
||||
}
|
||||
|
||||
// Upload dirty region only
|
||||
gl.bindTexture(gl.TEXTURE_2D, glTexture);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0, // mip level
|
||||
this.dirtyMinX,
|
||||
this.dirtyMinY,
|
||||
regionWidth,
|
||||
regionHeight,
|
||||
gl.RED,
|
||||
gl.UNSIGNED_BYTE,
|
||||
regionData,
|
||||
);
|
||||
} else {
|
||||
// Dirty region is large, upload full texture
|
||||
this.bufferSource.update();
|
||||
}
|
||||
|
||||
// Clear dirty flag
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
private setupShader() {
|
||||
if (!this.paletteTexture) {
|
||||
console.warn("Cannot setup shader: palette texture not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create filter with the territory shader
|
||||
this.territoryFilter = new Filter({
|
||||
glProgram: new GlProgram({
|
||||
vertex: TERRITORY_VERTEX_SHADER,
|
||||
fragment: TERRITORY_FRAGMENT_SHADER,
|
||||
}),
|
||||
resources: {
|
||||
// uTexture is auto-bound to sprite texture
|
||||
uPalette: this.paletteTexture.source,
|
||||
territoryUniforms: {
|
||||
uMapSize: { value: [this.width, this.height], type: "vec2<f32>" },
|
||||
uHighlightedNation: { value: -1, type: "f32" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Apply filter to sprite
|
||||
this.sprite.filters = [this.territoryFilter];
|
||||
}
|
||||
|
||||
// Get owner at specific tile
|
||||
getOwnerAt(tileX: number, tileY: number): number {
|
||||
if (tileX >= 0 && tileX < this.width && tileY >= 0 && tileY < this.height) {
|
||||
const index = tileY * this.width + tileX;
|
||||
return this.ownerData[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Set highlighted nation (-1 or null to clear)
|
||||
setHighlightedNation(nationId: number | null) {
|
||||
if (this.territoryFilter?.resources?.territoryUniforms) {
|
||||
this.territoryFilter.resources.territoryUniforms.uniforms.uHighlightedNation = nationId ?? -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all territory
|
||||
clear() {
|
||||
this.ownerData.fill(0);
|
||||
const imageData = this.bufferSource.resource as Uint8Array;
|
||||
|
||||
// Clear all pixels
|
||||
imageData.fill(0);
|
||||
|
||||
// Mark entire texture as dirty
|
||||
this.isDirty = true;
|
||||
this.dirtyMinX = 0;
|
||||
this.dirtyMinY = 0;
|
||||
this.dirtyMaxX = this.width - 1;
|
||||
this.dirtyMaxY = this.height - 1;
|
||||
|
||||
this.updateTexture();
|
||||
}
|
||||
}
|
||||
2
frontend/src/shared/render/index.ts
Normal file
2
frontend/src/shared/render/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CameraController, type CameraStateListener } from "./CameraController";
|
||||
export { GameRenderer } from "./GameRenderer";
|
||||
61
frontend/src/shared/utils/binaryDecoding.ts
Normal file
61
frontend/src/shared/utils/binaryDecoding.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Binary decoding utilities for territory snapshot data from Rust backend.
|
||||
*
|
||||
* The TerritorySnapshot uses a sparse binary format to minimize data transfer:
|
||||
* - Only claimed tiles (owner_id != 0) are transmitted
|
||||
* - All other tiles default to unclaimed (owner_id = 0)
|
||||
* - Binary format: [count:4][tile_changes...]
|
||||
* where each change is [index:4][owner:2] (6 bytes per claimed tile)
|
||||
*/
|
||||
|
||||
export interface TileOwnership {
|
||||
index: number;
|
||||
owner_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode sparse binary territory snapshot from Rust backend.
|
||||
*
|
||||
* @param data Binary data array (serialized from Vec<u8> in Rust)
|
||||
* @returns Array of claimed tiles with indices and owner IDs, or null if invalid
|
||||
*/
|
||||
export function decodeTerritorySnapshot(data: number[]): TileOwnership[] | null {
|
||||
if (data.length < 4) {
|
||||
console.error("Invalid territory snapshot: not enough data for count");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read count (4 bytes, little-endian u32)
|
||||
const count =
|
||||
data[0] |
|
||||
(data[1] << 8) |
|
||||
(data[2] << 16) |
|
||||
(data[3] << 24);
|
||||
|
||||
const expectedSize = 4 + count * 6;
|
||||
if (data.length !== expectedSize) {
|
||||
console.error(
|
||||
`Invalid territory snapshot: expected ${expectedSize} bytes, got ${data.length}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tiles: TileOwnership[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = 4 + i * 6;
|
||||
|
||||
// Read index (4 bytes, little-endian u32)
|
||||
const index =
|
||||
data[offset] |
|
||||
(data[offset + 1] << 8) |
|
||||
(data[offset + 2] << 16) |
|
||||
(data[offset + 3] << 24);
|
||||
|
||||
// Read owner_id (2 bytes, little-endian u16)
|
||||
const owner_id = data[offset + 4] | (data[offset + 5] << 8);
|
||||
|
||||
tiles.push({ index, owner_id });
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
15
frontend/src/vite-env.d.ts
vendored
Normal file
15
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __DESKTOP__: boolean;
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
declare module "@wasm/borders.js" {
|
||||
export { default } from "../pkg/borders";
|
||||
export * from "../pkg/borders";
|
||||
}
|
||||
|
||||
// vite-imagetools support
|
||||
declare module "*&imagetools" {
|
||||
const out: any;
|
||||
export default out;
|
||||
}
|
||||
3
frontend/tsconfig.browser.json
Normal file
3
frontend/tsconfig.browser.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
4
frontend/tsconfig.desktop.json
Normal file
4
frontend/tsconfig.desktop.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/browser", "node_modules"]
|
||||
}
|
||||
32
frontend/tsconfig.json
Normal file
32
frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@wasm": ["pkg"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "pkg"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
95
frontend/vite.config.ts
Normal file
95
frontend/vite.config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { defineConfig, HmrContext } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { imagetools } from "vite-imagetools";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// Read version from workspace Cargo.toml
|
||||
function getVersionFromCargoToml(): string {
|
||||
const cargoToml = readFileSync(resolve(__dirname, "../Cargo.toml"), "utf-8");
|
||||
const versionMatch = cargoToml.match(/^\[workspace\.package\][\s\S]*?^version\s*=\s*"(.+?)"$/m);
|
||||
if (!versionMatch) {
|
||||
throw new Error("Failed to find version in workspace Cargo.toml");
|
||||
}
|
||||
return versionMatch[1];
|
||||
}
|
||||
|
||||
const fullReloadAlways = {
|
||||
name: "full-reload-always",
|
||||
handleHotUpdate(context: HmrContext): void {
|
||||
context.server.ws.send({ type: "full-reload" });
|
||||
},
|
||||
};
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.GITHUB_PAGES === "true" ? "/borde.rs/" : "/",
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
imagetools(),
|
||||
fullReloadAlways,
|
||||
// Plugin to prevent .meta files from falling back to index.html
|
||||
{
|
||||
name: "block-meta-fallback",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.endsWith(".meta")) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
define: {
|
||||
__DESKTOP__: JSON.stringify(mode !== "browser"),
|
||||
__APP_VERSION__: JSON.stringify(getVersionFromCargoToml()),
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
"@wasm": resolve(__dirname, "pkg"),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: mode === "browser" ? "dist/browser" : "dist",
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/crates/borders-desktop/**"],
|
||||
},
|
||||
// Add headers for SharedArrayBuffer support in browser mode
|
||||
headers:
|
||||
mode === "browser"
|
||||
? {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user