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" /> + + {children} + ); diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 00000000..e136f534 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,34 @@ +import type { MetadataRoute } from 'next'; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'VOIDSTRIKE', + short_name: 'VOIDSTRIKE', + description: + 'A competitive real-time strategy game built for the browser. Command your forces, gather resources, and dominate the battlefield.', + start_url: '/', + display: 'standalone', + orientation: 'landscape', + background_color: '#000000', + theme_color: '#0a0015', + categories: ['games', 'entertainment'], + icons: [ + { + src: '/icon-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/icon-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + }; +} diff --git a/src/components/pwa/InstallPrompt.tsx b/src/components/pwa/InstallPrompt.tsx new file mode 100644 index 00000000..358c9e63 --- /dev/null +++ b/src/components/pwa/InstallPrompt.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useCallback } from 'react'; +import { usePWAStore } from '@/store/pwaStore'; + +/** + * Non-intrusive "Install App" button. Only renders when the browser + * has fired beforeinstallprompt and the app is not already installed. + */ +export function InstallPrompt() { + const installPrompt = usePWAStore((s) => s.installPrompt); + const isInstalled = usePWAStore((s) => s.isInstalled); + const isDismissed = usePWAStore((s) => s.isDismissed); + const dismiss = usePWAStore((s) => s.dismiss); + const setInstalled = usePWAStore((s) => s.setInstalled); + const setInstallPrompt = usePWAStore((s) => s.setInstallPrompt); + + const handleInstall = useCallback(async () => { + if (!installPrompt) return; + + await installPrompt.prompt(); + const result = await installPrompt.userChoice; + + if (result.outcome === 'accepted') { + setInstalled(true); + } + // Prompt can only be used once + setInstallPrompt(null); + }, [installPrompt, setInstalled, setInstallPrompt]); + + // Don't render if no prompt, already installed, or dismissed + if (!installPrompt || isInstalled || isDismissed) return null; + + return ( +
+ + +
+ ); +} diff --git a/src/components/pwa/ServiceWorkerRegistrar.tsx b/src/components/pwa/ServiceWorkerRegistrar.tsx new file mode 100644 index 00000000..c507ab46 --- /dev/null +++ b/src/components/pwa/ServiceWorkerRegistrar.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePWAStore } from '@/store/pwaStore'; + +/** + * Registers the service worker and captures the beforeinstallprompt event. + * Renders nothing — include once in the root layout. + */ +export function ServiceWorkerRegistrar() { + const setInstallPrompt = usePWAStore((s) => s.setInstallPrompt); + const setInstalled = usePWAStore((s) => s.setInstalled); + const setServiceWorkerReady = usePWAStore((s) => s.setServiceWorkerReady); + + useEffect(() => { + // Service worker registration + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/sw.js', { scope: '/', updateViaCache: 'none' }) + .then(() => { + setServiceWorkerReady(true); + }) + .catch((error) => { + console.warn('[PWA] Service worker registration failed:', error); + }); + } + + // Capture install prompt + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setInstallPrompt(e as BeforeInstallPromptEvent); + }; + + // Detect if already installed + const handleAppInstalled = () => { + setInstalled(true); + setInstallPrompt(null); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.addEventListener('appinstalled', handleAppInstalled); + + // Check if running in standalone mode (already installed) + if (window.matchMedia('(display-mode: standalone)').matches) { + setInstalled(true); + } + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + window.removeEventListener('appinstalled', handleAppInstalled); + }; + }, [setInstallPrompt, setInstalled, setServiceWorkerReady]); + + return null; +} diff --git a/src/store/pwaStore.ts b/src/store/pwaStore.ts new file mode 100644 index 00000000..4676236f --- /dev/null +++ b/src/store/pwaStore.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +/** + * Browser's BeforeInstallPromptEvent — not in lib.dom.d.ts yet. + * Declared globally in src/types/pwa.d.ts + */ +interface PWAState { + installPrompt: BeforeInstallPromptEvent | null; + isInstalled: boolean; + isDismissed: boolean; + serviceWorkerReady: boolean; + + setInstallPrompt: (prompt: BeforeInstallPromptEvent | null) => void; + setInstalled: (installed: boolean) => void; + setServiceWorkerReady: (ready: boolean) => void; + dismiss: () => void; +} + +export const usePWAStore = create((set) => ({ + installPrompt: null, + isInstalled: false, + isDismissed: false, + serviceWorkerReady: false, + + setInstallPrompt: (prompt) => set({ installPrompt: prompt }), + setInstalled: (installed) => set({ isInstalled: installed }), + setServiceWorkerReady: (ready) => set({ serviceWorkerReady: ready }), + dismiss: () => set({ isDismissed: true, installPrompt: null }), +})); diff --git a/src/types/pwa.d.ts b/src/types/pwa.d.ts new file mode 100644 index 00000000..5491249e --- /dev/null +++ b/src/types/pwa.d.ts @@ -0,0 +1,16 @@ +// BeforeInstallPromptEvent — not in lib.dom.d.ts as of TS 5.x +// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; + prompt(): Promise; +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} + +export {};