mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-10 04:08:45 -06:00
Update source files
This commit is contained in:
73
frontend/pages/+Head.tsx
Normal file
73
frontend/pages/+Head.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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>{`
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
|
||||
font-family: Arial, sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
filter: blur(4px) brightness(0.85);
|
||||
transform: scale(1.05);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(1px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
#game-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: block;
|
||||
background: #000;
|
||||
z-index: 0;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
frontend/pages/+Wrapper.client.tsx
Normal file
85
frontend/pages/+Wrapper.client.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { GameAPIProvider } from '@/shared/api/GameAPIContext'
|
||||
import { AnalyticsProvider } from '@/shared/analytics'
|
||||
|
||||
export default function Wrapper({ children }: { children: ReactNode }) {
|
||||
// Determine platform based on build-time define
|
||||
const isDesktop = typeof __DESKTOP__ !== 'undefined' ? __DESKTOP__ : false
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Create canvas element for the game renderer
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.id = 'game-canvas'
|
||||
document.body.appendChild(canvas)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu)
|
||||
userIdChannel.close()
|
||||
if (canvas.parentElement) {
|
||||
canvas.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Wait for platform-specific modules to load
|
||||
if (!gameAPI || !analytics) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalyticsProvider api={analytics}>
|
||||
<GameAPIProvider api={gameAPI}>{children}</GameAPIProvider>
|
||||
</AnalyticsProvider>
|
||||
)
|
||||
}
|
||||
17
frontend/pages/+Wrapper.tsx
Normal file
17
frontend/pages/+Wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
frontend/pages/+config.js
Normal file
15
frontend/pages/+config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
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
19
frontend/pages/fonts.css
Normal 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;
|
||||
}
|
||||
543
frontend/pages/index/+Page.css
Normal file
543
frontend/pages/index/+Page.css
Normal file
@@ -0,0 +1,543 @@
|
||||
:root {
|
||||
font-family: "Inter Variable", Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #1e293b;
|
||||
background-color: transparent; /* Transparent to show game canvas */
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input blocking system for desktop mode:
|
||||
*
|
||||
* In desktop mode, the InputForwarder component captures all input events
|
||||
* (mouse, keyboard, wheel) from the window and forwards them to the Bevy game engine.
|
||||
*
|
||||
* To prevent UI elements (menus, overlays, etc.) from accidentally forwarding clicks
|
||||
* to the game, those elements should be tagged with the 'block-game-input' class.
|
||||
*
|
||||
* The InputForwarder checks `event.target.closest('.block-game-input')` and skips
|
||||
* forwarding if the event originated from within a blocking element.
|
||||
*
|
||||
* For overlays that SHOULD allow clicks through (like spawn phase instructions),
|
||||
* simply don't add the 'block-game-input' class.
|
||||
*/
|
||||
.block-game-input {
|
||||
/* This class serves as a marker - no styles needed */
|
||||
}
|
||||
|
||||
/* Make the entire React overlay transparent to pointer events */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: transparent; /* Transparent to show game canvas */
|
||||
z-index: 1; /* Above canvas (z-index: 0) */
|
||||
}
|
||||
|
||||
/* In browser mode, enable pointer events on the game canvas */
|
||||
#game-canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
pointer-events: none; /* Let clicks pass through to interaction layer */
|
||||
position: relative;
|
||||
z-index: 1; /* Above interaction layer */
|
||||
}
|
||||
|
||||
/* Game canvas - PixiJS renderer layer */
|
||||
.game-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: auto; /* Capture input events */
|
||||
z-index: 0; /* Behind all UI elements */
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none; /* Let clicks pass through to interaction layer */
|
||||
z-index: 1; /* Above interaction layer and canvas */
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
pointer-events: auto; /* Make text selectable if needed */
|
||||
}
|
||||
|
||||
p {
|
||||
pointer-events: auto; /* Make text selectable */
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
text-decoration: inherit;
|
||||
pointer-events: auto; /* Make links clickable */
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
form {
|
||||
pointer-events: auto; /* Make form interactive */
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
pointer-events: auto; /* Re-enable clicks on interactive elements */
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto; /* Re-enable clicks on buttons */
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
button:active {
|
||||
border-color: #3b82f6;
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Leaderboard styles */
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
opacity: 0.6;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 13px; /* Base font size for scaling everything */
|
||||
}
|
||||
|
||||
.leaderboard:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leaderboard__header {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: white;
|
||||
padding: 0.5em 0.83em; /* 6px 10px scaled to em */
|
||||
border-radius: 0 0 0 0;
|
||||
font-weight: 500;
|
||||
font-size: 1.25em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.leaderboard__header--collapsed {
|
||||
border-radius: 0 0 0.2em 0.2em;
|
||||
}
|
||||
|
||||
.leaderboard__body {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 0 0 0.5em 0.5em; /* 6px scaled to em */
|
||||
}
|
||||
|
||||
.leaderboard__table-container {
|
||||
/* Height set dynamically via JavaScript */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaderboard__table {
|
||||
width: 23.5em; /* Increased to accommodate position column */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.leaderboard__player-highlight {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.leaderboard__row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 1.1em;
|
||||
|
||||
> td {
|
||||
padding-top: 0.25em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.leaderboard__row:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.leaderboard__row--player {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.leaderboard__row--eliminated {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.leaderboard__row--eliminated:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.leaderboard__row--highlighted {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
}
|
||||
|
||||
.leaderboard__position {
|
||||
padding-left: 0.67em;
|
||||
padding-right: 0.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 3em;
|
||||
min-width: 3em;
|
||||
font-size: 0.9em;
|
||||
letter-spacing: -0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.leaderboard__name {
|
||||
padding: 0 0.67em 0 0; /* Reduced left padding since position column has right padding */
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 55%; /* Reduced to make room for position column */
|
||||
}
|
||||
|
||||
.leaderboard__name-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.leaderboard__color-circle {
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 0 0.15em rgba(0, 0, 0, 0.4),
|
||||
0 0 0.3em rgba(0, 0, 0, 0.2),
|
||||
0 0 0.5em rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 0.1em rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.leaderboard__percent {
|
||||
padding: 0 0.67em; /* 6px 8px scaled to em */
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 20%; /* Fixed width for percentage column */
|
||||
}
|
||||
|
||||
.leaderboard__troops {
|
||||
padding-left: 0.45em;
|
||||
padding-right: 0.86em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
width: 20%; /* Fixed width for troops column */
|
||||
min-width: 5em; /* 60px scaled to em (60/12) */
|
||||
}
|
||||
|
||||
.leaderboard__placeholder {
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Responsive font sizing based on window size */
|
||||
@media (max-width: 1200px) {
|
||||
.leaderboard {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.leaderboard {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.leaderboard {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.leaderboard {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attacks styles */
|
||||
.attacks {
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 13px; /* Base font size for scaling everything */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.attacks:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attacks__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 0.83em;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
font-size: 1.1em;
|
||||
font-family: inherit;
|
||||
width: 21.67em;
|
||||
border-radius: 0.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attacks__row:hover {
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
.attacks__background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.attacks__nation {
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
padding-right: 1em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.attacks__troops {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: 5em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.attacks {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.attacks {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.attacks {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.attacks {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attack Controls styles */
|
||||
.attack-controls {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
opacity: 0.95;
|
||||
transition: opacity 400ms ease;
|
||||
font-size: 15.6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attack-controls:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.attack-controls__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attack-controls__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
min-width: 26em;
|
||||
}
|
||||
|
||||
.attack-controls__label {
|
||||
font-size: 0.7em;
|
||||
color: white;
|
||||
opacity: 0.6;
|
||||
padding-bottom: 0.15em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.8),
|
||||
0 2px 4px rgba(0, 0, 0, 0.6),
|
||||
1px 0 2px rgba(0, 0, 0, 0.8),
|
||||
-1px 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.attack-controls__attacks {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.attack-controls__slider-wrapper {
|
||||
width: 26em;
|
||||
}
|
||||
|
||||
.attack-controls__slider {
|
||||
height: 2.75em;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 0.4em;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.attack-controls__slider-fill {
|
||||
height: 100%;
|
||||
background: rgba(59, 130, 246, 0.6);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 0.15em;
|
||||
}
|
||||
|
||||
.attack-controls__percentage {
|
||||
position: absolute;
|
||||
left: 0.8em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.15em;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.85;
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.4),
|
||||
1px 0 2px rgba(0, 0, 0, 0.4),
|
||||
-1px 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.attack-controls {
|
||||
font-size: 14.4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.attack-controls {
|
||||
font-size: 14.4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.attack-controls {
|
||||
font-size: 13.8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.attack-controls {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
211
frontend/pages/index/+Page.tsx
Normal file
211
frontend/pages/index/+Page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import "./+Page.css";
|
||||
import { Attacks } from "@/shared/components/Attacks";
|
||||
import { AttackControls } from "@/shared/components/AttackControls";
|
||||
import { Leaderboard } from "@/shared/components/Leaderboard";
|
||||
import { MenuScreen } from "@/shared/components/MenuScreen";
|
||||
import { GameMenu } from "@/shared/components/GameMenu";
|
||||
import { SpawnPhaseOverlay } from "@/shared/components/SpawnPhaseOverlay";
|
||||
import { GameCanvas } from "@/shared/components/GameCanvas";
|
||||
import { useGameAPI } from "@/shared/api/GameAPIContext";
|
||||
import { useAnalytics } from "@/shared/analytics";
|
||||
import type { GameOutcome, LeaderboardSnapshot } from "@/shared/api/types";
|
||||
import type { GameRenderer } from "@/shared/render/GameRenderer";
|
||||
|
||||
// Lazy load conditional components that aren't needed immediately
|
||||
const GameEndOverlay = lazy(() => import("@/shared/components/GameEndOverlay"));
|
||||
const AlphaWarningModal = lazy(() => import("@/shared/components/AlphaWarningModal"));
|
||||
|
||||
function App() {
|
||||
const [showMenu, setShowMenu] = useState(true);
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
const [gameOutcome, setGameOutcome] = useState<GameOutcome | null>(null);
|
||||
const [spawnPhaseActive, setSpawnPhaseActive] = useState(false);
|
||||
const [spawnCountdown, setSpawnCountdown] = useState<{
|
||||
startedAtMs: number;
|
||||
durationSecs: number;
|
||||
} | null>(null);
|
||||
const [initialGameState, setInitialGameState] = useState<any | null>(null);
|
||||
const [initialLeaderboard, setInitialLeaderboard] = useState<LeaderboardSnapshot | null>(null);
|
||||
const [renderer, setRenderer] = useState<GameRenderer | null>(null);
|
||||
const [highlightedNation, setHighlightedNation] = useState<number | null>(null);
|
||||
const api = useGameAPI();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// Prefetch conditional components after initial render
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// Trigger chunk downloads without rendering
|
||||
import("@/shared/components/GameEndOverlay");
|
||||
import("@/shared/components/AlphaWarningModal");
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Track app started on mount
|
||||
useEffect(() => {
|
||||
if (!analytics) return;
|
||||
analytics.track("app_started", {
|
||||
platform: __DESKTOP__ ? "desktop" : "browser",
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
// Check for existing game state on mount (for reload recovery)
|
||||
useEffect(() => {
|
||||
if (!api || typeof api.getGameState !== "function") return;
|
||||
|
||||
api.getGameState().then((state) => {
|
||||
// Only recover if we actually have render data (indicates a running game)
|
||||
if (state && state.render_init) {
|
||||
console.log("Recovered game state after reload:", state);
|
||||
setInitialGameState(state.render_init);
|
||||
setInitialLeaderboard(state.leaderboard_snapshot);
|
||||
setGameStarted(true);
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to spawn phase events
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const unsubUpdate = api.onSpawnPhaseUpdate((update) => {
|
||||
setSpawnPhaseActive(true);
|
||||
setSpawnCountdown(update.countdown);
|
||||
});
|
||||
|
||||
const unsubEnd = api.onSpawnPhaseEnded(() => {
|
||||
setSpawnPhaseActive(false);
|
||||
setSpawnCountdown(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubUpdate();
|
||||
unsubEnd();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Subscribe to game end events
|
||||
useEffect(() => {
|
||||
if (!gameStarted || !api) return;
|
||||
|
||||
const unsubscribe = api.onGameEnded((outcome) => {
|
||||
console.log("Game outcome received:", outcome);
|
||||
setGameOutcome(outcome);
|
||||
setSpawnPhaseActive(false); // Hide spawn overlay on game end
|
||||
// Track game ended
|
||||
if (analytics) {
|
||||
analytics.track("game_ended", {
|
||||
outcome: outcome.toString().toLowerCase(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [gameStarted, 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 handleStartSingleplayer = () => {
|
||||
setShowMenu(false);
|
||||
setGameStarted(true);
|
||||
// Start the game in the backend
|
||||
if (api) {
|
||||
api.startGame();
|
||||
}
|
||||
// Track game started
|
||||
if (analytics) {
|
||||
analytics.track("game_started", {
|
||||
mode: "singleplayer",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExit = async () => {
|
||||
if (__DESKTOP__) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("request_exit").catch((err) => {
|
||||
console.error("Failed to request exit:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Game Canvas - always rendered at root, hidden under menu initially */}
|
||||
<GameCanvas
|
||||
className="game-canvas"
|
||||
initialState={initialGameState}
|
||||
onRendererReady={setRenderer}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
|
||||
{/* Menu Screen - covers everything when visible */}
|
||||
<MenuScreen onStartSingleplayer={handleStartSingleplayer} onExit={handleExit} isVisible={showMenu} />
|
||||
|
||||
{/* Game UI - only visible when game is started */}
|
||||
{gameStarted && (
|
||||
<div className="game-ui">
|
||||
{/* Spawn Phase Overlay */}
|
||||
<SpawnPhaseOverlay isVisible={spawnPhaseActive} countdown={spawnCountdown} />
|
||||
|
||||
<Leaderboard
|
||||
initialSnapshot={initialLeaderboard}
|
||||
highlightedNation={highlightedNation}
|
||||
onNationHover={setHighlightedNation}
|
||||
/>
|
||||
<AttackControls>
|
||||
<Attacks onNationHover={setHighlightedNation} />
|
||||
</AttackControls>
|
||||
<GameMenu
|
||||
onExit={() => {
|
||||
if (api) {
|
||||
api.quitGame();
|
||||
}
|
||||
setGameOutcome(null);
|
||||
setGameStarted(false);
|
||||
setShowMenu(true);
|
||||
}}
|
||||
onSettings={() => {
|
||||
// TODO: Implement settings
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Game End Overlay */}
|
||||
{gameOutcome && (
|
||||
<Suspense fallback={null}>
|
||||
<GameEndOverlay
|
||||
outcome={gameOutcome}
|
||||
onSpectate={() => setGameOutcome(null)}
|
||||
onExit={() => {
|
||||
if (api) {
|
||||
api.quitGame();
|
||||
}
|
||||
setGameOutcome(null);
|
||||
setGameStarted(false);
|
||||
setShowMenu(true);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user