Update source files

This commit is contained in:
2025-10-16 00:02:34 -05:00
commit 74127b0829
182 changed files with 30644 additions and 0 deletions

5
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"tabWidth": 2,
"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>

52
frontend/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"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",
"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"
]
}

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

@@ -0,0 +1,45 @@
// Import fonts CSS as raw string to inline in HTML
import fontsCss from "./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" />
{/* Preload critical Oswald font for faster title rendering */}
<link rel="preload" href={oswaldWoff2} as="font" type="font/woff2" crossOrigin="anonymous" />
{/* Inlined font definitions - processed by Vite at build time */}
<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,70 @@
import { useEffect, useState, type ReactNode } from "react";
import { GameAPIProvider } from "@/shared/api/GameAPIContext";
import { AnalyticsProvider } from "@/shared/analytics";
export default function Wrapper({ children }: { children: ReactNode }) {
const [gameAPI, setGameAPI] = useState<any>(null);
const [analytics, setAnalytics] = useState<any>(null);
// Dynamically import the appropriate platform implementation
// Use build-time constant for tree-shaking
useEffect(() => {
if (__DESKTOP__) {
Promise.all([import("@/desktop/api/tauriAPI"), import("@/desktop/analytics/tauriAnalytics")]).then(
([{ tauriAPI }, { tauriAnalytics }]) => {
setGameAPI(tauriAPI);
setAnalytics(tauriAnalytics);
},
);
} else {
Promise.all([import("@/browser/api/wasmBridge"), import("@/browser/analytics")]).then(([{ wasmBridge }, { wasmAnalytics }]) => {
setGameAPI(wasmBridge);
setAnalytics(wasmAnalytics);
});
}
}, []);
// Browser-specific setup (must be before early return to satisfy Rules of Hooks)
useEffect(() => {
if (!__DESKTOP__) {
// Disable context menu to prevent interference with right-click controls
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener("contextmenu", handleContextMenu);
// 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 () => {
document.removeEventListener("contextmenu", handleContextMenu);
userIdChannel.close();
};
}
}, []);
// Wait for platform-specific modules to load
if (!gameAPI || !analytics) {
return null;
}
return (
<AnalyticsProvider api={analytics}>
<GameAPIProvider api={gameAPI}>{children}</GameAPIProvider>
</AnalyticsProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { type ReactNode } from 'react'
import { GameAPIProvider } from '@/shared/api/GameAPIContext'
import { AnalyticsProvider } from '@/shared/analytics'
// Fonts are imported in +Head.tsx
// 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 (
<AnalyticsProvider api={null as any}>
<GameAPIProvider api={null as any}>{children}</GameAPIProvider>
</AnalyticsProvider>
)
}

14
frontend/pages/+config.js 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,
};

19
frontend/pages/fonts.css Normal file
View File

@@ -0,0 +1,19 @@
/* 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,250 @@
@import "tailwindcss";
@theme {
--font-oswald: "Oswald", sans-serif;
--font-inter: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
}
:root {
font-family: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 2.8em;
font-weight: 400;
color: #1e293b;
background-color: transparent; /* Transparent to show game canvas */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
/**
* Input blocking system for desktop mode:
*
* In desktop mode, the InputForwarder component captures all input events
* (mouse, keyboard, wheel) from the window and forwards them to the Bevy game engine.
*
* To prevent UI elements (menus, overlays, etc.) from accidentally forwarding clicks
* to the game, those elements should be tagged with the 'block-game-input' class.
*
* The InputForwarder checks `event.target.closest('.block-game-input')` and skips
* forwarding if the event originated from within a blocking element.
*
* For overlays that SHOULD allow clicks through (like spawn phase instructions),
* simply don't add the 'block-game-input' class.
*/
.block-game-input {
/* This class serves as a marker - no styles needed */
}
html,
body,
#root {
@apply fixed top-0 left-0;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
pointer-events: none; /* Let clicks pass through to interaction layer */
position: relative;
z-index: 1; /* Above interaction layer */
}
/* Game canvas - PixiJS renderer layer */
.game-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: auto; /* Capture input events */
z-index: 0; /* Behind all UI elements */
}
.game-ui {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Let clicks pass through to interaction layer */
z-index: 1; /* Above interaction layer and canvas */
}
/* Leaderboard styles */
.no-drag {
-webkit-app-region: no-drag;
}
/* Attacks styles */
.attacks {
user-select: none;
opacity: 0.6;
transition: opacity 400ms ease;
font-size: 13px; /* Base font size for scaling everything */
display: flex;
flex-direction: column;
gap: 2px;
}
.attacks:hover {
opacity: 1;
}
.attacks__row {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 0.83em;
border: none;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(2px);
color: white;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 1.1em;
font-family: inherit;
width: 21.67em;
border-radius: 0.5em;
overflow: hidden;
}
.attacks__row:hover {
background: rgba(15, 23, 42, 0.75);
}
.attacks__background {
position: absolute;
top: 0;
right: 0;
height: 100%;
pointer-events: none;
transition: width 0.3s ease;
}
.attacks__nation {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
padding-right: 1em;
position: relative;
z-index: 1;
}
.attacks__troops {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 5em;
position: relative;
z-index: 1;
}
@media (max-width: 1200px) {
.attacks {
font-size: 12px;
}
}
@media (max-width: 1000px) {
.attacks {
font-size: 12px;
}
}
@media (max-width: 800px) {
.attacks {
font-size: 11.5px;
}
}
@media (max-width: 600px) {
.attacks {
font-size: 10px;
}
}
/* Attack Controls styles */
.attack-controls {
position: fixed;
bottom: 12px;
left: 12px;
z-index: 10;
user-select: none;
opacity: 0.95;
transition: opacity 400ms ease;
font-size: 15.6px;
pointer-events: none;
}
.attack-controls:hover {
opacity: 1;
}
.attack-controls__content {
display: flex;
flex-direction: column;
pointer-events: auto;
}
.attack-controls__header {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 0.5em;
min-width: 26em;
}
.attack-controls__percentage {
position: absolute;
left: 0.8em;
top: 50%;
transform: translateY(-50%);
font-size: 1.15em;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: white;
pointer-events: none;
z-index: 1;
opacity: 0.85;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.4),
0 2px 4px rgba(0, 0, 0, 0.4),
1px 0 2px rgba(0, 0, 0, 0.4),
-1px 0 2px rgba(0, 0, 0, 0.4);
}
@media (max-width: 1200px) {
.attack-controls {
font-size: 14.4px;
}
}
@media (max-width: 1000px) {
.attack-controls {
font-size: 14.4px;
}
}
@media (max-width: 800px) {
.attack-controls {
font-size: 13.8px;
}
}
@media (max-width: 600px) {
.attack-controls {
font-size: 12px;
}
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect, lazy, Suspense } from "react";
import "./+Page.css";
import { MenuScreen } from "@/shared/components/menu/MenuScreen";
import { useAnalytics } from "@/shared/analytics";
// 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 [isHydrated, setIsHydrated] = useState(false);
const analytics = useAnalytics();
// Mark as hydrated after first render to prevent hydration mismatch
useEffect(() => {
setIsHydrated(true);
}, []);
// Prefetch components after initial render
useEffect(() => {
const timer = setTimeout(() => {
// Trigger chunk downloads without rendering
// Preload game container so lazy loading doesn't delay start
import("./Game.client");
import("@/shared/components/overlays/AlphaWarning");
// Also preload game renderer and canvas components
import("@/shared/render/GameRenderer");
import("@/shared/components/game/Canvas");
}, 1000); // Start earlier since user is reading menu
return () => clearTimeout(timer);
}, []);
// Track app started on mount
useEffect(() => {
if (!analytics) return;
analytics.track("app_started", {
platform: __DESKTOP__ ? "desktop" : "browser",
});
}, [analytics]);
const handleStartSingleplayer = () => {
setShowMenu(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);
});
}
};
// Don't render game components until hydration is complete
if (!isHydrated) {
return null;
}
return (
<>
{/* Menu Screen - pre-renderable, covers everything when visible */}
{showMenu && <MenuScreen onStartSingleplayer={handleStartSingleplayer} onExit={handleExit} />}
{/* Game Container - client-only, lazy loaded when game starts */}
{!showMenu && (
<Suspense fallback={null}>
<GameContainer onReturnToMenu={() => setShowMenu(true)} />
</Suspense>
)}
</>
);
}
export default App;

View File

@@ -0,0 +1,162 @@
import { useState, useEffect, lazy, Suspense } 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 { useGameAPI } from "@/shared/api/GameAPIContext";
import { useAnalytics } from "@/shared/analytics";
import type { GameOutcome, LeaderboardSnapshot } from "@/shared/api/types";
import type { GameRenderer } from "@/shared/render/GameRenderer";
const GameEndOverlay = lazy(() => import("@/shared/components/overlays/GameEnd"));
interface GameContainerProps {
onReturnToMenu: () => void;
}
export function GameContainer({ onReturnToMenu }: 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<any | null>(null);
const [initialLeaderboard, setInitialLeaderboard] = useState<LeaderboardSnapshot | null>(null);
const [renderer, setRenderer] = useState<GameRenderer | null>(null);
const [highlightedNation, setHighlightedNation] = useState<number | null>(null);
const api = useGameAPI();
const analytics = useAnalytics();
// Check for existing game state on mount (for reload recovery)
// Skip this in browser since WASM doesn't persist state
useEffect(() => {
if (!api || typeof api.getGameState !== "function") return;
// Only check for state recovery on desktop (Tauri)
// In browser, this always returns null so skip the async call
if (import.meta.env.MODE === "browser") return;
api.getGameState().then((state) => {
// Only recover if we actually have render data (indicates a running game)
if (state && state.render_init) {
console.log("Recovered game state after reload:", state);
setInitialGameState(state.render_init);
setInitialLeaderboard(state.leaderboard_snapshot);
}
});
}, [api]);
// Start the game on mount
useEffect(() => {
if (api) {
api.startGame();
}
// Track game started
if (analytics) {
analytics.track("game_started", {
mode: "singleplayer",
});
}
}, [api, analytics]);
// Subscribe to spawn phase events
useEffect(() => {
if (!api) return;
const unsubUpdate = api.onSpawnPhaseUpdate((update) => {
setSpawnPhaseActive(true);
setSpawnCountdown(update.countdown);
});
const unsubEnd = api.onSpawnPhaseEnded(() => {
setSpawnPhaseActive(false);
setSpawnCountdown(null);
});
return () => {
unsubUpdate();
unsubEnd();
};
}, [api]);
// Subscribe to game end events
useEffect(() => {
if (!api) return;
const unsubscribe = api.onGameEnded((outcome) => {
console.log("Game outcome received:", outcome);
setGameOutcome(outcome);
setSpawnPhaseActive(false);
// Track game ended
if (analytics) {
analytics.track("game_ended", {
outcome: outcome.toString().toLowerCase(),
});
}
});
return () => unsubscribe();
}, [api, analytics]);
// Track renderer initialization with GPU info
useEffect(() => {
if (renderer && analytics) {
const rendererInfo = renderer.getRendererInfo();
analytics.track("renderer_initialized", rendererInfo);
}
}, [renderer, analytics]);
// Sync highlighted nation with renderer
useEffect(() => {
if (renderer) {
renderer.setHighlightedNation(highlightedNation);
}
}, [highlightedNation, renderer]);
const handleExit = () => {
if (api) {
api.quitGame();
}
setGameOutcome(null);
onReturnToMenu();
};
return (
<>
{/* Game Canvas - always rendered at root */}
<GameCanvas
className="h-screen w-screen"
initialState={initialGameState}
onRendererReady={setRenderer}
onNationHover={setHighlightedNation}
/>
{/* Game UI */}
<div className="game-ui">
{/* Spawn Phase Overlay */}
<SpawnPhaseOverlay isVisible={spawnPhaseActive} countdown={spawnCountdown} />
<Leaderboard initialSnapshot={initialLeaderboard} highlightedNation={highlightedNation} onNationHover={setHighlightedNation} />
<AttackControls>
<Attacks onNationHover={setHighlightedNation} />
</AttackControls>
<GameMenu
onExit={handleExit}
onSettings={() => {
// TODO: Implement settings
}}
/>
{/* Game End Overlay */}
{gameOutcome && (
<Suspense fallback={null}>
<GameEndOverlay outcome={gameOutcome} onSpectate={() => setGameOutcome(null)} onExit={handleExit} />
</Suspense>
)}
</div>
</>
);
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- 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,5 @@
import { WasmAnalytics } from "@/browser/analytics/wasmAnalytics";
import { wasmBridge } from "@/browser/api/wasmBridge";
// Create singleton instance using the worker from wasmBridge
export const wasmAnalytics = new WasmAnalytics(wasmBridge.getWorker());

View File

@@ -0,0 +1,30 @@
import type { AnalyticsAPI } from "@/shared/analytics/AnalyticsAPI";
/**
* Browser/WASM implementation of AnalyticsAPI.
*
* Events are sent through the WASM worker which handles batching and
* sending to PostHog.
*/
export class WasmAnalytics implements AnalyticsAPI {
private worker: Worker | null = null;
constructor(worker: Worker) {
this.worker = worker;
}
track(event: string, properties?: Record<string, any>): void {
if (!this.worker) {
console.error("Worker not set for analytics");
return;
}
this.worker.postMessage({
type: "ANALYTICS_EVENT",
payload: {
event,
properties: properties || {},
},
});
}
}

View File

@@ -0,0 +1,362 @@
import type { GameAPI } from "@/shared/api/GameAPI";
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, ShipsUpdatePayload, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
import { decodeTerritorySnapshot } from "@/shared/utils/binaryDecoding";
/**
* Browser implementation of GameAPI using WASM bindings in a Web Worker.
*
* This wraps the WASM-exported functions from borders-wasm to match
* the unified GameAPI interface using the new UI event system.
* The WASM module runs in a dedicated worker to avoid blocking the main thread.
*/
export class WasmBridge implements GameAPI {
private worker: Worker;
private initialized: boolean = false;
private messageQueue: Array<any> = []; // Queue for messages sent before worker is ready
private renderer: any = null; // GameRenderer instance
private snapshotCallbacks: Array<(data: LeaderboardSnapshot) => void> = [];
private gameEndCallbacks: Array<(outcome: GameOutcome) => void> = [];
private attacksUpdateCallbacks: Array<(data: AttacksUpdatePayload) => void> = [];
private shipsUpdateCallbacks: Array<(data: ShipsUpdatePayload) => void> = [];
private spawnPhaseUpdateCallbacks: Array<(update: SpawnPhaseUpdate) => void> = [];
private spawnPhaseEndedCallbacks: Array<() => void> = [];
private queuedRenderMessages: Array<any> = []; // Queue for messages received before renderer is ready
constructor() {
this.worker = new Worker(new URL("../workers/game.worker.ts", import.meta.url), {
type: "module",
});
this.worker.addEventListener("message", (e) => {
const { type, payload } = e.data;
if (type === "INIT_COMPLETE") {
this.initialized = true;
console.log("WASM worker initialized, processing queued messages");
// Process any queued messages
while (this.messageQueue.length > 0) {
const queuedMessage = this.messageQueue.shift();
this.worker.postMessage(queuedMessage);
}
return;
}
if (type === "BACKEND_MESSAGE") {
this.handleBackendMessage(payload);
return;
}
if (type === "ERROR") {
console.error("Worker error:", payload);
return;
}
});
// Initialize the worker
this.worker.postMessage({ type: "INIT" });
}
private handleBackendMessage(message: any) {
// Messages use msg_type tag from serde
switch (message.msg_type) {
case "LeaderboardSnapshot":
this.snapshotCallbacks.forEach((callback) => callback(message as LeaderboardSnapshot));
break;
case "AttacksUpdate":
this.attacksUpdateCallbacks.forEach((callback) => callback(message as AttacksUpdatePayload));
break;
case "ShipsUpdate":
this.shipsUpdateCallbacks.forEach((callback) => callback(message as ShipsUpdatePayload));
// Also forward to renderer for visual updates
if (this.renderer) {
this.renderer.updateShips(message as ShipsUpdatePayload);
}
break;
case "GameEnded":
this.gameEndCallbacks.forEach((callback) => callback(message.outcome as GameOutcome));
break;
case "SpawnPhaseUpdate":
const update: SpawnPhaseUpdate = {
countdown: message.countdown
? {
startedAtMs: message.countdown.started_at_ms,
durationSecs: message.countdown.duration_secs,
}
: null,
};
this.spawnPhaseUpdateCallbacks.forEach((callback) => callback(update));
break;
case "SpawnPhaseEnded":
this.spawnPhaseEndedCallbacks.forEach((callback) => callback());
break;
case "HighlightNation":
this.renderer?.setHighlightedNation(message.nation_id ?? null);
break;
default:
// All other messages are render messages
this.handleRenderMessage(message);
break;
}
}
private sendWorkerMessage(message: any): void {
if (this.initialized) {
this.worker.postMessage(message);
} else {
this.messageQueue.push(message);
}
}
startGame(): void {
this.sendWorkerMessage({
type: "FRONTEND_MESSAGE",
payload: { msg_type: "StartGame" },
});
}
quitGame(): void {
this.sendWorkerMessage({
type: "FRONTEND_MESSAGE",
payload: { msg_type: "QuitGame" },
});
}
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
// Add callback to the list
this.snapshotCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.snapshotCallbacks.indexOf(callback);
if (index !== -1) {
this.snapshotCallbacks.splice(index, 1);
}
};
}
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
// Add callback to the list
this.gameEndCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.gameEndCallbacks.indexOf(callback);
if (index !== -1) {
this.gameEndCallbacks.splice(index, 1);
}
};
}
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
// Add callback to the list
this.attacksUpdateCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.attacksUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.attacksUpdateCallbacks.splice(index, 1);
}
};
}
onShipsUpdate(callback: (data: ShipsUpdatePayload) => void): UnsubscribeFn {
// Add callback to the list
this.shipsUpdateCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.shipsUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.shipsUpdateCallbacks.splice(index, 1);
}
};
}
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
// Add callback to the list
this.spawnPhaseUpdateCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.spawnPhaseUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.spawnPhaseUpdateCallbacks.splice(index, 1);
}
};
}
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
// Add callback to the list
this.spawnPhaseEndedCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.spawnPhaseEndedCallbacks.indexOf(callback);
if (index !== -1) {
this.spawnPhaseEndedCallbacks.splice(index, 1);
}
};
}
// Rendering integration methods
private handleRenderMessage(message: any) {
if (!this.renderer) {
this.queuedRenderMessages.push(message);
return;
}
// Handle flattened serde format: {msg_type: 'RenderInit', ...data}
if (message.msg_type === "RenderInit") {
// Apply all initialization data atomically
// For WASM, terrain and territory are included in the message
if (message.terrain && message.terrain_palette) {
this.renderer.setTerrainPalette(message.terrain_palette);
// Normalize glam::U16Vec2 array format [x, y] to { x, y } for consistent API
const terrain = {
...message.terrain,
size: Array.isArray(message.terrain.size)
? { x: message.terrain.size[0], y: message.terrain.size[1] }
: message.terrain.size
};
this.renderer.initTerrain(terrain);
} else {
console.warn("RenderInit missing terrain data (expected for WASM builds)");
}
this.renderer.initPalette(message.palette);
// Decode sparse binary territory snapshot (zero-allocation parallel arrays)
if (message.initial_territories) {
const snapshot = decodeTerritorySnapshot(message.initial_territories.data);
if (snapshot) {
this.renderer.applyTerritorySnapshot(message.initial_territories.turn, snapshot);
} else {
console.error("Failed to decode territory snapshot");
}
} else {
console.warn("RenderInit missing initial_territories (expected for WASM builds)");
}
} else if (message.msg_type === "TerritoryDelta") {
this.renderer.updateTerritoryDelta(message.turn, message.changes);
} else if (message.msg_type === "CameraCommand") {
// Handle camera commands from backend
if (message.type === "CenterOnTile") {
this.renderer.centerOnTile(message.tile_index, message.animate);
} else if (message.type === "SetZoom") {
this.renderer.setZoom(message.zoom, message.animate);
} else if (message.type === "PanBy") {
this.renderer.panBy(message.dx, message.dy, message.animate);
}
}
}
setRenderer(renderer: any): void {
this.renderer = renderer;
// Replay any queued messages that arrived before renderer was ready
if (this.queuedRenderMessages.length > 0) {
for (const message of this.queuedRenderMessages) {
this.handleRenderMessage(message);
}
this.queuedRenderMessages = []; // Clear queue after replay
}
}
sendCameraUpdate(state: { x: number; y: number; zoom: number }): void {
try {
this.sendWorkerMessage({
type: "CAMERA_UPDATE",
payload: state,
});
} catch (e) {
console.error("Failed to send camera update:", e);
}
}
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
try {
this.sendWorkerMessage({
type: "RENDER_INPUT",
payload: {
type: "MapClick",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
button: data.button,
},
});
} catch (e) {
console.error("Failed to send map click:", e);
}
}
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
try {
this.sendWorkerMessage({
type: "RENDER_INPUT",
payload: {
type: "MapHover",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
},
});
} catch (e) {
console.error("Failed to send map hover:", e);
}
}
sendKeyPress(data: { key: string; pressed: boolean }): void {
try {
this.sendWorkerMessage({
type: "RENDER_INPUT",
payload: {
type: "KeyPress",
key: data.key,
pressed: data.pressed,
},
});
} catch (e) {
console.error("Failed to send key press:", e);
}
}
sendAttackRatio(ratio: number): void {
try {
this.sendWorkerMessage({
type: "FRONTEND_MESSAGE",
payload: {
msg_type: "SetAttackRatio",
ratio: ratio,
},
});
} catch (e) {
console.error("Failed to send attack ratio:", e);
}
}
async getGameState(): Promise<any | null> {
// WASM doesn't persist state across reloads - always start fresh
return null;
}
/**
* Get the worker instance (for analytics or other purposes).
*/
getWorker(): Worker {
return this.worker;
}
}
// Export singleton instance
export const wasmBridge = new WasmBridge();

