diff --git a/launch/play.bat b/launch/play.bat new file mode 100644 index 00000000..2459abe8 --- /dev/null +++ b/launch/play.bat @@ -0,0 +1,140 @@ +@echo off +REM VOIDSTRIKE — Local Play Launcher (Windows) +REM Double-click this file to launch the game + +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%.." +set "PORT=3000" +if defined VOIDSTRIKE_PORT set "PORT=%VOIDSTRIKE_PORT%" +set "URL=http://localhost:%PORT%" + +cd /d "%PROJECT_DIR%" + +echo. +echo ======================================== +echo V O I D S T R I K E +echo Local Play Launcher +echo ======================================== +echo. + +REM ============================================ +REM Check Node.js +REM ============================================ +where node >nul 2>nul +if %errorlevel% neq 0 ( + echo [ERROR] Node.js not found. Install it from https://nodejs.org + pause + exit /b 1 +) + +for /f "tokens=1 delims=v." %%a in ('node -v') do set "NODE_MAJOR=%%a" +REM node -v returns "v20.x.x" — strip the 'v' prefix +for /f "tokens=1 delims=." %%a in ('node -v') do set "NODE_VER=%%a" +set "NODE_VER=%NODE_VER:v=%" + +if %NODE_VER% lss 18 ( + echo [ERROR] Node.js 18+ required + pause + exit /b 1 +) + +REM ============================================ +REM Install dependencies if needed +REM ============================================ +if not exist "node_modules" ( + echo Installing dependencies... + call npm install + echo. +) + +REM ============================================ +REM Determine mode (dev or prod) +REM ============================================ +set "MODE=dev" +if "%~1"=="build" set "MODE=build" +if "%~1"=="prod" set "MODE=build" + +if "%MODE%"=="build" ( + echo Building for production... + call npm run build + echo. + echo Starting production server on port %PORT%... + start "VOIDSTRIKE Server" /b cmd /c "npx next start -p %PORT%" +) else ( + echo Starting dev server on port %PORT%... + start "VOIDSTRIKE Server" /b cmd /c "npx next dev -p %PORT%" +) + +REM ============================================ +REM Wait for server to be ready +REM ============================================ +echo Waiting for server... +set "RETRIES=0" +:wait_loop +timeout /t 1 /nobreak >nul +curl -s "%URL%" >nul 2>nul +if %errorlevel% equ 0 goto server_ready +set /a RETRIES+=1 +if %RETRIES% geq 60 ( + echo [ERROR] Server failed to start after 60s + pause + exit /b 1 +) +goto wait_loop + +:server_ready +echo Server ready! +echo. + +REM ============================================ +REM Open browser in app mode +REM ============================================ +echo Launching VOIDSTRIKE... + +REM Try Chrome first (app mode for native-feeling window) +set "CHROME_PATH=" +for %%p in ( + "%ProgramFiles%\Google\Chrome\Application\chrome.exe" + "%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" + "%LocalAppData%\Google\Chrome\Application\chrome.exe" +) do ( + if exist %%p ( + set "CHROME_PATH=%%~p" + goto found_chrome + ) +) + +REM Try Edge (also supports app mode) +for %%p in ( + "%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe" + "%ProgramFiles%\Microsoft\Edge\Application\msedge.exe" +) do ( + if exist %%p ( + set "CHROME_PATH=%%~p" + goto found_chrome + ) +) + +REM Fallback: open default browser +echo No Chrome/Edge found, opening default browser... +start "" "%URL%" +goto after_launch + +:found_chrome +start "" "%CHROME_PATH%" --app="%URL%" --start-maximized + +:after_launch +echo. +echo Game running at: %URL% +echo. +echo Press any key to stop the server and exit... +pause >nul + +REM Kill the server +taskkill /fi "WINDOWTITLE eq VOIDSTRIKE Server" >nul 2>nul +taskkill /f /im node.exe >nul 2>nul + +echo Shutting down. +exit /b 0 diff --git a/launch/play.sh b/launch/play.sh new file mode 100755 index 00000000..43ed6f8c --- /dev/null +++ b/launch/play.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# VOIDSTRIKE — Local Play Launcher (macOS / Linux) +# Double-click or run: ./launch/play.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +PORT="${VOIDSTRIKE_PORT:-3000}" +URL="http://localhost:$PORT" + +cd "$PROJECT_DIR" + +# ============================================ +# Colors +# ============================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +PURPLE='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' + +echo -e "${PURPLE}${BOLD}" +echo " ╔═══════════════════════════════════╗" +echo " ║ V O I D S T R I K E ║" +echo " ║ Local Play Launcher ║" +echo " ╚═══════════════════════════════════╝" +echo -e "${NC}" + +# ============================================ +# Check Node.js +# ============================================ +if ! command -v node &>/dev/null; then + echo -e "${RED}Node.js not found. Install it from https://nodejs.org${NC}" + exit 1 +fi + +NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) +if [ "$NODE_VERSION" -lt 18 ]; then + echo -e "${RED}Node.js 18+ required (found $(node -v))${NC}" + exit 1 +fi + +# ============================================ +# Install dependencies if needed +# ============================================ +if [ ! -d "node_modules" ]; then + echo -e "${PURPLE}Installing dependencies...${NC}" + npm install + echo "" +fi + +# ============================================ +# Build or Dev mode +# ============================================ +MODE="${1:-dev}" + +if [ "$MODE" = "build" ] || [ "$MODE" = "prod" ]; then + echo -e "${PURPLE}Building for production...${NC}" + npm run build + echo "" + echo -e "${GREEN}Starting production server on port $PORT...${NC}" + # Start server in background + npx next start -p "$PORT" & + SERVER_PID=$! +else + echo -e "${GREEN}Starting dev server on port $PORT...${NC}" + # Start dev server in background + npx next dev -p "$PORT" & + SERVER_PID=$! +fi + +# ============================================ +# Wait for server to be ready +# ============================================ +echo -n "Waiting for server" +RETRIES=0 +MAX_RETRIES=60 +while ! curl -s "$URL" >/dev/null 2>&1; do + echo -n "." + sleep 1 + RETRIES=$((RETRIES + 1)) + if [ "$RETRIES" -ge "$MAX_RETRIES" ]; then + echo "" + echo -e "${RED}Server failed to start after ${MAX_RETRIES}s${NC}" + kill "$SERVER_PID" 2>/dev/null + exit 1 + fi +done +echo "" +echo -e "${GREEN}Server ready!${NC}" +echo "" + +# ============================================ +# Open browser in app mode +# ============================================ +open_app_mode() { + local url="$1" + + # Try Chrome first (app mode for native-feeling window) + if command -v google-chrome &>/dev/null; then + google-chrome --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v google-chrome-stable &>/dev/null; then + google-chrome-stable --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v chromium &>/dev/null; then + chromium --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + if command -v chromium-browser &>/dev/null; then + chromium-browser --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + + # macOS: try Chrome, then Edge, then default browser + if [ "$(uname)" = "Darwin" ]; then + if [ -d "/Applications/Google Chrome.app" ]; then + open -a "Google Chrome" --args --app="$url" --start-maximized 2>/dev/null + return 0 + fi + if [ -d "/Applications/Microsoft Edge.app" ]; then + open -a "Microsoft Edge" --args --app="$url" --start-maximized 2>/dev/null + return 0 + fi + # Fallback to default browser + open "$url" + return 0 + fi + + # Try Edge on Linux + if command -v microsoft-edge &>/dev/null; then + microsoft-edge --app="$url" --start-maximized 2>/dev/null & + return 0 + fi + + # Fallback: xdg-open + if command -v xdg-open &>/dev/null; then + xdg-open "$url" 2>/dev/null & + return 0 + fi + + echo -e "${PURPLE}Open manually: $url${NC}" + return 1 +} + +echo -e "${PURPLE}Launching VOIDSTRIKE...${NC}" +open_app_mode "$URL" + +echo "" +echo -e "${BOLD}Game running at: ${PURPLE}$URL${NC}" +echo -e "Press ${BOLD}Ctrl+C${NC} to stop the server." +echo "" + +# ============================================ +# Cleanup on exit +# ============================================ +trap "echo ''; echo 'Shutting down...'; kill $SERVER_PID 2>/dev/null; exit 0" INT TERM +wait "$SERVER_PID" diff --git a/next.config.js b/next.config.js index 7c398003..708ef7c6 100644 --- a/next.config.js +++ b/next.config.js @@ -31,6 +31,20 @@ const nextConfig = { }, ], }, + { + // Service worker must never be cached by the browser + source: '/sw.js', + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + { + key: 'Content-Type', + value: 'application/javascript; charset=utf-8', + }, + ], + }, ]; }, diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 00000000..5525131f Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 00000000..6d6934ff Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..8cd04e36 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,170 @@ +// VOIDSTRIKE Service Worker +// Runtime caching only — no precaching of game assets (260MB+ would be insane) + +const CACHE_VERSION = 1; +const SHELL_CACHE = `voidstrike-shell-v${CACHE_VERSION}`; +const ASSET_CACHE = `voidstrike-assets-v${CACHE_VERSION}`; +const DATA_CACHE = `voidstrike-data-v${CACHE_VERSION}`; + +const VALID_CACHES = [SHELL_CACHE, ASSET_CACHE, DATA_CACHE]; + +// Max asset cache size in entries (not bytes) — evict oldest when exceeded +const MAX_ASSET_ENTRIES = 500; + +// ============================================ +// INSTALL +// ============================================ + +self.addEventListener('install', () => { + // Skip waiting to activate immediately + self.skipWaiting(); +}); + +// ============================================ +// ACTIVATE +// ============================================ + +self.addEventListener('activate', (event) => { + // Clean up old versioned caches + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => !VALID_CACHES.includes(key)).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ); +}); + +// ============================================ +// FETCH +// ============================================ + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Only handle GET requests for same-origin + if (request.method !== 'GET') return; + if (url.origin !== self.location.origin) return; + + // Skip Next.js HMR / dev websocket / webpack chunks in dev + if (url.pathname.startsWith('/_next/webpack-hmr')) return; + if (url.pathname.includes('__nextjs')) return; + + // Route to appropriate caching strategy + if (isStaticGameAsset(url.pathname)) { + event.respondWith(cacheFirst(request, ASSET_CACHE)); + return; + } + + if (isGameData(url.pathname)) { + event.respondWith(staleWhileRevalidate(request, DATA_CACHE)); + return; + } + + if (isAppShell(request, url.pathname)) { + event.respondWith(staleWhileRevalidate(request, SHELL_CACHE)); + return; + } +}); + +// ============================================ +// ASSET CLASSIFICATION +// ============================================ + +function isStaticGameAsset(pathname) { + // WASM binaries + if (pathname.endsWith('.wasm')) return true; + // 3D models + if (/\.(glb|gltf|bin)$/.test(pathname)) return true; + // Textures (including compressed formats) + if (/\.(png|jpg|jpeg|webp|ktx2|basis)$/.test(pathname)) return true; + // Audio + if (/\.(mp3|ogg|wav|m4a)$/.test(pathname)) return true; + // Draco decoder + if (pathname.startsWith('/draco/')) return true; + return false; +} + +function isGameData(pathname) { + // Game definitions, configs, map data + if (pathname.startsWith('/data/') && pathname.endsWith('.json')) return true; + if (pathname.startsWith('/config/') && pathname.endsWith('.json')) return true; + return false; +} + +function isAppShell(request, pathname) { + // Navigation requests (HTML pages) + if (request.mode === 'navigate') return true; + // Next.js static assets (JS, CSS) — content-hashed, safe to cache + if (pathname.startsWith('/_next/static/')) return true; + // Fonts + if (/\.(woff2?|ttf|otf|eot)$/.test(pathname)) return true; + return false; +} + +// ============================================ +// CACHING STRATEGIES +// ============================================ + +// Cache-First: check cache, fallback to network, cache the response +// Best for immutable assets (WASM, models, textures, audio) +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + trimCache(cacheName, MAX_ASSET_ENTRIES); + } + return response; + } catch { + // Network failed and nothing in cache + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + } +} + +// Stale-While-Revalidate: return cache immediately, update in background +// Best for app shell and game data that may change between deploys +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + + // Return cached version immediately if available, otherwise wait for network + if (cached) return cached; + + const response = await fetchPromise; + if (response) return response; + + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); +} + +// ============================================ +// CACHE MANAGEMENT +// ============================================ + +// Evict oldest entries when cache exceeds max size +async function trimCache(cacheName, maxEntries) { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + if (keys.length <= maxEntries) return; + + // Delete oldest entries (first in the list) + const deleteCount = keys.length - maxEntries; + for (let i = 0; i < deleteCount; i++) { + await cache.delete(keys[i]); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d70741e9..3dbcf55f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,24 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import './globals.css'; +import { ServiceWorkerRegistrar } from '@/components/pwa/ServiceWorkerRegistrar'; +import { InstallPrompt } from '@/components/pwa/InstallPrompt'; export const metadata: Metadata = { title: 'VOIDSTRIKE - Browser-Based RTS', - description: 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', + description: + 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', keywords: ['RTS', 'strategy', 'game', 'browser', 'multiplayer', 'competitive'], + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'VOIDSTRIKE', + }, + applicationName: 'VOIDSTRIKE', +}; + +export const viewport: Viewport = { + themeColor: '#0a0015', + colorScheme: 'dark', }; export default function RootLayout({ @@ -21,9 +35,12 @@ export default function RootLayout({ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" /> +
+