Update source files

This commit is contained in:
2025-10-25 16:15:50 -05:00
commit 635712304f
215 changed files with 32973 additions and 0 deletions

5
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"tabWidth": 4,
"printWidth": 135,
"useTabs": false
}

7
frontend/index.html Normal file
View File

@@ -0,0 +1,7 @@
<!doctype html>
<html lang="en">
<head></head>
<body>
<div id="root"></div>
</body>
</html>

53
frontend/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "iron-borders",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit --project tsconfig.browser.json",
"dev": "vike dev",
"dev:browser": "vike dev --mode browser",
"build": "tsc --project tsconfig.browser.json && vike build",
"build:desktop": "tsc --project tsconfig.desktop.json && vike build --mode desktop",
"build:browser": "tsc --project tsconfig.browser.json && vike build --mode browser",
"preview": "vike preview",
"preview:browser": "vike preview --mode browser",
"tauri": "tauri"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/oswald": "^5.2.8",
"@radix-ui/react-dialog": "^1.1.15",
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-process": "^2.3.0",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"motion": "^12.23.22",
"overlayscrollbars": "^2.12.0",
"overlayscrollbars-react": "^0.5.6",
"pixi.js": "^8.14.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"ts-pattern": "^5.8.0",
"vike": "^0.4.242",
"vike-react": "^0.6.9"
},
"devDependencies": {
"@tauri-apps/cli": "^2.8.4",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"lightningcss": "^1.30.2",
"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"
]
}

40
frontend/pages/+Head.tsx Normal file
View File