View File

@@ -0,0 +1,84 @@
import init, {
register_backend_message_callback,
send_frontend_message,
handle_camera_update,
handle_render_input,
track_analytics_event,
} from "../../../pkg/borders";
let wasmInitialized = false;
type WorkerMessage =
| { type: "INIT" }
| { type: "FRONTEND_MESSAGE"; payload: any }
| { type: "CAMERA_UPDATE"; payload: any }
| { type: "RENDER_INPUT"; payload: any }
| { type: "ANALYTICS_EVENT"; payload: any };
self.addEventListener("message", async (e: MessageEvent<WorkerMessage>) => {
const message = e.data;
try {
switch (message.type) {
case "INIT": {
await init();
wasmInitialized = true;
// Register callback that posts backend messages back to main thread
register_backend_message_callback((backendMessage: any) => {
self.postMessage({ type: "BACKEND_MESSAGE", payload: backendMessage });
});
self.postMessage({ type: "INIT_COMPLETE" });
console.log("WASM initialized successfully in worker");
break;
}
case "FRONTEND_MESSAGE": {
if (!wasmInitialized) {
console.error("WASM module not initialized");
return;
}
send_frontend_message(message.payload);
break;
}
case "CAMERA_UPDATE": {
if (!wasmInitialized) {
console.error("WASM module not initialized");
return;
}
handle_camera_update(message.payload);
break;
}
case "RENDER_INPUT": {
if (!wasmInitialized) {
console.error("WASM module not initialized");
return;
}
handle_render_input(message.payload);
break;
}
case "ANALYTICS_EVENT": {
if (!wasmInitialized) {
console.error("WASM module not initialized");
return;
}
track_analytics_event(message.payload);
break;
}
default: {
console.warn("Unknown worker message type:", (message as any).type);
}
}
} catch (error) {
console.error("Worker error:", error);
self.postMessage({
type: "ERROR",
payload: { message: error instanceof Error ? error.message : String(error) },
});
}
});

View File

@@ -0,0 +1,32 @@
import { invoke } from "@tauri-apps/api/core";
import type { AnalyticsAPI } from "@/shared/analytics/AnalyticsAPI";
/**
* Desktop implementation of AnalyticsAPI using Tauri commands.
*
* Events are sent to the Rust backend which handles batching and
* sending to PostHog.
*/
export class TauriAnalytics implements AnalyticsAPI {
track(event: string, properties?: Record<string, any>): void {
invoke("track_analytics_event", {
payload: {
event,
properties: properties || {},
},
}).catch((err) => {
console.error("Failed to track analytics event:", err);
});
}
async flush(): Promise<void> {
try {
await invoke("flush_analytics");
} catch (err) {
console.error("Failed to flush analytics:", err);
}
}
}
// Export singleton instance
export const tauriAnalytics = new TauriAnalytics();

View File

