mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-15 18:13:14 -06:00
Update source files
This commit is contained in:
40
frontend/pages/+Head.tsx
Normal file
40
frontend/pages/+Head.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import fontsCss from "@/shared/styles/fonts.css?inline";
|
||||
import oswaldWoff2 from "@fontsource-variable/oswald/files/oswald-latin-wght-normal.woff2?url";
|
||||
|
||||
export default function HeadDefault() {
|
||||
return (
|
||||
<>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preload" href={oswaldWoff2} as="font" type="font/woff2" crossOrigin="anonymous" />
|
||||
{/* Inlined font definitions */}
|
||||
<style dangerouslySetInnerHTML={{ __html: fontsCss }} />
|
||||
{/* Global styles for initial render */}
|
||||
<style>{`
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(4px) brightness(0.85);
|
||||
transform: scale(1.05);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(1px);
|
||||
z-index: -1;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
frontend/pages/+Wrapper.client.tsx
Normal file
43
frontend/pages/+Wrapper.client.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { GameBridgeProvider } from "@/shared/api";
|
||||
import { getBridge, type GameBridge } from "@/shared/api";
|
||||
|
||||
// Start loading bridge immediately (not waiting for useEffect)
|
||||
const bridgePromise = getBridge();
|
||||
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
const [bridge, setBridge] = useState<GameBridge | null>(null);
|
||||
|
||||
// Dynamically import the appropriate platform implementation
|
||||
useEffect(() => {
|
||||
bridgePromise.then(setBridge);
|
||||
}, []);
|
||||
|
||||
// Browser-specific setup (must be before early return to satisfy Rules of Hooks)
|
||||
useEffect(() => {
|
||||
if (!__DESKTOP__) {
|
||||
// Handle user ID storage from worker
|
||||
const userIdChannel = new BroadcastChannel("user_id_storage");
|
||||
|
||||
userIdChannel.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg.action === "save") {
|
||||
localStorage.setItem("app_session_id", msg.id);
|
||||
} else if (msg.action === "load") {
|
||||
const id = localStorage.getItem("app_session_id");
|
||||
if (id) {
|
||||
userIdChannel.postMessage(JSON.stringify({ action: "load_response", id }));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return () => {
|
||||
userIdChannel.close();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return bridge ? <GameBridgeProvider bridge={bridge}>{children}</GameBridgeProvider> : null;
|
||||
}
|
||||
11
frontend/pages/+Wrapper.tsx
Normal file
11
frontend/pages/+Wrapper.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type ReactNode } from "react";
|
||||
// import { GameBridgeProvider } from "@/shared/api";
|
||||
|
||||
// Server-side wrapper provides stub implementations for SSG/pre-rendering
|
||||
// The real implementations are provided by +Wrapper.client.tsx on the client
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
// During SSR/pre-rendering, provide null implementations
|
||||
// These will be replaced by real implementations when the client-side wrapper takes over
|
||||
// return <GameBridgeProvider bridge={null as any}>{children}</GameBridgeProvider>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
14
frontend/pages/+config.ts
Normal file
14
frontend/pages/+config.ts
Normal 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,
|
||||
};
|
||||
82
frontend/pages/index/+Page.tsx
Normal file
82
frontend/pages/index/+Page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, Suspense, lazy } from "react";
|
||||
import { MenuScreen } from "@/shared/components/menu/MenuScreen";
|
||||
import { useGameBridge } from "@/shared/api";
|
||||
import "@/shared/styles/global.css";
|
||||
|
||||
// Lazy load components that aren't needed immediately
|
||||
const GameContainer = lazy(() => import("./Game.client").then((m) => ({ default: m.GameContainer })));
|
||||
|
||||
function App() {
|
||||
const [showMenu, setShowMenu] = useState(true);
|
||||
const [isStartingGame, setIsStartingGame] = useState(false);
|
||||
const [isGameReady, setIsGameReady] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const gameBridge = useGameBridge();
|
||||
|
||||
// Prefetch components after initial render
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
import("./Game.client");
|
||||
}, []);
|
||||
|
||||
// Track app started on mount
|
||||
useEffect(() => {
|
||||
if (!gameBridge) return;
|
||||
gameBridge.track("app_started", {
|
||||
platform: __DESKTOP__ ? "desktop" : "browser",
|
||||
});
|
||||
}, [gameBridge]);
|
||||
|
||||
const handleStartSingleplayer = () => {
|
||||
setIsStartingGame(true);
|
||||
};
|
||||
|
||||
const handleGameReady = () => {
|
||||
setIsGameReady(true);
|
||||
};
|
||||
|
||||
const handleMenuExitComplete = () => {
|
||||
setShowMenu(false);
|
||||
// Keep isStartingGame and isGameReady - no need to reset them
|
||||
};
|
||||
|
||||
const handleReturnToMenu = () => {
|
||||
setShowMenu(true);
|
||||
setIsStartingGame(false);
|
||||
setIsGameReady(false);
|
||||
};
|
||||
|
||||
const handleExit = async () => {
|
||||
if (__DESKTOP__) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("request_exit").catch((err) => {
|
||||
console.error("Failed to request exit:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Menu Screen - pre-renderable, covers everything when visible */}
|
||||
{!isHydrated ||
|
||||
(showMenu && (
|
||||
<MenuScreen
|
||||
onStartSingleplayer={handleStartSingleplayer}
|
||||
onExit={handleExit}
|
||||
isExiting={isStartingGame}
|
||||
gameReady={isGameReady}
|
||||
onExitComplete={handleMenuExitComplete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Game Container - client-only, lazy loaded when game starts */}
|
||||
{isHydrated && (isStartingGame || !showMenu) && (
|
||||
<Suspense fallback={null}>
|
||||
<GameContainer onReturnToMenu={handleReturnToMenu} onGameReady={handleGameReady} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
177
frontend/pages/index/Game.client.tsx
Normal file
177
frontend/pages/index/Game.client.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Attacks } from "@/shared/components/game/AttacksList";
|
||||
import { AttackControls } from "@/shared/components/game/AttackControls";
|
||||
import { Leaderboard } from "@/shared/components/game/Leaderboard";
|
||||
import { GameMenu } from "@/shared/components/game/Menu";
|
||||
import { SpawnPhaseOverlay } from "@/shared/components/overlays/SpawnPhase";
|
||||
import { GameCanvas } from "@/shared/components/game/Canvas";
|
||||
import { GameEndOverlay } from "@/shared/components/overlays/GameEnd";
|
||||
import type { GameOutcome, LeaderboardSnapshot } from "@/shared/api/types";
|
||||
import type { GameRenderer } from "@/shared/render/GameRenderer";
|
||||
import { useGameBridge, type RenderInitData } from "@/shared/api";
|
||||
|
||||
interface GameContainerProps {
|
||||
onReturnToMenu: () => void;
|
||||
onGameReady?: () => void;
|
||||
}
|
||||
|
||||
export function GameContainer({ onReturnToMenu, onGameReady }: GameContainerProps) {
|
||||
const [gameOutcome, setGameOutcome] = useState<GameOutcome | null>(null);
|
||||
const [spawnPhaseActive, setSpawnPhaseActive] = useState(false);
|
||||
const [spawnCountdown, setSpawnCountdown] = useState<{
|
||||
startedAtMs: number;
|
||||
durationSecs: number;
|
||||
} | null>(null);
|
||||
const [initialGameState, setInitialGameState] = useState<RenderInitData | null>(null);
|
||||
const [initialLeaderboard, setInitialLeaderboard] = useState<LeaderboardSnapshot | null>(null);
|
||||
const [renderer, setRenderer] = useState<GameRenderer | null>(null);
|
||||
const [highlightedNation, setHighlightedNation] = useState<number | null>(null);
|
||||
const gameBridge = useGameBridge();
|
||||
|
||||
// Check for existing game state on mount to recover after reload
|
||||
useEffect(() => {
|
||||
// Only check for state recovery on desktop
|
||||
if (!__DESKTOP__) return;
|
||||
|
||||
gameBridge?.getGameState().then((state) => {
|
||||
// State recovery is limited to leaderboard data only
|
||||
// Initialization data (terrain, territory, nation palette) is not recoverable after reload
|
||||
// The frontend must wait for a fresh game to start
|
||||
const leaderboard = state as LeaderboardSnapshot | null;
|
||||
if (leaderboard) {
|
||||
console.log("Recovered leaderboard state after reload:", leaderboard);
|
||||
setInitialLeaderboard(leaderboard);
|
||||
}
|
||||
});
|
||||
}, [gameBridge]);
|
||||
|
||||
// Subscribe to render initialization
|
||||
useEffect(() => {
|
||||
if (!gameBridge) return;
|
||||
|
||||
const unsubscribe = gameBridge.onRenderInit((renderData) => {
|
||||
console.log("RenderInit received:", renderData);
|
||||
setInitialGameState(renderData);
|
||||
onGameReady?.();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [gameBridge, onGameReady]);
|
||||
|
||||
// Start the game on mount
|
||||
useEffect(() => {
|
||||
if (!gameBridge) return;
|
||||
|
||||
gameBridge.startGame();
|
||||
gameBridge.track("game_started", {
|
||||
mode: "singleplayer",
|
||||
});
|
||||
}, [gameBridge]);
|
||||
|
||||
// Subscribe to spawn phase events
|
||||
useEffect(() => {
|
||||
if (!gameBridge) return;
|
||||
|
||||
const unsubUpdate = gameBridge.onSpawnPhaseUpdate((update) => {
|
||||
setSpawnPhaseActive(true);
|
||||
setSpawnCountdown(update.countdown);
|
||||
});
|
||||
|
||||
const unsubEnd = gameBridge.onSpawnPhaseEnded(() => {
|
||||
setSpawnPhaseActive(false);
|
||||
setSpawnCountdown(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubUpdate();
|
||||
unsubEnd();
|
||||
};
|
||||
}, [gameBridge]);
|
||||
|
||||
// Subscribe to game end events
|
||||
useEffect(() => {
|
||||
if (!gameBridge) return;
|
||||
|
||||
const unsubscribe = gameBridge.onGameEnded((outcome) => {
|
||||
console.log("Game outcome received:", outcome);
|
||||
setGameOutcome(outcome);
|
||||
setSpawnPhaseActive(false);
|
||||
gameBridge.track("game_ended", {
|
||||
outcome: outcome.toString().toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [gameBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderer) return;
|
||||
if (!gameBridge) return;
|
||||
|
||||
// Track renderer initialization with GPU info
|
||||
const rendererInfo = renderer.getRendererInfo();
|
||||
gameBridge.track("renderer_initialized", rendererInfo);
|
||||
}, [renderer, gameBridge]);
|
||||
|
||||
// Sync highlighted nation with renderer
|
||||
useEffect(() => {
|
||||
renderer?.setHighlightedNation(highlightedNation);
|
||||
}, [highlightedNation, renderer]);
|
||||
|
||||
const handleExit = () => {
|
||||
gameBridge?.quitGame();
|
||||
setGameOutcome(null);
|
||||
onReturnToMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCanvas
|
||||
className="fixed top-0 left-0 w-screen h-screen pointer-events-auto"
|
||||
initialState={initialGameState}
|
||||
onRendererReady={setRenderer}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
|
||||
{initialGameState && (
|
||||
<div className="fixed top-0 left-0 w-full h-full pointer-events-none flex flex-col">
|
||||
<SpawnPhaseOverlay isVisible={spawnPhaseActive} countdown={spawnCountdown} />
|
||||
|
||||
<div className="relative flex-1">
|
||||
{spawnPhaseActive && (
|
||||
<div className="absolute top-16 left-1/2 -translate-x-1/2 text-center select-none w-full px-4 z-50">
|
||||
<h1 className="font-oswald text-5xl leading-4 font-bold text-white drop-shadow-lg tracking-wide">
|
||||
Pick Your Spawn
|
||||
</h1>
|
||||
<p className="font-sans font-medium mx-auto mt-6 text-lg text-white drop-shadow-md max-w-xl leading-relaxed">
|
||||
Click anywhere on the map to place your starting territory.
|
||||
<br />
|
||||
You can change your mind before the timer expires.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Leaderboard
|
||||
initialSnapshot={initialLeaderboard}
|
||||
highlightedNation={highlightedNation}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
<AttackControls>
|
||||
<Attacks onNationHover={setHighlightedNation} />
|
||||
</AttackControls>
|
||||
<GameMenu
|
||||
onExit={handleExit}
|
||||
onSettings={() => {
|
||||
// TODO: Implement settings
|
||||
}}
|
||||
/>
|
||||
|
||||
{gameOutcome && (
|
||||
<GameEndOverlay outcome={gameOutcome} onSpectate={() => setGameOutcome(null)} onExit={handleExit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user