@@ -0,0 +1,40 @@
import fontsCss from "@/shared/styles/fonts.css?inline";
import oswaldWoff2 from "@fontsource-variable/oswald/files/oswald-latin-wght-normal.woff2?url";
export default function HeadDefault() {
return (
<>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preload" href={oswaldWoff2} as="font" type="font/woff2" crossOrigin="anonymous" />
{/* Inlined font definitions */}
<style dangerouslySetInnerHTML={{ __html: fontsCss }} />
{/* Global styles for initial render */}
<style>{`
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 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>
</>
);
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
import { GameBridgeProvider } from "@/shared/api";
import { getBridge, type GameBridge } from "@/shared/api";
// Start loading bridge immediately (not waiting for useEffect)
const bridgePromise = getBridge();
export default function Wrapper({ children }: { children: ReactNode }) {
const [bridge, setBridge] = useState<GameBridge | null>(null);
// Dynamically import the appropriate platform implementation
useEffect(() => {
bridgePromise.then(setBridge);
}, []);
// Browser-specific setup (must be before early return to satisfy Rules of Hooks)
useEffect(() => {
if (!__DESKTOP__) {
// Handle user ID storage from worker
const userIdChannel = new BroadcastChannel("user_id_storage");
userIdChannel.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string);
if (msg.action === "save") {
localStorage.setItem("app_session_id", msg.id);
} else if (msg.action === "load") {
const id = localStorage.getItem("app_session_id");
if (id) {
userIdChannel.postMessage(JSON.stringify({ action: "load_response", id }));
}
}
} catch {}
};
return () => {
userIdChannel.close();
};
}
}, []);
return bridge ? <GameBridgeProvider bridge={bridge}>{children}</GameBridgeProvider> : null;
}

View File

@@ -0,0 +1,11 @@
import { type ReactNode } from "react";
// import { GameBridgeProvider } from "@/shared/api";
// Server-side wrapper provides stub implementations for SSG/pre-rendering
// The real implementations are provided by +Wrapper.client.tsx on the client
export default function Wrapper({ children }: { children: ReactNode }) {
// During SSR/pre-rendering, provide null implementations
// These will be replaced by real implementations when the client-side wrapper takes over
// return <GameBridgeProvider bridge={null as any}>{children}</GameBridgeProvider>;
return <>{children}</>;
}

14
frontend/pages/+config.ts Normal file
View File

@@ -0,0 +1,14 @@
import vikeReact from "vike-react/config";
export default {
extends: [vikeReact],
// Enable pre-rendering for static site generation
prerender: true,
// Global head configuration
title: "Iron Borders",
description: "Strategic Territory Control",
// Disable React StrictMode to avoid double-mounting issues with PixiJS
reactStrictMode: false,
};

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, Suspense, lazy } from "react";
import { MenuScreen } from "@/shared/components/menu/MenuScreen";
import { useGameBridge } from "@/shared/api";
import "@/shared/styles/global.css";
// Lazy load components that aren't needed immediately
const GameContainer = lazy(() => import("./Game.client").then((m) => ({ default: m.GameContainer })));
function App() {
const [showMenu, setShowMenu] = useState(true);
const [isStartingGame, setIsStartingGame] = useState(false);
const [isGameReady, setIsGameReady] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
const gameBridge = useGameBridge();
// Prefetch components after initial render
useEffect(() => {
setIsHydrated(true);
import("./Game.client");
}, []);
// Track app started on mount
useEffect(() => {
if (!gameBridge) return;
gameBridge.track("app_started", {
platform: __DESKTOP__ ? "desktop" : "browser",
});
}, [gameBridge]);
const handleStartSingleplayer = () => {
setIsStartingGame(true);
};
const handleGameReady = () => {
setIsGameReady(true);
};
const handleMenuExitComplete = () => {
setShowMenu(false);
// Keep isStartingGame and isGameReady - no need to reset them
};
const handleReturnToMenu = () => {
setShowMenu(true);
setIsStartingGame(false);
setIsGameReady(false);
};
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 (
<>
{/* Menu Screen - pre-renderable, covers everything when visible */}
{!isHydrated ||
(showMenu && (
<MenuScreen
onStartSingleplayer={handleStartSingleplayer}
onExit={handleExit}
isExiting={isStartingGame}
gameReady={isGameReady}
onExitComplete={handleMenuExitComplete}
/>
))}
{/* Game Container - client-only, lazy loaded when game starts */}
{isHydrated && (isStartingGame || !showMenu) && (
<Suspense fallback={null}>
<GameContainer onReturnToMenu={handleReturnToMenu} onGameReady={handleGameReady} />
</Suspense>
)}
</>
);
}
export default App;

View File

@@ -0,0 +1,177 @@
import { useState, useEffect } from "react";
import { Attacks } from "@/shared/components/game/AttacksList";
import { AttackControls } from "@/shared/components/game/AttackControls";
import { Leaderboard } from "@/shared/components/game/Leaderboard";
import { GameMenu } from "@/shared/components/game/Menu";
import { SpawnPhaseOverlay } from "@/shared/components/overlays/SpawnPhase";
import { GameCanvas } from "@/shared/components/game/Canvas";
import { GameEndOverlay } from "@/shared/components/overlays/GameEnd";
import type { GameOutcome, LeaderboardSnapshot } from "@/shared/api/types";
import type { GameRenderer } from "@/shared/render/GameRenderer";
import { useGameBridge, type RenderInitData } from "@/shared/api";
interface GameContainerProps {
onReturnToMenu: () => void;
onGameReady?: () => void;
}
export function GameContainer({ onReturnToMenu, onGameReady }: GameContainerProps) {
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<RenderInitData | null>(null);
const [initialLeaderboard, setInitialLeaderboard] = useState<LeaderboardSnapshot | null>(null);
const [renderer, setRenderer] = useState<GameRenderer | null>(null);
const [highlightedNation, setHighlightedNation] = useState<number | null>(null);
const gameBridge = useGameBridge();
// Check for existing game state on mount to recover after reload
useEffect(() => {
// Only check for state recovery on desktop
if (!__DESKTOP__) return;
gameBridge?.getGameState().then((state) => {
// State recovery is limited to leaderboard data only
// Initialization data (terrain, territory, nation palette) is not recoverable after reload
// The frontend must wait for a fresh game to start
const leaderboard = state as LeaderboardSnapshot | null;
if (leaderboard) {
console.log("Recovered leaderboard state after reload:", leaderboard);
setInitialLeaderboard(leaderboard);
}
});
}, [gameBridge]);
// Subscribe to render initialization
useEffect(() => {
if (!gameBridge) return;
const unsubscribe = gameBridge.onRenderInit((renderData) => {
console.log("RenderInit received:", renderData);
setInitialGameState(renderData);
onGameReady?.();
});
return () => unsubscribe();
}, [gameBridge, onGameReady]);
// Start the game on mount
useEffect(() => {
if (!gameBridge) return;
gameBridge.startGame();
gameBridge.track("game_started", {
mode: "singleplayer",
});
}, [gameBridge]);
// Subscribe to spawn phase events
useEffect(() => {
if (!gameBridge) return;
const unsubUpdate = gameBridge.onSpawnPhaseUpdate((update) => {
setSpawnPhaseActive(true);
setSpawnCountdown(update.countdown);
});
const unsubEnd = gameBridge.onSpawnPhaseEnded(() => {
setSpawnPhaseActive(false);
setSpawnCountdown(null);
});
return () => {
unsubUpdate();
unsubEnd();
};
}, [gameBridge]);
// Subscribe to game end events
useEffect(() => {
if (!gameBridge) return;
const unsubscribe = gameBridge.onGameEnded((outcome) => {
console.log("Game outcome received:", outcome);
setGameOutcome(outcome);
setSpawnPhaseActive(false);
gameBridge.track("game_ended", {
outcome: outcome.toString().toLowerCase(),
});
});
return () => unsubscribe();
}, [gameBridge]);
useEffect(() => {
if (!renderer) return;
if (!gameBridge) return;
// Track renderer initialization with GPU info
const rendererInfo = renderer.getRendererInfo();
gameBridge.track("renderer_initialized", rendererInfo);
}, [renderer, gameBridge]);
// Sync highlighted nation with renderer
useEffect(() => {
renderer?.setHighlightedNation(highlightedNation);
}, [highlightedNation, renderer]);
const handleExit = () => {
gameBridge?.quitGame();
setGameOutcome(null);
onReturnToMenu();
};
return (
<>
<GameCanvas
className="fixed top-0 left-0 w-screen h-screen pointer-events-auto"
initialState={initialGameState}
onRendererReady={setRenderer}
onNationHover={setHighlightedNation}
/>
{initialGameState && (
<div className="fixed top-0 left-0 w-full h-full pointer-events-none flex flex-col">
<SpawnPhaseOverlay isVisible={spawnPhaseActive} countdown={spawnCountdown} />
<div className="relative flex-1">
{spawnPhaseActive && (
<div className="absolute top-16 left-1/2 -translate-x-1/2 text-center select-none w-full px-4 z-50">
<h1 className="font-oswald text-5xl leading-4 font-bold text-white drop-shadow-lg tracking-wide">
Pick Your Spawn
</h1>
<p className="font-sans font-medium mx-auto mt-6 text-lg text-white drop-shadow-md max-w-xl leading-relaxed">
Click anywhere on the map to place your starting territory.
<br />
You can change your mind before the timer expires.
</p>
</div>
)}
<Leaderboard
initialSnapshot={initialLeaderboard}
highlightedNation={highlightedNation}
onNationHover={setHighlightedNation}
/>
<AttackControls>
<Attacks onNationHover={setHighlightedNation} />
</AttackControls>
<GameMenu
onExit={handleExit}
onSettings={() => {
// TODO: Implement settings
}}
/>
{gameOutcome && (
<GameEndOverlay outcome={gameOutcome} onSpectate={() => setGameOutcome(null)} onExit={handleExit} />
)}
</div>
</div>
)}
</>
);
}

2881
frontend/pnpm-lock.yaml generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- esbuild
- puppeteer
- sharp

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

View File

@@ -0,0 +1,106 @@
import init, { register_backend_message_callback, register_binary_callback, send_message, track_analytics_event } from "@wasm/borders";
/** Analytics event payload */
interface AnalyticsEvent {
event: string;
properties: Record<string, unknown>;
}
/** Messages sent from main thread to worker */
type WorkerMessage =
/** Send any message to backend (FrontendMessage or RenderInputEvent) */
| { type: "MESSAGE"; payload: unknown }
/** Track analytics event (separate from game protocol) */
| { type: "ANALYTICS_EVENT"; payload: AnalyticsEvent };
/**
* Messages sent from worker to main thread.
* These notify the transport layer of backend events.
*/
type WorkerResponse =
/** JSON message from backend (BackendMessage protocol) */
| { type: "backend:message"; payload: unknown }
/** Binary initialization data (terrain + territory) */
| { type: "backend:binary_init"; payload: Uint8Array }
/** Binary territory delta update */
| { type: "backend:binary_delta"; payload: Uint8Array }
/** Worker encountered an error */
| { type: "ERROR"; payload: { message: string } };
/** Helper to post typed messages back to main thread */
function postResponse(response: WorkerResponse): void {
self.postMessage(response);
}
/** Buffer for messages received before WASM is ready */
const messageQueue: WorkerMessage[] = [];
let wasmReady = false;
/** Initialize WASM on worker load */
init()
.then(() => {
// Register callback for JSON messages from backend (BackendMessage)
register_backend_message_callback((backendMessage: unknown) => {
postResponse({ type: "backend:message", payload: backendMessage });
});
// Register unified callback for binary data (terrain/territory init and deltas)
register_binary_callback((data: Uint8Array, type: "init" | "delta") => {
if (type === "init") {
postResponse({ type: "backend:binary_init", payload: data });
} else {
postResponse({ type: "backend:binary_delta", payload: data });
}
});
wasmReady = true;
console.log("WASM module initialized");
// Process queued messages
while (messageQueue.length > 0) {
const message = messageQueue.shift()!;
processMessage(message);
}
})
.catch((error) => {
console.error("WASM module initialization failed:", error);
postResponse({
type: "ERROR",
payload: { message: error instanceof Error ? error.message : String(error) },
});
});
/** Process a worker message (either from queue or directly) */
function processMessage(message: WorkerMessage): void {
try {
switch (message.type) {
case "MESSAGE":
send_message(message.payload);
break;
case "ANALYTICS_EVENT":
track_analytics_event(message.payload);
break;
default: {
const _exhaustive: never = message;
console.warn("Unknown worker message type:", (_exhaustive as WorkerMessage).type);
}
}
} catch (error) {
console.error("Worker error:", error);
postResponse({
type: "ERROR",
payload: { message: error instanceof Error ? error.message : String(error) },
});
}
}
/** Handle incoming messages - queue if not ready, process if ready */
self.addEventListener("message", ({ data: message }: MessageEvent<WorkerMessage>) => {
if (!wasmReady) {
messageQueue.push(message);
} else {
processMessage(message);
}
});

View File

@@ -0,0 +1,11 @@
/**
* Browser platform exports.
* Provides the game bridge instance.
*/
import { GameBridge } from "@/shared/api/GameBridge";
import { WasmTransport } from "@/browser/transport";
const transport = new WasmTransport();
export const bridge = new GameBridge(transport);

View File

@@ -0,0 +1,94 @@
import type { Transport, JsonCallback, UnsubscribeFn } from "@/shared/api/Transport";
import type { FrontendMessage, RenderInputEvent, AnalyticsProperties } from "@/shared/api/messages";
/**
* WASM transport implementation using Web Worker for message passing.
*
* Pure transport layer that:
* - Manages worker lifecycle
* - Sends/receives JSON messages
* - Receives binary data (Uint8Array)
* - Does NOT handle callbacks, rendering, or state management
*
* Worker handles initialization and message buffering automatically.
*/
export class WasmTransport implements Transport {
private worker: Worker;
private backendMessageCallbacks: Set<JsonCallback> = new Set();
private binaryCallbacks: Set<(data: Uint8Array, type: "init" | "delta") => void> = new Set();
constructor() {
this.worker = new Worker(new URL("./game.worker.ts", import.meta.url), {
type: "module",
});
this.worker.addEventListener("message", (e) => {
const { type, payload } = e.data;
switch (type) {
case "backend:message":
this.backendMessageCallbacks.forEach((callback) => callback(payload));
break;
case "backend:binary_init":
this.binaryCallbacks.forEach((callback) => callback(payload, "init"));
break;
case "backend:binary_delta":
this.binaryCallbacks.forEach((callback) => callback(payload, "delta"));
break;
case "ERROR":
console.error("Worker error:", payload);
break;
default:
console.warn("Unknown worker message type:", type);
}
});
}
sendJson(message: FrontendMessage | RenderInputEvent): void {
this.worker.postMessage({
type: "MESSAGE",
payload: message,
});
}
onJson(callback: JsonCallback): UnsubscribeFn {
this.backendMessageCallbacks.add(callback);
return () => {
this.backendMessageCallbacks.delete(callback);
};
}
onBinary(callback: (data: Uint8Array, type: "init" | "delta") => void): UnsubscribeFn {
this.binaryCallbacks.add(callback);
return () => {
this.binaryCallbacks.delete(callback);
};
}
sendRenderInput(event: RenderInputEvent): void {
this.worker.postMessage({
type: "MESSAGE",
payload: event,
});
}
destroy(): void {
this.worker.terminate();
this.backendMessageCallbacks.clear();
this.binaryCallbacks.clear();
}
sendAnalytics(event: string, properties: AnalyticsProperties): void {
this.worker.postMessage({
type: "ANALYTICS_EVENT",
payload: {
event,
properties,
},
});
}
}

View File

@@ -0,0 +1,11 @@
/**
* Desktop platform exports.
* Provides the game bridge instance.
*/
import { GameBridge } from "@/shared/api/GameBridge";
import { TauriTransport } from "@/desktop/transport";
const transport = new TauriTransport();
export const bridge = new GameBridge(transport);

View File

@@ -0,0 +1,104 @@
import { listen } from "@tauri-apps/api/event";
import { invoke, Channel } from "@tauri-apps/api/core";
import type { Transport, JsonCallback, UnsubscribeFn } from "@/shared/api/Transport";
import type { BackendMessage, FrontendMessage, RenderInputEvent, AnalyticsProperties } from "@/shared/api/messages";
/**
* Tauri transport implementation using Tauri's event system and IPC.
*
* Pure transport layer that:
* - Listens to Tauri events for backend messages
* - Uses invoke for sending messages to backend
* - Receives binary data via events and channels
* - Does NOT handle callbacks, rendering, or state management
*/
export class TauriTransport implements Transport {
private backendMessageCallbacks: Set<JsonCallback> = new Set();
private binaryCallbacks: Set<(data: Uint8Array, type: "init" | "delta") => void> = new Set();
private backendMessageUnsubscribe?: () => void;
private binaryDeltaUnsubscribe?: () => void;
constructor() {
this.setupEventListeners();
this.setupBinaryInitChannel();
}
private async setupEventListeners() {
// Listen for JSON messages from backend
this.backendMessageUnsubscribe = await listen<BackendMessage>("backend:message", (event) => {
this.backendMessageCallbacks.forEach((callback) => callback(event.payload));
});
// Listen for binary delta updates (territory deltas)
this.binaryDeltaUnsubscribe = await listen<number[]>("backend:binary_delta", (event) => {
const uint8Array = new Uint8Array(event.payload);
this.binaryCallbacks.forEach((callback) => callback(uint8Array, "delta"));
});
}
private async setupBinaryInitChannel() {
const channel = new Channel<number[]>();
channel.onmessage = (data) => {
const binary = new Uint8Array(data);
this.binaryCallbacks.forEach((callback) => callback(binary, "init"));
};
try {
await invoke("register_binary_init_channel", { channel });
console.log("Binary init channel registered successfully");
} catch (err) {
console.error("Failed to register binary init channel:", err);
}
}
sendJson(message: FrontendMessage | RenderInputEvent): void {
invoke("send_frontend_message", { message }).catch((err) => {
console.error("Failed to send frontend message:", err);
});
}
onJson(callback: JsonCallback): UnsubscribeFn {
this.backendMessageCallbacks.add(callback);
return () => {
this.backendMessageCallbacks.delete(callback);
};
}
onBinary(callback: (data: Uint8Array, type: "init" | "delta") => void): UnsubscribeFn {
this.binaryCallbacks.add(callback);
return () => {
this.binaryCallbacks.delete(callback);
};
}
sendRenderInput(event: RenderInputEvent): void {
invoke("handle_render_input", { event }).catch((err) => {
console.error("Failed to send render input:", err);
});
}
sendAnalytics(event: string, properties: AnalyticsProperties): void {
invoke("track_analytics_event", {
payload: {
event,
properties,
},
}).catch((err) => {
console.error("Failed to track analytics event:", err);
});
}
destroy(): void {
if (this.backendMessageUnsubscribe) {
this.backendMessageUnsubscribe();
this.backendMessageUnsubscribe = undefined;
}
if (this.binaryDeltaUnsubscribe) {
this.binaryDeltaUnsubscribe();
this.binaryDeltaUnsubscribe = undefined;
}
this.backendMessageCallbacks.clear();
this.binaryCallbacks.clear();
}
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,265 @@
import type {
AttacksUpdatePayload,
GameOutcome,
LeaderboardSnapshot,
ShipsUpdatePayload,
SpawnPhaseUpdate,
UnsubscribeFn,
} from "@/shared/api/types";
import type { Transport } from "@/shared/api/Transport";
import type { BackendMessage, RenderInputEvent, AnalyticsProperties } from "@/shared/api/messages";
import type { GameRenderer, PaletteData, TerrainData } from "@/shared/render/GameRenderer";
import { CallbackManager } from "@/shared/utils/CallbackManager";
import { decodeInitBinary } from "@/shared/utils/binaryDecoding";
export interface RenderInitData {
palette: PaletteData; // Nation palette
terrain_palette: PaletteData; // Terrain palette
terrain: TerrainData;
initial_territories: {
turn: number;
territories: { indices: Uint32Array; ownerIds: Uint16Array };
};
}
interface QueuedRenderMessage {
_type: "binary";
data: Uint8Array;
binaryType: "init" | "delta";
}
/**
* Game API bridge that connects the UI to the game backend.
*
* This class provides:
* - Callback management per message type
* - Renderer integration & binary decoding
* - Message buffering & initialization sequencing
* - Typed API methods (startGame, onLeaderboardSnapshot, etc.)
*/
export class GameBridge {
private transport: Transport;
private renderer: GameRenderer | null = null;
private snapshotCallbacks = new CallbackManager<LeaderboardSnapshot>();
private gameEndCallbacks = new CallbackManager<GameOutcome>();
private attacksUpdateCallbacks = new CallbackManager<AttacksUpdatePayload>();
private shipsUpdateCallbacks = new CallbackManager<ShipsUpdatePayload>();
private spawnPhaseUpdateCallbacks = new CallbackManager<SpawnPhaseUpdate>();
private spawnPhaseEndedCallbacks = new CallbackManager<void>();
private renderInitCallbacks = new CallbackManager<RenderInitData>();
private queuedRenderMessages: QueuedRenderMessage[] = [];
constructor(transport: Transport) {
this.transport = transport;
// Subscribe to JSON messages from backend
this.transport.onJson((message) => this.handleBackendMessage(message));
// Subscribe to binary data from backend
this.transport.onBinary((data, type) => this.handleBinaryData(data, type));
}
private handleBackendMessage(message: BackendMessage): void {
// Messages use msg_type tag from serde
switch (message.msg_type) {
case "LeaderboardSnapshot":
this.snapshotCallbacks.notify(message as LeaderboardSnapshot);
break;
case "AttacksUpdate":
this.attacksUpdateCallbacks.notify(message as AttacksUpdatePayload);
break;
case "ShipsUpdate":
this.shipsUpdateCallbacks.notify(message as ShipsUpdatePayload);
// Also forward to renderer for visual updates
if (this.renderer) {
this.renderer.updateShips(message as ShipsUpdatePayload);
}
break;
case "GameEnded":
this.gameEndCallbacks.notify(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.notify(update);
break;
case "SpawnPhaseEnded":
this.spawnPhaseEndedCallbacks.notify();
break;
case "HighlightNation":
this.renderer?.setHighlightedNation(message.nation_id ?? null);
break;
default:
// Exhaustiveness check - this should never be reached
console.warn("Unknown backend message type:", (message as { msg_type: string }).msg_type);
break;
}
}
private handleBinaryData(data: Uint8Array, type: "init" | "delta"): void {
if (type === "init") {
// Decode complete initialization data (terrain + territory + nation palette)
const decoded = decodeInitBinary(data);
if (!decoded) {
console.error("Failed to decode init binary data");
return;
}
// Assemble complete initialization data
const completeInitData: RenderInitData = {
palette: {
colors: decoded.nationPalette,
},
terrain_palette: {
colors: decoded.terrain.palette,
},
terrain: {
size: {
x: decoded.terrain.width,
y: decoded.terrain.height,
},
terrain_data: decoded.terrain.tileIds,
},
initial_territories: {
turn: 0,
territories: {
indices: decoded.territory.indices,
ownerIds: decoded.territory.ownerIds,
},
},
};
console.log("Complete initialization data ready:", completeInitData);
// Notify subscribers
this.renderInitCallbacks.notify(completeInitData);
return;
}
// For delta updates, need renderer to be ready
if (!this.renderer) {
this.queuedRenderMessages.push({ _type: "binary", data, binaryType: type });
return;
}
// Apply delta
this.renderer.updateBinaryDelta(data);
}
startGame(): void {
this.transport.sendJson({ msg_type: "StartGame" });
}
quitGame(): void {
this.transport.sendJson({ msg_type: "QuitGame" });
}
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
return this.snapshotCallbacks.subscribe(callback);
}
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
return this.gameEndCallbacks.subscribe(callback);
}
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
return this.attacksUpdateCallbacks.subscribe(callback);
}
onShipsUpdate(callback: (data: ShipsUpdatePayload) => void): UnsubscribeFn {
return this.shipsUpdateCallbacks.subscribe(callback);
}
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
return this.spawnPhaseUpdateCallbacks.subscribe(callback);
}
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
return this.spawnPhaseEndedCallbacks.subscribe(callback);
}
onRenderInit(callback: (data: RenderInitData) => void): UnsubscribeFn {
return this.renderInitCallbacks.subscribe(callback);
}
setRenderer(renderer: GameRenderer): void {
this.renderer = renderer;
// Replay any queued binary delta messages that arrived before renderer was ready
if (this.queuedRenderMessages.length > 0) {
for (const message of this.queuedRenderMessages) {
if (message._type === "binary") {
this.handleBinaryData(message.data, message.binaryType);
}
}
this.queuedRenderMessages = [];
}
}
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
const event: RenderInputEvent = {
type: "MapClick",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
button: data.button,
};
// Transport implementations may have specialized methods for render input
if ("sendRenderInput" in this.transport) {
(this.transport as { sendRenderInput: (event: RenderInputEvent) => void }).sendRenderInput(event);
}
}
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
const event: RenderInputEvent = {
type: "MapHover",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
};
if ("sendRenderInput" in this.transport) {
(this.transport as { sendRenderInput: (event: RenderInputEvent) => void }).sendRenderInput(event);
}
}
sendKeyPress(data: { key: string; pressed: boolean }): void {
const event: RenderInputEvent = {
type: "KeyPress",
key: data.key,
pressed: data.pressed,
};
if ("sendRenderInput" in this.transport) {
(this.transport as { sendRenderInput: (event: RenderInputEvent) => void }).sendRenderInput(event);
}
}
sendAttackRatio(ratio: number): void {
this.transport.sendJson({
msg_type: "SetAttackRatio",
ratio: ratio,
});
}
async getGameState(): Promise<unknown> {
// WASM doesn't persist state across reloads - always start fresh
// Tauri could implement this if needed
return null;
}
track(event: string, properties?: AnalyticsProperties): void {
this.transport.sendAnalytics(event, properties || {});
}
}

View File

@@ -0,0 +1,17 @@
import { createContext, useContext, type ReactNode } from "react";
import type { GameBridge } from "@/shared/api/GameBridge";
const GameBridgeContext = createContext<GameBridge | null>(null);
export interface GameBridgeProviderProps {
bridge: GameBridge;
children: ReactNode;
}
export function GameBridgeProvider({ bridge, children }: GameBridgeProviderProps) {
return <GameBridgeContext.Provider value={bridge}>{children}</GameBridgeContext.Provider>;
}
export function useGameBridge(): GameBridge | null {
return useContext(GameBridgeContext);
}

View File

@@ -0,0 +1,50 @@
/**
* Core transport interface for frontend-backend communication.
* This is a pure transport layer that only handles message passing,
* without any higher-level concerns like callbacks, rendering, or state management.
*/
import type { BackendMessage, FrontendMessage, RenderInputEvent, AnalyticsProperties } from "@/shared/api/messages";
export type JsonCallback = (message: BackendMessage) => void;
export type BinaryCallback = (data: Uint8Array) => void;
export type UnsubscribeFn = () => void;
/**
* Pure transport interface for bidirectional communication.
* Implementations handle the platform-specific details (WASM worker, Tauri IPC).
*
* - sendJson: Send JSON messages to backend
* - onJson: Receive JSON messages from backend
* - onBinary: Receive binary data from backend (init or deltas, tagged with type)
*/
export interface Transport {
/**
* Send a JSON message to the backend.
* Message should be a serializable object.
*/
sendJson(message: FrontendMessage | RenderInputEvent): void;
/**
* Subscribe to JSON messages from the backend.
* Returns an unsubscribe function.
*/
onJson(callback: JsonCallback): UnsubscribeFn;
/**
* Subscribe to binary data from the backend.
* Callback receives the data and a type tag ("init" or "delta").
* Returns an unsubscribe function.
*/
onBinary(callback: (data: Uint8Array, type: "init" | "delta") => void): UnsubscribeFn;
/**
* Cleanup resources when transport is no longer needed.
*/
destroy?(): void;
/**
* Send an analytics event to the backend.
*/
sendAnalytics(event: string, properties: AnalyticsProperties): void;
}

View File

@@ -0,0 +1,14 @@
import type { GameBridge } from "@/shared/api/GameBridge";
/**
* Get the platform-specific GameBridge implementation.
*/
export async function getBridge(): Promise<GameBridge> {
const platform = await (__DESKTOP__ ? import("@/desktop") : import("@/browser"));
return platform.bridge;
}
// Re-export all API types and interfaces for convenient imports
export * from "@/shared/api/types";
export * from "@/shared/api/GameBridge";
export * from "@/shared/api/GameBridgeContext";

View File

@@ -0,0 +1,55 @@
// Protocol message types for frontend-backend communication
// These mirror the Rust enums defined in crates/borders-core/src/ui/protocol.rs
import type {
AttacksUpdatePayload,
GameOutcome,
LeaderboardSnapshot,
ShipsUpdatePayload,
} from "@/shared/api/types";
// Raw SpawnCountdown from backend (uses snake_case like Rust)
interface RawSpawnCountdown {
started_at_ms: number;
duration_secs: number;
}
// Messages sent from backend to frontend
export type BackendMessage =
| { msg_type: "LeaderboardSnapshot" } & LeaderboardSnapshot
| { msg_type: "AttacksUpdate" } & AttacksUpdatePayload
| { msg_type: "ShipsUpdate" } & ShipsUpdatePayload
| { msg_type: "GameEnded"; outcome: GameOutcome }
| { msg_type: "SpawnPhaseUpdate"; countdown: RawSpawnCountdown | null }
| { msg_type: "SpawnPhaseEnded" }
| { msg_type: "HighlightNation"; nation_id: number | null };
// Messages sent from frontend to backend
export type FrontendMessage =
| { msg_type: "StartGame" }
| { msg_type: "QuitGame" }
| { msg_type: "SetAttackRatio"; ratio: number };
// Render input events sent from frontend to backend
export type RenderInputEvent =
| {
type: "MapClick";
tile_index: number | null;
world_x: number;
world_y: number;
button: number;
}
| {
type: "KeyPress";
key: string;
pressed: boolean;
}
| {
type: "MapHover";
tile_index: number | null;
world_x: number;
world_y: number;
};
// Analytics event properties
export type AnalyticsProperties = Record<string, string | number | boolean | null | undefined>;

View File

@@ -0,0 +1,69 @@
// 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;
rank: number; // Current rank (1-indexed, updates every tick)
display_order: number; // Visual position (0-indexed, updates every 3rd tick)
};
export type LeaderboardSnapshot = {
turn: number;
total_land_tiles: number;
entries: LeaderboardEntry[];
client_player_id: number;
};
export type GameOutcome = "Victory" | "Defeat";
export type AttackEntry = {
id: number;
attacker_id: number;
target_id: number | null; // null for unclaimed territory
troops: 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 ShipUpdateVariant =
| {
type: "Create";
id: number;
owner_id: number;
path: number[];
troops: number;
}
| {
type: "Move";
id: number;
current_path_index: number;
}
| {
type: "Destroy";
id: number;
};
export type ShipsUpdatePayload = {
turn: number;
updates: ShipUpdateVariant[];
};
export type UnsubscribeFn = () => void;

View File

@@ -0,0 +1,222 @@
import { useCallback, useEffect, useRef, useState, ReactNode } from "react";
import { useGameBridge } from "@/shared/api";
import type { LeaderboardSnapshot } from "@/shared/api/types";
import { formatTroopCount } from "@/shared/utils/formatting";
const MIN_PERCENTAGE = 1;
const MAX_PERCENTAGE = 100;
const DEFAULT_PERCENTAGE = 50;
const WHEEL_DELTA = 5;
const MIN_STEP_PERCENTAGE = 5; // Jump to 5% from 1% to avoid tiny increments at the low end
interface AttackControlsProps {
children?: ReactNode;
}
export function AttackControls({ children }: AttackControlsProps) {
const gameBridge = useGameBridge();
const [percentage, setPercentage] = useState(DEFAULT_PERCENTAGE);
const [playerTroops, setPlayerTroops] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const isHoveringRef = useRef(false);
// Track last sent value to avoid redundant network calls when rounding yields same percentage
const lastSentPercentageRef = useRef<number | null>(null);
const sendAttackRatio = useCallback(
(percent: number) => {
if (percent !== lastSentPercentageRef.current) {
lastSentPercentageRef.current = percent;
gameBridge?.sendAttackRatio(percent / 100);
}
},
[gameBridge],
);
const updatePercentage = useCallback((newPercent: number) => {
const clampedPercent = Math.max(MIN_PERCENTAGE, Math.min(MAX_PERCENTAGE, Math.round(newPercent)));
setPercentage(clampedPercent);
return clampedPercent;
}, []);
const calculatePercentageFromPosition = useCallback(
(clientX: number): number => {
if (!sliderRef.current) return percentage;
const rect = sliderRef.current.getBoundingClientRect();
const x = clientX - rect.left;
return (x / rect.width) * 100;
},
[percentage],
);
const handleWheelChange = useCallback(
(deltaY: number) => {
const delta = deltaY > 0 ? -WHEEL_DELTA : WHEEL_DELTA;
// Skip directly to 5% when scrolling up from minimum to avoid awkward 1%→6% jump
const newPercent = percentage === MIN_PERCENTAGE && delta > 0 ? MIN_STEP_PERCENTAGE : percentage + delta;
const clampedPercent = updatePercentage(newPercent);
sendAttackRatio(clampedPercent);
},
[percentage, updatePercentage, sendAttackRatio],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
const rawPercent = calculatePercentageFromPosition(e.clientX);
updatePercentage(rawPercent);
},
[calculatePercentageFromPosition, updatePercentage],
);
const handleTouchStart = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => {
e.preventDefault();
const touch = e.touches[0];
if (!touch) return;
isDraggingRef.current = true;
const rawPercent = calculatePercentageFromPosition(touch.clientX);
updatePercentage(rawPercent);
},
[calculatePercentageFromPosition, updatePercentage],
);
const handleMouseEnter = useCallback(() => {
isHoveringRef.current = true;
}, []);
const handleMouseLeave = useCallback(() => {
isHoveringRef.current = false;
}, []);
// Subscribe to leaderboard snapshots to track player's troop count
useEffect(() => {
if (!gameBridge) return;
const unsubscribe = gameBridge.onLeaderboardSnapshot((snapshot: LeaderboardSnapshot) => {
const playerEntry = snapshot.entries.find((entry) => entry.id === snapshot.client_player_id);
setPlayerTroops(playerEntry?.troops ?? null);
});
return () => unsubscribe();
}, [gameBridge]);
useEffect(() => {
const handleLocalWheel = (e: WheelEvent) => {
// Allow scroll without Shift when hovering over controls
if (!isHoveringRef.current && !e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
handleWheelChange(e.deltaY);
};
const container = containerRef.current;
if (container) {
container.addEventListener("wheel", handleLocalWheel, { passive: false });
}
return () => {
if (container) {
container.removeEventListener("wheel", handleLocalWheel);
}
};
}, [handleWheelChange]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const rawPercent = calculatePercentageFromPosition(e.clientX);
updatePercentage(rawPercent);
};
const handleMouseUp = () => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
sendAttackRatio(percentage);
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isDraggingRef.current) return;
e.preventDefault();
const touch = e.touches[0];
if (!touch) return;
const rawPercent = calculatePercentageFromPosition(touch.clientX);
updatePercentage(rawPercent);
};
const handleTouchEnd = () => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
sendAttackRatio(percentage);
}
};
const handleGlobalWheel = (e: WheelEvent) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
handleWheelChange(e.deltaY);
};
// Use window-level listeners to continue drag/scroll operations even when cursor leaves slider
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);
};
}, [calculatePercentageFromPosition, updatePercentage, sendAttackRatio, percentage, handleWheelChange]);
return (
// Click-through container: pointer-events-none allows clicks to pass through to the game canvas.
// Interactive child has pointer-events-auto to re-enable user interaction.
<div
ref={containerRef}
className="absolute bottom-3 left-3 z-10 select-none opacity-95 hover:opacity-100 transition-opacity duration-400 text-base sm:text-xs md:text-sm pointer-events-none"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex flex-col pointer-events-auto">
<div className="flex flex-row items-end gap-2 min-w-96">
<div className="text-shadow-sm font-medium uppercase opacity-60 text-xs shrink-0 whitespace-nowrap pl-1 pb-0.5 text-white tracking-wider">
Troops
</div>
<div className="flex flex-1 flex-col items-end gap-0.5 mb-1">{children}</div>
</div>
<div className="w-104">
<div
ref={sliderRef}
className="bg-black/40 rounded-md relative cursor-pointer overflow-hidden border border-white/10 h-11"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
<div
className="h-full absolute left-0 top-0 rounded-sm bg-blue-600/65"
style={{ width: `${percentage}%` }}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-lg font-medium tabular-nums text-white z-10 opacity-85 drop-shadow-md">
{percentage}%
</div>
{playerTroops !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-lg font-medium tabular-nums text-white z-10 opacity-85 drop-shadow-md">
{formatTroopCount(Math.floor((playerTroops * percentage) / 100))}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import type { LeaderboardEntry } from "@/shared/api/types";
import { formatTroopCount } from "@/shared/utils/formatting";
import { match, P } from "ts-pattern";
import * as motion from "motion/react-client";
// Attack row state from AttacksList parent component
interface AttackRowState {
rowId: string;
currentAttack: {
id: number;
attacker_id: number;
target_id: number | null;
troops: number;
is_outgoing: boolean;
} | null;
phase: "active" | "finished" | "exiting";
isHovered: boolean;
firstAppearanceTime: number;
finishedAt: number | null;
dismissTimer: NodeJS.Timeout | null;
}
// 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 = 0;
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);
const normalized = (powerTroops - powerMin) / (powerMax - powerMin);
return minWidth + normalized * (maxWidth - minWidth);
}
interface AttackRowProps {
rowState: AttackRowState;
playerMap: Map<number, LeaderboardEntry>;
onNationHover?: (nationId: number | null) => void;
onHoverChange: (isHovered: boolean) => void;
}
export function AttackRow({ rowState, playerMap, onNationHover, onHoverChange }: AttackRowProps) {
// Use current attack if active, otherwise display last known attack (for finished rows)
const attack = rowState.currentAttack;
// Don't render if we have no attack data (shouldn't happen, but safety check)
if (!attack) return null;
// 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 displayName = match({ id: displayPlayerId, outgoing: attack.is_outgoing })
.with({ id: P.number, outgoing: P.boolean }, ({ id }) => playerMap.get(id)?.name || `Error: Player ${id} not found`)
.with({ id: P.nullish, outgoing: P.boolean }, ({ outgoing }) =>
outgoing ? "Unclaimed Territory" : "Error: Incoming attack with no target found",
)
.exhaustive();
const backgroundWidth = calculateBackgroundWidth(attack.troops);
const backgroundColor = attack.is_outgoing ? "rgba(59, 130, 246, 0.5)" : "rgba(239, 68, 68, 0.5)";
return (
<motion.div
layout
layoutId={`attack-row-${rowState.rowId}`}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{
duration: 0.2,
layout: { duration: 0.3, ease: "easeInOut" },
}}
>
<button
className="relative flex justify-between items-center py-2 px-3 border-none bg-slate-900/60 backdrop-blur-sm text-white cursor-pointer transition-all duration-150 text-lg font-inherit w-80 rounded-lg overflow-hidden hover:bg-slate-900/75 opacity-60 hover:opacity-100"
onClick={() => console.log("Attack clicked:", attack)}
onMouseEnter={() => {
if (displayPlayerId !== null) {
onNationHover?.(displayPlayerId);
}
onHoverChange(true);
}}
onMouseLeave={() => {
onNationHover?.(null);
onHoverChange(false);
}}
>
<div
className="absolute top-0 right-0 h-full transition-[width] duration-300"
style={{
width: `${backgroundWidth}%`,
backgroundColor,
}}
/>
<div className="text-left overflow-hidden text-ellipsis whitespace-nowrap flex-1 pr-4 relative z-10">
{displayName}
</div>
<div className="text-right tabular-nums whitespace-nowrap min-w-20 relative z-10">
{formatTroopCount(attack.troops)}
</div>
</button>
</motion.div>
);
}

View File

@@ -0,0 +1,292 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useGameBridge } from "@/shared/api";
import type { AttackEntry, AttacksUpdatePayload, LeaderboardEntry, UnsubscribeFn } from "@/shared/api/types";
import { AttackRow } from "@/shared/components/game/AttackRow";
import { AnimatePresence } from "motion/react";
// Timing constants
const WAIT_BEFORE_DISMISS_MS = 450;
const WAIT_AFTER_HOVER_MS = 250;
const REASSIGNMENT_WINDOW_MS = 2000;
type AttackPhase = "active" | "finished" | "exiting";
interface AttackRowState {
rowId: string; // Frontend-only UUID for this display row
currentAttack: AttackEntry | null; // null if attack has ended
phase: AttackPhase;
isHovered: boolean;
firstAppearanceTime: number; // Timestamp for stable sorting
finishedAt: number | null; // When attack ended (for reassignment window)
dismissTimer: NodeJS.Timeout | null;
}
export function Attacks({ onNationHover }: { onNationHover: (nationId: number | null) => void }) {
const gameBridge = useGameBridge();
const [playerMap, setPlayerMap] = useState<Map<number, LeaderboardEntry>>(new Map());
const [attackRows, setAttackRows] = useState<Map<string, AttackRowState>>(new Map());
// Track current backend attack IDs to detect removals
const currentBackendAttackIds = useRef<Set<number>>(new Set());
// Start exit animation for a row
const startExitAnimation = useCallback((rowId: string) => {
setAttackRows((prevRows) => {
const newRows = new Map(prevRows);
const row = newRows.get(rowId);
if (!row) return prevRows;
// Clear any existing timer
if (row.dismissTimer) {
clearTimeout(row.dismissTimer);
}
// Transition to exiting phase
newRows.set(rowId, {
...row,
phase: "exiting",
dismissTimer: null,
});
// Remove row after exit animation completes (300ms)
setTimeout(() => {
setAttackRows((prev) => {
const next = new Map(prev);
next.delete(rowId);
return next;
});
}, 300);
return newRows;
});
}, []);
// Find a row that can be reassigned to a new attack
const findReassignableRow = useCallback(
(targetId: number | null, rows: Map<string, AttackRowState>, now: number): string | null => {
for (const [rowId, state] of rows) {
// Only reassign if:
// 1. Row is in 'finished' phase (not 'exiting')
// 2. Same target
// 3. Attack ended within reassignment window
if (state.phase !== "finished") continue;
const previousTarget = state.currentAttack?.target_id ?? null;
if (previousTarget !== targetId) continue;
if (state.finishedAt === null) continue;
const timeSinceEnd = now - state.finishedAt;
if (timeSinceEnd > REASSIGNMENT_WINDOW_MS) continue;
return rowId;
}
return null;
},
[],
);
// Reconcile backend attacks with frontend row states
const reconcileAttacks = useCallback(
(payload: AttacksUpdatePayload) => {
const newBackendAttackIds = new Set(payload.entries.map((a) => a.id));
setAttackRows((prevRows) => {
const newRows = new Map(prevRows);
const now = Date.now();
// Process each backend attack
for (const attack of payload.entries) {
// Check if we already have a row displaying this attack
let existingRowId: string | null = null;
for (const [rowId, rowState] of newRows) {
if (rowState.currentAttack?.id === attack.id) {
existingRowId = rowId;
break;
}
}
if (existingRowId) {
// Update existing row
const row = newRows.get(existingRowId)!;
newRows.set(existingRowId, {
...row,
currentAttack: attack,
phase: "active",
finishedAt: null,
});
} else {
// Try to find a reassignable row
const reassignableRowId = findReassignableRow(attack.target_id, newRows, now);
if (reassignableRowId) {
// Reassign existing row to new attack
const row = newRows.get(reassignableRowId)!;
if (row.dismissTimer) {
clearTimeout(row.dismissTimer);
}
newRows.set(reassignableRowId, {
...row,
currentAttack: attack,
phase: "active",
isHovered: false,
finishedAt: null,
dismissTimer: null,
});
} else {
// Create new row
const rowId = crypto.randomUUID();
newRows.set(rowId, {
rowId,
currentAttack: attack,
phase: "active",
isHovered: false,
firstAppearanceTime: now,
finishedAt: null,
dismissTimer: null,
});
}
}
}
// Handle rows whose attacks have ended
for (const [rowId, rowState] of newRows) {
if (rowState.currentAttack && !newBackendAttackIds.has(rowState.currentAttack.id)) {
// Attack ended
if (rowState.phase === "active") {
// Transition to finished
const shouldStartTimer = !rowState.isHovered;
const timer = shouldStartTimer
? setTimeout(() => startExitAnimation(rowId), WAIT_BEFORE_DISMISS_MS)
: null;
newRows.set(rowId, {
...rowState,
currentAttack: rowState.currentAttack,
phase: "finished",
finishedAt: now,
dismissTimer: timer,
});
}
}
}
currentBackendAttackIds.current = newBackendAttackIds;
return newRows;
});
},
[findReassignableRow, startExitAnimation],
);
// Handle hover state changes
const handleRowHover = useCallback(
(rowId: string, isHovered: boolean) => {
setAttackRows((prevRows) => {
const newRows = new Map(prevRows);
const row = newRows.get(rowId);
if (!row) return prevRows;
if (isHovered) {
// Cancel any pending dismiss timer
if (row.dismissTimer) {
clearTimeout(row.dismissTimer);
}
newRows.set(rowId, {
...row,
isHovered: true,
dismissTimer: null,
});
} else {
// Unhovered
newRows.set(rowId, {
...row,
isHovered: false,
});
// If attack is finished, start short timer before dismissing
if (row.phase === "finished") {
const timer = setTimeout(() => startExitAnimation(rowId), WAIT_AFTER_HOVER_MS);
newRows.set(rowId, {
...newRows.get(rowId)!,
dismissTimer: timer,
});
}
}
return newRows;
});
},
[startExitAnimation],
);
useEffect(() => {
if (!gameBridge) return;
let unsubscribeAttacks: UnsubscribeFn = () => {};
let unsubscribeLeaderboard: UnsubscribeFn = () => {};
// Subscribe to leaderboard snapshots to get player names/colors
unsubscribeLeaderboard = gameBridge.onLeaderboardSnapshot((snapshot) => {
setPlayerMap((prevMap) => {
// Start with existing entries to preserve eliminated players
const newMap = new Map(prevMap);
// Add/update entries from the snapshot
let hasChanges = false;
for (const entry of snapshot.entries) {
const prevEntry = prevMap.get(entry.id);
if (!prevEntry || prevEntry.name !== entry.name || prevEntry.color !== entry.color || prevEntry.tile_count !== entry.tile_count || prevEntry.troops !== entry.troops) {
hasChanges = true;
}
newMap.set(entry.id, entry);
}
// Only update if there were actual changes
return hasChanges ? newMap : prevMap;
});
});
// Subscribe to attacks updates
unsubscribeAttacks = gameBridge.onAttacksUpdate((payload) => {
reconcileAttacks(payload);
});
return () => {
unsubscribeAttacks();
unsubscribeLeaderboard();
// Clean up all timers on unmount
setAttackRows((rows) => {
rows.forEach((row) => {
if (row.dismissTimer) {
clearTimeout(row.dismissTimer);
}
});
return rows;
});
};
}, [gameBridge, reconcileAttacks]);
// Sort rows by first appearance time (newest first)
const sortedRows = Array.from(attackRows.values()).sort((a, b) => b.firstAppearanceTime - a.firstAppearanceTime);
// Keep container mounted even when empty to allow exit animations
return (
<div
className="select-none text-[13px] sm:text-[10px] md:text-[11.5px] lg:text-[12px] xl:text-[12px] flex flex-col gap-0.5"
onMouseLeave={() => onNationHover(null)}
>
<AnimatePresence>
{sortedRows.map((row) => (
<AttackRow
key={row.rowId}
rowState={row}
playerMap={playerMap}
onNationHover={onNationHover}
onHoverChange={(hovered) => handleRowHover(row.rowId, hovered)}
/>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useEffect, useRef, useState } from "react";
import { GameRenderer } from "@/shared/render";
import { useGameBridge, type RenderInitData } from "@/shared/api";
import { useThrottledCallback } from "@/shared/hooks";
interface GameCanvasProps {
className?: string;
initialState?: RenderInitData | 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 lastHoveredTileRef = useRef<number | null>(null);
const gameBridge = useGameBridge();
// Initialize renderer once initial state is available
useEffect(() => {
if (!canvasRef.current || !initialState || rendererRef.current) return;
let cancelled = false;
// Create renderer with all required data
GameRenderer.create({
canvas: canvasRef.current,
terrainPalette: initialState.terrain_palette,
terrain: initialState.terrain,
nationPalette: initialState.palette,
initialTerritories: {
turn: initialState.initial_territories.turn,
territories: initialState.initial_territories.territories,
},
})
.then(async (renderer) => {
if (cancelled) {
renderer.destroy();
return;
}
rendererRef.current = renderer;
setIsInitialized(true);
// Register renderer with API for receiving updates
if (gameBridge && typeof gameBridge.setRenderer === "function") {
await gameBridge.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) {
rendererRef.current.destroy();
rendererRef.current = null;
onRendererReady?.(null);
}
setIsInitialized(false);
};
}, [initialState, gameBridge, 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 || !gameBridge) return;
const handleCanvasClick = (e: MouseEvent) => {
if (!rendererRef.current || !gameBridge) 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 gameBridge.sendMapClick === "function") {
const worldPos = rendererRef.current.coordinateMapper.tileIndexToWorld(tileIndex || 0);
gameBridge.sendMapClick({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
button: e.button,
});
}
};
canvas.addEventListener("click", handleCanvasClick);
return () => canvas.removeEventListener("click", handleCanvasClick);
}, [gameBridge, 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);
}
}
// Only send map hover if tile changed
if (tileIndex !== lastHoveredTileRef.current) {
lastHoveredTileRef.current = tileIndex;
if (gameBridge && typeof gameBridge.sendMapHover === "function") {
const worldPos = rendererRef.current.coordinateMapper.tileIndexToWorld(tileIndex);
// Only send if we have valid coordinates
if (isFinite(worldPos.x) && isFinite(worldPos.y)) {
gameBridge.sendMapHover({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
});
}
}
}
}, 10);
// Handle keyboard input
useEffect(() => {
if (!gameBridge) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (typeof gameBridge.sendKeyPress === "function") {
gameBridge.sendKeyPress({
key: e.code,
pressed: true,
});
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (typeof gameBridge.sendKeyPress === "function") {
gameBridge.sendKeyPress({
key: e.code,
pressed: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [gameBridge]);
return (
<div className={className}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onContextMenu={(e) => e.preventDefault()}
style={{
display: "block",
width: "100%",
height: "100%",
cursor: isInitialized ? undefined : "wait",
}}
/>
</div>
);
};

View File

@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useGameBridge } from "@/shared/api";
import type { LeaderboardSnapshot, UnsubscribeFn } from "@/shared/api/types";
import * as motion from "motion/react-client";
import type { Transition } from "motion/react";
// 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)
}
const VISIBLE_TOP_N = 8;
const RENDERED_BUFFER = 15;
const transition: Transition = {
type: "tween",
duration: 0.5,
ease: "easeInOut",
};
function focusNation(nationId: number) {
console.log("Focus nation:", nationId);
}
export function Leaderboard({
initialSnapshot,
highlightedNation,
onNationHover,
}: {
initialSnapshot?: LeaderboardSnapshot | null;
highlightedNation: number | null;
onNationHover: (nationId: number | null) => void;
}) {
const gameBridge = useGameBridge();
const [collapsed, setCollapsed] = useState(false);
const [snapshot, setSnapshot] = useState<LeaderboardSnapshot | null>(initialSnapshot || null);
const [status, setStatus] = useState<"loading" | "waiting" | "ready" | "error">(initialSnapshot ? "ready" : "waiting");
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
useEffect(() => {
if (!gameBridge) return;
let unsubscribe: UnsubscribeFn = () => {};
// Subscribe to leaderboard snapshots
try {
unsubscribe = gameBridge.onLeaderboardSnapshot((snapshotData) => {
setSnapshot(snapshotData);
setStatus("ready");
});
} catch (error) {
console.warn("Failed to subscribe to leaderboard snapshots:", error);
setStatus("error");
}
return () => {
unsubscribe();
};
}, [gameBridge]);
const { topRows, playerEntry, playerInTopN } = useMemo(() => {
if (!snapshot) {
return {
topRows: [],
playerEntry: null,
playerInTopN: false,
};
}
// Sort entries by display_order for visual positioning
const sortedEntries = [...snapshot.entries].sort((a, b) => a.display_order - b.display_order);
// Render top 15 rows for animation buffer (only top 8 will be visible)
const topRows = sortedEntries.slice(0, RENDERED_BUFFER);
// Find player and check if they're in visible top N
const playerEntry = sortedEntries.find((e) => e.id === snapshot.client_player_id);
const visibleTopEntries = sortedEntries.slice(0, VISIBLE_TOP_N);
const playerInTopN = playerEntry ? visibleTopEntries.some((e) => e.id === playerEntry.id) : false;
return {
topRows,
playerEntry,
playerInTopN,
};
}, [snapshot]);
const precision = useMemo(() => {
if (!snapshot || topRows.length === 0) return 0;
const percentages = topRows.map((r) => r.territory_percent);
return calculatePrecision(percentages);
}, [snapshot, topRows]);
// Dynamically calculate container height based on actual row height
useEffect(() => {
if (!tableRef.current || topRows.length === 0) return;
// Wait for next frame to ensure rows are rendered
requestAnimationFrame(() => {
const firstRow = tableRef.current?.querySelector("tr");
if (firstRow) {
const rowHeight = firstRow.getBoundingClientRect().height;
// 8 visible rows with small buffer
setContainerHeight(rowHeight * VISIBLE_TOP_N + 1);
}
});
}, [topRows.length]);
function renderRow(
entry: typeof playerEntry,
isPlayer: boolean,
isHighlighted: boolean,
clickBehavior: "focus" | "collapse" | "expand" | "none",
showActualRank = false, // If true, show entry.rank; otherwise show display_order + 1
animate = true,
) {
if (!entry) return null;
const isClickable = clickBehavior !== "none";
const className = `leading-7 transition-colors duration-150 ${
isClickable ? "cursor-pointer hover:bg-white/[0.06]" : "cursor-default"
} ${isPlayer ? "text-white" : "text-white/75"} ${isHighlighted ? "!bg-white/[0.12]" : ""}`;
const handleClick = () => {
if (clickBehavior === "none") return;
switch (clickBehavior) {
case "focus":
focusNation(entry.id);
break;
case "collapse":
setCollapsed(true);
break;
case "expand":
setCollapsed(false);
break;
}
};
const displayedRank = showActualRank ? entry.rank : entry.display_order + 1;
const content = (
<>
<td className="pl-3 pr-2 text-center whitespace-nowrap w-12 min-w-12 tracking-tighter">{displayedRank}</td>
<td className="pr-3 text-left overflow-hidden text-ellipsis whitespace-nowrap w-[55%]">
<div className="flex items-center gap-2">
<div className="size-3 rounded-full shrink-0 shadow-md" style={{ backgroundColor: `#${entry.color}` }} />
<span>{entry.name}</span>
</div>
</td>
<td className="px-3 text-right whitespace-nowrap w-[20%]">{`${(entry.territory_percent * 100).toFixed(precision)}%`}</td>
<td className="pl-3 pr-4 text-right whitespace-nowrap w-[20%] min-w-20">{entry.troops.toLocaleString()}</td>
</>
);
if (!animate) {
return (
<tr
key={entry.id}
className={className}
onClick={handleClick}
onMouseEnter={() => onNationHover(entry.id)}
onMouseLeave={() => onNationHover(null)}
>
{content}
</tr>
);
}
return (
<motion.tr
key={entry.id}
layout
layoutId={`nation-${entry.id}`}
transition={transition}
className={className}
onClick={handleClick}
onMouseEnter={() => onNationHover(entry.id)}
onMouseLeave={() => onNationHover(null)}
>
{content}
</motion.tr>
);
}
return (
// pointer-events-auto: Required if this component is rendered inside a pointer-events-none container
<div
className="absolute top-0 left-3 z-10 text-sm select-none pointer-events-auto opacity-60 hover:opacity-100 transition-opacity duration-400 max-xl:text-xs max-[800px]:text-[0.72rem] max-[600px]:text-[0.625rem]"
onMouseLeave={() => onNationHover(null)}
>
<div className="bg-slate-900/60 rounded-bl-lg rounded-br-lg w-96">
{status === "ready" && snapshot ? (
<>
<div
className="overflow-hidden"
style={collapsed && playerEntry ? undefined : containerHeight ? { maxHeight: `${containerHeight}px` } : undefined}
>
<table className="w-96 border-collapse" ref={tableRef}>
<tbody>
{collapsed && playerEntry
? renderRow(playerEntry, true, highlightedNation === playerEntry.id, "expand", true, false)
: topRows.map((r) => {
const isPlayer = r.id === snapshot.client_player_id;
const isHighlighted = highlightedNation === r.id;
return renderRow(r, isPlayer, isHighlighted, isPlayer ? "none" : "focus");
})}
</tbody>
</table>
</div>
{!collapsed && playerEntry && (
<div className="bg-slate-900/65 rounded-lg">
<table className="w-96 border-collapse">
<tbody>
{!playerInTopN ? (
renderRow(playerEntry, true, highlightedNation === playerEntry.id, "collapse", true)
) : (
<tr>
<td colSpan={4} className="text-center text-xs text-white/75 py-0.5">
<button className="w-full cursor-pointer" onClick={() => setCollapsed(true)}>
Collapse
</button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</>
) : (
<div className="p-4 text-center text-slate-400 italic">
{status === "loading" && "Loading leaderboard…"}
{status === "waiting" && "Waiting for updates…"}
{status === "error" && "Error loading leaderboard"}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
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="absolute top-4 right-4">
{/* pointer-events-auto: Required if this component is rendered inside a pointer-events-none container */}
<button
className="bg-slate-900/75 border-none rounded-md text-white p-2 cursor-pointer pointer-events-auto flex items-center justify-center transition-colors duration-150 hover:bg-slate-900/90"
onClick={() => setIsOpen((prev) => !prev)}
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{isOpen && (
<div className="absolute top-full mt-2 right-0 bg-slate-900/75 rounded-md min-w-36 overflow-hidden">
<button
className={cn(
"w-full bg-transparent border-none text-white py-1.5 px-4 text-right cursor-pointer font-inter transition-colors duration-150 hover:bg-white/6",
{ "border-t border-white/10": onSettings },
)}
onClick={() => {
setShowConfirmation(true);
setIsClosing(false);
setIsOpen(false);
}}
>
Exit
</button>
</div>
)}
{showConfirmation && (
<div
className={cn(
"fixed inset-0 bg-black/50 flex items-center justify-center",
isClosing ? "animate-[fadeOut_forwards] duration-200 ease-out" : "animate-[fadeIn] duration-200 ease-out",
)}
onClick={closeConfirmation}
>
<div
className={cn(
"bg-zinc-900 rounded-lg p-8 min-w-80 max-w-96 shadow-lg shadow-zinc-950/50",
isClosing
? "animate-[slideDown_forwards] duration-200 ease-out"
: "animate-[slideUp] duration-200 ease-out",
)}
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-white text-xl font-inter font-semibold">Are you sure?</h3>
<p className="mb-6 text-white/80 font-inter leading-normal text-pretty">
You will not be able to return to this game after exiting.
</p>
<div className="flex gap-3 justify-end">
<button
className="border-none rounded-md text-white px-5 cursor-pointer font-inter transition-colors duration-150 ease-in-out bg-white/10 hover:bg-white/15"
onClick={closeConfirmation}
>
Nevermind
</button>
<button
className="border-none rounded-md text-white px-5 cursor-pointer font-inter transition-colors duration-150 bg-red-500 font-medium hover:bg-red-400"
onClick={() => {
setIsClosing(true);
setTimeout(() => {
setShowConfirmation(false);
setIsClosing(false);
onExit();
}, 200);
}}
>
I'm sure
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,517 @@
import { useState, useEffect, useRef, lazy, Suspense } from "react";
import { motion, AnimatePresence, Variants } from "motion/react";
import { Power } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { MultiplayerView } from "@/shared/components/menu/views/Multiplayer";
import { SettingsView } from "@/shared/components/menu/views/Settings";
import { DownloadsView } from "@/shared/components/menu/views/Downloads";
import inlineScreenshot from "@/assets/images/screenshot.png?w=100&format=webp&inline&imagetools";
import singleplayerImage from "@/assets/images/menu/singleplayer.png";
import multiplayerImage from "@/assets/images/menu/multiplayer.png";
import settingsImage from "@/assets/images/menu/settings.png";
import "overlayscrollbars/overlayscrollbars.css";
import "@/shared/styles/animations.css";
import { cn } from "@/lib/utils";
const AlphaWarningModal = lazy(() => import("@/shared/components/overlays/AlphaWarning"));
type MenuView = "home" | "multiplayer" | "settings" | "downloads";
// Animation constants
const TRANSITION_CONFIG = {
duration: 0.25,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
};
const slideVariants: Variants = {
homeEnter: { x: "-200%" },
contentEnter: { x: "200%" },
homeCenter: { x: 0 },
contentCenter: { x: 0 },
homeExit: { x: "-200%" },
contentExit: { x: "200%" },
};
const cardVariants: Variants = {
cardEnter: { y: "100%", opacity: 0 },
cardCenter: { y: 0, opacity: 1 },
cardExit: { y: "100%", opacity: 0 },
};
const footerVariants: Variants = {
visible: { y: 0, opacity: 1 },
hidden: { y: 100, opacity: 0 },
};
const titleVariants: Variants = {
visible: { y: 0, opacity: 1 },
hidden: { y: -100, opacity: 0 },
};
const buttonExitVariants: Variants = {
visible: { scale: 1, opacity: 1 },
hidden: { scale: 0.8, opacity: 0 },
};
const backgroundVariants: Variants = {
visible: { opacity: 1 },
loading: { opacity: 1 },
exit: { opacity: 0 },
};
// Loading percentage constants
const PROGRESS_RATE = 3; // Controls curve speed - higher = faster approach
const PROGRESS_CAP = 95; // Max % before game ready (never naturally reaches 100%)
const FINAL_JUMP_DURATION = 0.2; // Time to jump to 100% when game ready (seconds)
// Shared button styles
const BASE_BUTTON_CLASSES =
"border-none rounded-md cursor-pointer transition-all duration-300 relative overflow-hidden select-none px-4 py-2 text-left shadow-xl flex flex-col justify-start items-start";
const BUTTON_HOVER_CLASSES =
"hover:enabled:-translate-y-1 hover:enabled:shadow-2xl active:enabled:-translate-y-0.5 disabled:cursor-default";
// Internal Components
interface MenuBackgroundProps {
state: "visible" | "loading" | "exit";
duration: number;
onExitComplete?: () => void;
}
function MenuBackground({ state, duration, onExitComplete }: MenuBackgroundProps) {
return (
<motion.div
className="absolute inset-0 w-full h-full bg-gradient-to-br from-slate-50 via-slate-200 to-slate-300 overflow-hidden"
initial="visible"
animate={state}
variants={backgroundVariants}
transition={{
duration: state === "exit" ? duration : 0,
ease: [0.4, 0, 0.2, 1],
}}
onAnimationComplete={(variant) => {
if (variant === "exit") {
onExitComplete?.();
}
}}
>
<img
src={inlineScreenshot}
alt="Menu background"
className="absolute w-full h-full object-cover blur-[1px] brightness-65 sepia-30 contrast-150 scale-105"
/>
<div className="absolute inset-0 bg-white/40 backdrop-blur"></div>
</motion.div>
);
}
interface LoadingPercentageProps {
isLoading: boolean;
gameReady: boolean;
}
function LoadingPercentage({ isLoading, gameReady }: LoadingPercentageProps) {
const [progress, setProgress] = useState(0);
const startTimeRef = useRef<number | null>(null);
const jumpStartTimeRef = useRef<number | null>(null);
const jumpStartProgressRef = useRef<number>(0);
const animationFrameRef = useRef<number | null>(null);
const gameReadyRef = useRef(false);
useEffect(() => {
gameReadyRef.current = gameReady;
}, [gameReady]);
useEffect(() => {
if (!isLoading) {
setProgress(0);
startTimeRef.current = null;
jumpStartTimeRef.current = null;
jumpStartProgressRef.current = 0;
gameReadyRef.current = false;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
startTimeRef.current = Date.now();
const animate = () => {
if (!startTimeRef.current) return;
if (gameReadyRef.current && !jumpStartTimeRef.current) {
// Game just became ready, start final jump
jumpStartTimeRef.current = Date.now();
jumpStartProgressRef.current =
PROGRESS_CAP * (1 - Math.exp(-PROGRESS_RATE * ((Date.now() - startTimeRef.current) / 1000)));
}
if (jumpStartTimeRef.current) {
// Animate final jump to 100%
const elapsed = (Date.now() - jumpStartTimeRef.current) / 1000;
const t = Math.min(elapsed / FINAL_JUMP_DURATION, 1);
const newProgress = jumpStartProgressRef.current + (100 - jumpStartProgressRef.current) * t;
setProgress(newProgress);
if (t < 1) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
setProgress(100);
}
} else {
// Exponential curve animation
const elapsed = (Date.now() - startTimeRef.current) / 1000;
const newProgress = PROGRESS_CAP * (1 - Math.exp(-PROGRESS_RATE * elapsed));
setProgress(newProgress);
animationFrameRef.current = requestAnimationFrame(animate);
}
};
animate();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isLoading]);
if (!isLoading) return null;
const value = Math.round(progress);
const hundreds = value >= 100 ? "1" : "";
const tens = value >= 10 ? Math.floor((value % 100) / 10).toString() : "";
const ones = (value % 10).toString();
const slotStyle = {
fontSize: "clamp(15rem, 35vw, 40rem)",
opacity: 0.15,
textShadow: "2px 2px 4px rgba(0, 0, 0, 0.4), -1px -1px 2px rgba(255, 255, 255, 0.1)",
width: "1ch",
textAlign: "center" as const,
};
return (
<motion.div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
initial={{ opacity: 1 }}
animate={{ opacity: gameReadyRef.current ? 0 : 1 }}
transition={{ duration: gameReadyRef.current ? 0.8 : 0 }}
>
<div className="flex font-oswald font-bold text-white select-none leading-none" style={{ marginRight: "clamp(10.5rem, 24.5vw, 28rem)" }}>
<span style={slotStyle}>{hundreds}</span>
<span style={slotStyle}>{tens}</span>
<span style={slotStyle}>{ones}</span>
<span style={slotStyle}>%</span>
</div>
</motion.div>
);
}
interface MenuHeaderProps {
shouldHide: boolean;
duration: number;
}
function MenuHeader({ shouldHide, duration }: MenuHeaderProps) {
return (
// Click-through header: pointer-events-none prevents blocking interactions with elements below
<motion.div
className="flex flex-col items-center gap-2 w-full py-8 mt-8 bg-zinc-900/45 backdrop-blur-lg backdrop-contrast-150 pointer-events-none"
initial="visible"
animate={shouldHide ? "hidden" : "visible"}
variants={titleVariants}
transition={{ duration, ease: TRANSITION_CONFIG.ease }}
>
<h1 className="font-oswald uppercase text-8xl font-semibold select-none text-white m-0 leading-[0.8] text-shadow-lg animate-[shimmer] duration-3000 ease-in-out">
Iron Borders
</h1>
</motion.div>
);
}
interface MenuFooterProps {
shouldHide: boolean;
onExit: () => void;
onVersionClick: () => void;
duration: number;
}
function MenuFooter({ shouldHide, onExit, onVersionClick, duration }: MenuFooterProps) {
return (
// Click-through container: pointer-events-none allows clicks through to menu cards.
// Interactive children (buttons, links) use pointer-events-auto to re-enable interaction.
<motion.div
className="fixed bottom-6 inset-x-0 flex justify-between items-end pr-12 pl-8 pointer-events-none z-50"
initial="visible"
animate={shouldHide ? "hidden" : "visible"}
variants={footerVariants}
transition={{ duration, ease: TRANSITION_CONFIG.ease }}
>
<div className="flex items-end">
{__DESKTOP__ && (
<button
className="flex items-center justify-center bg-transparent border-none cursor-pointer transition-all duration-200 text-white opacity-85 mix-blend-screen pointer-events-auto drop-shadow-lg hover:opacity-100 hover:scale-110 hover:drop-shadow-md active:scale-90 focus-visible:outline-2 focus-visible:outline-white/50 focus-visible:outline-offset-4"
onClick={onExit}
>
<Power size={40} strokeWidth={2.5} />
</button>
)}
</div>
<div className="flex flex-col items-end">
<button
className="font-oswald text-3xl font-bold p-0 bg-transparent text-white border-none cursor-pointer transition-all duration-200 pointer-events-auto select-none uppercase tracking-tight inline-block relative shadow-none drop-shadow-md hover:-translate-y-px active:translate-y-0 active:bg-transparent leading-4"
onClick={onVersionClick}
title={`Git: ${__GIT_COMMIT__.substring(0, 7)}\nBuild: ${new Date(__BUILD_TIME__).toLocaleString()}`}
>
<span className="drop-shadow-md">
{__APP_VERSION__ === "0.0.0" ? "Unknown Version" : `v${__APP_VERSION__.trimEnd()}`}
</span>
<span className="text-[#ffc56d] absolute -right-3 top-0 drop-shadow-md">*</span>
</button>
<div className="pointer-events-auto select-none">
<a
className="text-shadow-md text-gray-50 hover:text-gray-100 transition-colors duration-200"
href="https://github.com/Xevion"
target="_blank"
>
© 2025 Ryan Walters
</a>
</div>
</div>
</motion.div>
);
}
type ButtonColorScheme = "orange" | "blue" | "beige";
interface MenuButtonProps {
title: string;
description: string;
onClick: () => void;
colorScheme: ButtonColorScheme;
image?: string;
imagePosition?: "left" | "right";
className?: string;
disabled?: boolean;
}
function MenuButton({
title,
description,
onClick,
colorScheme,
image,
imagePosition = "right",
className,
disabled = false,
}: MenuButtonProps) {
const colorClasses = {
orange: "bg-[#c77a06] text-white active:enabled:bg-[#a66305]",
blue: "bg-[#4591c0] text-white active:enabled:bg-[#3c7397]",
beige: "bg-[#f1ebdb] text-[#424c4a] active:enabled:bg-[#d9d1bb]",
};
const hasWhiteText = colorScheme === "orange" || colorScheme === "blue";
return (
<button
className={cn(
BASE_BUTTON_CLASSES,
BUTTON_HOVER_CLASSES,
colorClasses[colorScheme],
{ BUTTON_TEXT_SHADOW: hasWhiteText },
className,
)}
onClick={onClick}
disabled={disabled}
>
{image && (
<img
src={image}
alt=""
className={cn(
"absolute bottom-0 w-full h-auto object-contain pointer-events-none z-0 drop-shadow-lg [image-rendering:pixelated]",
imagePosition === "left" ? "left-0" : "right-0",
)}
/>
)}
<div>
<h2 className="font-oswald text-[2rem] font-bold mb-1 uppercase tracking-wide">{title}</h2>
<p className="leading-6 opacity-95">{description}</p>
</div>
</button>
);
}
interface MenuScreenProps {
onStartSingleplayer: () => void;
onExit: () => void;
isExiting?: boolean;
gameReady?: boolean;
onExitComplete?: () => void;
}
export function MenuScreen({ onStartSingleplayer, onExit, isExiting = false, gameReady = false, onExitComplete }: MenuScreenProps) {
const [activeView, setActiveView] = useState<MenuView>("home");
const [isModalOpen, setIsModalOpen] = useState(false);
const [transitionStartTime, setTransitionStartTime] = useState<number | null>(null);
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
const view = (event.state?.view as MenuView) || "home";
setActiveView(view);
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
const handleSingleplayerClick = () => {
setTransitionStartTime(Date.now());
onStartSingleplayer();
};
const handleNavigate = (view: MenuView) => {
setActiveView(view);
window.history.pushState({ view }, "");
};
const handleBackToHome = () => {
setActiveView("home");
window.history.replaceState({ view: "home" }, "");
};
// Calculate transition timings based on load speed
const loadTime = transitionStartTime && gameReady ? Date.now() - transitionStartTime : null;
const isFastLoad = loadTime !== null && loadTime < 500;
// Transition durations (in seconds)
const componentExitDuration = isFastLoad ? 0.15 : 0.25;
const backgroundFadeDuration = isFastLoad ? 0.5 : 0.8;
// Determine current state for animations
const shouldHideChrome = activeView === "multiplayer" || isExiting;
const backgroundState = !isExiting ? "visible" : gameReady ? "exit" : "loading";
const handleBackgroundExitComplete = () => {
if (gameReady && backgroundState === "exit") {
onExitComplete?.();
}
};
return (
<div className="fixed inset-0 flex items-center justify-center select-none z-50">
<MenuBackground state={backgroundState} duration={backgroundFadeDuration} onExitComplete={handleBackgroundExitComplete} />
<LoadingPercentage isLoading={backgroundState === "loading" || backgroundState === "exit"} gameReady={gameReady} />
<OverlayScrollbarsComponent
className="absolute inset-0 w-full h-full z-1 overflow-x-hidden"
defer
options={{ scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } }}
>
<div className="flex flex-col items-center text-center w-full min-h-full">
<MenuHeader shouldHide={shouldHideChrome} duration={componentExitDuration} />
<AnimatePresence mode="wait" initial={false}>
{activeView === "home" ? (
<motion.div
key="home"
className="grid grid-cols-[1fr_2fr] gap-4 w-full max-w-3xl my-12 mx-auto pointer-events-auto"
initial="homeEnter"
animate={isExiting ? "hidden" : "homeCenter"}
exit="homeExit"
variants={{ ...slideVariants, ...buttonExitVariants }}
transition={{
duration: isExiting ? componentExitDuration : TRANSITION_CONFIG.duration,
ease: TRANSITION_CONFIG.ease,
}}
>
<MenuButton
title="Local"
description="Battle against AI opponents in strategic territorial warfare"
onClick={handleSingleplayerClick}
colorScheme="orange"
image={singleplayerImage}
imagePosition="left"
className="rounded-md shadow-lg aspect-[1/2]"
disabled={isExiting}
/>
<div className="flex flex-col gap-[1.05rem] aspect-square">
<MenuButton
title="Multiplayer"
description="Challenge other players in real-time matches"
onClick={() => handleNavigate("multiplayer")}
colorScheme="blue"
image={multiplayerImage}
className="flex-1"
/>
<MenuButton
title="Settings"
description="Customize your experience"
onClick={() => handleNavigate("settings")}
colorScheme="beige"
image={settingsImage}
className="flex-1"
/>
</div>
{!__DESKTOP__ && (
<MenuButton
title="Downloads"
description="Get standalone versions for Windows, Mac, and Linux"
onClick={() => handleNavigate("downloads")}
colorScheme="beige"
className="col-span-full"
/>
)}
</motion.div>
) : activeView === "multiplayer" ? (
<motion.div
key="multiplayer"
className="absolute inset-0 w-full h-full min-h-full"
initial="contentEnter"
animate="contentCenter"
exit="contentExit"
variants={slideVariants}
transition={TRANSITION_CONFIG}
>
<MultiplayerView onBack={handleBackToHome} />
</motion.div>
) : (
<motion.div
key={activeView}
className="grid grid-cols-[1fr_2fr] gap-4 w-full max-w-3xl my-12 mx-auto pointer-events-auto"
initial="cardEnter"
animate="cardCenter"
exit="cardExit"
variants={cardVariants}
transition={TRANSITION_CONFIG}
>
{activeView === "settings" && <SettingsView />}
{activeView === "downloads" && <DownloadsView />}
</motion.div>
)}
</AnimatePresence>
</div>
</OverlayScrollbarsComponent>
<MenuFooter
shouldHide={shouldHideChrome}
onExit={onExit}
onVersionClick={() => setIsModalOpen(true)}
duration={componentExitDuration}
/>
<Suspense fallback={null}>
<AlphaWarningModal open={isModalOpen} onOpenChange={setIsModalOpen} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Download } from "lucide-react";
import { FeatureCard } from "@/shared/components/menu/views/FeatureCard";
export function DownloadsView() {
return (
<div className="col-span-full flex items-center justify-center">
<div className="w-full max-w-4xl bg-parchment/95 backdrop-blur-xl rounded-xl shadow-2xl p-10 box-border overflow-y-auto">
<h1 className="font-oswald text-5xl font-bold uppercase m-0 mb-6 text-[#424c4a] tracking-wide text-center">
Downloads
</h1>
<div className="flex flex-col gap-6">
<p className="font-sans text-2xl font-normal opacity-80 m-0 text-center">Standalone applications coming soon...</p>
<div className="flex flex-col gap-6 mt-8">
<FeatureCard
title="Windows"
description="Native desktop application for Windows 10+"
icon={<Download size={24} />}
/>
<FeatureCard
title="macOS"
description="Universal binary for Apple Silicon and Intel Macs"
icon={<Download size={24} />}
/>
<FeatureCard
title="Linux"
description="AppImage and Debian packages available"
icon={<Download size={24} />}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
export interface FeatureCardProps {
title: string;
description: string;
icon?: React.ReactNode;
variant?: "default" | "multiplayer";
className?: string;
}
export function FeatureCard({ title, description, icon, variant = "default", className }: FeatureCardProps) {
return (
<div
className={cn(
"p-8 rounded-xl shadow-lg transition-all duration-200",
"hover:-translate-y-0.5 hover:shadow-xl",
variant === "multiplayer" && "bg-white/10 backdrop-blur-sm",
className,
)}
>
{icon ? (
<div className="flex items-center gap-2 mb-2">
{icon}
<h3 className="font-oswald text-3xl md:text-2xl font-semibold uppercase tracking-tight m-0">{title}</h3>
</div>
) : (
<h3 className="font-oswald text-3xl md:text-2xl font-semibold uppercase tracking-tight m-0 mb-2">{title}</h3>
)}
<p className="font-sans text-base md:text-sm m-0 opacity-90 leading-normal">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { ArrowLeft } from "lucide-react";
import { FeatureCard } from "@/shared/components/menu/views/FeatureCard";
interface MultiplayerViewProps {
onBack: () => void;
}
export function MultiplayerView({ onBack }: MultiplayerViewProps) {
return (
<div className="w-full min-h-full flex flex-col p-12 box-border bg-gradient-to-br from-[#4591c0] to-[#3c7397] text-white">
<button
className="fixed top-8 left-8 flex items-center gap-2 px-5 py-3 bg-white/15 backdrop-blur-lg border-none rounded-lg text-white font-sans text-base font-semibold cursor-pointer transition-all duration-200 z-50 shadow-md hover:bg-white/25 hover:-translate-x-1 hover:shadow-xl active:-translate-x-0.5"
onClick={onBack}
aria-label="Back to menu"
>
<ArrowLeft size={32} strokeWidth={2.5} />
<span>Back</span>
</button>
<div className="max-w-4xl w-full mx-auto pt-16">
<h1 className="font-oswald text-7xl font-bold uppercase mb-8 drop-shadow-lg tracking-wide">Multiplayer</h1>
<div className="flex flex-col gap-8">
<p className="font-sans text-2xl font-normal opacity-80 m-0 text-center">Multiplayer lobby coming soon...</p>
<div className="flex flex-col gap-6 mt-8">
<FeatureCard
title="Real-time Matches"
description="Challenge players from around the world"
variant="multiplayer"
/>
<FeatureCard
title="Ranked Play"
description="Climb the leaderboard and prove your strategic prowess"
variant="multiplayer"
/>
<FeatureCard title="Custom Lobbies" description="Create private matches with friends" variant="multiplayer" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { FeatureCard } from "@/shared/components/menu/views/FeatureCard";
export function SettingsView() {
return (
<div className="col-span-full flex items-center justify-center">
<div className="w-full max-w-4xl bg-parchment/95 backdrop-blur-xl rounded-xl shadow-2xl p-10 box-border overflow-y-auto">
<h1 className="font-oswald text-5xl font-bold uppercase m-0 mb-6 text-[#424c4a] tracking-wide text-center">
Settings
</h1>
<div className="flex flex-col gap-6">
<p className="font-sans text-2xl font-normal opacity-80 m-0 text-center">Settings panel coming soon...</p>
<div className="flex flex-col gap-6 mt-8">
<FeatureCard title="Graphics" description="Adjust visual quality and performance" />
<FeatureCard title="Audio" description="Configure sound effects and music volume" />
<FeatureCard title="Controls" description="Customize keybindings and input preferences" />
<FeatureCard title="Accessibility" description="Enable colorblind modes and UI scaling" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import * as Dialog from "@radix-ui/react-dialog";
import { BlurOverlay } from "@/shared/components/overlays/BlurOverlay";
interface AlphaWarningModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AlphaWarningModal({ open, onOpenChange }: AlphaWarningModalProps) {
return (
<BlurOverlay open={open} onOpenChange={onOpenChange}>
<div className="flex flex-col items-center justify-center max-w-lg text-center gap-6 select-none mx-6">
<Dialog.Title className="font-oswald text-6xl font-extrabold uppercase tracking-wide text-white text-shadow-md">
Alpha Software
</Dialog.Title>
<Dialog.Description className="text-2xl leading-7 text-slate-100 text-shadow-sm">
This application is in early development. Expect bugs, missing features, and breaking changes. Your feedback is
appreciated as we continue building.
</Dialog.Description>
</div>
</BlurOverlay>
);
}
export default AlphaWarningModal;

View File

@@ -0,0 +1,51 @@
import { X } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { MouseEvent, ReactNode, useMemo } from "react";
import "@/shared/styles/animations.css";
interface BlurOverlayProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
showCloseButton?: boolean;
}
export function BlurOverlay({ open, onOpenChange, children, showCloseButton = true }: BlurOverlayProps) {
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
onOpenChange?.(false);
}
};
const closeButton = useMemo(() => {
return (
<div className="absolute top-12 right-12 max-md:top-6 max-md:right-6">
<Dialog.Close asChild>
<button
className="flex items-center justify-center cursor-pointer transition-all duration-200 text-white opacity-60 mix-blend-screen hover:opacity-100 hover:scale-110 active:scale-90 focus:outline-none focus-visible:outline-2 focus-visible:outline-white/50 focus-visible:outline-offset-4"
aria-label="Close"
>
<X size={64} strokeWidth={2.5} />
</button>
</Dialog.Close>
</div>
);
}, []);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange ?? undefined}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 fade-blur-in" />
<Dialog.Content
className="fixed inset-0 flex flex-col items-center justify-center outline-none fade-in"
onPointerDown={handleBackdropClick}
>
{showCloseButton && closeButton}
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export default BlurOverlay;

View File

@@ -0,0 +1,59 @@
import type { GameOutcome } from "@/shared/api/types";
interface GameEndOverlayProps {
outcome: GameOutcome;
onSpectate: () => void;
onExit: () => void;
}
export function GameEndOverlay({ outcome, onSpectate, onExit }: GameEndOverlayProps) {
return (
<div className="fixed top-0 left-0 right-0 bottom-0 flex items-center justify-center z-[1000] pointer-events-none">
<div className="pointer-events-auto user-select-none w-80 text-sm">
{/* Victory/Defeat text */}
<div className="bg-slate-900/75 p-6 text-center rounded-t-md">
<div className="font-oswald text-2xl font-medium mb-2">{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 className="font-inter text-lg font-medium opacity-85">
{outcome === "Victory" ? "You conquered the map." : "Your nation fell."}
</div>
</div>
{/* Button row */}
<div className="flex bg-slate-900/60 rounded-b-md">
<button
className="bg-transparent flex-1 p-3 border-none text-white font-inter text-lg font-medium transition-colors duration-150 hover:bg-white/6 cursor-pointer"
onClick={onSpectate}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.06)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
Spectate
</button>
<button
className="bg-transparent flex-1 p-3 border-none text-white font-inter text-lg font-medium transition-colors duration-150 hover:bg-white/6 cursor-pointer"
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>
);
}
export default GameEndOverlay;

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
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 (
// Timeout bar only - sits at top of UI container
<div className="w-full h-2 pointer-events-none relative bg-black/50 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-500 to-lime-400"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "@/shared/hooks/useThrottledCallback";

View File

@@ -0,0 +1,72 @@
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;
}

View File

@@ -0,0 +1,347 @@
import { Container } from "pixi.js";
// Camera zoom constraints (shared with GameRenderer)
const MIN_CAMERA_SCALE = 0.5;
const MAX_CAMERA_SCALE = 13.5;
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 readonly minScale = MIN_CAMERA_SCALE;
private readonly maxScale = MAX_CAMERA_SCALE;
// 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;
constructor(container: Container, canvas: HTMLCanvasElement) {
this.container = container;
this.canvas = canvas;
this.setupEventListeners(canvas);
this.startAnimationLoop();
}
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;
}
// 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);
// 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";
// 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, animate: boolean = false) {
// Convert tile coordinates to world position (center of tile)
const worldX = tileX + 0.5;
const worldY = tileY + 0.5;
// 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;
}
}
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);
}
}
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;
}
}
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;
}
// 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;
}
}

View File

@@ -0,0 +1,92 @@
/**
* Utility for converting between tile indices, tile coordinates, and world positions.
*
* Coordinate systems:
* - Tile index: Linear index into terrain array (0 to width*height-1)
* - Tile coords: (x, y) grid coordinates (0,0 = top-left corner)
* - World position: Centered coordinates where (0,0) = map center, used for rendering
*/
export class CoordinateMapper {
private mapWidth: number;
private mapHeight: number;
constructor(mapWidth: number, mapHeight: number) {
this.mapWidth = mapWidth;
this.mapHeight = mapHeight;
}
/**
* Convert tile index to tile coordinates (x, y)
*/
tileIndexToCoords(tileIndex: number): { x: number; y: number } {
return {
x: tileIndex % this.mapWidth,
y: Math.floor(tileIndex / this.mapWidth),
};
}
/**
* Convert tile coordinates (x, y) to tile index
*/
coordsToTileIndex(tileX: number, tileY: number): number {
return tileY * this.mapWidth + tileX;
}
/**
* Convert tile index to world position (centered at tile center)
*/
tileIndexToWorld(tileIndex: number): { x: number; y: number } {
const { x, y } = this.tileIndexToCoords(tileIndex);
return this.tileCoordsToWorld(x, y);
}
/**
* Convert tile coordinates to world position (centered at tile center)
*/
tileCoordsToWorld(tileX: number, tileY: number): { x: number; y: number } {
const worldX = tileX + 0.5 - this.mapWidth / 2;
const worldY = tileY + 0.5 - this.mapHeight / 2;
return { x: worldX, y: worldY };
}
/**
* Convert world position to tile index (returns null if out of bounds)
*/
worldToTileIndex(worldX: number, worldY: number): number | null {
const adjustedX = worldX + this.mapWidth / 2;
const adjustedY = worldY + this.mapHeight / 2;
const tileX = Math.floor(adjustedX);
const tileY = Math.floor(adjustedY);
if (tileX >= 0 && tileX < this.mapWidth && tileY >= 0 && tileY < this.mapHeight) {
return this.coordsToTileIndex(tileX, tileY);
}
return null;
}
/**
* Check if tile coordinates are within map bounds
*/
isInBounds(tileX: number, tileY: number): boolean {
return tileX >= 0 && tileX < this.mapWidth && tileY >= 0 && tileY < this.mapHeight;
}
/**
* Check if tile index is within map bounds
*/
isValidTileIndex(tileIndex: number): boolean {
return tileIndex >= 0 && tileIndex < this.mapWidth * this.mapHeight;
}
/**
* Get map dimensions in world units
*/
getMapWorldSize(): { width: number; height: number } {
return {
width: this.mapWidth,
height: this.mapHeight,
};
}
}

View File

@@ -0,0 +1,314 @@
import { Application, Container, WebGPURenderer, Renderer, WebGLRenderer } from "pixi.js";
import { CameraController } from "@/shared/render/CameraController";
import { TerritoryLayer } from "@/shared/render/TerritoriesRenderer";
import { ShipLayer } from "@/shared/render/ShipsRenderer";
import { CoordinateMapper } from "@/shared/render/CoordinateMapper";
import { TerrainTextureBuilder } from "@/shared/render/TerrainTextureBuilder";
import type { ShipsUpdatePayload } from "@/shared/api/types";
import { RgbColor } from "@/shared/render";
// Renderer configuration constants
const DEFAULT_BACKGROUND_COLOR = 0x1a1a2e; // Dark blue-grey
const MIN_CAMERA_SCALE = 0.5;
const MAX_CAMERA_SCALE = 13.5;
const FIT_ZOOM_PADDING = 0.8; // 0.8 = 20% padding around map when fitting to viewport
const GAME_TICKS_PER_SECOND = 10;
const TARGET_FPS = 60;
export interface TileChange {
index: number;
owner_id: number;
}
export interface TerrainData {
size: { x: number; y: number }; // Normalized from glam::U16Vec2 (WASM converts [x, y] → { x, y })
terrain_data: Uint8Array | number[]; // Tile type IDs (u8 values from backend)
}
export interface PaletteData {
colors: Array<RgbColor>;
}
export interface GameRendererConfig {
canvas: HTMLCanvasElement;
terrainPalette: PaletteData;
terrain: TerrainData;
nationPalette: PaletteData;
initialTerritories: {
turn: number;
territories: { indices: Uint32Array; ownerIds: Uint16Array };
};
}
export class GameRenderer {
private readonly app: Application;
private readonly territoryLayer: TerritoryLayer;
private readonly shipLayer: ShipLayer;
private readonly cameraController: CameraController;
public readonly coordinateMapper: CoordinateMapper;
private readonly resizeHandler: () => void;
private constructor(
app: Application,
territoryLayer: TerritoryLayer,
shipLayer: ShipLayer,
cameraController: CameraController,
coordinateMapper: CoordinateMapper,
resizeHandler: () => void,
) {
this.app = app;
this.territoryLayer = territoryLayer;
this.shipLayer = shipLayer;
this.cameraController = cameraController;
this.coordinateMapper = coordinateMapper;
this.resizeHandler = resizeHandler;
}
static async create(config: GameRendererConfig): Promise<GameRenderer> {
const { canvas, terrainPalette, terrain, nationPalette, initialTerritories } = config;
// 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
const app = new Application();
await app.init({
canvas,
width,
height,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
antialias: false,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Verify initialization succeeded
if (!app.stage) {
throw new Error("PixiJS Application failed to initialize - stage is null");
}
// Create main container
const mainContainer = new Container();
app.stage.addChild(mainContainer);
// Create layers container (this gets transformed by camera)
const layersContainer = new Container();
layersContainer.sortableChildren = true; // Enable z-index sorting
mainContainer.addChild(layersContainer);
// Extract terrain dimensions
const mapWidth = terrain.size.x;
const mapHeight = terrain.size.y;
// Create coordinate mapper
const coordinateMapper = new CoordinateMapper(mapWidth, mapHeight);
// Layer z-index ordering: 0=terrain (bottom), 1=territories, 2=ships (top)
// 1. Terrain layer (water/land/mountains)
const terrainLayer = new Container();
terrainLayer.zIndex = 0;
layersContainer.addChild(terrainLayer);
const { sprite: terrainSprite } = TerrainTextureBuilder.createTerrainTexture(
terrain.terrain_data,
terrainPalette.colors,
mapWidth,
mapHeight,
);
terrainLayer.addChild(terrainSprite);
// 2. Territory layer (nation borders and ownership)
const territoryLayer = new TerritoryLayer(mapWidth, mapHeight, nationPalette.colors);
territoryLayer.container.zIndex = 1;
layersContainer.addChild(territoryLayer.container);
territoryLayer.applySnapshot(initialTerritories.territories);
// 3. Ship layer (animated units moving between territories)
const shipLayer = new ShipLayer(coordinateMapper, nationPalette.colors);
shipLayer.container.zIndex = 2;
layersContainer.addChild(shipLayer.container);
// Initialize camera controller
const cameraController = new CameraController(layersContainer, canvas);
// Handle window resize
const resizeHandler = () => {
const parent = canvas.parentElement;
if (parent) {
const resizeWidth = parent.clientWidth;
const resizeHeight = parent.clientHeight;
app.renderer.resize(resizeWidth, resizeHeight);
canvas.style.width = `${resizeWidth}px`;
canvas.style.height = `${resizeHeight}px`;
}
};
window.addEventListener("resize", resizeHandler);
// Add ticker for ship interpolation updates
app.ticker.add((ticker) => {
// Convert frame deltaTime to game ticks
// deltaTime is in frames (60fps = 1.0), convert to game ticks per frame
const tickDelta = ticker.deltaTime * (GAME_TICKS_PER_SECOND / TARGET_FPS);
shipLayer.update(tickDelta);
});
// Force an immediate render to ensure texture is processed
app.renderer.render(app.stage);
// Center camera on map and set initial zoom
// Calculate zoom to fit map in viewport with padding
const fitZoom = Math.min(width / mapWidth, height / mapHeight) * FIT_ZOOM_PADDING;
// Clamp zoom to valid range
const initialZoom = Math.max(MIN_CAMERA_SCALE, Math.min(MAX_CAMERA_SCALE, fitZoom));
// Apply zoom first
cameraController.setZoom(initialZoom, false);
// Center on world origin (0, 0) since terrain sprite is already centered there
cameraController.panBy(width / 2, height / 2, false);
// Create and return the renderer instance
return new GameRenderer(app, territoryLayer, shipLayer, cameraController, coordinateMapper, resizeHandler);
}
// Update ships with new variant-based system
updateShips(data: ShipsUpdatePayload) {
this.shipLayer.processUpdates(data.updates);
}
// Handle binary territory delta (used by both WASM and 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);
// Turn number at offset 0-7, not currently used
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.territoryLayer.applyDelta(changes);
}
// 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);
return this.coordinateMapper.worldToTileIndex(worldPos.x, worldPos.y);
}
// Get nation ID at a tile index
getNationAtTile(tileIndex: number | null): number | null {
if (tileIndex === null) {
return null;
}
const { x: tileX, y: tileY } = this.coordinateMapper.tileIndexToCoords(tileIndex);
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);
}
// Get renderer information for analytics
getRendererInfo(): {
renderer: string;
gpu_vendor?: string;
gpu_device?: string;
} {
if (!this.app || !this.app.renderer) {
return {
renderer: "unknown",
};
}
const renderer = this.app.renderer;
const isWebGLRenderer = (renderer: Renderer): renderer is WebGLRenderer => {
return (renderer as any).gl != undefined;
};
const isWebGPURenderer = (renderer: Renderer): renderer is WebGPURenderer => {
return (renderer as any).gpu != undefined;
};
let gpuVendor: string | undefined;
let gpuDevice: string | undefined;
let rendererName = "unknown";
// Try to extract GPU info from WebGPU adapter
if (isWebGPURenderer(renderer)) {
const gpuAdapter = renderer.gpu?.adapter;
gpuVendor = gpuAdapter.info?.vendor;
gpuDevice = gpuAdapter.info?.device;
rendererName = "webgpu";
}
// Fallback to WebGL renderer info
else if (isWebGLRenderer(renderer)) {
const gl = (renderer as WebGLRenderer).gl;
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (debugInfo) {
gpuVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
gpuDevice = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
}
rendererName = "webgl";
}
return {
renderer: rendererName,
gpu_vendor: gpuVendor,
gpu_device: gpuDevice,
};
}
// Clean up
destroy() {
// Remove window resize listener
window.removeEventListener("resize", this.resizeHandler);
// Destroy camera controller
this.cameraController.destroy();
// Destroy layers
this.territoryLayer.container.destroy({ children: true });
this.shipLayer.destroy();
// Destroy PixiJS application (this will clean up all containers and textures)
this.app.destroy(true, { children: true, texture: true });
}
}

View File

@@ -0,0 +1,184 @@
import { Container, Graphics } from "pixi.js";
import { CoordinateMapper } from "@/shared/render/CoordinateMapper";
import type { ShipUpdateVariant } from "@/shared/api/types";
import { RgbColor } from "@/shared/render";
// Must match backend SHIP_TICKS_PER_TILE constant
const SHIP_TICKS_PER_TILE = 1;
// Ship visual size constants
const MIN_SHIP_SIZE = 0.15; // Minimum radius for ships with few troops
const MAX_SHIP_SIZE = 0.5; // Maximum radius for large troop counts
const SHIP_SIZE_SCALE_FACTOR = 1001; // Logarithmic scale factor (1 troop = MIN, 1000 troops ≈ MAX)
interface ShipState {
owner_id: number;
path: number[];
current_path_index: number;
ticks_until_move: number;
troops: number;
sprite: Container;
}
export class ShipLayer {
public readonly container: Container;
private readonly ships: Map<number, ShipState>;
private readonly palette: Array<RgbColor>;
private readonly coordinateMapper: CoordinateMapper;
constructor(coordinateMapper: CoordinateMapper, palette: Array<RgbColor>) {
this.container = new Container();
this.container.zIndex = 2; // Above territory layer
this.ships = new Map();
this.palette = palette;
this.coordinateMapper = coordinateMapper;
}
/**
* Process ship update variants (Create/Move/Destroy)
*/
processUpdates(updates: ShipUpdateVariant[]) {
for (const update of updates) {
switch (update.type) {
case "Create":
this.createShip(update);
break;
case "Move":
this.moveShip(update);
break;
case "Destroy":
this.destroyShip(update);
break;
}
}
}
/**
* Create a new ship with full state
*/
private createShip(update: Extract<ShipUpdateVariant, { type: "Create" }>) {
const sprite = this.createShipSprite(update.owner_id, update.troops);
const shipState: ShipState = {
owner_id: update.owner_id,
path: update.path,
current_path_index: 0,
ticks_until_move: SHIP_TICKS_PER_TILE,
troops: update.troops,
sprite,
};
this.ships.set(update.id, shipState);
this.container.addChild(sprite);
// Position at start of path
this.updateShipPosition(shipState);
}
/**
* Update ship to next tile in path
*/
private moveShip(update: Extract<ShipUpdateVariant, { type: "Move" }>) {
const ship = this.ships.get(update.id);
if (!ship) return;
ship.current_path_index = update.current_path_index;
ship.ticks_until_move = SHIP_TICKS_PER_TILE;
this.updateShipPosition(ship);
}
/**
* Remove ship from map
*/
private destroyShip(update: Extract<ShipUpdateVariant, { type: "Destroy" }>) {
const ship = this.ships.get(update.id);
if (!ship) return;
this.container.removeChild(ship.sprite);
ship.sprite.destroy({ children: true });
this.ships.delete(update.id);
}
/**
* Create a ship sprite (circle with logarithmic size scaling)
*/
private createShipSprite(owner_id: number, troops: number): Container {
const container = new Container();
const color: RgbColor = this.palette[owner_id] || { r: 128, g: 128, b: 128 };
const hexColor = (color.r << 16) | (color.g << 8) | color.b;
const graphics = new Graphics();
// Logarithmic size scaling based on troop count
const size = MIN_SHIP_SIZE + (Math.log10(troops + 1) / Math.log10(SHIP_SIZE_SCALE_FACTOR)) * (MAX_SHIP_SIZE - MIN_SHIP_SIZE);
// Draw circle
graphics.circle(0, 0, size);
graphics.fill({ color: hexColor, alpha: 0.9 });
container.addChild(graphics);
return container;
}
/**
* Update ship position with interpolation
*/
private updateShipPosition(ship: ShipState) {
const currentTile = ship.path[ship.current_path_index];
const nextTile = ship.path[ship.current_path_index + 1];
// Get world position of current tile
const currentWorld = this.coordinateMapper.tileIndexToWorld(currentTile);
let worldX = currentWorld.x;
let worldY = currentWorld.y;
// Interpolate to next tile if available
if (nextTile !== undefined) {
const nextWorld = this.coordinateMapper.tileIndexToWorld(nextTile);
// Calculate interpolation progress (0 to 1)
const progress = 1.0 - ship.ticks_until_move / SHIP_TICKS_PER_TILE;
// Lerp between current and next position
worldX = worldX + (nextWorld.x - worldX) * progress;
worldY = worldY + (nextWorld.y - worldY) * progress;
}
ship.sprite.x = worldX;
ship.sprite.y = worldY;
}
/**
* Update interpolation for all ships (called each render frame)
*/
update(deltaTime: number) {
for (const ship of this.ships.values()) {
// Decrement ticks_until_move locally
ship.ticks_until_move = Math.max(0, ship.ticks_until_move - deltaTime);
// Update visual position
this.updateShipPosition(ship);
}
}
/**
* Clear all ships
*/
clear() {
for (const ship of this.ships.values()) {
this.container.removeChild(ship.sprite);
ship.sprite.destroy({ children: true });
}
this.ships.clear();
}
/**
* Destroy the layer and clean up resources
*/
destroy() {
this.clear();
this.container.destroy();
}
}

View File

@@ -0,0 +1,83 @@
import { RgbColor } from "@/shared/render";
import { Sprite, Texture, BufferImageSource } from "pixi.js";
export interface TerrainTextureResult {
texture: Texture;
sprite: Sprite;
}
/**
* Builds terrain textures from tile type data and color palettes.
* Uses optimized Uint32 writes for fast texture generation.
*/
export class TerrainTextureBuilder {
/**
* Create a terrain texture from tile type IDs and a color palette
*/
static createTerrainTexture(
terrainData: Uint8Array | number[],
palette: RgbColor[],
width: number,
height: number,
): TerrainTextureResult {
// Normalize terrain_data to Uint8Array for consistent handling
const normalizedTerrainData = terrainData instanceof Uint8Array ? terrainData : new Uint8Array(terrainData);
// Create texture from tile type IDs
const totalPixels = width * height;
const imageData = new Uint8Array(totalPixels * 4); // RGBA
// Pre-compute RGBA tuples for all palette entries (cache-friendly)
const paletteRGBA = new Uint32Array(palette.length);
for (let i = 0; i < palette.length; i++) {
const c = palette[i];
// Pack RGBA as single uint32 (little-endian: ABGR in memory)
paletteRGBA[i] = (255 << 24) | (c.b << 16) | (c.g << 8) | c.r;
}
// Fast path: Use Uint32 view for 4x fewer writes
const imageData32 = new Uint32Array(imageData.buffer);
const defaultColor = (255 << 24) | (100 << 16) | (100 << 8) | 100; // Default gray
for (let i = 0; i < totalPixels; i++) {
const tileTypeId = normalizedTerrainData[i];
imageData32[i] = paletteRGBA[tileTypeId] ?? defaultColor;
}
const bufferSource = new BufferImageSource({
resource: imageData,
width,
height,
});
// Create the texture
const texture = new Texture({ source: bufferSource });
// Use nearest neighbor filtering for crisp pixel edges
texture.source.scaleMode = "nearest";
// Mark source as needing update
bufferSource.update();
// Create sprite with the texture
const sprite = new Sprite(texture);
// Set dimensions explicitly (don't use width/height setters which can cause issues)
sprite.scale.set(width / texture.width, height / texture.height);
// Center the terrain
sprite.anchor.set(0.5, 0.5);
sprite.x = 0;
sprite.y = 0;
// Ensure sprite is visible and rendered
sprite.alpha = 1.0;
sprite.visible = true;
sprite.renderable = true;
// Disable culling to prevent disappearing at certain zoom levels
sprite.cullable = false;
return { texture, sprite };
}
}

View File

@@ -0,0 +1,428 @@
import { Container, Sprite, Texture, BufferImageSource, Filter, GlProgram } from "pixi.js";
import { RgbColor } from "@/shared/render";
// Territory ownership sentinel values (must match backend)
const OWNER_UNCLAIMED = 65535; // Unclaimed/water tiles (rendered as transparent)
/**
* Territory rendering shaders
*
* This shader system renders nation ownership on the map using:
* - RG8 texture storing u16 nation IDs (R=low byte, G=high byte)
* - 256x256 palette texture for nation colors (supports up to 65536 nations)
* - Edge detection for borders between different nations
* - Highlighting system for UI hover states
*/
// 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 = 65535 (backend sends this for tiles with no owner)
if (centerOwner >= 65535.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;
}
}
// Map nation ID to 2D palette texture (256x256 grid = 65536 colors)
// X = nation_id % 256, Y = nation_id / 256
float x = mod(centerOwner, 256.0) / 256.0;
float y = floor(centerOwner / 256.0) / 256.0;
vec4 territoryColor = texture2D(uPalette, vec2(x, y));
// 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 readonly container: Container;
private readonly sprite: Sprite;
private readonly texture: Texture;
private readonly bufferSource: BufferImageSource;
private readonly ownerData: Uint16Array;
private readonly textureData: Uint8Array;
private readonly paletteTexture: Texture;
private readonly paletteSource: BufferImageSource;
private readonly paletteData: Uint8Array;
private readonly 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 readonly width: number;
private readonly height: number;
constructor(width: number, height: number, palette: Array<RgbColor>) {
this.width = width;
this.height = height;
this.container = new Container();
// Initialize all tiles to unclaimed (65535)
this.ownerData = new Uint16Array(width * height);
this.ownerData.fill(65535);
this.textureData = new Uint8Array(width * height * 2);
// Support up to 65536 players (u16::MAX + 1)
this.paletteData = new Uint8Array(65536 * 4);
// Initialize palette
for (let i = 0; i < Math.min(palette.length, 65536); i++) {
const color = palette[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;
}
// Use 256x256 2D texture (65536 total colors) to avoid WebGL texture size limits
this.paletteSource = new BufferImageSource({
resource: this.paletteData,
width: 256,
height: 256,
});
this.paletteTexture = new Texture({ source: this.paletteSource });
const { sprite, texture, bufferSource } = this.initTexture();
this.sprite = sprite;
this.texture = texture;
this.bufferSource = bufferSource;
this.territoryFilter = this.setupShader();
}
private initTexture(): { sprite: Sprite; texture: Texture; bufferSource: BufferImageSource } {
// RG8 format: R=low byte, G=high byte (stores u16 nation IDs)
// Initialize all tiles to OWNER_UNCLAIMED which the shader will discard
for (let i = 0; i < this.textureData.length; i += 2) {
this.textureData[i] = OWNER_UNCLAIMED & 0xff; // R = 255
this.textureData[i + 1] = (OWNER_UNCLAIMED >> 8) & 0xff; // G = 255
}
const bufferSource = new BufferImageSource({
resource: this.textureData,
width: this.width,
height: this.height,
format: "rg8unorm",
});
const texture = new Texture({ source: bufferSource });
texture.source.scaleMode = "nearest";
texture.source.update();
const sprite = new Sprite(texture);
sprite.scale.set(this.width / texture.width, this.height / texture.height);
sprite.anchor.set(0.5, 0.5);
sprite.x = 0;
sprite.y = 0;
sprite.alpha = 1.0;
sprite.visible = true;
sprite.renderable = true;
sprite.cullable = false;
sprite.blendMode = "screen";
this.container.addChild(sprite);
return { sprite, texture, bufferSource };
}
applySnapshot(snapshot: { indices: Uint32Array; ownerIds: Uint16Array }) {
// Clear all tiles to unclaimed
// The sparse format only includes player-owned tiles (0-65533)
// All other tiles default to OWNER_UNCLAIMED which the shader discards
this.ownerData.fill(OWNER_UNCLAIMED);
// Fill texture data with OWNER_UNCLAIMED encoded as RG8 (low byte, high byte)
for (let i = 0; i < this.textureData.length; i += 2) {
this.textureData[i] = OWNER_UNCLAIMED & 0xff; // R = 255
this.textureData[i + 1] = (OWNER_UNCLAIMED >> 8) & 0xff; // G = 255
}
// Apply claimed tiles from sparse snapshot using parallel arrays (no object allocations)
const { indices, ownerIds } = snapshot;
const length = indices.length;
for (let i = 0; i < length; i++) {
const index = indices[i];
const owner_id = ownerIds[i];
if (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;
}
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;
// Update texture with ALL values, including 65535 (unclaimed)
// The shader will discard unclaimed tiles during rendering
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(): Filter {
// Create filter with the territory shader
const filter = 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 = [filter];
return filter;
}
// 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) {
this.territoryFilter.resources.territoryUniforms.uniforms.uHighlightedNation = nationId ?? -1;
}
// Clear all territory
clear() {
this.ownerData.fill(OWNER_UNCLAIMED);
const imageData = this.bufferSource.resource as Uint8Array;
// Clear all pixels to OWNER_UNCLAIMED
for (let i = 0; i < imageData.length; i += 2) {
imageData[i] = OWNER_UNCLAIMED & 0xff;
imageData[i + 1] = (OWNER_UNCLAIMED >> 8) & 0xff;
}
// 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();
}
}

View File

@@ -0,0 +1,7 @@
export { GameRenderer } from "@/shared/render/GameRenderer";
export interface RgbColor {
r: number;
g: number;
b: number;
}

View File

@@ -0,0 +1,111 @@
.fade-blur-in[data-state="open"] {
animation: fadeBlurIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.fade-blur-in[data-state="closed"] {
animation: fadeBlurOut 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeBlurIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(8px);
}
}
@keyframes fadeBlurOut {
from {
opacity: 1;
backdrop-filter: blur(8px);
}
to {
opacity: 0;
backdrop-filter: blur(0px);
}
}
.fade-in[data-state="open"] {
animation: fadeIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.fade-in[data-state="closed"] {
animation: fadeOut 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@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);
}
}
@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);
}
}