@@ -0,0 +1,519 @@
import { listen } from "@tauri-apps/api/event";
import { invoke, Channel } from "@tauri-apps/api/core";
import type { GameAPI } from "@/shared/api/GameAPI";
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, ShipsUpdatePayload, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
import { GameRenderer } from "@/shared/render/GameRenderer";
import { decodeTerritorySnapshot } from "@/shared/utils/binaryDecoding";
/**
* Desktop implementation of GameAPI using Tauri's unified UI event system.
*
* This listens to the "ui:event" channel which carries all UiEvent enum variants
* from the game core.
*/
export class TauriAPI implements GameAPI {
private snapshotCallbacks: Array<(data: LeaderboardSnapshot) => void> = [];
private gameEndCallbacks: Array<(outcome: GameOutcome) => void> = [];
private attacksUpdateCallbacks: Array<(data: AttacksUpdatePayload) => void> = [];
private shipsUpdateCallbacks: Array<(data: ShipsUpdatePayload) => void> = [];
private spawnPhaseUpdateCallbacks: Array<(update: SpawnPhaseUpdate) => void> = [];
private spawnPhaseEndCallbacks: Array<() => void> = [];
private renderer: GameRenderer | null = null;
private messageBuffer: any[] = [];
private initReceived: boolean = false;
private pendingDeltas: any[] = [];
// Track initialization state
private initDataReceived: boolean = false;
private bufferedRenderInit: any = null;
// Store unsubscribe functions for event listeners
private uiEventUnsubscribe?: () => void;
private pixelStreamUnsubscribe?: () => void;
constructor() {
// Start listening to UI events immediately
this.setupEventListener();
this.setupRenderListeners();
this.setupInitChannel();
}
private async setupEventListener() {
this.uiEventUnsubscribe = await listen<any>("backend:message", (event) => {
this.handleBackendMessage(event.payload);
});
}
private handleBackendMessage(message: any) {
// Messages use msg_type tag from serde
switch (message.msg_type) {
case "LeaderboardSnapshot":
this.snapshotCallbacks.forEach((callback) => callback(message as LeaderboardSnapshot));
break;
case "AttacksUpdate":
this.attacksUpdateCallbacks.forEach((callback) => callback(message as AttacksUpdatePayload));
break;
case "ShipsUpdate":
this.shipsUpdateCallbacks.forEach((callback) => callback(message as ShipsUpdatePayload));
// Also forward to renderer for visual updates
if (this.renderer) {
this.renderer.updateShips(message as ShipsUpdatePayload);
}
break;
case "GameEnded":
console.log("Game ended:", message.outcome);
this.gameEndCallbacks.forEach((callback) => callback(message.outcome as GameOutcome));
break;
case "SpawnPhaseUpdate":
const update: SpawnPhaseUpdate = {
countdown: message.countdown
? {
startedAtMs: message.countdown.started_at_ms,
durationSecs: message.countdown.duration_secs,
}
: null,
};
this.spawnPhaseUpdateCallbacks.forEach((callback) => callback(update));
break;
case "SpawnPhaseEnded":
this.spawnPhaseEndCallbacks.forEach((callback) => callback());
break;
case "HighlightNation":
this.renderer?.setHighlightedNation(message.nation_id ?? null);
break;
default:
// All other messages are render messages
this.handleRenderMessage(message);
break;
}
}
startGame(): void {
import("@tauri-apps/api/core").then(({ invoke }) => {
invoke("send_frontend_message", { message: { msg_type: "StartGame" } });
});
}
quitGame(): void {
import("@tauri-apps/api/core").then(({ invoke }) => {
invoke("send_frontend_message", { message: { msg_type: "QuitGame" } });
});
}
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn {
// Add callback to the list
this.snapshotCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.snapshotCallbacks.indexOf(callback);
if (index !== -1) {
this.snapshotCallbacks.splice(index, 1);
}
};
}
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn {
// Add callback to the list
this.gameEndCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.gameEndCallbacks.indexOf(callback);
if (index !== -1) {
this.gameEndCallbacks.splice(index, 1);
}
};
}
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn {
// Add callback to the list
this.attacksUpdateCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.attacksUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.attacksUpdateCallbacks.splice(index, 1);
}
};
}
onShipsUpdate(callback: (data: ShipsUpdatePayload) => void): UnsubscribeFn {
// Add callback to the list
this.shipsUpdateCallbacks.push(callback);
// Return cleanup function that removes this specific callback
return () => {
const index = this.shipsUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.shipsUpdateCallbacks.splice(index, 1);
}
};
}
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn {
this.spawnPhaseUpdateCallbacks.push(callback);
return () => {
const index = this.spawnPhaseUpdateCallbacks.indexOf(callback);
if (index !== -1) {
this.spawnPhaseUpdateCallbacks.splice(index, 1);
}
};
}
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn {
this.spawnPhaseEndCallbacks.push(callback);
return () => {
const index = this.spawnPhaseEndCallbacks.indexOf(callback);
if (index !== -1) {
this.spawnPhaseEndCallbacks.splice(index, 1);
}
};
}
// Rendering integration methods
private async setupRenderListeners() {
// Listen for binary pixel stream (territory deltas)
// Note: Regular render messages now come through backend:message
this.pixelStreamUnsubscribe = await listen<number[]>("render:pixel_stream", (event) => {
const uint8Array = new Uint8Array(event.payload);
// Buffer deltas until initialization is complete
if (!this.initReceived) {
this.pendingDeltas.push({
msg_type: "_binary_delta",
data: uint8Array
});
return;
}
if (this.renderer) {
this.renderer.updateBinaryDelta(uint8Array);
} else {
console.warn("Received pixel stream but renderer not set");
}
});
}
// Set up unified init data channel for binary streaming
private async setupInitChannel() {
const channel = new Channel<number[]>();
channel.onmessage = (data) => {
const binary = new Uint8Array(data);
this.processInitBinary(binary);
};
try {
await invoke("register_init_channel", { initChannel: channel });
console.log("Init channel registered successfully");
} catch (err) {
console.error("Failed to register init channel:", err);
}
}
// Decode and process binary initialization data (terrain + territory)
private processInitBinary(data: Uint8Array) {
console.log(`Processing 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
const terrainLen = view.getUint32(offset, true);
offset += 4;
const terrainData = data.subarray(offset, offset + terrainLen);
offset += terrainLen;
// Parse territory length and extract territory data
const territoryLen = view.getUint32(offset, true);
offset += 4;
const territoryData = data.subarray(offset, offset + territoryLen);
// Decode terrain data: [width:2][height:2][tile_ids:N][palette_count:2][palette_rgb:N*3]
const terrainView = new DataView(terrainData.buffer, terrainData.byteOffset, terrainData.byteLength);
const width = terrainView.getUint16(0, true); // Changed from getUint32 to getUint16
const height = terrainView.getUint16(2, true); // Changed from getUint32 to getUint16, offset now 2
const tileDataLength = width * height;
const tileIds = terrainData.subarray(4, 4 + tileDataLength); // Changed from 8 to 4
const paletteStart = 4 + tileDataLength; // Changed from 8 to 4
const paletteCount = terrainView.getUint16(paletteStart, true);
const palette: Array<{ r: number; g: number; b: number }> = [];
let paletteOffset = paletteStart + 2;
for (let i = 0; i < paletteCount; i++) {
palette.push({
r: terrainData[paletteOffset++],
g: terrainData[paletteOffset++],
b: terrainData[paletteOffset++],
});
}
console.log(`Decoded terrain: ${width}x${height}, ${paletteCount} colors`);
// Decode territory data using existing function
const snapshot = decodeTerritorySnapshot(territoryData);
if (!snapshot) {
console.error("Failed to decode territory data from init binary");
return;
}
console.log(`Decoded territory: ${snapshot.indices.length} claimed tiles`);
// Apply to renderer if available, otherwise buffer
if (this.renderer) {
this.renderer.setTerrainPalette({ colors: palette });
this.renderer.initTerrain({ size: { x: width, y: height }, terrain_data: tileIds });
this.renderer.applyTerritorySnapshot(0, snapshot);
console.log("✓ Applied init data to renderer");
} else {
console.warn("Received init binary but renderer not set yet, buffering");
this.messageBuffer.push({
msg_type: "_init_binary",
terrainData: { size: { x: width, y: height }, terrain_data: tileIds },
terrainPalette: { colors: palette },
snapshot,
});
}
// Mark init data as received and attempt to complete initialization
this.initDataReceived = true;
this.tryCompleteInitialization();
}
// Try to complete initialization when all data is ready
private tryCompleteInitialization() {
// Wait until both pieces are ready: init binary data and RenderInit metadata
if (!this.initDataReceived || !this.bufferedRenderInit || !this.renderer) {
return;
}
console.log("All initialization data received, processing RenderInit");
try {
// Init binary data already applied (terrain + territory)
// Now apply player palette
this.renderer.initPalette(this.bufferedRenderInit.palette);
this.initReceived = true;
this.bufferedRenderInit = null;
// Process any deltas that arrived before initialization
if (this.pendingDeltas.length > 0) {
console.log(`Processing ${this.pendingDeltas.length} buffered deltas`);
for (const delta of this.pendingDeltas) {
this.processRenderMessage(delta);
}
this.pendingDeltas = [];
}
} catch (error) {
console.error("FATAL: Failed to complete initialization:", error);
throw error;
}
}
private async handleRenderMessage(message: any): Promise<void> {
if (!this.renderer) {
console.warn("Renderer not set yet, buffering message:", message.msg_type);
this.messageBuffer.push(message);
return;
}
await this.processRenderMessage(message);
}
private async processRenderMessage(message: any): Promise<void> {
// The message structure from Rust is flat with msg_type field
// Defensive check: ensure renderer is still valid
if (!this.renderer) {
console.warn("Cannot process render message - renderer not available, buffering:", message.msg_type);
// Re-buffer the message if renderer is not available
this.messageBuffer.push(message);
return;
}
// Handle RenderInit message (contains only palette for Tauri, all data for WASM)
if (message.msg_type === "RenderInit") {
// Buffer RenderInit until init binary data is ready
this.bufferedRenderInit = message;
this.tryCompleteInitialization();
return;
}
// Handle buffered init binary data
if (message.msg_type === "_init_binary") {
this.renderer!.setTerrainPalette(message.terrainPalette);
this.renderer!.initTerrain(message.terrainData);
this.renderer!.applyTerritorySnapshot(0, message.snapshot);
return;
}
// Handle buffered binary territory delta
if (message.msg_type === "_binary_delta") {
this.renderer!.updateBinaryDelta(message.data);
return;
}
// Buffer deltas if initialization not received yet
if (!this.initReceived && message.msg_type === "TerritoryDelta") {
this.pendingDeltas.push(message);
return;
}
// Process other messages normally
switch (message.msg_type) {
case "TerritoryDelta":
this.renderer!.updateTerritoryDelta(message.turn, message.changes);
break;
default:
console.warn("Unknown render message type:", message.msg_type);
}
}
async setRenderer(renderer: GameRenderer | null): Promise<void> {
if (renderer === null) {
console.log("Renderer unregistered from TauriAPI - cleaning up");
this.renderer = null;
this.initReceived = false;
this.pendingDeltas = [];
this.messageBuffer = [];
this.initDataReceived = false;
this.bufferedRenderInit = null;
return;
}
this.renderer = renderer;
console.log("Renderer registered with TauriAPI");
// Process buffered messages asynchronously to avoid blocking UI during HMR
if (this.messageBuffer.length > 0) {
const bufferedMessages = [...this.messageBuffer];
this.messageBuffer = [];
// Process messages in next tick to avoid blocking
setTimeout(async () => {
for (const message of bufferedMessages) {
await this.processRenderMessage(message);
}
}, 0);
}
}
destroy() {
// Unsubscribe from all Tauri event listeners
if (this.uiEventUnsubscribe) {
this.uiEventUnsubscribe();
this.uiEventUnsubscribe = undefined;
}
if (this.pixelStreamUnsubscribe) {
this.pixelStreamUnsubscribe();
this.pixelStreamUnsubscribe = undefined;
}
// Clear all callbacks
this.snapshotCallbacks = [];
this.gameEndCallbacks = [];
this.attacksUpdateCallbacks = [];
this.shipsUpdateCallbacks = [];
this.spawnPhaseUpdateCallbacks = [];
this.spawnPhaseEndCallbacks = [];
// Clear state
this.renderer = null;
this.messageBuffer = [];
this.initReceived = false;
this.pendingDeltas = [];
this.initDataReceived = false;
this.bufferedRenderInit = null;
}
sendCameraUpdate(state: { x: number; y: number; zoom: number }): void {
invoke("handle_camera_update", { update: state }).catch((err) => {
console.error("Failed to send camera update:", err);
});
}
sendMapClick(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void {
invoke("handle_render_input", {
event: {
type: "MapClick",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
button: data.button,
},
}).catch((err) => {
console.error("Failed to send map click:", err);
});
}
sendMapHover(data: { tile_index: number | null; world_x: number; world_y: number }): void {
invoke("handle_render_input", {
event: {
type: "MapHover",
tile_index: data.tile_index,
world_x: data.world_x,
world_y: data.world_y,
},
}).catch((err) => {
console.error("Failed to send map hover:", err);
});
}
sendKeyPress(data: { key: string; pressed: boolean }): void {
invoke("handle_render_input", {
event: {
type: "KeyPress",
key: data.key,
pressed: data.pressed,
},
}).catch((err) => {
console.error("Failed to send key press:", err);
});
}
sendAttackRatio(ratio: number): void {
invoke("send_frontend_message", {
message: {
msg_type: "SetAttackRatio",
ratio: ratio,
},
}).catch((err) => {
console.error("Failed to send attack ratio:", err);
});
}
/**
* Get current game state for recovery after reload.
* Returns render and leaderboard state if a game is running, null otherwise.
* ONLY called on frontend init/reload - not streamed.
*/
async getGameState(): Promise<{
render_init: any | null;
leaderboard_snapshot: LeaderboardSnapshot | null;
} | null> {
try {
const state = await invoke<{
render_init: any | null;
leaderboard_snapshot: LeaderboardSnapshot | null;
}>("get_game_state");
return state;
} catch (err) {
console.error("Failed to get game state:", err);
return null;
}
}
}
// Export singleton instance
export const tauriAPI = new TauriAPI();

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,24 @@
/**
* Unified analytics API interface for both desktop and browser builds.
*
* Desktop implementation uses Tauri commands to send events to Rust backend.
* Browser implementation sends events through the WASM worker to Rust backend.
*/
export interface AnalyticsAPI {
/**
* Track an analytics event.
* @param event - Event name (e.g., "app_started", "game_ended")
* @param properties - Optional properties to attach to the event
*/
track(event: string, properties?: Record<string, any>): void;
/**
* Initialize the analytics client (called once at app startup).
*/
init?(): void;
/**
* Flush any pending events (useful for cleanup).
*/
flush?(): Promise<void>;
}

View File

@@ -0,0 +1,13 @@
import { createContext, useContext, type ReactNode } from "react";
import type { AnalyticsAPI } from "@/shared/analytics/AnalyticsAPI";
const AnalyticsContext = createContext<AnalyticsAPI | null>(null);
export function AnalyticsProvider({ api, children }: { api: AnalyticsAPI; children: ReactNode }) {
return <AnalyticsContext.Provider value={api}>{children}</AnalyticsContext.Provider>;
}
export function useAnalytics(): AnalyticsAPI | null {
const api = useContext(AnalyticsContext);
return api;
}

View File

@@ -0,0 +1,2 @@
export type { AnalyticsAPI } from "./AnalyticsAPI";
export { AnalyticsProvider, useAnalytics } from "./AnalyticsContext";

View File

@@ -0,0 +1,100 @@
import type { AttacksUpdatePayload, GameOutcome, LeaderboardSnapshot, ShipsUpdatePayload, SpawnPhaseUpdate, UnsubscribeFn } from "@/shared/api/types";
// TODO: GameRenderer has been removed - needs refactoring
type GameRenderer = any;
/**
* Unified game API interface for both desktop and browser builds.
*
* Desktop implementation uses Tauri's invoke/listen.
* Browser implementation uses WASM bindings with JS callbacks.
*/
export interface GameAPI {
/**
* Start a new game.
*/
startGame(): void;
/**
* Quit the current game.
*/
quitGame(): void;
/**
* Subscribe to leaderboard snapshots (includes names, colors, and stats).
* Returns an unsubscribe function to clean up the listener.
*/
onLeaderboardSnapshot(callback: (data: LeaderboardSnapshot) => void): UnsubscribeFn;
/**
* Subscribe to game end events (victory/defeat).
* Returns an unsubscribe function to clean up the listener.
*/
onGameEnded(callback: (outcome: GameOutcome) => void): UnsubscribeFn;
/**
* Subscribe to dynamic attacks updates.
* Returns an unsubscribe function to clean up the listener.
*/
onAttacksUpdate(callback: (data: AttacksUpdatePayload) => void): UnsubscribeFn;
/**
* Subscribe to ship updates.
* Returns an unsubscribe function to clean up the listener.
*/
onShipsUpdate(callback: (data: ShipsUpdatePayload) => void): UnsubscribeFn;
/**
* Subscribe to spawn phase updates.
* - countdown: null = phase active, waiting for first spawn
* - countdown: non-null = countdown in progress with epoch timestamp
*/
onSpawnPhaseUpdate(callback: (update: SpawnPhaseUpdate) => void): UnsubscribeFn;
/**
* Subscribe to spawn phase end event (game has started).
*/
onSpawnPhaseEnded(callback: () => void): UnsubscribeFn;
// Rendering integration methods
/**
* Register the PixiJS renderer to receive render updates from backend.
*/
setRenderer?(renderer: GameRenderer): void;
/**
* Send camera state updates to the backend.
*/
sendCameraUpdate?(state: { x: number; y: number; zoom: number }): void;
/**
* Send map click events to the backend.
*/
sendMapClick?(data: { tile_index: number | null; world_x: number; world_y: number; button: number }): void;
/**
* Send map hover events to the backend.
*/
sendMapHover?(data: { tile_index: number | null; world_x: number; world_y: number }): void;
/**
* Send keyboard input to the backend.
*/
sendKeyPress?(data: { key: string; pressed: boolean }): void;
/**
* Set attack ratio (percentage of troops to use when attacking).
* @param ratio - Attack ratio as a decimal (0.01 to 1.0, representing 1% to 100%)
*/
sendAttackRatio?(ratio: number): void;
/**
* Get current game state for recovery after reload (desktop only).
* Returns render and leaderboard state if a game is running, null otherwise.
* ONLY called on frontend init/reload - not streamed.
*/
getGameState?(): Promise<{
render_init: any | null;
leaderboard_snapshot: LeaderboardSnapshot | null;
} | null>;
}

View File

@@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from "react";
import type { GameAPI } from "@/shared/api/GameAPI";
/**
* React context for the game API.
* Platform-specific implementations (TauriAPI or WasmBridge) are injected at the root.
*/
const GameAPIContext = createContext<GameAPI | null>(null);
export interface GameAPIProviderProps {
api: GameAPI;
children: ReactNode;
}
/**
* Provider component that injects the platform-specific GameAPI implementation.
*/
export function GameAPIProvider({ api, children }: GameAPIProviderProps) {
return <GameAPIContext.Provider value={api}>{children}</GameAPIContext.Provider>;
}
/**
* Hook to access the game API from any component.
* Returns null during SSR/pre-rendering, real API implementation on client.
*/
export function useGameAPI(): GameAPI | null {
const api = useContext(GameAPIContext);
return api;
}

View File

@@ -0,0 +1,4 @@
// Re-export all API types and interfaces for convenient imports
export * from "./types";
export * from "./GameAPI";
export * from "./GameAPIContext";

View File

@@ -0,0 +1,72 @@
// Shared type definitions for game API
// These mirror the Rust types used in both desktop and WASM builds
export type LeaderboardEntry = {
id: number;
name: string;
color: string; // Hex color without alpha, e.g. "0A44FF"
tile_count: number;
troops: number;
territory_percent: number;
};
export type LeaderboardSnapshot = {
turn: number;
total_land_tiles: number;
entries: LeaderboardEntry[];
client_player_id: number;
};
export type GameOutcome = "Victory" | "Defeat";
export type AttackEntry = {
attacker_id: number;
target_id: number | null; // null for unclaimed territory
troops: number;
start_turn: number;
is_outgoing: boolean;
};
export type AttacksUpdatePayload = {
turn: number;
entries: AttackEntry[];
};
export type SpawnCountdown = {
startedAtMs: number; // Unix epoch milliseconds
durationSecs: number;
};
export type SpawnPhaseUpdate = {
countdown: SpawnCountdown | null; // null = waiting, non-null = countdown active
};
export type HighlightNationMessage = {
msg_type: "HighlightNation";
nation_id: number | null;
};
export type 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,168 @@
import { useEffect, useRef, useState, ReactNode } from "react";
import { useGameAPI } from "@/shared/api/GameAPIContext";
import { useThrottledCallback } from "@/shared/hooks";
interface AttackControlsProps {
children?: ReactNode;
}
export function AttackControls({ children }: AttackControlsProps) {
const api = useGameAPI();
const [percentage, setPercentage] = useState(50);
const sliderRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const lastSentPercentageRef = useRef<number | null>(null);
const updateAttackRatioThrottled = useThrottledCallback((percent: number) => {
if (api && typeof api.sendAttackRatio === "function") {
api.sendAttackRatio(percent / 100);
}
}, 50);
const handlePercentageChange = (newPercent: number) => {
const clampedPercent = Math.max(1, Math.min(100, Math.round(newPercent)));
setPercentage(clampedPercent);
// Only send if the value actually changed
if (clampedPercent !== lastSentPercentageRef.current) {
lastSentPercentageRef.current = clampedPercent;
updateAttackRatioThrottled(clampedPercent);
}
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
updateSliderFromMouse(e);
};
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
updateSliderFromTouch(e);
};
const updateSliderFromMouse = (e: React.MouseEvent<HTMLDivElement> | MouseEvent) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
const rawPercent = (x / width) * 100;
handlePercentageChange(rawPercent);
};
const updateSliderFromTouch = (e: React.TouchEvent<HTMLDivElement> | TouchEvent) => {
if (!sliderRef.current) return;
const touch = e.touches[0];
if (!touch) return;
const rect = sliderRef.current.getBoundingClientRect();
const x = touch.clientX - rect.left;
const width = rect.width;
const rawPercent = (x / width) * 100;
handlePercentageChange(rawPercent);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDraggingRef.current) {
updateSliderFromMouse(e);
}
};
const handleMouseUp = () => {
isDraggingRef.current = false;
};
const handleTouchMove = (e: TouchEvent) => {
if (isDraggingRef.current) {
e.preventDefault();
updateSliderFromTouch(e);
}
};
const handleTouchEnd = () => {
isDraggingRef.current = false;
};
const handleGlobalWheel = (e: WheelEvent) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -5 : 5;
let newPercent: number;
if (percentage === 1 && delta > 0) {
newPercent = 5;
} else {
newPercent = percentage + delta;
}
handlePercentageChange(newPercent);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleTouchEnd);
window.addEventListener("wheel", handleGlobalWheel, { passive: false });
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
window.removeEventListener("wheel", handleGlobalWheel);
};
}, [percentage, handlePercentageChange]);
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -5 : 5;
let newPercent: number;
if (percentage === 1 && delta > 0) {
newPercent = 5;
} else {
newPercent = percentage + delta;
}
handlePercentageChange(newPercent);
};
return (
<div className="attack-controls no-drag block-game-input" onWheelCapture={handleWheel}>
<div className="attack-controls__content">
<div className="attack-controls__header">
<div className="attack-controls__label 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 attack-controls__attacks">{children}</div>
</div>
<div className="w-104">
<div
ref={sliderRef}
className="bg-black/40 rounded-[0.4em] relative cursor-pointer overflow-hidden border border-white/10 h-11"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onWheel={handleWheel}
>
<div
className="h-full absolute left-0 top-0 pointer-events-none rounded-[0.15em] bg-blue-600/65"
style={{ width: `${percentage}%` }}
/>
<div className="attack-controls__percentage">{percentage}%</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import type { AttackEntry, LeaderboardEntry } from "@/shared/api/types";
// Format troop count for human-readable display
// 100 => "100", 12,493 => "12.4k", 980,455 => "980k"
export function formatTroopCount(count: number): string {
if (count < 1000) return count.toString();
if (count < 1000000) {
const k = count / 1000;
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
}
const m = count / 1000000;
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`;
}
// Calculate the background width percentage based on troop count
// Uses power scale (x^0.4): 100 troops = 5%, 1k = 7.9%, 10k = 15.3%, 100k = 33.7%, 1M = 80%
function calculateBackgroundWidth(troops: number): number {
const minTroops = 100;
const maxTroops = 1000000;
const minWidth = 5;
const maxWidth = 80;
// Clamp troops to range
const clampedTroops = Math.max(minTroops, Math.min(maxTroops, troops));
// Power scale with exponent 0.4 provides gentler progression than logarithmic
const powerMin = Math.pow(minTroops, 0.4);
const powerMax = Math.pow(maxTroops, 0.4);
const powerTroops = Math.pow(clampedTroops, 0.4);
// Map to 5-80% range
const normalized = (powerTroops - powerMin) / (powerMax - powerMin);
return minWidth + normalized * (maxWidth - minWidth);
}
interface AttackRowProps {
attack: AttackEntry;
playerMap: Map<number, LeaderboardEntry>;
onNationHover?: (nationId: number | null) => void;
}
export function AttackRow({ attack, playerMap, onNationHover }: AttackRowProps) {
// For outgoing attacks, show target's name (who we're attacking)
// For incoming attacks, show attacker's name (who is attacking us)
const displayPlayerId = attack.is_outgoing ? attack.target_id : attack.attacker_id;
const displayPlayer = displayPlayerId !== null ? playerMap.get(displayPlayerId) : null;
const displayName = displayPlayer?.name || "Unclaimed Territory";
const backgroundWidth = calculateBackgroundWidth(attack.troops);
const backgroundColor = attack.is_outgoing
? "rgba(59, 130, 246, 0.3)" // Blue with 30% opacity
: "rgba(239, 68, 68, 0.3)"; // Red with 30% opacity
return (
<button
className="attacks__row"
onClick={() => console.log("Attack clicked:", attack)}
onMouseEnter={() => displayPlayerId !== null && onNationHover?.(displayPlayerId)}
onMouseLeave={() => onNationHover?.(null)}
>
<div
className="attacks__background"
style={{
width: `${backgroundWidth}%`,
backgroundColor,
}}
/>
<div className="attacks__nation">{displayName}</div>
<div className="attacks__troops">{formatTroopCount(attack.troops)}</div>
</button>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { useGameAPI } from "@/shared/api/GameAPIContext";
import type { AttacksUpdatePayload, LeaderboardEntry, UnsubscribeFn } from "@/shared/api/types";
import { AttackRow } from "@/shared/components/game/AttackRow";
export function Attacks({
onNationHover
}: {
onNationHover: (nationId: number | null) => void;
}) {
const gameAPI = useGameAPI();
const [attacksData, setAttacksData] = useState<AttacksUpdatePayload | null>(null);
const [playerMap, setPlayerMap] = useState<Map<number, LeaderboardEntry>>(new Map());
useEffect(() => {
if (!gameAPI) return;
let unsubscribeAttacks: UnsubscribeFn = () => {};
let unsubscribeLeaderboard: UnsubscribeFn = () => {};
// Subscribe to leaderboard snapshots to get player names/colors
unsubscribeLeaderboard = gameAPI.onLeaderboardSnapshot((snapshot) => {
const newMap = new Map<number, LeaderboardEntry>();
snapshot.entries.forEach((entry) => {
newMap.set(entry.id, entry);
});
// Only update state if the map actually changed (prevents unnecessary re-renders)
setPlayerMap((prevMap) => {
// Check if sizes differ
if (prevMap.size !== newMap.size) {
return newMap;
}
// Check if any entries changed
for (const [id, entry] of newMap) {
const prevEntry = prevMap.get(id);
if (!prevEntry || prevEntry.name !== entry.name || prevEntry.color !== entry.color) {
return newMap;
}
}
// No changes - return previous map to avoid re-render
return prevMap;
});
});
// Subscribe to attacks updates
unsubscribeAttacks = gameAPI.onAttacksUpdate((payload) => {
setAttacksData(payload);
});
return () => {
unsubscribeAttacks();
unsubscribeLeaderboard();
};
}, [gameAPI]);
// Don't render if no attacks
if (!attacksData || attacksData.entries.length === 0) {
return null;
}
return (
<div className="attacks no-drag block-game-input" onMouseLeave={() => onNationHover(null)}>
{attacksData.entries.map((attack, index) => (
<AttackRow
key={`${attack.attacker_id}-${attack.target_id}-${attack.start_turn}-${index}`}
attack={attack}
playerMap={playerMap}
onNationHover={onNationHover}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import React, { useEffect, useRef, useState } from "react";
import { CameraStateListener, GameRenderer } from "@/shared/render";
import { useGameAPI } from "@/shared/api";
import { useThrottledCallback } from "@/shared/hooks";
interface GameCanvasProps {
className?: string;
initialState?: any | null;
onRendererReady?: (renderer: GameRenderer | null) => void;
onNationHover?: (nationId: number | null) => void;
}
export const GameCanvas: React.FC<GameCanvasProps> = ({ className, initialState, onRendererReady, onNationHover }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<GameRenderer | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const lastHoveredNationRef = useRef<number | null>(null);
const lastHoveredTileRef = useRef<number | null>(null);
const api = useGameAPI();
// Apply initial state when provided (for reload recovery)
useEffect(() => {
if (!initialState || !rendererRef.current || !isInitialized) return;
console.log("Applying initial game state to renderer:", initialState);
const renderer = rendererRef.current;
try {
// Apply RenderInit data: terrain palette, terrain, player palette, territories
renderer.setTerrainPalette(initialState.terrain_palette);
renderer.initTerrain(initialState.terrain);
renderer.initPalette(initialState.palette);
renderer.applyTerritorySnapshot(initialState.initial_territories.turn, initialState.initial_territories.territories);
console.log("Initial state applied successfully");
} catch (err) {
console.error("Failed to apply initial state:", err);
}
}, [initialState, isInitialized]);
// Initialize renderer once on mount
useEffect(() => {
if (!canvasRef.current || rendererRef.current) return;
let cancelled = false;
const renderer = new GameRenderer();
rendererRef.current = renderer;
// Initialize renderer
renderer
.init(canvasRef.current)
.then(async () => {
if (cancelled) {
renderer.destroy();
return;
}
setIsInitialized(true);
// Set up camera listener
const cameraListener: CameraStateListener = {
onCameraUpdate: (x, y, zoom) => {
if (api && typeof api.sendCameraUpdate === "function") {
api.sendCameraUpdate({ x, y, zoom });
}
},
};
renderer.setCameraListener(cameraListener);
// Register renderer with API for receiving updates
if (api && typeof api.setRenderer === "function") {
await api.setRenderer(renderer);
}
// Notify parent that renderer is ready
onRendererReady?.(renderer);
})
.catch((err: unknown) => {
if (!cancelled) {
console.error("Failed to initialize GameRenderer:", err);
}
});
// Cleanup
return () => {
cancelled = true;
if (rendererRef.current) {
// Unregister renderer from API before destroying
if (api && typeof api.setRenderer === "function") {
api.setRenderer(null);
}
rendererRef.current.destroy();
rendererRef.current = null;
onRendererReady?.(null);
}
setIsInitialized(false);
};
}, [api, onRendererReady]);
// Handle canvas clicks using native event listener (after renderer initialization)
// React onClick doesn't work reliably when native listeners are attached to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !rendererRef.current || !api) return;
const handleCanvasClick = (e: MouseEvent) => {
if (!rendererRef.current || !api) return;
// Ignore clicks that were camera drags
if (rendererRef.current.hadCameraInteraction()) {
return;
}
const rect = canvas.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
if (typeof api.sendMapClick === "function") {
const worldPos = rendererRef.current.tileToWorld(tileIndex || 0);
api.sendMapClick({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
button: e.button,
});
}
};
canvas.addEventListener("click", handleCanvasClick);
return () => canvas.removeEventListener("click", handleCanvasClick);
}, [api, isInitialized]);
// Handle mouse move (for hover) - throttled to reduce IPC overhead
const handleMouseMove = useThrottledCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!rendererRef.current) return;
const rect = canvasRef.current!.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const tileIndex = rendererRef.current.screenToTile(screenX, screenY);
if (tileIndex === null) return;
// Handle nation hover detection
if (onNationHover) {
const nationId = rendererRef.current.getNationAtTile(tileIndex);
if (nationId !== lastHoveredNationRef.current) {
lastHoveredNationRef.current = nationId;
onNationHover(nationId);
}
}
// Only send map hover if tile changed
if (tileIndex !== lastHoveredTileRef.current) {
lastHoveredTileRef.current = tileIndex;
if (api && typeof api.sendMapHover === "function") {
const worldPos = rendererRef.current.tileToWorld(tileIndex);
// Only send if we have valid coordinates
if (isFinite(worldPos.x) && isFinite(worldPos.y)) {
api.sendMapHover({
tile_index: tileIndex,
world_x: worldPos.x,
world_y: worldPos.y,
});
}
}
}
}, 10);
// Handle keyboard input
useEffect(() => {
if (!api) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (typeof api.sendKeyPress === "function") {
api.sendKeyPress({
key: e.code,
pressed: true,
});
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (typeof api.sendKeyPress === "function") {
api.sendKeyPress({
key: e.code,
pressed: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [api]);
return (
<div className={className}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onContextMenu={(e) => e.preventDefault()}
style={{
display: "block",
width: "100%",
height: "100%",
cursor: isInitialized ? undefined : "wait",
}}
/>
{!isInitialized && (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
color: "white",
fontSize: "18px",
fontFamily: "Inter, sans-serif",
}}
>
Initializing renderer...
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,251 @@
import { useEffect, useMemo, useState, useRef, ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { useGameAPI } from "@/shared/api/GameAPIContext";
import type { LeaderboardSnapshot, UnsubscribeFn } from "@/shared/api/types";
import * as motion from "motion/react-client";
import type { Transition } from "motion/react";
import { cn } from "@/lib/utils";
// 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 spring: Transition = {
type: "spring",
damping: 20,
stiffness: 300,
};
export function Leaderboard({
initialSnapshot,
highlightedNation,
onNationHover,
}: {
initialSnapshot?: LeaderboardSnapshot | null;
highlightedNation: number | null;
onNationHover: (nationId: number | null) => void;
}) {
const gameAPI = useGameAPI();
const [collapsed, setCollapsed] = useState(false);
const [snapshot, setSnapshot] = useState<LeaderboardSnapshot | null>(initialSnapshot || null);
const [status, setStatus] = useState<"loading" | "waiting" | "ready" | "error">(initialSnapshot ? "ready" : "waiting");
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
useEffect(() => {
if (!gameAPI) return;
let unsubscribe: UnsubscribeFn = () => {};
// Subscribe to leaderboard snapshots
try {
unsubscribe = gameAPI.onLeaderboardSnapshot((snapshotData) => {
setSnapshot(snapshotData);
setStatus("ready");
});
} catch (error) {
console.warn("Failed to subscribe to leaderboard snapshots:", error);
setStatus("error");
}
return () => {
unsubscribe();
};
}, [gameAPI]);
const { topRows, playerEntry, playerInTopN, playerPosition } = useMemo(() => {
if (!snapshot) {
return {
topRows: [],
playerEntry: null,
playerInTopN: false,
playerPosition: null,
};
}
// Render top 15 rows for animation buffer (only top 8 will be visible)
const topRows = snapshot.entries.slice(0, RENDERED_BUFFER);
// Find player and check if they're in visible top N
const playerEntry = snapshot.entries.find((e) => e.id === snapshot.client_player_id);
const visibleTopEntries = snapshot.entries.slice(0, VISIBLE_TOP_N);
const playerInTopN = playerEntry ? visibleTopEntries.some((e) => e.id === playerEntry.id) : false;
const playerPosition = playerEntry ? snapshot.entries.findIndex((e) => e.id === playerEntry.id) + 1 : null;
return {
topRows,
playerEntry,
playerInTopN,
playerPosition,
};
}, [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 icon(collapsed: boolean): ReactNode {
return (
<ChevronRight
className={cn("ml-1.5 pt-0.5 transition-transform duration-200 ease-in-out", {
"rotate-90": collapsed,
})}
size={16}
/>
);
}
return (
<div
className="fixed top-0 left-3 z-10 text-sm select-none pointer-events-auto opacity-60 hover:opacity-100 transition-opacity duration-[400ms] max-xl:text-xs max-[1000px]:text-xs max-[800px]:text-[11.5px] max-[600px]:text-[10px] no-drag block-game-input"
onMouseLeave={() => onNationHover(null)}
>
<div
className={`bg-slate-900/75 text-white px-3 py-2 font-medium cursor-pointer flex items-center justify-between ${collapsed ? "rounded-bl-[0.2em] rounded-br-[0.2em]" : ""}`}
onClick={() => setCollapsed((c) => !c)}
>
<span>Leaderboard</span>
{icon(collapsed)}
</div>
{!collapsed && (
<div className="bg-slate-900/60 backdrop-blur-[2px] rounded-bl-[0.5em] rounded-br-[0.5em]">
{status === "ready" && snapshot ? (
<>
<div className="overflow-hidden" style={containerHeight ? { maxHeight: `${containerHeight}px` } : undefined}>
<table className="w-[23.5em] border-collapse" ref={tableRef}>
<tbody>
{topRows.map((r) => {
const position = snapshot.entries.findIndex((e) => e.id === r.id) + 1;
const isPlayer = r.id === snapshot.client_player_id;
const isEliminated = r.territory_percent === 0;
const isHighlighted = highlightedNation === r.id;
return (
<motion.tr
key={r.id}
layout
layoutId={`nation-${r.id}`}
transition={spring}
className={`leading-7 transition-[background-color] duration-150 ease-[ease] ${isEliminated ? "cursor-default" : "cursor-pointer hover:bg-white/[0.06]"} ${isPlayer ? "text-white" : "text-white/75"} ${isHighlighted ? "!bg-white/[0.12]" : ""} [&>td]:py-[0.25em]`}
onClick={() => !isEliminated && console.log("select", r.id)}
onMouseEnter={() => !isEliminated && onNationHover(r.id)}
onMouseLeave={() => !isEliminated && onNationHover(null)}
>
<td className="pl-[0.67em] pr-[0.5em] text-center whitespace-nowrap w-[3em] min-w-[3em] tracking-[-0.02em] align-middle">
{isEliminated ? "" : position}
</td>
<td className="pr-[0.67em] text-left overflow-hidden text-ellipsis whitespace-nowrap w-[55%]">
<div className="flex items-center gap-[0.5em]">
<div
className="w-[0.75em] h-[0.75em] rounded-full shrink-0 shadow-[0_0_0.15em_rgba(0,0,0,0.4),0_0_0.3em_rgba(0,0,0,0.2),0_0_0.5em_rgba(0,0,0,0.1),inset_0_0_0.1em_rgba(255,255,255,0.2)]"
style={{
backgroundColor: `#${r.color}`,
}}
/>
<span>{r.name}</span>
</div>
</td>
<td className="px-3 text-right whitespace-nowrap w-[20%]">
{isEliminated ? "—" : `${(r.territory_percent * 100).toFixed(precision)}%`}
</td>
<td className="pl-3 pr-4 text-right whitespace-nowrap w-[20%] min-w-20">
{isEliminated ? "—" : r.troops.toLocaleString()}
</td>
</motion.tr>
);
})}
</tbody>
</table>
</div>
{!playerInTopN && playerEntry && (
<div className="bg-slate-900/65 rounded-lg">
<table className="w-[23.5em] border-collapse">
<tbody>
<motion.tr
key={playerEntry.id}
layout
layoutId={`nation-${playerEntry.id}`}
transition={spring}
className={`leading-7 text-white transition-colors duration-150 ease-[ease] ${playerEntry.territory_percent === 0 ? "cursor-default" : "cursor-pointer hover:bg-white/[0.06]"} ${highlightedNation === playerEntry.id ? "!bg-white/[0.12]" : ""} [&>td]:py-[0.25em]`}
onClick={() => playerEntry.territory_percent > 0 && console.log("select", playerEntry.id)}
onMouseEnter={() => playerEntry.territory_percent > 0 && onNationHover(playerEntry.id)}
onMouseLeave={() => playerEntry.territory_percent > 0 && onNationHover(null)}
>
<td className="pl-3 pr-2 text-center whitespace-nowrap w-12 min-w-12 text-sm tracking-tight align-middle">
{playerEntry.territory_percent === 0 ? "—" : playerPosition}
</td>
<td className="pr-3 text-left overflow-hidden text-ellipsis whitespace-nowrap w-[55%]">
<div className="flex items-center gap-[0.5em]">
<div
className="size-3 rounded-full shrink-0 shadow-[0_0_0.15em_rgba(0,0,0,0.4),0_0_0.3em_rgba(0,0,0,0.2),0_0_0.5em_rgba(0,0,0,0.1),inset_0_0_0.1em_rgba(255,255,255,0.2)]"
style={{
backgroundColor: `#${playerEntry.color}`,
}}
/>
<span>{playerEntry.name}</span>
</div>
</td>
<td className="px-3 text-right whitespace-nowrap w-[20%]">
{playerEntry.territory_percent === 0 ? "—" : `${(playerEntry.territory_percent * 100).toFixed(precision)}%`}
</td>
<td className="pl-3 pr-4 text-right whitespace-nowrap w-[20%] min-w-[5em]">
{playerEntry.territory_percent === 0 ? "—" : playerEntry.troops.toLocaleString()}
</td>
</motion.tr>
</tbody>
</table>
</div>
)}
</>
) : (
<div className="p-[1em] 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,39 @@
@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);
}
}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import "./Menu.css";
interface GameMenuProps {
onExit: () => void;
onSettings?: () => void;
}
export function GameMenu({ onExit, onSettings }: GameMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeConfirmation = () => {
setIsClosing(true);
setTimeout(() => {
setShowConfirmation(false);
setIsClosing(false);
}, 200);
};
return (
<div className="fixed top-4 right-4 z-999 no-drag block-game-input">
{/* Hamburger/Close Button */}
<button
className="bg-[rgba(15,23,42,0.75)] border-none rounded-md text-white p-2 cursor-pointer flex items-center justify-center transition-colors duration-150 ease-[ease] hover:bg-[rgba(15,23,42,0.9)]"
onClick={() => setIsOpen((prev) => !prev)}
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute top-[calc(100%+0.5rem)] right-0 bg-[rgba(15,23,42,0.75)] rounded-md min-w-[140px] 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-[background-color] duration-150 ease-[ease] hover:bg-[rgba(255,255,255,0.06)]",
onSettings && "border-t border-white/10",
)}
onClick={() => {
setShowConfirmation(true);
setIsClosing(false);
setIsOpen(false);
}}
>
Exit
</button>
</div>
)}
{/* Confirmation Dialog */}
{showConfirmation && (
<div
className={cn(
"fixed inset-0 bg-[rgba(0,0,0,0.5)] flex items-center justify-center z-[1000]",
isClosing ? "animate-[fadeOut_0.2s_ease-out_forwards]" : "animate-[fadeIn_0.2s_ease-out]",
)}
onClick={closeConfirmation}
>
<div
className={cn(
"bg-zinc-900 rounded-lg p-8 min-w-[300px] max-w-[400px] shadow-lg shadow-zinc-950/50",
isClosing ? "animate-[slideDown_0.2s_ease-out_forwards]" : "animate-[slideUp_0.2s_ease-out]",
)}
onClick={(e) => e.stopPropagation()}
>
<h3 className="my-0 mb-4 text-white text-xl font-inter font-semibold">Are you sure?</h3>
<p className="my-0 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-[background-color] duration-150 ease-[ease] 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,672 @@
.menu-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s ease-out;
opacity: 1;
pointer-events: auto;
user-select: none;
overflow: hidden;
}
.menu-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.menu-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
z-index: -1;
overflow: hidden;
}
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(1px) brightness(0.65) sepia(0.3) contrast(1.5);
transform: scale(1.05);
}
.background-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(4px);
}
.menu-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
min-height: 100%;
pointer-events: auto;
padding-top: 0;
}
.title-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 2rem 0;
margin-top: 2rem;
background: rgba(20, 20, 20, 0.45);
backdrop-filter: blur(8px) contrast(1.5);
pointer-events: none;
}
.game-title {
font-family: "Oswald Variable", sans-serif;
text-transform: uppercase;
font-size: 6rem;
font-weight: 600;
user-select: none;
color: #ffffff;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
line-height: 0.8;
animation: shimmer 3s ease-in-out infinite;
}
.game-subtitle {
padding-top: 0.5rem;
font-family: "Inter Variable", sans-serif;
font-size: 1.3rem;
color: #7e7e7e;
display: none;
margin: 0;
user-select: none;
font-weight: 400;
}
.menu-islands {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1.05rem;
width: 700px;
max-width: 90%;
margin: 3rem auto;
pointer-events: auto;
}
.island-button {
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
user-select: none;
padding: 0.5rem 1rem;
text-align: left;
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.175);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
&.island-local,
&.island-multiplayer {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
}
.island-button:hover:not(:disabled) {
transform: translateY(-4px);
box-shadow: 0 12px 15px rgba(0, 0, 0, 0.27);
}
.island-button:active:not(:disabled) {
transform: translateY(-2px);
}
.island-button:disabled {
cursor: default;
}
.island-content {
position: relative;
z-index: 10;
}
.island-title {
font-family: "Oswald Variable", sans-serif;
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.2rem 0;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.island-description {
font-family: "Inter Variable", sans-serif;
font-size: 0.95rem;
font-weight: 400;
margin: 0;
line-height: 1.4;
opacity: 0.95;
}
.island-local {
background: #c77a06;
color: #ffffff;
aspect-ratio: 1 / 2;
position: relative;
}
.island-local:active {
background: #a66305;
}
.island-local .island-background-image {
position: absolute;
bottom: 0%;
left: 0%;
transform: scale(1) rotate(-0deg);
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
width: 100%;
height: auto;
object-fit: contain;
pointer-events: none;
z-index: 0;
image-rendering: pixelated;
}
.island-right-column {
display: flex;
flex-direction: column;
gap: 1.05rem;
aspect-ratio: 1 / 1;
}
.island-multiplayer {
background: #4591c0;
color: #ffffff;
flex: 1;
position: relative;
}
.island-multiplayer:active {
background: #3c7397;
}
.island-multiplayer .island-background-image {
position: absolute;
bottom: 0%;
right: 0%;
transform: scale(1) rotate(-0deg);
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
width: 100%;
height: auto;
object-fit: contain;
pointer-events: none;
z-index: 0;
image-rendering: pixelated;
}
.island-settings {
background: #f1ebdb;
color: #424c4a;
flex: 1;
position: relative;
}
.island-settings:active {
background: #d9d1bb;
}
.island-settings .island-background-image {
position: absolute;
bottom: 0%;
right: 0%;
transform: scale(1) rotate(-0deg);
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
width: 100%;
height: auto;
object-fit: contain;
pointer-events: none;
z-index: 0;
image-rendering: pixelated;
}
.island-downloads {
background: #f1ebdb;
color: #424c4a;
grid-column: 1 / -1;
position: relative;
}
.island-downloads:active {
background: #d9d1bb;
}
@keyframes shimmer {
0%,
100% {
color: #ffffff;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 0 10px rgba(30, 41, 59, 0.2);
}
50% {
color: #ececec;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 0 15px rgba(51, 65, 85, 0.3);
}
}
/* Responsive design */
@media (max-width: 768px) {
.game-title {
font-size: 4rem;
}
.game-subtitle {
font-size: 1rem;
}
.menu-islands {
grid-template-columns: 1fr;
width: 95%;
padding: 1.5rem 0;
}
.island-local {
grid-row: auto;
aspect-ratio: auto;
}
.island-button {
padding: 2rem;
}
.island-title {
font-size: 2rem;
}
.island-description {
font-size: 0.95rem;
}
}
.menu-footer {
position: fixed;
bottom: 1.6rem;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 3rem 0 2rem;
font-family: "Inter Variable", sans-serif;
pointer-events: none;
z-index: 100;
}
.footer-left {
display: flex;
align-items: flex-end;
}
.exit-button {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
outline: none;
box-shadow: none;
cursor: pointer;
transition: all 0.2s ease;
color: #ffffff;
opacity: 0.85;
mix-blend-mode: screen;
padding: 0;
pointer-events: auto;
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.3));
}
.exit-button:hover {
opacity: 1;
transform: scale(1.1);
background: transparent;
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.4));
}
.exit-button:active {
transform: scale(0.9);
background: transparent;
}
.exit-button:focus {
outline: none;
background: transparent;
}
.exit-button:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 4px;
background: transparent;
}
.version-warning-button {
font-family: "Oswald Variable", sans-serif;
font-size: 1.8rem;
font-weight: 700;
padding: 0;
background: none;
color: #ffffff;
border: none;
cursor: pointer;
transition: all 0.2s ease;
pointer-events: auto;
user-select: none;
text-transform: uppercase;
letter-spacing: 0.01em;
display: inline-block;
position: relative;
box-shadow: none;
&:active {
background-color: transparent;
}
&,
span {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.version-warning-button-asterisk {
color: #ffc56d;
position: absolute;
right: -0.42em;
top: 0;
}
}
.version-warning-button svg {
flex-shrink: 0;
}
.version-warning-button:hover {
transform: translateY(-1px);
}
.version-warning-button:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.menu-footer {
padding: 0 1.5rem;
bottom: 1.5rem;
}
.copyright {
font-size: 0.95rem;
}
.version-warning-button {
font-size: 1.4rem;
}
.version-warning-button svg {
width: 22px;
height: 22px;
}
}
/* Shared scrollbar container - stays in place during transitions */
.menu-scroll-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: auto;
overflow-x: hidden;
}
/* Menu view container for Motion animations */
.menu-view-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-height: 100%;
}
/* Base styles for all menu views */
.menu-view {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
padding: 3rem;
box-sizing: border-box;
}
/* Multiplayer view specific styling */
.multiplayer-view {
background: linear-gradient(135deg, #4591c0 0%, #3c7397 100%);
color: #ffffff;
}
.multiplayer-view .view-feature-item {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
}
/* Back button styling */
.view-back-button {
position: fixed;
top: 2rem;
left: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
border: none;
border-radius: 8px;
color: #ffffff;
font-family: "Inter Variable", sans-serif;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.view-back-button:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateX(-4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.view-back-button:active {
transform: translateX(-2px);
}
/* View content layout */
.view-content {
max-width: 900px;
width: 100%;
margin: 0 auto;
padding-top: 4rem;
}
.view-title {
font-family: "Oswald Variable", sans-serif;
font-size: 4rem;
font-weight: 700;
text-transform: uppercase;
margin: 0 0 2rem 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
letter-spacing: 0.02em;
}
.view-body {
display: flex;
flex-direction: column;
gap: 2rem;
}
.view-placeholder {
font-family: "Inter Variable", sans-serif;
font-size: 1.5rem;
font-weight: 400;
opacity: 0.8;
margin: 0;
text-align: center;
}
.view-feature-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 2rem;
}
.view-feature-item {
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.view-feature-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.view-feature-item h3 {
font-family: "Oswald Variable", sans-serif;
font-size: 1.8rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.view-feature-item p {
font-family: "Inter Variable", sans-serif;
font-size: 1rem;
margin: 0;
opacity: 0.9;
line-height: 1.5;
}
/* Card wrapper - occupies same space as island buttons */
.view-card-wrapper {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
}
/* Card styling */
.view-card {
width: 100%;
max-width: 900px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: 2.5rem;
box-sizing: border-box;
overflow-y: auto;
}
.settings-card {
background: rgba(241, 235, 219, 0.95);
}
.downloads-card {
background: rgba(241, 235, 219, 0.95);
}
.view-card-title {
font-family: "Oswald Variable", sans-serif;
font-size: 2.5rem;
font-weight: 700;
text-transform: uppercase;
margin: 0 0 1.5rem 0;
color: #424c4a;
letter-spacing: 0.02em;
text-align: center;
}
.view-card-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Responsive design for views */
@media (max-width: 768px) {
.menu-view {
padding: 2rem 1.5rem;
}
.view-back-button {
top: 1.5rem;
left: 1.5rem;
padding: 0.6rem 1rem;
font-size: 0.9rem;
}
.view-title {
font-size: 2.5rem;
}
.view-placeholder {
font-size: 1.2rem;
}
.view-feature-item h3 {
font-size: 1.4rem;
}
.view-feature-item p {
font-size: 0.9rem;
}
.view-card {
padding: 2rem 1.5rem;
max-height: 500px;
}
.view-card-title {
font-size: 1.8rem;
}
}

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, lazy, Suspense } from "react";
import { motion, AnimatePresence, Variants } from "motion/react";
import "overlayscrollbars/overlayscrollbars.css";
import "./MenuScreen.css";
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 { 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";
const AlphaWarningModal = lazy(() => import("@/shared/components/overlays/AlphaWarning"));
type MenuView = "home" | "multiplayer" | "settings" | "downloads";
interface MenuScreenProps {
onStartSingleplayer: () => void;
onExit: () => void;
}
export function MenuScreen({ onStartSingleplayer, onExit }: MenuScreenProps) {
const [activeView, setActiveView] = useState<MenuView>("home");
const [isTransitioning, setIsTransitioning] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// 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);
}, []);
// Prefetch modal component and warm up game assets after delay
useEffect(() => {
const timer = setTimeout(async () => {
import("@/shared/components/overlays/AlphaWarning");
// Pre-warm platform-specific backends
if (!__DESKTOP__) {
// Browser: Pre-warm WASM worker so it's ready when user clicks play
// This initializes the worker, loads WASM module, and compiles it
import("@/browser/api/wasmBridge").then(() => {
console.log("WASM worker pre-warmed and ready");
});
} else {
// Desktop: Pre-warm Tauri API and native backend
import("@/desktop/api/tauriAPI").then(() => {
console.log("Tauri API pre-warmed and ready");
});
}
// Pre-warm PixiJS by importing its heavy modules (applies to both platforms)
// This triggers WebGLRenderer, browserAll, and other chunks to load
// Without this, they load on-demand when GameRenderer.init() is called
try {
await import("pixi.js");
console.log("PixiJS modules pre-loaded");
} catch (err) {
console.warn("Failed to preload PixiJS:", err);
}
}, 1000); // Start after 1s (user is reading menu)
return () => clearTimeout(timer);
}, []);
const handleSingleplayerClick = () => {
setIsTransitioning(true);
// Start game initialization IMMEDIATELY while animation plays
// Don't wait for CSS animation to finish - parallelize!
onStartSingleplayer();
};
const handleNavigate = (view: MenuView) => {
setActiveView(view);
window.history.pushState({ view }, "");
};
const handleBackToHome = () => {
setActiveView("home");
window.history.replaceState({ view: "home" }, "");
};
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 },
};
// Only hide footer/title for multiplayer (full-screen view), not for card views
const shouldHideChrome = activeView === "multiplayer";
const transition = {
duration: 0.25,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
};
return (
<div className={`menu-screen block-game-input ${isTransitioning ? "fade-out" : ""}`}>
<div className="menu-background">
<img src={inlineScreenshot} alt="Menu background" className="background-image" />
<div className="background-overlay"></div>
</div>
<OverlayScrollbarsComponent
className="menu-scroll-container"
defer
options={{ scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } }}
>
<div className="menu-content">
<motion.div
className="title-section"
initial="visible"
animate={shouldHideChrome ? "hidden" : "visible"}
variants={titleVariants}
transition={transition}
>
<h1 className="game-title">Iron Borders</h1>
<p className="game-subtitle">Strategic Territory Control</p>
</motion.div>
<AnimatePresence mode="wait" initial={false}>
{activeView === "home" ? (
<motion.div
key="home"
className="menu-islands"
initial="homeEnter"
animate="homeCenter"
exit="homeExit"
variants={slideVariants}
transition={transition}
>
<button className="island-button island-local" onClick={handleSingleplayerClick} disabled={isTransitioning}>
<img src={singleplayerImage} alt="" className="island-background-image" />
<div className="island-content">
<h2 className="island-title">Local</h2>
<p className="island-description">Battle against AI opponents in strategic territorial warfare</p>
</div>
</button>
<div className="island-right-column">
<button className="island-button island-multiplayer" onClick={() => handleNavigate("multiplayer")}>
<img src={multiplayerImage} alt="" className="island-background-image" />
<div className="island-content">
<h2 className="island-title">Multiplayer</h2>
<p className="island-description">Challenge other players in real-time matches</p>
</div>
</button>
<button className="island-button island-settings" onClick={() => handleNavigate("settings")}>
<img src={settingsImage} alt="" className="island-background-image" />
<div className="island-content">
<h2 className="island-title">Settings</h2>
<p className="island-description">Customize your experience</p>
</div>
</button>
</div>
{!__DESKTOP__ && (
<button className="island-button island-downloads" onClick={() => handleNavigate("downloads")}>
<div className="island-content">
<h2 className="island-title">Downloads</h2>
<p className="island-description">Get standalone versions for Windows, Mac, and Linux</p>
</div>
</button>
)}
</motion.div>
) : activeView === "multiplayer" ? (
<motion.div
key="multiplayer"
className="menu-view-container"
initial="contentEnter"
animate="contentCenter"
exit="contentExit"
variants={slideVariants}
transition={transition}
>
<MultiplayerView onBack={handleBackToHome} />
</motion.div>
) : (
<motion.div
key={activeView}
className="menu-islands"
initial="cardEnter"
animate="cardCenter"
exit="cardExit"
variants={cardVariants}
transition={transition}
>
{activeView === "settings" && <SettingsView />}
{activeView === "downloads" && <DownloadsView />}
</motion.div>
)}
</AnimatePresence>
</div>
</OverlayScrollbarsComponent>
<motion.div
className="menu-footer"
initial="visible"
animate={shouldHideChrome ? "hidden" : "visible"}
variants={footerVariants}
transition={transition}
>
<div className="flex items-end">
{__DESKTOP__ && (
<button className="exit-button" onClick={onExit}>
<Power size={40} strokeWidth={2.5} />
</button>
)}
</div>
<div className="flex flex-col items-end">
<button
className="version-warning-button"
onClick={() => setIsModalOpen(true)}
title={`Git: ${__GIT_COMMIT__.substring(0, 7)}\nBuild: ${new Date(__BUILD_TIME__).toLocaleString()}`}
>
<span>{__APP_VERSION__ === "0.0.0" ? "Unknown Version" : `v${__APP_VERSION__.trimEnd()}`}</span>
<span className="version-warning-button-asterisk">*</span>
</button>
<div className="pointer-events-auto select-none">
<a
className="text-shadow-xl text-sky-100 hover:text-sky-50 transition-colors duration-200 ease-[ease]"
href="https://github.com/Xevion"
target="_blank"
>
© 2025 Ryan Walters
</a>
</div>
</div>
</motion.div>
<Suspense fallback={null}>
<AlphaWarningModal open={isModalOpen} onOpenChange={setIsModalOpen} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Download } from "lucide-react";
export function DownloadsView() {
return (
<div className="view-card-wrapper">
<div className="view-card downloads-card">
<h1 className="view-card-title">Downloads</h1>
<div className="view-card-body">
<p className="view-placeholder">Standalone applications coming soon...</p>
<div className="view-feature-list">
<div className="view-feature-item downloads-platform">
<div className="platform-header">
<Download size={24} />
<h3>Windows</h3>
</div>
<p>Native desktop application for Windows 10+</p>
</div>
<div className="view-feature-item downloads-platform">
<div className="platform-header">
<Download size={24} />
<h3>macOS</h3>
</div>
<p>Universal binary for Apple Silicon and Intel Macs</p>
</div>
<div className="view-feature-item downloads-platform">
<div className="platform-header">
<Download size={24} />
<h3>Linux</h3>
</div>
<p>AppImage and Debian packages available</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { ArrowLeft } from "lucide-react";
interface MultiplayerViewProps {
onBack: () => void;
}
export function MultiplayerView({ onBack }: MultiplayerViewProps) {
return (
<div className="menu-view multiplayer-view">
<button className="view-back-button" onClick={onBack} aria-label="Back to menu">
<ArrowLeft size={32} strokeWidth={2.5} />
<span>Back</span>
</button>
<div className="view-content">
<h1 className="view-title">Multiplayer</h1>
<div className="view-body">
<p className="view-placeholder">Multiplayer lobby coming soon...</p>
<div className="view-feature-list">
<div className="view-feature-item">
<h3>Real-time Matches</h3>
<p>Challenge players from around the world</p>
</div>
<div className="view-feature-item">
<h3>Ranked Play</h3>
<p>Climb the leaderboard and prove your strategic prowess</p>
</div>
<div className="view-feature-item">
<h3>Custom Lobbies</h3>
<p>Create private matches with friends</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
export function SettingsView() {
return (
<div className="view-card-wrapper">
<div className="view-card settings-card">
<h1 className="view-card-title">Settings</h1>
<div className="view-card-body">
<p className="view-placeholder">Settings panel coming soon...</p>
<div className="view-feature-list">
<div className="view-feature-item">
<h3>Graphics</h3>
<p>Adjust visual quality and performance</p>
</div>
<div className="view-feature-item">
<h3>Audio</h3>
<p>Configure sound effects and music volume</p>
</div>
<div className="view-feature-item">
<h3>Controls</h3>
<p>Customize keybindings and input preferences</p>
</div>
<div className="view-feature-item">
<h3>Accessibility</h3>
<p>Enable colorblind modes and UI scaling</p>
</div>
</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 "@/shared/styles/animations.css";
import { MouseEvent, ReactNode, useMemo } from "react";
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,133 @@
import type { GameOutcome } from "@/shared/api/types";
interface GameEndOverlayProps {
outcome: GameOutcome;
onSpectate: () => void;
onExit: () => void;
}
export function GameEndOverlay({ outcome, onSpectate, onExit }: GameEndOverlayProps) {
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
pointerEvents: "none",
}}
>
<div
className="game-end-card no-drag block-game-input"
style={{
pointerEvents: "auto",
userSelect: "none",
width: "300px",
fontSize: "13px",
}}
>
{/* Victory/Defeat text */}
<div
style={{
background: "rgba(15, 23, 42, 0.75)",
color: "white",
padding: "1.5em 2em",
textAlign: "center",
borderRadius: "0.5em 0.5em 0 0",
}}
>
<div
style={{
fontFamily: '"Oswald Variable", sans-serif',
fontSize: "2.85em",
fontWeight: "400",
marginBottom: "0.25em",
}}
>
{outcome === "Victory" ? "Victory" : "Defeat"}
</div>
{/* TODO: Make subtitle dynamic based on win/loss condition:
- Victory by elimination: "You destroyed all other Nations."
- Victory by occupation: "You reached 80% occupation of the map."
- Defeat by elimination: "Your nation was eradicated."
- Defeat by enemy occupation: "{PlayerName} reached 80% occupation of the map."
*/}
<div
style={{
fontFamily: '"Inter Variable", sans-serif',
fontSize: "1.15em",
fontWeight: "400",
opacity: 0.85,
}}
>
{outcome === "Victory" ? "You conquered the map." : "Your nation fell."}
</div>
</div>
{/* Button row */}
<div
style={{
display: "flex",
background: "rgba(15, 23, 42, 0.6)",
borderRadius: "0 0 0.5em 0.5em",
}}
>
<button
style={{
flex: 1,
background: "transparent",
color: "white",
border: "none",
borderRight: "1px solid rgba(255, 255, 255, 0.1)",
padding: "0.75em",
cursor: "pointer",
fontSize: "1.15em",
fontWeight: "500",
fontFamily: '"Inter Variable", sans-serif',
transition: "background-color 0.15s ease",
}}
onClick={onSpectate}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.06)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
Spectate
</button>
<button
style={{
flex: 1,
background: "transparent",
color: "white",
border: "none",
padding: "0.75em",
cursor: "pointer",
fontSize: "1.15em",
fontWeight: "500",
fontFamily: '"Inter Variable", sans-serif',
transition: "background-color 0.15s ease",
}}
onClick={onExit}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255, 255, 255, 0.06)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
Exit
</button>
</div>
</div>
</div>
);
}
export default GameEndOverlay;

View File

@@ -0,0 +1,61 @@
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 (
<div className="fixed inset-0 w-full h-full pointer-events-none z-[100]">
{/* Timeout bar at top */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[87.5%] max-w-[900px] h-2 bg-black/50 rounded-b overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-500 to-lime-400 rounded-b mx-auto"
style={{ width: `${progress * 100}%` }}
/>
</div>
{/* Instructions */}
<div className="absolute top-[60px] left-1/2 -translate-x-1/2 text-center select-none pointer-events-none">
<h1 className="m-0 font-[Oswald_Variable,sans-serif] text-5xl leading-[0.85] font-bold text-white [text-shadow:2px_2px_5px_rgba(0,0,0,0.6),0_0_20px_rgba(0,0,0,0.4)] tracking-wide">
Pick Your Spawn
</h1>
<p className="font-[Inter_Variable,sans-serif] font-medium mx-0 mb-0 mt-8 text-lg text-white [text-shadow:1px_1px_1px_rgba(0,0,0,0.6),0_0_12px_rgba(0,0,0,0.4)] max-w-[600px] leading-relaxed">
Click anywhere on the map to place your starting territory.
<br />
You can change your mind before the timer expires.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { useThrottledCallback } from "./useThrottledCallback";

View File

@@ -0,0 +1,75 @@
import { useCallback, useRef } from "react";
/**
* Creates a throttled callback with trailing edge behavior.
* Ensures the last call is executed after the throttle period elapses.
*
* @param callback - The function to throttle
* @param delay - The throttle delay in milliseconds
* @returns A throttled version of the callback
*/
export function useThrottledCallback<T extends (...args: any[]) => void>(
callback: T,
delay: number
): T {
const lastCallTimeRef = useRef<number>(0);
const pendingTimeoutRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
// Keep callback ref updated
callbackRef.current = callback;
const throttledCallback = useCallback(
(...args: Parameters<T>) => {
const now = Date.now();
const timeSinceLastCall = now - lastCallTimeRef.current;
const executeCallback = () => {
callbackRef.current(...args);
};
if (timeSinceLastCall >= delay) {
// Execute immediately if throttle period has elapsed
executeCallback();
lastCallTimeRef.current = now;
// Clear any pending timeout since we just executed
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
} else {
// Schedule execution after the remaining throttle period
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
}
const remainingTime = delay - timeSinceLastCall;
pendingTimeoutRef.current = window.setTimeout(() => {
executeCallback();
lastCallTimeRef.current = Date.now();
pendingTimeoutRef.current = null;
}, remainingTime);
}
},
[delay]
) as T;
// Cleanup on unmount
const cleanupRef = useRef(() => {
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
});
// Update cleanup function
cleanupRef.current = () => {
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
};
return throttledCallback;
}

View File

@@ -0,0 +1,377 @@
import { Container } from "pixi.js";
export interface CameraStateListener {
onCameraUpdate(x: number, y: number, zoom: number): void;
}
export class CameraController {
private container: Container;
private canvas: HTMLCanvasElement;
private isDragging = false;
private lastMouseX = 0;
private lastMouseY = 0;
private mouseDownX = 0;
private mouseDownY = 0;
private hasDragged = false;
private scale = 1;
private minScale = 0.5;
private maxScale = 13.5;
private listener?: CameraStateListener;
// Zoom sensitivity configuration
private readonly BASE_ZOOM_SENSITIVITY = 0.15; // Base: 10% zoom per scroll
private readonly ZOOM_IN_SENSITIVITY = 1.1; // 90% of base (10% less sensitive)
private readonly ZOOM_OUT_SENSITIVITY = 0.9; // 120% of base (20% more sensitive)
// Smooth interpolation state
private targetX = 0;
private targetY = 0;
private targetScale = 1;
private lerpFactor = 0.15; // Smoothness factor (0.1-0.2 is good range)
private isAnimating = false;
// Animation loop control
private animationFrameId: number | null = null;
// Event listener references for cleanup
private wheelHandler?: (e: WheelEvent) => void;
private mouseDownHandler?: (e: MouseEvent) => void;
private mouseMoveHandler?: (e: MouseEvent) => void;
private mouseUpHandler?: () => void;
// Track if window listeners are attached (during drag)
private windowListenersAttached = false;
// Throttle camera state updates to reduce IPC overhead
private lastListenerNotifyTime = 0;
private readonly LISTENER_NOTIFY_THROTTLE_MS = 100; // Max 10 updates/second
constructor(container: Container, canvas: HTMLCanvasElement) {
this.container = container;
this.canvas = canvas;
this.setupEventListeners(canvas);
this.startAnimationLoop();
}
setStateListener(listener: CameraStateListener) {
this.listener = listener;
}
private notifyListener(force: boolean = false) {
if (!this.listener) return;
// Throttle notifications to reduce IPC overhead (unless forced)
const now = Date.now();
if (!force && now - this.lastListenerNotifyTime < this.LISTENER_NOTIFY_THROTTLE_MS) {
return;
}
this.lastListenerNotifyTime = now;
this.listener.onCameraUpdate(this.container.x, this.container.y, this.scale);
}
private startAnimationLoop() {
// Animation loop is started on demand, not continuously
// This is handled by updateSmooth() which requests next frame if needed
}
private lerp(current: number, target: number, factor: number): number {
return current + (target - current) * factor;
}
private updateSmooth() {
const threshold = 0.01; // Stop animating when close enough
// Interpolate position
const oldX = this.container.x;
const oldY = this.container.y;
this.container.x = this.lerp(this.container.x, this.targetX, this.lerpFactor);
this.container.y = this.lerp(this.container.y, this.targetY, this.lerpFactor);
// Interpolate scale
const oldScale = this.scale;
this.scale = this.lerp(this.scale, this.targetScale, this.lerpFactor);
this.container.scale.set(this.scale);
// Check if we're close enough to stop animating
const posChanged = Math.abs(this.container.x - oldX) > threshold || Math.abs(this.container.y - oldY) > threshold;
const scaleChanged = Math.abs(this.scale - oldScale) > threshold;
if (posChanged || scaleChanged) {
if (!this.isAnimating) {
this.isAnimating = true;
}
this.notifyListener();
// Continue animation
this.animationFrameId = requestAnimationFrame(() => this.updateSmooth());
} else if (this.isAnimating) {
this.isAnimating = false;
// Snap to final position
this.container.x = this.targetX;
this.container.y = this.targetY;
this.scale = this.targetScale;
this.container.scale.set(this.scale);
this.notifyListener(true); // Force final update
// Animation stopped - no need to request next frame
this.animationFrameId = null;
}
}
private ensureAnimationRunning() {
// Start animation loop if not already running
if (this.animationFrameId === null) {
this.animationFrameId = requestAnimationFrame(() => this.updateSmooth());
}
}
private setupEventListeners(canvas: HTMLCanvasElement) {
// Mouse wheel zoom
this.wheelHandler = (e: WheelEvent) => {
// Skip zoom when shift is pressed (reserved for attack controls)
if (e.shiftKey) {
return;
}
e.preventDefault();
const delta = -e.deltaY;
const scaleFactor =
delta > 0
? 1 + this.BASE_ZOOM_SENSITIVITY * this.ZOOM_IN_SENSITIVITY // Zooming in: base * 0.9
: 1 - this.BASE_ZOOM_SENSITIVITY * this.ZOOM_OUT_SENSITIVITY; // Zooming out: base * 1.2
const newScale = this.targetScale * scaleFactor;
if (newScale >= this.minScale && newScale <= this.maxScale) {
// Get mouse position relative to canvas
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate world position before zoom using TARGET scale
const worldX = (mouseX - this.targetX) / this.targetScale;
const worldY = (mouseY - this.targetY) / this.targetScale;
// Update target scale
this.targetScale = newScale;
// Adjust target position to keep mouse point stable
this.targetX = mouseX - worldX * this.targetScale;
this.targetY = mouseY - worldY * this.targetScale;
// Ensure animation loop is running
this.ensureAnimationRunning();
}
};
// Mouse down - prepare for potential drag
this.mouseDownHandler = (e: MouseEvent) => {
if (e.button === 0) {
// Left mouse button only
this.isDragging = true;
this.hasDragged = false;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
this.mouseDownX = e.clientX;
this.mouseDownY = e.clientY;
// Attach window-level listeners to track mouse even over UI elements
this.attachWindowListeners();
}
};
// Mouse move - pan
this.mouseMoveHandler = (e: MouseEvent) => {
if (this.isDragging) {
const dx = e.clientX - this.lastMouseX;
const dy = e.clientY - this.lastMouseY;
// Check if user has moved enough to count as a drag (3px threshold)
const totalMoved = Math.abs(e.clientX - this.mouseDownX) + Math.abs(e.clientY - this.mouseDownY);
if (totalMoved > 3 && !this.hasDragged) {
this.hasDragged = true;
canvas.style.cursor = "grabbing";
}
// Update target position for smooth dragging
this.targetX += dx;
this.targetY += dy;
// Also update current position directly for responsive feel during drag
this.container.x += dx;
this.container.y += dy;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
}
};
// Mouse up - stop dragging
const stopDragging = () => {
if (this.isDragging) {
this.isDragging = false;
canvas.style.cursor = "default";
this.notifyListener(true); // Force update when user stops dragging
// Remove window-level listeners
this.detachWindowListeners();
}
};
this.mouseUpHandler = stopDragging;
canvas.addEventListener("wheel", this.wheelHandler);
canvas.addEventListener("mousedown", this.mouseDownHandler);
}
// Attach mousemove and mouseup to window during drag
private attachWindowListeners() {
if (this.windowListenersAttached) return;
if (this.mouseMoveHandler) {
window.addEventListener("mousemove", this.mouseMoveHandler);
}
if (this.mouseUpHandler) {
window.addEventListener("mouseup", this.mouseUpHandler);
}
this.windowListenersAttached = true;
}
// Detach window listeners when drag ends
private detachWindowListeners() {
if (!this.windowListenersAttached) return;
if (this.mouseMoveHandler) {
window.removeEventListener("mousemove", this.mouseMoveHandler);
}
if (this.mouseUpHandler) {
window.removeEventListener("mouseup", this.mouseUpHandler);
}
this.windowListenersAttached = false;
}
// Check if the last interaction was a drag (for click filtering)
public hadCameraInteraction(): boolean {
return this.hasDragged;
}
// Public methods for backend control
centerOnTile(tileX: number, tileY: number, tileSize: number, animate: boolean = false) {
// Convert tile coordinates to world position (center of tile)
const worldX = (tileX + 0.5) * tileSize;
const worldY = (tileY + 0.5) * tileSize;
// Center the camera on the world position
const newX = this.canvas.width / 2 - worldX * this.scale;
const newY = this.canvas.height / 2 - worldY * this.scale;
if (animate) {
// Smooth animation - set target
this.targetX = newX;
this.targetY = newY;
this.ensureAnimationRunning();
} else {
// Instant - set both current and target
this.container.x = newX;
this.container.y = newY;
this.targetX = newX;
this.targetY = newY;
this.notifyListener(true); // Force update for programmatic camera control
}
}
setZoom(zoom: number, animate: boolean = false) {
const newScale = Math.max(this.minScale, Math.min(this.maxScale, zoom));
if (animate) {
// Smooth animation - set target
this.targetScale = newScale;
this.ensureAnimationRunning();
} else {
// Instant - set both current and target
this.scale = newScale;
this.targetScale = newScale;
this.container.scale.set(this.scale);
this.notifyListener(true); // Force update for programmatic camera control
}
}
panBy(dx: number, dy: number, animate: boolean = false) {
if (animate) {
// Smooth animation - add to target
this.targetX += dx;
this.targetY += dy;
this.ensureAnimationRunning();
} else {
// Instant - add to both current and target
this.container.x += dx;
this.container.y += dy;
this.targetX += dx;
this.targetY += dy;
this.notifyListener(true); // Force update for programmatic camera control
}
}
reset() {
this.scale = 1;
this.targetScale = 1;
this.container.scale.set(1);
this.container.x = 0;
this.container.y = 0;
this.targetX = 0;
this.targetY = 0;
this.notifyListener(true); // Force update on reset
}
// Convert screen coordinates to world coordinates
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const worldX = (screenX - this.container.x) / this.scale;
const worldY = (screenY - this.container.y) / this.scale;
return { x: worldX, y: worldY };
}
// Convert world coordinates to screen coordinates
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
const screenX = worldX * this.scale + this.container.x;
const screenY = worldY * this.scale + this.container.y;
return { x: screenX, y: screenY };
}
getState() {
return {
x: this.container.x,
y: this.container.y,
zoom: this.scale,
};
}
destroy() {
// Reset cursor
this.canvas.style.cursor = "default";
// Stop animation loop
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Remove window listeners if attached
this.detachWindowListeners();
// Remove canvas event listeners
if (this.wheelHandler) {
this.canvas.removeEventListener("wheel", this.wheelHandler);
}
if (this.mouseDownHandler) {
this.canvas.removeEventListener("mousedown", this.mouseDownHandler);
}
// Clear references
this.wheelHandler = undefined;
this.mouseDownHandler = undefined;
this.mouseMoveHandler = undefined;
this.mouseUpHandler = undefined;
this.listener = undefined;
}
}

View File

@@ -0,0 +1,519 @@
import { Application, Container, Sprite, Texture, BufferImageSource, WebGPURenderer, Renderer, WebGLRenderer } from "pixi.js";
import { CameraController, CameraStateListener } from "@/shared/render/CameraController";
import { TerritoryLayer } from "@/shared/render/TerritoriesRenderer";
import { ShipLayer } from "@/shared/render/ShipsRenderer";
import type { ShipsUpdatePayload } from "@/shared/api/types";
export interface RendererConfig {
mapWidth: number;
mapHeight: number;
tileSize: number;
}
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[] | string; // Tile type IDs (u8 values from backend, or base64 string from Tauri)
}
export interface PaletteData {
colors: Array<{ r: number; g: number; b: number }>;
}
export interface TerrainPaletteData {
colors: Array<{ r: number; g: number; b: number }>;
}
export enum TerrainType {
Water = 0,
Land = 1,
Mountain = 2,
}
export class GameRenderer {
private app!: Application;
private mainContainer!: Container;
private layersContainer!: Container;
private terrainLayer!: Container;
private territoryLayer?: TerritoryLayer;
private shipLayer?: ShipLayer;
// Camera
private cameraController!: CameraController;
// Map dimensions (set when terrain data is loaded)
private mapWidth: number = 0;
private mapHeight: number = 0;
private tileSize: number = 1;
private isInitialized = false;
// Terrain data
private terrainTexture?: Texture;
private terrainSprite?: Sprite;
private terrainPalette: Array<{ r: number; g: number; b: number }> = [];
// Window resize handler reference
private resizeHandler?: () => void;
constructor() {
// No config needed - dimensions will be set from terrain data
}
async init(canvas: HTMLCanvasElement) {
// Validate canvas dimensions
const width = canvas.clientWidth || canvas.width || 800;
const height = canvas.clientHeight || canvas.height || 600;
if (width === 0 || height === 0) {
throw new Error(
`Canvas has invalid dimensions: ${width}x${height}. ` + `Ensure the canvas element and its parent have explicit dimensions.`,
);
}
// Initialize Pixi application
this.app = new Application();
await this.app.init({
canvas,
width,
height,
backgroundColor: 0x1a1a2e, // Dark blue-grey background
antialias: false,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
// Verify initialization succeeded
if (!this.app.stage) {
throw new Error("PixiJS Application failed to initialize - stage is null");
}
// Create main container
this.mainContainer = new Container();
this.app.stage.addChild(this.mainContainer);
// Create layers container (this gets transformed by camera)
this.layersContainer = new Container();
this.layersContainer.sortableChildren = true; // Enable z-index sorting
this.mainContainer.addChild(this.layersContainer);
// 1. Terrain layer (water/land/mountains) - created on init
this.terrainLayer = new Container();
this.terrainLayer.zIndex = 0; // Bottom layer
this.layersContainer.addChild(this.terrainLayer);
// 2. Territory colors layer - will be created when terrain data arrives
// (will be added with zIndex = 1 when created)
// Initialize camera controller
this.cameraController = new CameraController(this.layersContainer, canvas);
// Handle window resize
this.resizeHandler = () => this.handleResize();
window.addEventListener("resize", this.resizeHandler);
// Add ticker for ship interpolation updates
this.app.ticker.add((ticker) => {
if (this.shipLayer) {
// deltaTime is in frames (60fps = 1.0), convert to ticks
// Assuming 60fps and 10 ticks/sec, deltaTime * (10/60) = deltaTime / 6
const tickDelta = ticker.deltaTime / 6;
this.shipLayer.update(tickDelta);
}
});
// Mark as initialized
this.isInitialized = true;
}
private handleResize() {
const canvas = this.app.canvas as HTMLCanvasElement;
const parent = canvas.parentElement;
if (parent) {
const width = parent.clientWidth;
const height = parent.clientHeight;
this.app.renderer.resize(width, height);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
}
// Set terrain palette colors
setTerrainPalette(palette: TerrainPaletteData): void {
this.terrainPalette = palette.colors;
}
// Initialize terrain from backend data
initTerrain(data: TerrainData): void {
if (!this.isInitialized) {
console.error("FATAL: Renderer not initialized - aborting terrain init");
throw new Error("Renderer not initialized");
}
if (!this.app) {
console.error("FATAL: PixiJS app is null");
throw new Error("PixiJS app is null");
}
if (!this.app.renderer) {
console.error("FATAL: PixiJS renderer is null");
throw new Error("PixiJS renderer is null");
}
if (this.terrainPalette.length === 0) {
console.error("FATAL: Terrain palette not set - call setTerrainPalette first");
throw new Error("Terrain palette not set");
}
const { size, terrain_data } = data;
const width = size.x;
const height = size.y;
// Set map dimensions from terrain data
this.mapWidth = width;
this.mapHeight = height;
// Normalize terrain_data to Uint8Array for consistent handling
let normalizedTerrainData: Uint8Array;
if (typeof terrain_data === 'string') {
// Decode base64 string (from Tauri/desktop)
const binaryString = atob(terrain_data);
normalizedTerrainData = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
normalizedTerrainData[i] = binaryString.charCodeAt(i);
}
} else if (terrain_data instanceof Uint8Array) {
normalizedTerrainData = terrain_data;
} else {
normalizedTerrainData = new Uint8Array(terrain_data);
}
// Create territory layer if it doesn't exist
if (!this.territoryLayer) {
this.territoryLayer = new TerritoryLayer(this.mapWidth, this.mapHeight, this.tileSize);
this.territoryLayer.container.zIndex = 1; // Above terrain layer
this.layersContainer.addChild(this.territoryLayer.container);
}
// Create ship layer if it doesn't exist
if (!this.shipLayer) {
this.shipLayer = new ShipLayer(this.mapWidth, this.mapHeight, this.tileSize);
this.shipLayer.container.zIndex = 2; // Above territory layer
this.layersContainer.addChild(this.shipLayer.container);
}
// Create terrain texture from tile type IDs
// Optimization: Pre-allocate and use direct memory writes
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(this.terrainPalette.length);
for (let i = 0; i < this.terrainPalette.length; i++) {
const c = this.terrainPalette[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;
}
// Clean up old terrain texture if it exists
if (this.terrainTexture) {
this.terrainTexture.destroy(true); // Destroy texture and base texture
this.terrainTexture = undefined;
}
// Clean up old terrain sprite if it exists
if (this.terrainSprite) {
this.terrainSprite.destroy();
this.terrainSprite = undefined;
}
// Clear terrain layer to prevent duplicate sprites
this.terrainLayer.removeChildren();
const bufferSource = new BufferImageSource({
resource: imageData,
width,
height,
});
// Create the texture
this.terrainTexture = new Texture({ source: bufferSource });
// Use nearest neighbor filtering for crisp pixel edges
this.terrainTexture.source.scaleMode = "nearest";
// Mark source as needing update
bufferSource.update();
// Create sprite with the texture (now guaranteed to be uploaded)
this.terrainSprite = new Sprite(this.terrainTexture);
// Set dimensions explicitly (don't use width/height setters which can cause issues)
this.terrainSprite.scale.set(
(width * this.tileSize) / this.terrainTexture.width,
(height * this.tileSize) / this.terrainTexture.height,
);
// Center the terrain
this.terrainSprite.anchor.set(0.5, 0.5);
this.terrainSprite.x = 0;
this.terrainSprite.y = 0;
// Ensure sprite is visible and rendered
this.terrainSprite.alpha = 1.0;
this.terrainSprite.visible = true;
this.terrainSprite.renderable = true;
// Disable culling to prevent disappearing at certain zoom levels
this.terrainSprite.cullable = false;
this.terrainLayer.addChild(this.terrainSprite);
// Force an immediate render to ensure texture is processed
if (this.app.renderer) {
this.app.renderer.render(this.app.stage);
}
// Center camera on map and set initial zoom
const canvas = this.app.canvas as HTMLCanvasElement;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const mapPixelWidth = this.mapWidth * this.tileSize;
const mapPixelHeight = this.mapHeight * this.tileSize;
// Calculate zoom to fit map in viewport with padding
const zoomX = canvasWidth / mapPixelWidth;
const zoomY = canvasHeight / mapPixelHeight;
const fitZoom = Math.min(zoomX, zoomY) * 0.8; // 0.8 adds 20% padding
// Clamp zoom to valid range
const minScale = 0.5;
const maxScale = 13.5;
const initialZoom = Math.max(minScale, Math.min(maxScale, fitZoom));
// Apply zoom first
this.cameraController.setZoom(initialZoom, false);
// Center on world origin (0, 0) since terrain sprite is already centered there
// The camera position represents where world (0,0) appears on screen
// To center the map, we want world (0,0) at the center of the canvas
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
this.cameraController.panBy(centerX, centerY, false);
}
// Initialize palette for territory colors
initPalette(data: PaletteData) {
if (!this.territoryLayer) {
console.error("Cannot initialize palette - territory layer not created yet");
throw new Error("Territory layer not initialized");
}
this.territoryLayer.setPalette(data.colors);
// Also set palette for ship layer if it exists
if (this.shipLayer) {
this.shipLayer.setPalette(data.colors);
}
}
// Apply full territory snapshot (initial state - sparse format using parallel arrays)
applyTerritorySnapshot(_turn: number, snapshot: { indices: Uint32Array; ownerIds: Uint16Array }) {
if (!this.territoryLayer) {
console.error("Cannot apply territory snapshot - territory layer not created yet");
return;
}
this.territoryLayer.applySnapshot(snapshot);
}
// Update territory ownership
updateTerritoryDelta(_turn: number, changes: TileChange[]) {
if (!this.territoryLayer) {
console.error("Cannot update territory delta - territory layer not created yet");
return;
}
this.territoryLayer.applyDelta(changes);
}
// Update ships with new variant-based system
updateShips(data: ShipsUpdatePayload) {
if (!this.shipLayer) {
console.error("Cannot update ships - ship layer not created yet");
return;
}
this.shipLayer.processUpdates(data.updates);
}
// Handle binary territory delta (for Tauri)
updateBinaryDelta(data: Uint8Array) {
// Decode binary format: [turn:8][count:4][changes...]
if (data.length < 12) {
return;
}
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const turn = view.getBigUint64(0, true);
const count = view.getUint32(8, true);
const expectedSize = 12 + count * 6;
if (data.length !== expectedSize) {
console.error(`Invalid binary delta size: expected ${expectedSize}, got ${data.length}`);
return;
}
const changes: TileChange[] = [];
for (let i = 0; i < count; i++) {
const offset = 12 + i * 6;
const index = view.getUint32(offset, true);
const owner_id = view.getUint16(offset + 4, true);
changes.push({ index, owner_id });
}
this.updateTerritoryDelta(Number(turn), changes);
}
// Camera control methods
setCameraListener(listener: CameraStateListener) {
this.cameraController.setStateListener(listener);
}
centerOnTile(tileIndex: number, animate: boolean = false) {
const x = tileIndex % this.mapWidth;
const y = Math.floor(tileIndex / this.mapWidth);
this.cameraController.centerOnTile(x, y, this.tileSize, animate);
}
setZoom(zoom: number, animate: boolean = false) {
this.cameraController.setZoom(zoom, animate);
}
panBy(dx: number, dy: number, animate: boolean = false) {
this.cameraController.panBy(dx, dy, animate);
}
getCameraState() {
return this.cameraController.getState();
}
// Check if the last interaction was a camera drag
hadCameraInteraction(): boolean {
return this.cameraController.hadCameraInteraction();
}
// Convert screen position to tile index
screenToTile(screenX: number, screenY: number): number | null {
const worldPos = this.cameraController.screenToWorld(screenX, screenY);
// Adjust for centered map
const adjustedX = worldPos.x + (this.mapWidth * this.tileSize) / 2;
const adjustedY = worldPos.y + (this.mapHeight * this.tileSize) / 2;
const tileX = Math.floor(adjustedX / this.tileSize);
const tileY = Math.floor(adjustedY / this.tileSize);
if (tileX >= 0 && tileX < this.mapWidth && tileY >= 0 && tileY < this.mapHeight) {
return tileY * this.mapWidth + tileX;
}
return null;
}
// Convert tile index to world position
tileToWorld(tileIndex: number): { x: number; y: number } {
const x = tileIndex % this.mapWidth;
const y = Math.floor(tileIndex / this.mapWidth);
// Center of tile in world coordinates
const worldX = (x + 0.5) * this.tileSize - (this.mapWidth * this.tileSize) / 2;
const worldY = (y + 0.5) * this.tileSize - (this.mapHeight * this.tileSize) / 2;
return { x: worldX, y: worldY };
}
// Get nation ID at a tile index
getNationAtTile(tileIndex: number | null): number | null {
if (tileIndex === null || !this.territoryLayer) {
return null;
}
const tileX = tileIndex % this.mapWidth;
const tileY = Math.floor(tileIndex / this.mapWidth);
const nationId = this.territoryLayer.getOwnerAt(tileX, tileY);
// Nation IDs: 0-65533 (valid), 65534 (unclaimed), 65535 (water)
return nationId < 65534 ? nationId : null;
}
// Set highlighted nation
setHighlightedNation(nationId: number | null) {
this.territoryLayer?.setHighlightedNation(nationId);
}
// 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() {}
}

View File

@@ -0,0 +1,201 @@
import { Container, Graphics } from "pixi.js";
import type { ShipUpdateVariant } from "@/shared/api/types";
// Must match backend SHIP_TICKS_PER_TILE constant
const SHIP_TICKS_PER_TILE = 1;
interface ShipState {
owner_id: number;
path: number[];
current_path_index: number;
ticks_until_move: number;
troops: number;
sprite: Container;
}
export class ShipLayer {
public container: Container;
private ships: Map<number, ShipState>; // Map of ship ID to ship state
private palette: Array<{ r: number; g: number; b: number }> = [];
private mapWidth: number;
private mapHeight: number;
private tileSize: number;
constructor(mapWidth: number, mapHeight: number, tileSize: number) {
this.container = new Container();
this.container.zIndex = 2; // Above territory layer
this.ships = new Map();
this.mapWidth = mapWidth;
this.mapHeight = mapHeight;
this.tileSize = tileSize;
}
/**
* Set the color palette for nations
*/
setPalette(colors: Array<{ r: number; g: number; b: number }>) {
this.palette = colors;
}
/**
* 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 = 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 baseSize = 0.15 * this.tileSize; // Min size for small troops
const maxSize = 0.5 * this.tileSize; // Max size for large troops
// Scale: 1 troop = baseSize, 1000 troops ≈ maxSize
const size = baseSize + (Math.log10(troops + 1) / Math.log10(1001)) * (maxSize - baseSize);
// Draw circle (no rotation needed)
graphics.circle(0, 0, size);
graphics.fill({ color: hexColor, alpha: 0.9 });
// Add border
graphics.circle(0, 0, size);
graphics.stroke({ width: 1, color: 0x000000, alpha: 0.5 });
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];
// Calculate current tile position
const currentX = currentTile % this.mapWidth;
const currentY = Math.floor(currentTile / this.mapWidth);
// World position of current tile
let worldX = (currentX + 0.5) * this.tileSize - (this.mapWidth * this.tileSize) / 2;
let worldY = (currentY + 0.5) * this.tileSize - (this.mapHeight * this.tileSize) / 2;
// Interpolate to next tile if available
if (nextTile !== undefined) {
const nextX = nextTile % this.mapWidth;
const nextY = Math.floor(nextTile / this.mapWidth);
const nextWorldX = (nextX + 0.5) * this.tileSize - (this.mapWidth * this.tileSize) / 2;
const nextWorldY = (nextY + 0.5) * this.tileSize - (this.mapHeight * this.tileSize) / 2;
// 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 + (nextWorldX - worldX) * progress;
worldY = worldY + (nextWorldY - 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,436 @@
import { Container, Sprite, Texture, BufferImageSource, Filter, GlProgram } from "pixi.js";
export interface RgbColor {
r: number;
g: number;
b: number;
}
// Vertex shader - PixiJS v8 standard filter vertex shader (GLSL 100 ES)
const TERRITORY_VERTEX_SHADER = `
attribute vec2 aPosition;
varying vec2 vTextureCoord;
varying vec2 vFilterCoord;
uniform vec4 uInputSize;
uniform vec4 uOutputFrame;
uniform vec4 uOutputTexture;
vec4 filterVertexPosition() {
vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy;
position.x = position.x * (2.0 / uOutputTexture.x) - 1.0;
position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z;
return vec4(position, 0.0, 1.0);
}
vec2 filterTextureCoord() {
return aPosition * (uOutputFrame.zw * uInputSize.zw);
}
void main() {
gl_Position = filterVertexPosition();
vTextureCoord = filterTextureCoord();
vFilterCoord = vTextureCoord;
}
`;
const TERRITORY_FRAGMENT_SHADER = `
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D uTexture;
uniform vec2 uMapSize;
uniform sampler2D uPalette;
uniform float uHighlightedNation;
// Decode u16 from RG8 (R=low byte, G=high byte)
float decodeU16(vec2 rg) {
return rg.r * 255.0 + rg.g * 65280.0;
}
void main() {
vec4 center = texture2D(uTexture, vTextureCoord);
float centerOwner = decodeU16(center.rg);
// Unclaimed = 65534, Water = 65535
if (centerOwner >= 65534.0) {
discard;
}
vec2 texelSize = 1.0 / uMapSize;
bool isBorder = false;
for (float dist = 1.0; dist <= 2.0; dist += 1.0) {
vec2 offset = texelSize * dist;
float left = decodeU16(texture2D(uTexture, vTextureCoord + vec2(-offset.x, 0.0)).rg);
float right = decodeU16(texture2D(uTexture, vTextureCoord + vec2(offset.x, 0.0)).rg);
float top = decodeU16(texture2D(uTexture, vTextureCoord + vec2(0.0, -offset.y)).rg);
float bottom = decodeU16(texture2D(uTexture, vTextureCoord + vec2(0.0, offset.y)).rg);
if (centerOwner != left || centerOwner != right ||
centerOwner != top || centerOwner != bottom) {
isBorder = true;
break;
}
}
// 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 container: Container;
private sprite!: Sprite;
private texture!: Texture;
private bufferSource!: BufferImageSource;
private ownerData: Uint16Array;
private textureData: Uint8Array;
private paletteTexture?: Texture;
private paletteSource?: BufferImageSource;
private paletteData: Uint8Array;
// Shader/filter
private territoryFilter?: Filter;
// Dirty region tracking for partial updates
private isDirty: boolean = false;
private dirtyMinX: number = 0;
private dirtyMinY: number = 0;
private dirtyMaxX: number = 0;
private dirtyMaxY: number = 0;
// Batching state
private pendingChanges: Array<{ index: number; owner_id: number }> = [];
private updateScheduled: boolean = false;
private width: number;
private height: number;
private tileSize: number;
constructor(width: number, height: number, tileSize: number) {
this.width = width;
this.height = height;
this.tileSize = tileSize;
this.container = new Container();
// 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);
this.initTexture();
}
private initTexture() {
// RG8: R=low byte, G=high byte
// Initialize all tiles to unclaimed (65535) which the shader will discard
const OWNER_UNCLAIMED = 65535;
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
}
this.bufferSource = new BufferImageSource({
resource: this.textureData,
width: this.width,
height: this.height,
format: "rg8unorm",
});
this.texture = new Texture({ source: this.bufferSource });
this.texture.source.scaleMode = "nearest";
this.texture.source.update();
this.sprite = new Sprite(this.texture);
this.sprite.scale.set((this.width * this.tileSize) / this.texture.width, (this.height * this.tileSize) / this.texture.height);
this.sprite.anchor.set(0.5, 0.5);
this.sprite.x = 0;
this.sprite.y = 0;
this.sprite.alpha = 1.0;
this.sprite.visible = true;
this.sprite.renderable = true;
this.sprite.cullable = false;
this.sprite.blendMode = "screen";
this.container.addChild(this.sprite);
}
setPalette(colors: Array<RgbColor>) {
// Support up to 65536 players (u16::MAX + 1)
for (let i = 0; i < Math.min(colors.length, 65536); i++) {
const color = colors[i];
const pixelIndex = i * 4;
this.paletteData[pixelIndex] = color.r;
this.paletteData[pixelIndex + 1] = color.g;
this.paletteData[pixelIndex + 2] = color.b;
this.paletteData[pixelIndex + 3] = 255;
}
if (!this.paletteSource) {
// 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 });
} else {
this.paletteSource.update();
}
this.setupShader();
}
applySnapshot(snapshot: { indices: Uint32Array; ownerIds: Uint16Array }) {
// Clear all tiles to unclaimed (65535)
// The sparse format only includes player-owned tiles (0-65533)
// All other tiles default to 65535 (unclaimed) which the shader discards
const OWNER_UNCLAIMED = 65535;
this.ownerData.fill(OWNER_UNCLAIMED);
// Fill texture data with 65535 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;
}
const OWNER_WATER = 65535;
let minX = this.width;
let minY = this.height;
let maxX = 0;
let maxY = 0;
for (const change of this.pendingChanges) {
if (change.index >= 0 && change.index < this.ownerData.length) {
const value = change.owner_id;
if (value === OWNER_WATER) {
continue;
}
this.ownerData[change.index] = value;
const texIndex = change.index * 2;
this.textureData[texIndex] = value & 0xff;
this.textureData[texIndex + 1] = (value >> 8) & 0xff;
const x = change.index % this.width;
const y = Math.floor(change.index / this.width);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
this.pendingChanges = [];
if (maxX >= minX && maxY >= minY) {
this.isDirty = true;
this.dirtyMinX = minX;
this.dirtyMinY = minY;
this.dirtyMaxX = maxX;
this.dirtyMaxY = maxY;
this.updateTexture();
}
}
private updateTexture() {
if (!this.isDirty) {
return;
}
const imageData = this.bufferSource.resource as Uint8Array;
const gl = (this.texture.source as any)._glTextures?.[0]?.gl;
const glTexture = (this.texture.source as any)._glTextures?.[0]?.texture;
// If GL context not ready, fall back to full upload
if (!gl || !glTexture) {
this.bufferSource.update();
this.isDirty = false;
return;
}
// Calculate dirty region dimensions
const regionWidth = this.dirtyMaxX - this.dirtyMinX + 1;
const regionHeight = this.dirtyMaxY - this.dirtyMinY + 1;
const totalPixels = this.width * this.height;
const dirtyPixels = regionWidth * regionHeight;
// Use partial update if dirty region is small (< 25% of texture)
if (dirtyPixels < totalPixels * 0.25) {
// Extract dirty region into contiguous buffer (R8 format)
const regionData = new Uint8Array(regionWidth * regionHeight);
for (let y = 0; y < regionHeight; y++) {
const srcY = this.dirtyMinY + y;
const srcOffset = srcY * this.width + this.dirtyMinX;
const dstOffset = y * regionWidth;
// Copy one row at a time (single channel)
regionData.set(imageData.subarray(srcOffset, srcOffset + regionWidth), dstOffset);
}
// Upload dirty region only
gl.bindTexture(gl.TEXTURE_2D, glTexture);
gl.texSubImage2D(
gl.TEXTURE_2D,
0, // mip level
this.dirtyMinX,
this.dirtyMinY,
regionWidth,
regionHeight,
gl.RED,
gl.UNSIGNED_BYTE,
regionData,
);
} else {
// Dirty region is large, upload full texture
this.bufferSource.update();
}
// Clear dirty flag
this.isDirty = false;
}
private setupShader() {
if (!this.paletteTexture) {
console.warn("Cannot setup shader: palette texture not initialized");
return;
}
// Create filter with the territory shader
this.territoryFilter = new Filter({
glProgram: new GlProgram({
vertex: TERRITORY_VERTEX_SHADER,
fragment: TERRITORY_FRAGMENT_SHADER,
}),
resources: {
// uTexture is auto-bound to sprite texture
uPalette: this.paletteTexture.source,
territoryUniforms: {
uMapSize: { value: [this.width, this.height], type: "vec2<f32>" },
uHighlightedNation: { value: -1, type: "f32" },
},
},
});
// Apply filter to sprite
this.sprite.filters = [this.territoryFilter];
}
// Get owner at specific tile
getOwnerAt(tileX: number, tileY: number): number {
if (tileX >= 0 && tileX < this.width && tileY >= 0 && tileY < this.height) {
const index = tileY * this.width + tileX;
return this.ownerData[index];
}
return 0;
}
// Set highlighted nation (-1 or null to clear)
setHighlightedNation(nationId: number | null) {
if (this.territoryFilter?.resources?.territoryUniforms) {
this.territoryFilter.resources.territoryUniforms.uniforms.uHighlightedNation = nationId ?? -1;
}
}
// Clear all territory
clear() {
const OWNER_UNCLAIMED = 65535;
this.ownerData.fill(OWNER_UNCLAIMED);
const imageData = this.bufferSource.resource as Uint8Array;
// Clear all pixels to unclaimed (65535)
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,2 @@
export { CameraController, type CameraStateListener } from "./CameraController";
export { GameRenderer } from "./GameRenderer";

View File

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

View File

@@ -0,0 +1,82 @@
/**
* Binary decoding utilities for territory snapshot data from Rust backend.
*
* The TerritorySnapshot uses a sparse binary format to minimize data transfer:
* - Only player-owned tiles (owner_id 0-65533) are transmitted
* - Special values NOT included (default state):
* - 65534 = Water
* - 65535 = Unclaimed
* - Binary format: [count:4][tile_changes...]
* where each change is [index:4][owner:2] (6 bytes per player-owned tile)
*
* Note: Map dimensions use u16 (max 65535) in Rust, but TypeScript uses number type.
*/
export interface TileOwnership {
index: number;
owner_id: number;
}
/**
* Decoded territory snapshot using parallel arrays for zero-allocation parsing.
* This avoids creating millions of temporary objects which would trigger GC.
*/
export interface DecodedSnapshot {
indices: Uint32Array;
ownerIds: Uint16Array;
}
/**
* 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) or base64 string (Tauri)
* @returns Parallel arrays of indices and owner IDs, or null if invalid
*/
export function decodeTerritorySnapshot(data: number[] | Uint8Array | string): DecodedSnapshot | null {
// Handle base64 string (Tauri/desktop mode)
let bytes: Uint8Array;
if (typeof data === 'string') {
// Decode base64 string to Uint8Array
const binaryString = atob(data);
bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
} else {
// Convert to Uint8Array if needed for faster access
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);
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 };
}

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,10 @@
{
"extends": "./tsconfig.json",
"include": [
"src/browser/**/*",
"src/shared/**/*",
"src/lib/**/*",
"src/*.ts",
"pages/**/*"
]
}

View File

@@ -0,0 +1,10 @@
{
"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"]
}

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

@@ -0,0 +1,127 @@
import { defineConfig, HmrContext } from "vite";
import react from "@vitejs/plugin-react";
import vike from "vike/plugin";
import { resolve } from "path";
import { readFileSync, existsSync } from "fs";
import { execSync } from "child_process";
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: {
"@": resolve(__dirname, "src"),
"@wasm": resolve(__dirname, "pkg"),
},
},
build: {
outDir: mode === "browser" ? "dist/browser" : "dist",
cssMinify: "lightningcss",
rollupOptions: {
output: {
// manualChunks: (id) => {
// // console.log(id);
// if (id.includes("node_modules")) {
// // React core - always needed
// if (id.includes("react") || id.includes("react-dom")) {
// return "vendor-react";
// }
// // PixiJS - large rendering library
// if (id.includes("pixi")) {
// return "vendor-pixi";
// }
// // UI libraries - icons, scrollbars, animations
// if (id.includes("radix-ui") || id.includes("lucide-react") || id.includes("overlayscrollbars") || id.includes("motion")) {
// return "vendor-ui";
// }
// return "vendor-other";
// }
// },
},
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
// 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: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/crates/borders-desktop/**"],
},
// Add headers for SharedArrayBuffer support in browser mode
headers:
mode === "browser"
? {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
: undefined,
},
}));