View File

@@ -0,0 +1,23 @@
/* oswald-latin-wght-normal */
@font-face {
font-family: "Oswald Variable";
font-style: normal;
font-display: block;
font-weight: 200 700;
src: url(@fontsource-variable/oswald/files/oswald-latin-wght-normal.woff2) format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* inter-latin-wght-normal */
@font-face {
font-family: "Inter Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/inter/files/inter-latin-wght-normal.woff2) format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -0,0 +1,25 @@
@import "tailwindcss";
@theme {
--font-oswald: "Oswald Variable", sans-serif;
--font-inter: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
--color-parchment: rgb(241 235 219);
}
:root {
font-family: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
color: #1e293b;
background-color: transparent;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#root {
@apply fixed top-0 left-0;
}

View File

@@ -0,0 +1,45 @@
import type { UnsubscribeFn } from "@/shared/api/types";
/**
* Generic callback manager for event subscriptions.
* Handles registration, notification, and cleanup of callback functions.
*/
export class CallbackManager<T = void> {
private callbacks: Array<T extends void ? () => void : (data: T) => void> = [];
/**
* Subscribe to events with a callback.
* Returns an unsubscribe function to remove this specific callback.
*/
subscribe(callback: T extends void ? () => void : (data: T) => void): UnsubscribeFn {
this.callbacks.push(callback);
return () => {
const index = this.callbacks.indexOf(callback);
if (index !== -1) {
this.callbacks.splice(index, 1);
}
};
}
/**
* Notify all subscribed callbacks with data.
* For void callbacks, call with no arguments.
*/
notify(data?: T): void {
if (data === undefined) {
// Handle void callbacks
this.callbacks.forEach((callback) => (callback as () => void)());
} else {
// Handle callbacks with data
this.callbacks.forEach((callback) => (callback as (data: T) => void)(data));
}
}
/**
* Clear all callbacks (used for cleanup/destroy).
*/
clear(): void {
this.callbacks = [];
}
}

View File

@@ -0,0 +1,199 @@
/**
* Binary decoding for initialization and territory data.
* Optimized for minimal allocations and fast parsing.
*/
import { RgbColor } from "@/shared/render";
/**
* Decoded territory snapshot using parallel arrays for zero-allocation parsing.
* This avoids creating millions of temporary objects which would trigger GC.
*/
interface DecodedSnapshot {
indices: Uint32Array;
ownerIds: Uint16Array;
}
export interface TerrainData {
width: number;
height: number;
tileIds: Uint8Array;
palette: Array<RgbColor>;
}
export interface DecodedInitBinary {
terrain: TerrainData;
territory: DecodedSnapshot;
nationPalette: Array<RgbColor>;
}
/**
* Decode RGB palette from binary data with pre-allocation.
* Helper to avoid code duplication and optimize memory allocation.
*/
function decodeRgbPalette(data: Uint8Array, offset: number, count: number): Array<RgbColor> {
const palette = new Array<RgbColor>(count);
let idx = offset;
for (let i = 0; i < count; i++) {
palette[i] = {
r: data[idx++],
g: data[idx++],
b: data[idx++],
};
}
return palette;
}
/**
* Decode sparse binary territory snapshot from Rust backend.
* Uses parallel typed arrays instead of objects to eliminate GC pressure.
*
* @param data Binary data array (serialized from Vec<u8> in Rust)
* @returns Parallel arrays of indices and owner IDs, or null if invalid
*/
function decodeTerritorySnapshot(data: number[] | Uint8Array): DecodedSnapshot | null {
// Convert to Uint8Array if needed for faster access
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
if (bytes.length < 4) {
console.error("Invalid territory snapshot: not enough data for count");
return null;
}
// Use DataView for faster little-endian reads
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// Read count (4 bytes, little-endian u32)
const count = view.getUint32(0, true);
// Sanity check: reject unreasonably large counts (likely malformed data)
const MAX_TILES = 10_000_000; // Adjust based on your max map size
if (count > MAX_TILES) {
console.error(`Invalid territory snapshot: count ${count} exceeds maximum ${MAX_TILES}`);
return null;
}
const expectedSize = 4 + count * 6;
if (bytes.length !== expectedSize) {
console.error(`Invalid territory snapshot: expected ${expectedSize} bytes, got ${bytes.length}`);
return null;
}
// Pre-allocate typed arrays (no objects, no GC pressure)
const indices = new Uint32Array(count);
const ownerIds = new Uint16Array(count);
// Decode directly into typed arrays
for (let i = 0; i < count; i++) {
const offset = 4 + i * 6;
indices[i] = view.getUint32(offset, true);
ownerIds[i] = view.getUint16(offset + 4, true);
}
return { indices, ownerIds };
}
/**
* Decode binary initialization data sent from Rust backend.
* Format: [terrain_len:4][terrain_data][territory_len:4][territory_data][nation_palette_count:2][nation_palette_rgb:N*3]
* Terrain data: [width:2][height:2][tile_ids:N][palette_count:2][palette_rgb:N*3]
* Territory data: [count:4][tiles...] where tiles = [index:4][owner:2]
*/
export function decodeInitBinary(data: Uint8Array): DecodedInitBinary | null {
console.log(`Decoding init binary: ${data.length} bytes`);
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
let offset = 0;
// Parse terrain length and extract terrain data
if (offset + 4 > data.length) {
console.error("Invalid init binary: not enough data for terrain length");
return null;
}
const terrainLen = view.getUint32(offset, true);
offset += 4;
if (offset + terrainLen > data.length) {
console.error(`Invalid init binary: terrain data truncated (expected ${terrainLen} bytes)`);
return null;
}
const terrainStart = offset;
offset += terrainLen;
// Parse territory length and extract territory data
if (offset + 4 > data.length) {
console.error("Invalid init binary: not enough data for territory length");
return null;
}
const territoryLen = view.getUint32(offset, true);
offset += 4;
if (offset + territoryLen > data.length) {
console.error(`Invalid init binary: territory data truncated (expected ${territoryLen} bytes)`);
return null;
}
const territoryData = data.subarray(offset, offset + territoryLen);
offset += territoryLen;
// Decode terrain data using main DataView (optimization: reuse view instead of creating new one)
if (terrainLen < 4) {
console.error("Invalid terrain data: not enough data for dimensions");
return null;
}
const width = view.getUint16(terrainStart, true);
const height = view.getUint16(terrainStart + 2, true);
const tileDataLength = width * height;
if (terrainLen < 4 + tileDataLength + 2) {
console.error("Invalid terrain data: not enough data for tile IDs and palette count");
return null;
}
const tileIds = data.subarray(terrainStart + 4, terrainStart + 4 + tileDataLength);
const paletteStart = terrainStart + 4 + tileDataLength;
const paletteCount = view.getUint16(paletteStart, true);
if (terrainLen < 4 + tileDataLength + 2 + paletteCount * 3) {
console.error("Invalid terrain data: not enough data for palette colors");
return null;
}
const palette = decodeRgbPalette(data, paletteStart + 2, paletteCount);
console.log(`Decoded terrain: ${width}x${height}, ${paletteCount} colors`);
// Decode territory data using existing function
const territory = decodeTerritorySnapshot(territoryData);
if (!territory) {
console.error("Failed to decode territory data from init binary");
return null;
}
console.log(`Decoded territory: ${territory.indices.length} claimed tiles`);
// Decode nation palette
if (offset + 2 > data.length) {
console.error("Invalid init binary: not enough data for nation palette count");
return null;
}
const nationPaletteCount = view.getUint16(offset, true);
offset += 2;
if (offset + nationPaletteCount * 3 > data.length) {
console.error(`Invalid init binary: nation palette truncated (expected ${nationPaletteCount * 3} bytes)`);
return null;
}
const nationPalette = decodeRgbPalette(data, offset, nationPaletteCount);
console.log(`Decoded nation palette: ${nationPaletteCount} colors`);
return {
terrain: { width, height, tileIds, palette },
territory,
nationPalette,
};
}

View File

@@ -0,0 +1,11 @@
// 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`;
}

17
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
declare const __DESKTOP__: boolean;
declare const __APP_VERSION__: string;
declare const __GIT_COMMIT__: string;
declare const __BUILD_TIME__: 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;
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/browser/**/*", "src/shared/**/*", "src/lib/**/*", "src/*.ts", "pages/**/*"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/desktop/**/*", "src/shared/**/*", "src/lib/**/*", "src/*.ts", "pages/**/*"]
}

32
frontend/tsconfig.json Normal file
View 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/shared/**/*", "src/*", "src/assets/**/*", "pages/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

101
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,101 @@
import { defineConfig, HmrContext } from "vite";
import { resolve } from "path";
import { readFileSync, existsSync } from "fs";
import { execSync } from "child_process";
// Plugins
import react from "@vitejs/plugin-react";
import vike from "vike/plugin";
import { imagetools } from "vite-imagetools";
import tailwindcss from "@tailwindcss/vite";
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];
}
// Read git commit from .source-commit file or git command
function getGitCommit(): string {
const sourceCommitPath = resolve(__dirname, "../.source-commit");
if (existsSync(sourceCommitPath)) {
return readFileSync(sourceCommitPath, "utf-8").trim();
}
// Fallback to git command for local development
try {
return execSync("git rev-parse HEAD", { encoding: "utf-8", cwd: resolve(__dirname, "..") }).trim();
} catch {
return "unknown";
}
}
// Get current build time in UTC (ISO 8601 format)
function getBuildTime(): string {
return new Date().toISOString();
}
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/" : "/",
css: {
transformer: "lightningcss",
},
plugins: [vike(), react(), imagetools(), fullReloadAlways, tailwindcss()],
define: {
__DESKTOP__: JSON.stringify(mode !== "browser"),
__APP_VERSION__: JSON.stringify(getVersionFromCargoToml()),
__GIT_COMMIT__: JSON.stringify(getGitCommit()),
__BUILD_TIME__: JSON.stringify(getBuildTime()),
},
resolve: {
alias: {
"@wasm": resolve(__dirname, "pkg"),
"@": resolve(__dirname, "src"),
},
},
build: {
outDir: mode === "browser" ? "dist/browser" : "dist",
cssMinify: "lightningcss",
sourcemap: process.env.VITE_DEBUG_BUILD === "true",
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// Prevents Vite from obscuring rust errors
clearScreen: false,
// Tauri expects a fixed port, fail if that port is not available
server: {
// Browser mode uses port 1421, desktop mode uses port 1420
port: mode === "browser" ? 1421 : 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: mode === "browser" ? 1422 : 1421,
}
: undefined,
watch: {
// tell Vite to ignore watching the crates
ignored: ["**/crates/**"],
},
},
}));