diff --git a/api/fps_matches.php b/api/fps_matches.php new file mode 100644 index 0000000..603417a --- /dev/null +++ b/api/fps_matches.php @@ -0,0 +1,107 @@ + ['min_range' => 1]]); + if (!$id) { + jsonResponse(['success' => false, 'error' => 'Invalid match id.'], 422); + } + + $match = fetchMatchById($id); + if (!$match) { + jsonResponse(['success' => false, 'error' => 'Match not found.'], 404); + } + + jsonResponse(['success' => true, 'match' => $match]); + } + + $limit = filter_var($_GET['limit'] ?? 8, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 20]]) ?: 8; + jsonResponse(['success' => true, 'matches' => fetchRecentMatches($limit)]); + } + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + jsonResponse(['success' => false, 'error' => 'Method not allowed.'], 405); + } + + $raw = file_get_contents('php://input') ?: ''; + $payload = json_decode($raw, true); + if (!is_array($payload)) { + jsonResponse(['success' => false, 'error' => 'Invalid request payload.'], 422); + } + + $playerName = trim((string)($payload['player_name'] ?? 'Operator')); + $weaponKey = trim((string)($payload['weapon_key'] ?? 'carbine')); + $weaponName = trim((string)($payload['weapon_name'] ?? 'Carbine')); + $outcome = trim((string)($payload['outcome'] ?? 'defeat')); + + if ($playerName === '' || safeLength($playerName) > 80) { + jsonResponse(['success' => false, 'error' => 'Player name must be between 1 and 80 characters.'], 422); + } + + if ($weaponKey === '' || safeLength($weaponKey) > 40 || $weaponName === '' || safeLength($weaponName) > 80) { + jsonResponse(['success' => false, 'error' => 'Weapon metadata is invalid.'], 422); + } + + if (!in_array($outcome, ['victory', 'defeat', 'timeout'], true)) { + $outcome = 'defeat'; + } + + $kills = max(0, min(999, (int)($payload['kills'] ?? 0))); + $shotsFired = max(0, min(9999, (int)($payload['shots_fired'] ?? 0))); + $shotsHit = max(0, min($shotsFired, (int)($payload['shots_hit'] ?? 0))); + $damageTaken = max(0, min(999, (int)($payload['damage_taken'] ?? 0))); + $durationSeconds = max(0, min(3600, (int)($payload['duration_seconds'] ?? 0))); + $score = max(0, min(999999, (int)($payload['score'] ?? 0))); + $accuracy = $shotsFired > 0 ? round(($shotsHit / $shotsFired) * 100, 2) : 0.0; + + $stmt = db()->prepare( + 'INSERT INTO fps_matches ( + player_name, weapon_key, weapon_name, kills, shots_fired, shots_hit, accuracy, + damage_taken, duration_seconds, score, outcome + ) VALUES ( + :player_name, :weapon_key, :weapon_name, :kills, :shots_fired, :shots_hit, :accuracy, + :damage_taken, :duration_seconds, :score, :outcome + )' + ); + + $stmt->bindValue(':player_name', $playerName, PDO::PARAM_STR); + $stmt->bindValue(':weapon_key', $weaponKey, PDO::PARAM_STR); + $stmt->bindValue(':weapon_name', $weaponName, PDO::PARAM_STR); + $stmt->bindValue(':kills', $kills, PDO::PARAM_INT); + $stmt->bindValue(':shots_fired', $shotsFired, PDO::PARAM_INT); + $stmt->bindValue(':shots_hit', $shotsHit, PDO::PARAM_INT); + $stmt->bindValue(':accuracy', $accuracy); + $stmt->bindValue(':damage_taken', $damageTaken, PDO::PARAM_INT); + $stmt->bindValue(':duration_seconds', $durationSeconds, PDO::PARAM_INT); + $stmt->bindValue(':score', $score, PDO::PARAM_INT); + $stmt->bindValue(':outcome', $outcome, PDO::PARAM_STR); + $stmt->execute(); + + $id = (int)db()->lastInsertId(); + $match = fetchMatchById($id); + + jsonResponse(['success' => true, 'message' => 'Match saved.', 'match' => $match], 201); +} catch (Throwable $e) { + error_log('fps_matches API error: ' . $e->getMessage()); + jsonResponse(['success' => false, 'error' => 'Unable to process match request right now.'], 500); +} diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..e599b8c 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,429 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; +:root { + --bg: #0b0f14; + --bg-alt: #11161d; + --surface: #121821; + --surface-2: #171e28; + --border: #26303d; + --text: #eef2f7; + --muted: #9aa4b2; + --accent: #dbe2ea; + --danger: #f87171; + --success: #34d399; + --warning: #fbbf24; + --shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --space: 24px; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; +html { + scroll-behavior: smooth; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +body.app-shell { + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.55; } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; +body.app-shell::selection { + background: rgba(219, 226, 234, 0.2); } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; +.bg-body-tertiary { + background-color: rgba(11, 15, 20, 0.82) !important; + backdrop-filter: blur(10px); } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.navbar { + border-color: rgba(255,255,255,0.06) !important; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; +.small-brand { + letter-spacing: 0.16em; + font-size: 0.82rem; + color: var(--text); } -::-webkit-scrollbar-track { +.nav-link { + color: var(--muted); +} + +.nav-link:hover, +.nav-link:focus, +.navbar-brand:hover, +.navbar-brand:focus { + color: var(--text); +} + +.hero-section, +.section-block { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +.py-lg-6 { + padding-top: 5rem; + padding-bottom: 5rem; } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); +.display-title { + font-size: clamp(2.15rem, 4vw, 4.25rem); + letter-spacing: -0.04em; + line-height: 1.02; + max-width: 11ch; } -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +.hero-copy, +.text-secondary, +.form-text { + color: var(--muted) !important; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; +.eyebrow { text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; + letter-spacing: 0.14em; + color: var(--muted); + font-size: 0.78rem; font-weight: 600; - font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.surface-chip, +.score-pill, +.outcome-tag { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + padding: 0.45rem 0.75rem; + border-radius: 999px; + font-size: 0.82rem; + color: var(--text); } +.surface-panel { + background: rgba(18, 24, 33, 0.98); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +.mini-report, +.status-note { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + padding: 1rem 0; + border-top: 1px solid rgba(255,255,255,0.06); +} + +.mini-report:first-child { + border-top: 0; + padding-top: 0; +} + +.mini-label { + display: block; + color: var(--muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 0.35rem; +} + +.status-note { + grid-template-columns: auto 1fr; + align-items: start; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--success); + margin-top: 0.45rem; + box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.08); +} + +.top-offset { + top: 100px; +} + +.form-control, .form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + color: var(--text); + box-shadow: none; } -.header-container { +.form-control::placeholder { + color: #7d8795; +} + +.weapon-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.weapon-card { + width: 100%; + text-align: left; + background: rgba(255,255,255,0.02); + border: 1px solid rgba(255,255,255,0.08); + color: var(--text); + border-radius: var(--radius-md); + padding: 1rem; + transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease; +} + +.weapon-card:hover, +.weapon-card:focus-visible { + border-color: rgba(255,255,255,0.2); + transform: translateY(-1px); +} + +.weapon-card.active { + border-color: rgba(219, 226, 234, 0.55); + background: rgba(219, 226, 234, 0.08); +} + +.weapon-title { + display: block; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.weapon-meta { + display: block; + color: var(--muted); + font-size: 0.86rem; +} + +.btn { + border-radius: 10px; + padding: 0.68rem 1rem; + font-weight: 600; + border-width: 1px; +} + +.btn-light { + background: var(--accent); + border-color: var(--accent); + color: #0b0f14; +} + +.btn-light:hover, +.btn-light:focus { + background: #eef2f7; + border-color: #eef2f7; + color: #0b0f14; +} + +.btn-outline-light { + color: var(--text); + border-color: rgba(255,255,255,0.14); +} + +.btn-outline-light:hover, +.btn-outline-light:focus { + background: rgba(255,255,255,0.06); + border-color: rgba(255,255,255,0.2); + color: var(--text); +} + +#gameCanvas { + width: 100%; + height: auto; + aspect-ratio: 5 / 3; + display: block; + border-radius: 14px; + border: 1px solid rgba(255,255,255,0.08); + background: #0f141b; +} + +.hud-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; +} + +.hud-strip div, +.metric-card { + border: 1px solid rgba(255,255,255,0.07); + border-radius: 10px; + background: rgba(255,255,255,0.02); + padding: 0.8rem 0.85rem; +} + +.hud-strip span, +.metric-card span { + display: block; + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.3rem; +} + +.hud-strip strong, +.metric-card strong { + display: block; + font-size: 1rem; +} + +.controls-note { + color: var(--muted); + font-size: 0.92rem; +} + +.controls-note strong { + color: var(--text); +} + +.summary-state { + border: 1px dashed rgba(255,255,255,0.08); + border-radius: var(--radius-md); + padding: 1rem; + background: rgba(255,255,255,0.02); + min-height: 240px; +} + +.summary-state.ready { + border-style: solid; +} + +.summary-title { + font-size: 1.05rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.summary-copy { + font-size: 0.92rem; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 1rem 0; +} + +.summary-grid .metric-card strong { + font-size: 1.15rem; +} + +.compact-list li { + padding: 0.7rem 0; + border-top: 1px solid rgba(255,255,255,0.06); + color: var(--muted); +} + +.compact-list li:first-child { + border-top: 0; + padding-top: 0; +} + +.matches-list { + display: flex; + flex-direction: column; +} + +.match-row { display: flex; justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; gap: 1rem; + padding: 1rem 1.25rem; + color: var(--text); + text-decoration: none; + border-top: 1px solid rgba(255,255,255,0.06); } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.match-row:first-child { + border-top: 0; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.match-row:hover, +.match-row:focus { + background: rgba(255,255,255,0.03); + color: var(--text); } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.match-row span { + display: block; + color: var(--muted); + font-size: 0.88rem; + margin-top: 0.2rem; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; +.match-score-wrap { + display: flex; + align-items: center; + gap: 0.7rem; white-space: nowrap; - font-size: 0.85em; - color: #555; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.outcome-tag { + font-size: 0.72rem; + padding: 0.35rem 0.65rem; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.outcome-tag.victory { border-color: rgba(52,211,153,0.3); color: var(--success); } +.outcome-tag.defeat { border-color: rgba(248,113,113,0.3); color: var(--danger); } +.outcome-tag.timeout { border-color: rgba(251,191,36,0.3); color: var(--warning); } + +.empty-state { + color: var(--muted); } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.stats-grid .metric-card { + height: 100%; +} + +.table-dark { + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: rgba(255,255,255,0.06); +} + +.toast { + border-radius: 12px; +} + +@media (max-width: 991.98px) { + .display-title { + max-width: 100%; + } + + .hud-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 767.98px) { + .weapon-grid, + .summary-grid, + .mini-report { + grid-template-columns: 1fr; + } + + .match-row, + .match-score-wrap { + flex-direction: column; + align-items: flex-start; + } + + .hud-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + + +.weapon-card:focus-visible, .btn:focus-visible, .nav-link:focus-visible, .match-row:focus-visible, canvas:focus-visible, .form-control:focus-visible { + outline: 2px solid rgba(219, 226, 234, 0.7); + outline-offset: 2px; +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..a83bc54 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,1291 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const boot = window.__FPS_BOOTSTRAP__ || {}; + const apiUrl = boot.apiUrl || '/api/fps_matches.php'; + const initialMatches = Array.isArray(boot.initialMatches) ? boot.initialMatches : []; - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const loadoutForm = document.getElementById('loadout-form'); + const playerNameInput = document.getElementById('playerName'); + const weaponInput = document.getElementById('weaponInput'); + const weaponButtons = Array.from(document.querySelectorAll('.weapon-card')); + const canvas = document.getElementById('gameCanvas'); + const ctx = canvas.getContext('2d'); + const restartButton = document.getElementById('restartButton'); + const saveButton = document.getElementById('saveButton'); + const refreshMatchesButton = document.getElementById('refreshMatchesButton'); + const summaryPanel = document.getElementById('summaryPanel'); + const matchesList = document.getElementById('matchesList'); + const hudName = document.getElementById('hudName'); + const hudWeapon = document.getElementById('hudWeapon'); + const hudHealth = document.getElementById('hudHealth'); + const hudAmmo = document.getElementById('hudAmmo'); + const hudKills = document.getElementById('hudKills'); + const hudScore = document.getElementById('hudScore'); + const hudTime = document.getElementById('hudTime'); + const toastElement = document.getElementById('appToast'); + const toastMessage = document.getElementById('toastMessage'); + const appToast = toastElement ? new bootstrap.Toast(toastElement, { delay: 2400 }) : null; + + const FOV = Math.PI / 2.7; + const MAX_PITCH = Math.PI * 0.33; + const ROUND_DURATION = 75; + const WORLD = { width: 1800, height: 1200 }; + const TERRAIN_HILLS = [ + { x: 260, y: 220, radiusX: 290, radiusY: 210, height: 42 }, + { x: 620, y: 360, radiusX: 300, radiusY: 220, height: 68 }, + { x: 1120, y: 320, radiusX: 360, radiusY: 260, height: 78 }, + { x: 1480, y: 250, radiusX: 260, radiusY: 210, height: 48 }, + { x: 420, y: 760, radiusX: 320, radiusY: 240, height: 64 }, + { x: 930, y: 700, radiusX: 360, radiusY: 280, height: 92 }, + { x: 1450, y: 790, radiusX: 310, radiusY: 230, height: 72 }, + { x: 740, y: 1010, radiusX: 300, radiusY: 220, height: 58 }, + { x: 1240, y: 980, radiusX: 250, radiusY: 200, height: 44 } + ]; + const keys = {}; + const pointer = { active: false, locked: false, sensitivity: 0.0028 }; + + const weapons = { + carbine: { key: 'carbine', name: 'VX Carbine', damage: 28, fireRate: 180, range: 760, spread: 0.02, magSize: 30, reserve: 120, reloadTime: 1500, pellets: 1, recoil: 0.014 }, + smg: { key: 'smg', name: 'Mako SMG', damage: 16, fireRate: 95, range: 520, spread: 0.038, magSize: 36, reserve: 180, reloadTime: 1350, pellets: 1, recoil: 0.021 }, + shotgun: { key: 'shotgun', name: 'Breach-8', damage: 14, fireRate: 520, range: 340, spread: 0.12, magSize: 8, reserve: 40, reloadTime: 1800, pellets: 6, recoil: 0.06 }, + marksman: { key: 'marksman', name: 'Atlas DMR', damage: 56, fireRate: 420, range: 960, spread: 0.012, magSize: 12, reserve: 48, reloadTime: 1700, pellets: 1, recoil: 0.01 } }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const state = { + player: null, + weapon: weapons.carbine, + bots: [], + props: [], + running: false, + roundEnded: false, + startTime: 0, + endTime: 0, + lastTick: performance.now(), + nextShotAt: 0, + reloadingUntil: 0, + recoil: 0, + flashUntil: 0, + noticeUntil: 0, + message: 'Choose a weapon and press Start round.', + matchSummary: null, + saveInFlight: false, + currentName: playerNameInput?.value.trim() || 'Operator Nova', + renderMatches: initialMatches, + mouseTriggerHeld: false, + spaceTriggerHeld: false + }; - appendMessage(message, 'visitor'); - chatInput.value = ''; + function showToast(message) { + if (!toastMessage || !appToast) return; + toastMessage.textContent = message; + appToast.show(); + } + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function normalizeAngle(angle) { + while (angle > Math.PI) angle -= Math.PI * 2; + while (angle < -Math.PI) angle += Math.PI * 2; + return angle; + } + + function randomBetween(min, max) { + return min + Math.random() * (max - min); + } + + function mix(start, end, amount) { + return start + (end - start) * amount; + } + + function terrainHeightAt(x, y) { + const px = clamp(x, 0, WORLD.width); + const py = clamp(y, 0, WORLD.height); + const nx = px / WORLD.width - 0.5; + const ny = py / WORLD.height - 0.5; + const islandMask = clamp(1 - (nx * nx * 2.3 + ny * ny * 2), 0, 1); + const shorelineLift = Math.pow(islandMask, 0.72) * 18; + const rollingNoise = (Math.sin(px / 108) + Math.cos(py / 132) + Math.sin((px + py) / 168)) * 6; + + let hillHeight = shorelineLift + rollingNoise; + TERRAIN_HILLS.forEach((hill) => { + const dx = (px - hill.x) / hill.radiusX; + const dy = (py - hill.y) / hill.radiusY; + const falloff = Math.max(0, 1 - dx * dx - dy * dy); + hillHeight += hill.height * falloff * falloff; + }); + + return Math.max(0, hillHeight); + } + + function getViewMetrics() { + const elevation = state.player ? (state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y)) : 18; + const climbFactor = clamp(elevation / 150, 0, 1); + const pitch = state.player?.pitch || 0; + const pitchFactor = clamp(pitch / MAX_PITCH, -1, 1); + const pitchOffset = pitchFactor * canvas.height * 0.16; + const horizonBase = canvas.height * (0.45 - climbFactor * 0.065); + const floorBase = canvas.height * (0.72 - climbFactor * 0.055); + return { + elevation, + pitch, + pitchFactor, + pitchOffset, + horizonY: clamp(horizonBase + pitchOffset, canvas.height * 0.16, canvas.height * 0.72), + floorY: clamp(floorBase + pitchOffset * 1.05, canvas.height * 0.42, canvas.height * 0.92) + }; + } + + function resetKeys() { + Object.keys(keys).forEach((code) => { + keys[code] = false; + }); + state.mouseTriggerHeld = false; + state.spaceTriggerHeld = false; + } + + function activatePointerLock() { + if (!canvas) return; + canvas.focus(); + + if (document.pointerLockElement === canvas) { + pointer.active = true; + pointer.locked = true; + return; + } + + if (typeof canvas.requestPointerLock === 'function') { + canvas.requestPointerLock(); + return; + } + + pointer.active = true; + pointer.locked = false; + showToast('Arena focused. Move mouse to look around.'); + } + + function distance(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + + function setWeapon(key) { + if (!weapons[key]) return; + weaponButtons.forEach((button) => { + const active = button.dataset.weapon === key; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + weaponInput.value = key; + state.weapon = weapons[key]; + hudWeapon.textContent = state.weapon.name; + } + + function createBot(id, index) { + const edgePadding = 140; + const spawnEdges = [ + { x: randomBetween(edgePadding, WORLD.width - edgePadding), y: edgePadding }, + { x: WORLD.width - edgePadding, y: randomBetween(edgePadding, WORLD.height - edgePadding) }, + { x: randomBetween(edgePadding, WORLD.width - edgePadding), y: WORLD.height - edgePadding }, + { x: edgePadding, y: randomBetween(edgePadding, WORLD.height - edgePadding) } + ]; + const silhouettes = [ + { jacket: '#d9685f', accent: '#7f1d1d', pants: '#1f2937', skin: '#f1c27d' }, + { jacket: '#ef4444', accent: '#7c2d12', pants: '#111827', skin: '#d6a07a' }, + { jacket: '#fb7185', accent: '#881337', pants: '#1e293b', skin: '#c58c61' }, + { jacket: '#f97316', accent: '#9a3412', pants: '#0f172a', skin: '#8d5b3d' } + ]; + const point = spawnEdges[index % spawnEdges.length]; + return { + id, + x: point.x, + y: point.y, + radius: 24, + hp: 70 + Math.round(randomBetween(0, 25)), + speed: 84 + randomBetween(0, 36), + wanderAngle: randomBetween(0, Math.PI * 2), + turnTimer: randomBetween(0.6, 2.1), + attackCooldown: randomBetween(0.4, 1.2), + hurtUntil: 0, + shootFlashUntil: 0, + angle: randomBetween(0, Math.PI * 2), + strideOffset: randomBetween(0, Math.PI * 2), + bodyScale: 0.94 + randomBetween(-0.08, 0.12), + silhouette: silhouettes[index % silhouettes.length], + preferredRange: randomBetween(180, 250), + strafeDir: Math.random() < 0.5 ? -1 : 1, + strafeTimer: randomBetween(0.45, 1.2), + dodgeCooldown: randomBetween(0.8, 1.7), + aggression: randomBetween(0.92, 1.18), + state: 'patrol' + }; + } + + function generateProps() { + return [ + { x: 280, y: 250, size: 64, type: 'pine' }, + { x: 520, y: 360, size: 74, type: 'rock' }, + { x: 790, y: 320, size: 70, type: 'pine' }, + { x: 1100, y: 360, size: 84, type: 'rock' }, + { x: 1420, y: 270, size: 66, type: 'pine' }, + { x: 360, y: 760, size: 78, type: 'rock' }, + { x: 680, y: 910, size: 68, type: 'pine' }, + { x: 980, y: 760, size: 86, type: 'rock' }, + { x: 1290, y: 860, size: 72, type: 'pine' }, + { x: 1520, y: 980, size: 74, type: 'rock' } + ]; + } + + function spawnRound() { + const name = (playerNameInput.value || '').trim(); + if (!name) { + playerNameInput.focus(); + showToast('Add a call sign before starting.'); + return; + } + + resetKeys(); + state.currentName = name; + state.player = { + x: WORLD.width / 2, + y: WORLD.height * 0.62, + angle: -Math.PI / 2, + pitch: 0, + health: 100, + speed: 220, + ammo: state.weapon.magSize, + reserve: state.weapon.reserve, + damageTaken: 0, + terrainHeight: terrainHeightAt(WORLD.width / 2, WORLD.height * 0.62) + }; + state.bots = Array.from({ length: 7 }, (_, index) => createBot(index + 1, index)); + state.props = generateProps(); + state.running = true; + state.roundEnded = false; + state.startTime = performance.now(); + state.endTime = 0; + state.lastTick = performance.now(); + state.nextShotAt = 0; + state.reloadingUntil = 0; + state.recoil = 0; + state.flashUntil = 0; + state.noticeUntil = performance.now() + 2200; + state.message = 'Round live. Push up the hills and clear the arena.'; + state.matchSummary = null; + state.saveInFlight = false; + saveButton.disabled = true; + hudName.textContent = name; + hudWeapon.textContent = state.weapon.name; + updateSummary(); + updateHud(); + canvas.focus(); + showToast(`${state.weapon.name} equipped. Round started.`); + } + + function updateHud() { + if (!state.player) return; + const elapsed = state.running ? (performance.now() - state.startTime) / 1000 : (state.endTime - state.startTime) / 1000; + const remaining = Math.max(0, ROUND_DURATION - elapsed); + const kills = 7 - state.bots.length; + const score = Math.max(0, kills * 140 + Math.round(remaining * 4) - state.player.damageTaken * 2); + hudName.textContent = state.currentName; + hudWeapon.textContent = state.weapon.name; + hudHealth.textContent = `${Math.max(0, Math.round(state.player.health))}`; + hudAmmo.textContent = `${state.player.ammo} / ${state.player.reserve}`; + hudKills.textContent = String(kills); + hudScore.textContent = String(score); + hudTime.textContent = `${Math.ceil(remaining)}s`; + } + + function getScoreSnapshot() { + const elapsedMs = (state.roundEnded ? state.endTime : performance.now()) - state.startTime; + const seconds = Math.max(1, Math.round(elapsedMs / 1000)); + const kills = 7 - state.bots.length; + const shotsFired = state.player?.shotsFired || 0; + const shotsHit = state.player?.shotsHit || 0; + const score = Math.max(0, kills * 140 + Math.max(0, ROUND_DURATION - seconds) * 4 - (state.player?.damageTaken || 0) * 2 + (state.bots.length === 0 ? 250 : 0)); + let outcome = 'defeat'; + if (state.bots.length === 0) outcome = 'victory'; + else if (seconds >= ROUND_DURATION && state.player.health > 0) outcome = 'timeout'; + return { + player_name: state.currentName, + weapon_key: state.weapon.key, + weapon_name: state.weapon.name, + kills, + shots_fired: shotsFired, + shots_hit: shotsHit, + damage_taken: Math.round(state.player?.damageTaken || 0), + duration_seconds: seconds, + score, + outcome + }; + } + + function updateSummary(savedMatch = null) { + if (!summaryPanel) return; + if (!state.matchSummary) { + summaryPanel.className = 'summary-state empty'; + summaryPanel.innerHTML = ` +

No round finished yet.

+

Start a round to generate a combat report, then save it into recent matches.

+ `; + return; + } + + const s = state.matchSummary; + summaryPanel.className = 'summary-state ready'; + summaryPanel.innerHTML = ` +
+
+

${s.outcome === 'victory' ? 'Squad cleared' : s.outcome === 'timeout' ? 'Timer expired' : 'Operator down'}

+

${s.weapon_name} • ${s.player_name}

+
+ ${s.outcome.toUpperCase()} +
+
+
Score${s.score}
+
Kills${s.kills}
+
Accuracy${s.shots_fired ? ((s.shots_hit / s.shots_fired) * 100).toFixed(1) : '0.0'}%
+
Duration${s.duration_seconds}s
+
+

${savedMatch ? `Saved as match #${savedMatch.id}.` : 'Use “Save result” to store this run.'}

+ `; + } + + function finishRound(reason) { + if (!state.running || state.roundEnded || !state.player) return; + state.running = false; + state.roundEnded = true; + state.endTime = performance.now(); + state.matchSummary = getScoreSnapshot(); + saveButton.disabled = false; + updateHud(); + updateSummary(); + const messages = { + victory: 'Victory. Arena cleared.', + timeout: 'Round complete. Timer expired.', + defeat: 'Round failed. Restart to try again.' + }; + state.message = messages[reason] || 'Round complete.'; + state.noticeUntil = performance.now() + 3200; + showToast(messages[reason] || 'Round complete.'); + } + + function shoot(now) { + if (!state.running || !state.player) return; + if (now < state.nextShotAt) return; + if (now < state.reloadingUntil) return; + if (state.player.ammo <= 0) { + reload(); + return; + } + + state.player.ammo -= 1; + state.nextShotAt = now + state.weapon.fireRate; + state.recoil = Math.min(0.11, state.recoil + state.weapon.recoil); + state.flashUntil = now + 55; + state.player.shotsFired = (state.player.shotsFired || 0) + 1; + + const pellets = state.weapon.pellets || 1; + for (let i = 0; i < pellets; i += 1) { + const offset = randomBetween(-state.weapon.spread, state.weapon.spread); + const rayAngle = state.player.angle + offset; + let bestTarget = null; + let bestDistance = Infinity; + + state.bots.forEach((bot) => { + const dx = bot.x - state.player.x; + const dy = bot.y - state.player.y; + const dist = Math.hypot(dx, dy); + if (dist > state.weapon.range) return; + const diff = Math.abs(normalizeAngle(Math.atan2(dy, dx) - rayAngle)); + const threshold = Math.atan2(bot.radius, Math.max(dist, 1)) * 1.2 + state.weapon.spread * 0.4; + if (diff < threshold && dist < bestDistance) { + bestTarget = bot; + bestDistance = dist; + } + }); + + if (bestTarget) { + bestTarget.hp -= state.weapon.damage; + bestTarget.hurtUntil = now + 90; + state.player.shotsHit = (state.player.shotsHit || 0) + 1; + if (bestTarget.hp <= 0) { + state.bots = state.bots.filter((bot) => bot !== bestTarget); + state.message = 'Target down.'; + state.noticeUntil = now + 900; + if (state.bots.length === 0) { + finishRound('victory'); + } + } + } + } + + updateHud(); + } + + function reload() { + if (!state.player || !state.running) return; + if (state.player.ammo >= state.weapon.magSize) return; + if (state.player.reserve <= 0) return; + if (performance.now() < state.reloadingUntil) return; + state.reloadingUntil = performance.now() + state.weapon.reloadTime; + state.message = 'Reloading…'; + state.noticeUntil = performance.now() + 1000; + showToast('Reloading.'); + } + + function completeReload(now) { + if (!state.player || !state.running) return; + if (state.reloadingUntil && now >= state.reloadingUntil) { + const needed = state.weapon.magSize - state.player.ammo; + const transfer = Math.min(needed, state.player.reserve); + state.player.ammo += transfer; + state.player.reserve -= transfer; + state.reloadingUntil = 0; + updateHud(); + } + } + + function getBotAvoidance(bot) { + let avoidX = 0; + let avoidY = 0; + + state.bots.forEach((other) => { + if (other === bot) return; + const dx = bot.x - other.x; + const dy = bot.y - other.y; + const dist = Math.hypot(dx, dy) || 1; + const desired = bot.radius + other.radius + 34; + if (dist < desired) { + const push = (desired - dist) / desired; + avoidX += (dx / dist) * push; + avoidY += (dy / dist) * push; + } + }); + + state.props.forEach((prop) => { + const dx = bot.x - prop.x; + const dy = bot.y - prop.y; + const dist = Math.hypot(dx, dy) || 1; + const desired = prop.size * 0.85 + bot.radius + 36; + if (dist < desired) { + const push = (desired - dist) / desired; + avoidX += (dx / dist) * push * 1.35; + avoidY += (dy / dist) * push * 1.35; + } + }); + + return { x: avoidX, y: avoidY }; + } + + function getPrimaryThreat() { + if (!state.player || !state.bots.length) return null; + let best = null; + let bestScore = -Infinity; + const centerX = canvas.width * 0.5; + + state.bots.forEach((bot) => { + const projection = projectObject(bot); + if (!projection) return; + const screenOffset = Math.abs(projection.screenX - centerX) / centerX; + const rangeFactor = 1 - clamp(projection.distance / Math.max(state.weapon.range, 1), 0, 1); + const score = (1 - screenOffset) * 0.72 + rangeFactor * 0.28; + if (score > bestScore) { + bestScore = score; + best = { bot, projection, score }; + } + }); + + return best; + } + + function attemptAutoFire(now) { + if (!state.running || !state.player) return; + if (!(state.mouseTriggerHeld || state.spaceTriggerHeld)) return; + if (!pointer.active && !state.spaceTriggerHeld) return; + shoot(now); + } + + function updateBots(delta, now) { + if (!state.player) return; + state.bots.forEach((bot) => { + const dx = state.player.x - bot.x; + const dy = state.player.y - bot.y; + const dist = Math.hypot(dx, dy); + const targetAngleToPlayer = Math.atan2(dy, dx); + const dirX = dx / (dist || 1); + const dirY = dy / (dist || 1); + const sideX = -dirY; + const sideY = dirX; + const avoidance = getBotAvoidance(bot); + + bot.attackCooldown -= delta; + bot.turnTimer -= delta; + bot.strafeTimer -= delta; + bot.dodgeCooldown -= delta; + let targetAngle = bot.wanderAngle; + let moveX = 0; + let moveY = 0; + let speedFactor = 0.5; + + if (dist < 620) { + if (bot.strafeTimer <= 0) { + bot.strafeTimer = randomBetween(0.45, 1.25); + bot.strafeDir = Math.random() < 0.5 ? -1 : 1; + } + + let forwardBias = 0; + if (dist > bot.preferredRange + 65) { + forwardBias = 1; + bot.state = 'chase'; + } else if (dist < bot.preferredRange - 35) { + forwardBias = -0.78; + bot.state = 'retreat'; + } else { + forwardBias = 0.18; + bot.state = 'strafe'; + } + + let strafeBias = bot.strafeDir * (dist < 300 ? 0.92 : 0.6); + if (bot.dodgeCooldown <= 0 && dist < 320) { + bot.dodgeCooldown = randomBetween(1.1, 2.1); + bot.strafeDir = Math.random() < 0.5 ? -1 : 1; + strafeBias = bot.strafeDir * 1.35; + bot.state = 'dodge'; + } + + moveX = dirX * forwardBias + sideX * strafeBias + avoidance.x * 1.8; + moveY = dirY * forwardBias + sideY * strafeBias + avoidance.y * 1.8; + speedFactor = bot.state === 'dodge' ? 1.18 : dist > bot.preferredRange ? 1.02 : 0.88; + targetAngle = targetAngleToPlayer; + } else { + bot.state = 'patrol'; + if (bot.turnTimer <= 0) { + bot.turnTimer = randomBetween(0.8, 2.4); + bot.wanderAngle += randomBetween(-1.15, 1.15); + } + moveX = Math.cos(bot.wanderAngle) * 0.65 + avoidance.x * 1.4; + moveY = Math.sin(bot.wanderAngle) * 0.65 + avoidance.y * 1.4; + speedFactor = 0.42; + targetAngle = bot.wanderAngle; + } + + const currentTerrain = bot.terrainHeight ?? terrainHeightAt(bot.x, bot.y); + const moveLength = Math.hypot(moveX, moveY) || 1; + const intendedSpeed = bot.speed * bot.aggression * speedFactor; + const trialX = bot.x + (moveX / moveLength) * intendedSpeed * delta; + const trialY = bot.y + (moveY / moveLength) * intendedSpeed * delta; + const nextTerrain = terrainHeightAt(trialX, trialY); + const climbDelta = nextTerrain - currentTerrain; + const slopeFactor = climbDelta > 0 ? clamp(1 - climbDelta / 150, 0.7, 1) : clamp(1 + Math.abs(climbDelta) / 320, 1, 1.06); + + bot.x += (moveX / moveLength) * intendedSpeed * slopeFactor * delta; + bot.y += (moveY / moveLength) * intendedSpeed * slopeFactor * delta; + + if (bot.x < 80 || bot.x > WORLD.width - 80) bot.wanderAngle = Math.PI - bot.wanderAngle; + if (bot.y < 80 || bot.y > WORLD.height - 80) bot.wanderAngle = -bot.wanderAngle; + bot.x = clamp(bot.x, 80, WORLD.width - 80); + bot.y = clamp(bot.y, 80, WORLD.height - 80); + bot.terrainHeight = mix(currentTerrain, terrainHeightAt(bot.x, bot.y), Math.min(1, delta * 7)); + bot.angle = normalizeAngle(bot.angle + normalizeAngle(targetAngle - bot.angle) * Math.min(1, delta * 8.4)); + + const aimError = Math.abs(normalizeAngle(targetAngleToPlayer - bot.angle)); + const canShoot = dist < 320 && aimError < 0.44; + if (canShoot && bot.attackCooldown <= 0 && state.running) { + const damage = randomBetween(4, 9); + state.player.health = clamp(state.player.health - damage, 0, 100); + state.player.damageTaken += damage; + bot.attackCooldown = randomBetween(0.42, 0.95); + bot.shootFlashUntil = now + 90; + state.message = bot.state === 'dodge' ? 'Enemy strafe fire incoming.' : 'Taking fire.'; + state.noticeUntil = now + 700; + updateHud(); + if (state.player.health <= 0) { + finishRound('defeat'); + } + } + }); + } + + function updatePlayer(delta) { + if (!state.player || !state.running) return; + const rotationSpeed = 2.2; + const pitchSpeed = 1.55; + if (keys.ArrowLeft) state.player.angle -= rotationSpeed * delta; + if (keys.ArrowRight) state.player.angle += rotationSpeed * delta; + if (keys.ArrowUp) state.player.pitch = clamp(state.player.pitch + pitchSpeed * delta, -MAX_PITCH, MAX_PITCH); + if (keys.ArrowDown) state.player.pitch = clamp(state.player.pitch - pitchSpeed * delta, -MAX_PITCH, MAX_PITCH); + + let moveX = 0; + let moveY = 0; + const forwardX = Math.cos(state.player.angle); + const forwardY = Math.sin(state.player.angle); + const sideX = Math.cos(state.player.angle + Math.PI / 2); + const sideY = Math.sin(state.player.angle + Math.PI / 2); + + if (keys.KeyW) { + moveX += forwardX; + moveY += forwardY; + } + if (keys.KeyS) { + moveX -= forwardX; + moveY -= forwardY; + } + if (keys.KeyA) { + moveX -= sideX; + moveY -= sideY; + } + if (keys.KeyD) { + moveX += sideX; + moveY += sideY; + } + + const length = Math.hypot(moveX, moveY); + if (length > 0) { + const currentTerrain = state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y); + const stepX = (moveX / length) * state.player.speed * delta; + const stepY = (moveY / length) * state.player.speed * delta; + const trialX = clamp(state.player.x + stepX, 70, WORLD.width - 70); + const trialY = clamp(state.player.y + stepY, 70, WORLD.height - 70); + const nextTerrain = terrainHeightAt(trialX, trialY); + const climbDelta = nextTerrain - currentTerrain; + const slopeFactor = climbDelta > 0 ? clamp(1 - climbDelta / 160, 0.68, 1) : clamp(1 + Math.abs(climbDelta) / 340, 1, 1.08); + + state.player.x = clamp(state.player.x + stepX * slopeFactor, 70, WORLD.width - 70); + state.player.y = clamp(state.player.y + stepY * slopeFactor, 70, WORLD.height - 70); + state.player.terrainHeight = mix(currentTerrain, terrainHeightAt(state.player.x, state.player.y), Math.min(1, delta * 8)); + } else { + state.player.terrainHeight = mix(state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y), terrainHeightAt(state.player.x, state.player.y), Math.min(1, delta * 5)); + } + } + + function drawBackground(width, height) { + ctx.clearRect(0, 0, width, height); + const view = getViewMetrics(); + const horizonY = view.horizonY; + const floorY = view.floorY; + + const skyGradient = ctx.createLinearGradient(0, 0, 0, horizonY); + skyGradient.addColorStop(0, '#67b7ff'); + skyGradient.addColorStop(0.52, '#8fd3ff'); + skyGradient.addColorStop(1, '#d8f2ff'); + ctx.fillStyle = skyGradient; + ctx.fillRect(0, 0, width, horizonY); + + const sunX = width * 0.82; + const sunY = horizonY * 0.25; + const sunGlow = ctx.createRadialGradient(sunX, sunY, 8, sunX, sunY, 86); + sunGlow.addColorStop(0, 'rgba(255, 244, 167, 0.95)'); + sunGlow.addColorStop(0.45, 'rgba(255, 225, 124, 0.42)'); + sunGlow.addColorStop(1, 'rgba(255, 225, 124, 0)'); + ctx.fillStyle = sunGlow; + ctx.beginPath(); + ctx.arc(sunX, sunY, 86, 0, Math.PI * 2); + ctx.fill(); + + const waterGradient = ctx.createLinearGradient(0, horizonY - 16, 0, floorY - 8); + waterGradient.addColorStop(0, '#7fd7e6'); + waterGradient.addColorStop(1, '#4aa8bc'); + ctx.fillStyle = waterGradient; + ctx.fillRect(0, horizonY - 8, width, Math.max(12, floorY - horizonY - 22)); + + const farHillLayers = [ + { base: horizonY + 8, ampA: 16, ampB: 11, freqA: 170, freqB: 95, color: '#7dbb78' }, + { base: horizonY + 36, ampA: 22, ampB: 16, freqA: 138, freqB: 82, color: '#5e9d57' }, + { base: horizonY + 74, ampA: 28, ampB: 18, freqA: 116, freqB: 70, color: '#4d8447' } + ]; + + farHillLayers.forEach((layer) => { + ctx.fillStyle = layer.color; + ctx.beginPath(); + ctx.moveTo(0, height); + for (let x = 0; x <= width; x += 22) { + const ridgeY = layer.base - Math.sin(x / layer.freqA) * layer.ampA - Math.cos((x + view.elevation * 5) / layer.freqB) * layer.ampB; + ctx.lineTo(x, ridgeY); + } + ctx.lineTo(width, height); + ctx.closePath(); + ctx.fill(); + }); + + const grassGradient = ctx.createLinearGradient(0, floorY - 10, 0, height); + grassGradient.addColorStop(0, '#8fd162'); + grassGradient.addColorStop(0.42, '#5fb141'); + grassGradient.addColorStop(1, '#2f6d26'); + ctx.fillStyle = grassGradient; + ctx.fillRect(0, floorY - 10, width, height - floorY + 10); + + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 1.3; + for (let i = 0; i < 7; i += 1) { + const stripeY = floorY + i * 38; + const stripeCurve = Math.sin((i + 1) * 0.7 + view.elevation * 0.015) * 28; + ctx.beginPath(); + ctx.moveTo(0, stripeY + stripeCurve); + ctx.quadraticCurveTo(width * 0.45, stripeY - 18, width, stripeY + 10 - stripeCurve * 0.2); + ctx.stroke(); + } + + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + [[0.18, 0.16, 0.11], [0.54, 0.22, 0.14], [0.72, 0.11, 0.1]].forEach(([x, y, s]) => { + const cloudX = width * x; + const cloudY = horizonY * y + 18; + ctx.beginPath(); + ctx.ellipse(cloudX, cloudY, width * s * 0.34, 18, 0, 0, Math.PI * 2); + ctx.ellipse(cloudX + 28, cloudY - 8, width * s * 0.24, 15, 0, 0, Math.PI * 2); + ctx.ellipse(cloudX - 26, cloudY - 4, width * s * 0.21, 13, 0, 0, Math.PI * 2); + ctx.fill(); + }); + } + + function projectObject(obj) { + const dx = obj.x - state.player.x; + const dy = obj.y - state.player.y; + const distanceToPlayer = Math.hypot(dx, dy); + const relAngle = normalizeAngle(Math.atan2(dy, dx) - state.player.angle); + if (distanceToPlayer < 1 || Math.abs(relAngle) > FOV * 0.75) return null; + const perspective = Math.tan(relAngle) / Math.tan(FOV / 2); + const screenX = canvas.width * 0.5 + perspective * canvas.width * 0.5; + const view = getViewMetrics(); + const objectTerrain = obj.terrainHeight ?? terrainHeightAt(obj.x, obj.y); + const playerTerrain = state.player?.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y); + const elevationDelta = objectTerrain - playerTerrain; + const verticalFactor = clamp(540 / Math.max(distanceToPlayer, 120), 0.24, 1.7); + const floorY = view.floorY - elevationDelta * verticalFactor; + const size = clamp((32000 / distanceToPlayer) * (1 + elevationDelta / 1400), 28, 360); + return { screenX, size, distance: distanceToPlayer, relAngle, floorY, elevationDelta }; + } + + function drawProps() { + const projected = state.props + .map((prop) => ({ prop, p: projectObject(prop) })) + .filter((item) => item.p) + .sort((a, b) => b.p.distance - a.p.distance); + + projected.forEach(({ prop, p }) => { + const floorY = p.floorY; + if (prop.type === 'pine') { + const trunkHeight = p.size * 0.34; + const crownHeight = p.size * 0.82; + const crownWidth = p.size * 0.58; + drawGroundShadow(p.screenX, floorY + p.size * 0.05, p.size * 0.2, p.size * 0.06, 0.18); + ctx.fillStyle = '#6b4f2a'; + ctx.fillRect(p.screenX - p.size * 0.05, floorY - trunkHeight, p.size * 0.1, trunkHeight); + ctx.fillStyle = '#205e2b'; + ctx.beginPath(); + ctx.moveTo(p.screenX, floorY - crownHeight); + ctx.lineTo(p.screenX - crownWidth * 0.55, floorY - trunkHeight * 0.32); + ctx.lineTo(p.screenX + crownWidth * 0.55, floorY - trunkHeight * 0.32); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#2d7a33'; + ctx.beginPath(); + ctx.moveTo(p.screenX, floorY - crownHeight * 0.74); + ctx.lineTo(p.screenX - crownWidth * 0.42, floorY - trunkHeight * 0.04); + ctx.lineTo(p.screenX + crownWidth * 0.42, floorY - trunkHeight * 0.04); + ctx.closePath(); + ctx.fill(); + } else { + const rockWidth = p.size * 0.62; + const rockHeight = p.size * 0.4; + const rockY = floorY - rockHeight; + const rockGradient = ctx.createLinearGradient(p.screenX, rockY, p.screenX, floorY); + rockGradient.addColorStop(0, '#94a3b8'); + rockGradient.addColorStop(1, '#475569'); + drawGroundShadow(p.screenX, floorY + p.size * 0.04, rockWidth * 0.62, p.size * 0.06, 0.18); + ctx.fillStyle = rockGradient; + ctx.beginPath(); + ctx.moveTo(p.screenX - rockWidth * 0.52, floorY); + ctx.quadraticCurveTo(p.screenX - rockWidth * 0.66, rockY + rockHeight * 0.44, p.screenX - rockWidth * 0.22, rockY + rockHeight * 0.08); + ctx.quadraticCurveTo(p.screenX - rockWidth * 0.02, rockY - rockHeight * 0.06, p.screenX + rockWidth * 0.2, rockY + rockHeight * 0.04); + ctx.quadraticCurveTo(p.screenX + rockWidth * 0.68, rockY + rockHeight * 0.28, p.screenX + rockWidth * 0.52, floorY); + ctx.closePath(); + ctx.fill(); + } + }); + } + + function drawLimb(x1, y1, x2, y2, width, color) { + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + function drawGroundShadow(x, y, rx, ry, alpha = 0.2) { + ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`; + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); + ctx.fill(); + } + + function drawBots(now) { + const projected = state.bots + .map((bot) => ({ bot, p: projectObject(bot) })) + .filter((item) => item.p) + .sort((a, b) => b.p.distance - a.p.distance); + + projected.forEach(({ bot, p }) => { + const floorY = p.floorY; + const scale = bot.bodyScale || 1; + const headRadius = p.size * 0.1 * scale; + const shoulderY = floorY - p.size * 0.46 * scale; + const torsoHeight = p.size * 0.28 * scale; + const torsoWidth = p.size * 0.23 * scale; + const hipY = shoulderY + torsoHeight * 0.92; + const movingAggressively = ['chase', 'strafe', 'dodge', 'retreat'].includes(bot.state); + const stride = Math.sin(now / (movingAggressively ? 88 : 148) + bot.strideOffset) * p.size * (bot.state === 'dodge' ? 0.11 : movingAggressively ? 0.07 : 0.04); + const facing = Math.sin(bot.angle - state.player.angle); + const centerX = p.screenX + facing * p.size * 0.04; + const legSpread = p.size * 0.08; + const footY = floorY + p.size * 0.12; + const leftFootX = centerX - legSpread - stride; + const rightFootX = centerX + legSpread + stride; + const legWidth = Math.max(4, p.size * 0.05); + const armWidth = Math.max(4, p.size * 0.045); + const hurt = now < bot.hurtUntil; + const skinTone = bot.silhouette?.skin || '#d6a07a'; + const jacket = hurt ? '#fca5a5' : (bot.silhouette?.jacket || '#d9685f'); + const jacketAccent = hurt ? '#ef4444' : (bot.silhouette?.accent || '#7f1d1d'); + const pants = hurt ? '#7f1d1d' : (bot.silhouette?.pants || '#1f2937'); + const headY = shoulderY - headRadius * 0.62; + const aimDirX = Math.cos(bot.angle) * p.size * 0.26; + const aimDirY = Math.sin(bot.angle) * p.size * 0.08; + const rifleBaseX = centerX + torsoWidth * 0.12; + const rifleBaseY = shoulderY + torsoHeight * 0.42; + const muzzleX = rifleBaseX + aimDirX; + const muzzleY = rifleBaseY + aimDirY; + const leftHandX = rifleBaseX - torsoWidth * 0.36; + const leftHandY = rifleBaseY + torsoHeight * 0.14; + const rightHandX = rifleBaseX + torsoWidth * 0.14; + const rightHandY = rifleBaseY - torsoHeight * 0.04; + + drawGroundShadow(centerX, footY + p.size * 0.02, p.size * 0.18, p.size * 0.05, 0.18); + drawLimb(centerX - legSpread * 0.45, hipY, leftFootX, footY, legWidth, pants); + drawLimb(centerX + legSpread * 0.45, hipY, rightFootX, footY, legWidth, pants); + drawLimb(leftFootX - p.size * 0.02, footY, leftFootX + p.size * 0.03, footY, Math.max(4, p.size * 0.042), '#020617'); + drawLimb(rightFootX - p.size * 0.02, footY, rightFootX + p.size * 0.03, footY, Math.max(4, p.size * 0.042), '#020617'); + + const torsoGradient = ctx.createLinearGradient(centerX, shoulderY, centerX, shoulderY + torsoHeight); + torsoGradient.addColorStop(0, jacket); + torsoGradient.addColorStop(1, jacketAccent); + ctx.fillStyle = torsoGradient; + ctx.fillRect(centerX - torsoWidth / 2, shoulderY, torsoWidth, torsoHeight); + ctx.fillStyle = 'rgba(255,255,255,0.14)'; + ctx.fillRect(centerX - torsoWidth * 0.18, shoulderY + torsoHeight * 0.18, torsoWidth * 0.36, torsoHeight * 0.3); + ctx.strokeStyle = 'rgba(255,255,255,0.16)'; + ctx.lineWidth = Math.max(1, p.size * 0.008); + ctx.strokeRect(centerX - torsoWidth / 2, shoulderY, torsoWidth, torsoHeight); + + drawLimb(centerX - torsoWidth * 0.42, shoulderY + torsoHeight * 0.22, leftHandX, leftHandY, armWidth, jacket); + drawLimb(centerX + torsoWidth * 0.38, shoulderY + torsoHeight * 0.18, rightHandX, rightHandY, armWidth, jacket); + + ctx.fillStyle = skinTone; + ctx.beginPath(); + ctx.arc(leftHandX, leftHandY, Math.max(3, p.size * 0.03), 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(rightHandX, rightHandY, Math.max(3, p.size * 0.03), 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = skinTone; + ctx.beginPath(); + ctx.arc(centerX, headY, headRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#0f172a'; + ctx.beginPath(); + ctx.arc(centerX, headY - headRadius * 0.18, headRadius * 0.98, Math.PI, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.fillRect(centerX - headRadius * 0.56, headY - headRadius * 0.12, headRadius * 1.12, headRadius * 0.2); + ctx.fillStyle = '#475569'; + ctx.fillRect(centerX - torsoWidth * 0.12, shoulderY - headRadius * 0.18, torsoWidth * 0.24, headRadius * 0.3); + + ctx.strokeStyle = '#020617'; + ctx.lineWidth = Math.max(4, p.size * 0.055); + ctx.beginPath(); + ctx.moveTo(rifleBaseX - torsoWidth * 0.42, rifleBaseY + torsoHeight * 0.08); + ctx.lineTo(muzzleX, muzzleY); + ctx.stroke(); + ctx.strokeStyle = '#64748b'; + ctx.lineWidth = Math.max(3, p.size * 0.03); + ctx.beginPath(); + ctx.moveTo(rifleBaseX - torsoWidth * 0.04, rifleBaseY - torsoHeight * 0.04); + ctx.lineTo(rifleBaseX + torsoWidth * 0.34, rifleBaseY + torsoHeight * 0.02); + ctx.stroke(); + + if (now < bot.shootFlashUntil) { + ctx.fillStyle = '#fde68a'; + ctx.beginPath(); + ctx.arc(muzzleX, muzzleY, p.size * 0.06, 0, Math.PI * 2); + ctx.fill(); + } + + const hpWidth = torsoWidth * 1.2; + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(centerX - hpWidth / 2, headY - headRadius - 16, hpWidth, 5); + ctx.fillStyle = '#34d399'; + ctx.fillRect(centerX - hpWidth / 2, headY - headRadius - 16, hpWidth * clamp(bot.hp / 95, 0, 1), 5); + }); + } + + function drawWeapon(now) { + const width = canvas.width; + const height = canvas.height; + const moving = Boolean(keys.KeyW || keys.KeyA || keys.KeyS || keys.KeyD); + const bob = state.running ? Math.sin(now / (moving ? 112 : 190)) * (moving ? 7 : 2.4) : 0; + const sway = state.running ? Math.cos(now / (moving ? 148 : 240)) * (moving ? 10 : 4) : 0; + const recoilLift = state.recoil * 240; + const target = getPrimaryThreat(); + const targetBias = target ? clamp(-target.projection.relAngle / (FOV * 0.45), -1, 1) : 0; + const targetLift = target ? clamp((1 - target.projection.distance / Math.max(state.weapon.range, 1)) * 14, 0, 14) : 0; + const gunX = width * 0.54 + sway - recoilLift * 0.28 + targetBias * 34; + const gunY = height * 0.83 + bob + recoilLift * 0.2 - targetLift; + const sleeveColor = '#334155'; + const skinTone = '#d6a07a'; + const gloveColor = '#0f172a'; + const barrelLengthByWeapon = { carbine: 116, smg: 92, shotgun: 102, marksman: 144 }; + const barrelLength = barrelLengthByWeapon[state.weapon.key] || 112; + const stockLength = state.weapon.key === 'marksman' ? 42 : 34; + const movingHandLift = moving ? Math.sin(now / 140) * 4 : 0; + const pitchFactor = state.player ? clamp((state.player.pitch || 0) / MAX_PITCH, -1, 1) : 0; + const pitchLift = pitchFactor * 46; + const weaponRotation = targetBias * 0.14 - state.recoil * 0.3 - pitchFactor * 0.08; + + drawGroundShadow(width * 0.5 + targetBias * 24, height * 0.965, width * 0.2, height * 0.02, 0.12); + + ctx.save(); + ctx.translate(gunX, gunY + pitchLift); + ctx.rotate(weaponRotation); + + drawLimb(-276, 188, -128, 86, 28, sleeveColor); + drawLimb(-128, 86, -64, 24 + movingHandLift, 24, skinTone); + drawLimb(204, 188, 58, 68, 28, sleeveColor); + drawLimb(58, 68, 18, 10, 24, skinTone); + + ctx.fillStyle = gloveColor; + ctx.beginPath(); + ctx.arc(-62, 20 + movingHandLift, 13, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(18, 8, 13, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#dbe2ea'; + ctx.fillRect(-36, -34, 98, 30); + ctx.fillStyle = '#9aa4b2'; + ctx.fillRect(-stockLength, -22, stockLength, 22); + ctx.fillStyle = '#cbd5e1'; + ctx.fillRect(20, -20, barrelLength, 12); + ctx.fillStyle = '#64748b'; + ctx.fillRect(2, -46, 34, 12); + ctx.fillStyle = '#0f172a'; + ctx.fillRect(-6, -4, 24, 48); + ctx.fillRect(52, -12, 18, 18); + ctx.fillStyle = '#475569'; + ctx.fillRect(-12, 8, 18, 48); + ctx.fillStyle = '#1e293b'; + ctx.fillRect(barrelLength - 6, -22, 16, 10); + ctx.fillRect(4, -44, 10, 18); + + if (now < state.flashUntil) { + ctx.fillStyle = '#fde68a'; + ctx.beginPath(); + ctx.moveTo(barrelLength + 6, -16); + ctx.lineTo(barrelLength + 38, -2); + ctx.lineTo(barrelLength + 10, 8); + ctx.lineTo(barrelLength + 26, 26); + ctx.lineTo(barrelLength - 2, 12); + ctx.closePath(); + ctx.fill(); + } + + ctx.restore(); + } + + function drawCrosshair() { + const cx = canvas.width / 2; + const cy = canvas.height / 2; + ctx.strokeStyle = 'rgba(255,255,255,0.78)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(cx - 12, cy); + ctx.lineTo(cx - 4, cy); + ctx.moveTo(cx + 4, cy); + ctx.lineTo(cx + 12, cy); + ctx.moveTo(cx, cy - 12); + ctx.lineTo(cx, cy - 4); + ctx.moveTo(cx, cy + 4); + ctx.lineTo(cx, cy + 12); + ctx.stroke(); + } + + function drawRadar() { + const size = 130; + const padding = 18; + const x = canvas.width - size - padding; + const y = padding; + ctx.fillStyle = 'rgba(11,15,20,0.7)'; + ctx.fillRect(x, y, size, size); + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.strokeRect(x, y, size, size); + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.beginPath(); + ctx.moveTo(x + size / 2, y); + ctx.lineTo(x + size / 2, y + size); + ctx.moveTo(x, y + size / 2); + ctx.lineTo(x + size, y + size / 2); + ctx.stroke(); + + ctx.fillStyle = '#dbe2ea'; + ctx.beginPath(); + ctx.arc(x + size / 2, y + size / 2, 4, 0, Math.PI * 2); + ctx.fill(); + + state.bots.forEach((bot) => { + const relX = (bot.x - state.player.x) / 12; + const relY = (bot.y - state.player.y) / 12; + const dotX = clamp(x + size / 2 + relX, x + 8, x + size - 8); + const dotY = clamp(y + size / 2 + relY, y + 8, y + size - 8); + ctx.fillStyle = '#f87171'; + ctx.beginPath(); + ctx.arc(dotX, dotY, 3, 0, Math.PI * 2); + ctx.fill(); + }); + } + + function drawStatus(now) { + if (now < state.noticeUntil) { + ctx.fillStyle = 'rgba(11,15,20,0.66)'; + ctx.fillRect(16, 16, 260, 42); + ctx.fillStyle = '#eef2f7'; + ctx.font = '600 16px Inter, system-ui, sans-serif'; + ctx.fillText(state.message, 30, 42); + } + if (performance.now() < state.reloadingUntil) { + ctx.fillStyle = '#fbbf24'; + ctx.font = '600 14px Inter, system-ui, sans-serif'; + ctx.fillText('Reloading…', canvas.width * 0.5 - 34, canvas.height - 110); + } + } + + function render(now) { + drawBackground(canvas.width, canvas.height); + if (!state.player) { + ctx.fillStyle = '#eef2f7'; + ctx.font = '600 30px Inter, system-ui, sans-serif'; + ctx.fillText('Configure your loadout to begin.', 54, 120); + ctx.font = '400 18px Inter, system-ui, sans-serif'; + ctx.fillStyle = '#9aa4b2'; + ctx.fillText('The first delivery includes gun selection, moving bots, round summaries, and saved match reports.', 54, 156); + return; + } + + drawProps(); + drawBots(now); + drawCrosshair(); + drawRadar(); + drawWeapon(now); + drawStatus(now); + + if (state.roundEnded) { + ctx.fillStyle = 'rgba(11,15,20,0.46)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#eef2f7'; + ctx.font = '700 34px Inter, system-ui, sans-serif'; + const title = state.matchSummary?.outcome === 'victory' ? 'Victory' : state.matchSummary?.outcome === 'timeout' ? 'Time up' : 'Defeat'; + ctx.fillText(title, canvas.width / 2 - 60, canvas.height / 2 - 20); + ctx.font = '400 18px Inter, system-ui, sans-serif'; + ctx.fillStyle = '#c7d0db'; + ctx.fillText('Review the summary on the right and save the result.', canvas.width / 2 - 170, canvas.height / 2 + 16); + } + } + + function tick(now) { + const delta = Math.min(0.032, (now - state.lastTick) / 1000); + state.lastTick = now; + if (state.recoil > 0) state.recoil = Math.max(0, state.recoil - delta * 0.14); + + if (state.player && state.running) { + updatePlayer(delta); + updateBots(delta, now); + completeReload(now); + attemptAutoFire(now); + const elapsed = (now - state.startTime) / 1000; + if (elapsed >= ROUND_DURATION) finishRound('timeout'); + updateHud(); + } + + render(now); + requestAnimationFrame(tick); + } + + async function fetchMatches() { try { - const response = await fetch('api/chat.php', { + const response = await fetch(`${apiUrl}?limit=8`, { headers: { 'Accept': 'application/json' } }); + const data = await response.json(); + if (!response.ok || !data.success) throw new Error(data.error || 'Unable to load matches.'); + state.renderMatches = data.matches || []; + renderMatchesList(); + } catch (error) { + showToast(error.message || 'Unable to refresh matches.'); + } + } + + function renderMatchesList() { + if (!matchesList) return; + const matches = state.renderMatches || []; + if (!matches.length) { + matchesList.innerHTML = ` +
+

No matches saved yet

+

Finish a round and use “Save result” to create the first combat report.

+
+ `; + return; + } + + matchesList.innerHTML = matches.map((match) => ` + +
+ #${match.id} • ${escapeHtml(match.player_name)} + ${escapeHtml(match.weapon_name)} • ${match.kills} kills • ${formatDate(match.created_at)} +
+
+ ${escapeHtml(match.outcome.toUpperCase())} + ${Number(match.score).toLocaleString()} +
+
+ `).join(''); + } + + function formatDate(value) { + const date = new Date(value.replace(' ', 'T') + 'Z'); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' UTC'; + } + + function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + async function saveMatch() { + if (!state.matchSummary || state.saveInFlight) return; + state.saveInFlight = true; + saveButton.disabled = true; + try { + const response = await fetch(apiUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(state.matchSummary) }); const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); + if (!response.ok || !data.success) throw new Error(data.error || 'Unable to save match.'); + updateSummary(data.match); + showToast(`Saved match #${data.match.id}.`); + await fetchMatches(); } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + saveButton.disabled = false; + showToast(error.message || 'Unable to save result.'); + } finally { + state.saveInFlight = false; + } + } + + weaponButtons.forEach((button) => { + button.addEventListener('click', () => setWeapon(button.dataset.weapon)); + }); + + loadoutForm?.addEventListener('submit', (event) => { + event.preventDefault(); + setWeapon(weaponInput.value || 'carbine'); + spawnRound(); + document.getElementById('arena')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + + restartButton?.addEventListener('click', spawnRound); + saveButton?.addEventListener('click', saveMatch); + refreshMatchesButton?.addEventListener('click', fetchMatches); + + canvas?.addEventListener('click', () => { + activatePointerLock(); + }); + + canvas?.addEventListener('mousemove', (event) => { + if (!state.player || !pointer.active) return; + state.player.angle = normalizeAngle(state.player.angle + event.movementX * pointer.sensitivity); + state.player.pitch = clamp(state.player.pitch - event.movementY * pointer.sensitivity, -MAX_PITCH, MAX_PITCH); + }); + + canvas?.addEventListener('mousedown', (event) => { + if (event.button !== 0) return; + if (!pointer.active) return; + state.mouseTriggerHeld = true; + shoot(performance.now()); + }); + + window.addEventListener('mouseup', (event) => { + if (event.button === 0) { + state.mouseTriggerHeld = false; } }); + + canvas?.addEventListener('contextmenu', (event) => event.preventDefault()); + + document.addEventListener('pointerlockchange', () => { + const locked = document.pointerLockElement === canvas; + pointer.locked = locked; + pointer.active = locked; + if (!locked) { + state.mouseTriggerHeld = false; + } + + if (locked) { + showToast('Mouse locked. Use WASD to move and mouse to look around.'); + } else if (state.running) { + showToast('Mouse released. Click the arena to aim again.'); + } + }); + + document.addEventListener('pointerlockerror', () => { + pointer.locked = false; + pointer.active = true; + showToast('Mouse lock unavailable. Aim while the arena stays focused.'); + }); + + window.addEventListener('blur', resetKeys); + document.addEventListener('visibilitychange', () => { + if (document.hidden) resetKeys(); + }); + + window.addEventListener('keydown', (event) => { + if (["KeyW","KeyA","KeyS","KeyD","ArrowLeft","ArrowRight","Space","KeyR"].includes(event.code)) { + event.preventDefault(); + } + keys[event.code] = true; + if (event.code === 'Space') { + state.spaceTriggerHeld = true; + shoot(performance.now()); + } + if (event.code === 'KeyR') reload(); + }); + + window.addEventListener('keyup', (event) => { + keys[event.code] = false; + if (event.code === 'Space') { + state.spaceTriggerHeld = false; + } + }); + + renderMatchesList(); + updateSummary(); + setWeapon('carbine'); + requestAnimationFrame(tick); }); diff --git a/game_bootstrap.php b/game_bootstrap.php new file mode 100644 index 0000000..f54cd80 --- /dev/null +++ b/game_bootstrap.php @@ -0,0 +1,67 @@ +exec($sql); + $ensured = true; +} + +function fetchRecentMatches(int $limit = 8): array +{ + ensureFpsMatchesTable(); + $limit = max(1, min($limit, 20)); + $stmt = db()->prepare( + 'SELECT id, player_name, weapon_key, weapon_name, kills, shots_fired, shots_hit, accuracy, damage_taken, duration_seconds, score, outcome, created_at + FROM fps_matches + ORDER BY created_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); +} + +function fetchMatchById(int $id): ?array +{ + ensureFpsMatchesTable(); + $stmt = db()->prepare( + 'SELECT id, player_name, weapon_key, weapon_name, kills, shots_fired, shots_hit, accuracy, damage_taken, duration_seconds, score, outcome, created_at + FROM fps_matches + WHERE id = :id + LIMIT 1' + ); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->execute(); + $row = $stmt->fetch(); + + return $row ?: null; +} diff --git a/index.php b/index.php index 7205f3d..98facfa 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,265 @@ - New Style - + <?= htmlspecialchars($projectName) ?> | Browser FPS Arena + - - - - - - - - - - + + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + + + +
+
+
+
+
+

Browser FPS MVP

+

Pick a gun, drop into the arena, and fight moving bots in your browser.

+

This first slice ships a full mini loop: choose a loadout, survive an active round, get a combat summary, save the result, and review past matches.

+
+ 4 weapon profiles + Moving bot AI + Round summary + saved scores + Canvas gameplay + PHP history +
+ +
+
+
+
+
+ Mode + Skirmish vs. bots +
+
+ Round length + 75 seconds +
+
+
+
+ Objective + Clear the squad or outscore the timer +
+
+ Controls + WASD • Mouse • R • Space +
+
+
+
+
+ Live prototype +

Built as a thin MVP slice so you can test feel, weapons, and bot movement before adding bigger maps.

+
+
+
+
+
+
+
+ +
+
+
+
+
+

Step 1

+

Set your operator profile

+

Choose a call sign and weapon preset. The selected loadout feeds directly into the live arena and the saved match record.

+
+
+
+
+
+
+ + +
Used in score history and match detail pages.
+
+
+ +
+ + + + +
+ +
+
+ + Round starts instantly in the arena panel below. +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+

Step 2

+

Arena skirmish

+
+
+ + +
+
+ +
+
OperatorOperator Nova
+
WeaponVX Carbine
+
Health100
+
Ammo30 / 120
+
Kills0
+
Score0
+
Time75s
+
+
+ Click inside the arena to lock the mouse. Use WASD to move and climb the hills, move the mouse to look up / down / left / right, use ← → plus ↑ ↓ as keyboard fallback, hold click/space for rapid fire, R to reload, and Esc to release aim. +
+
+
+
+
+
+

Step 3

+

Round summary

+
+

No round finished yet.

+

Start a round to generate a combat report, then save it into recent matches.

+
+
+
+

Combat cues

+
    +
  • The arena now has climbable hills, greener grass, and a brighter island-style sky palette.
  • +
  • Human-like enemy silhouettes still strafe, dodge, retreat, and chase across the new terrain.
  • +
  • Clearing all bots awards a bonus score and marks the round as victory.
  • +
+
+
+
+
+
+
+ +
+
+
+
+

Step 4

+

Recent match reports

+

Stored server-side with PHP and MariaDB so each round becomes a reviewable score card.

+
+ +
+
+
+ +
+

No matches saved yet

+

Finish a round and use “Save result” to create the first combat report.

+
+ + + +
+ # + kills • UTC +
+
+ + +
+
+ + +
+
+
+
-
- Page updated: (UTC) -
+ +
+
+
+
Ready.
+ +
+
+
+ + + + diff --git a/match.php b/match.php new file mode 100644 index 0000000..4f6841a --- /dev/null +++ b/match.php @@ -0,0 +1,103 @@ + ['min_range' => 1]]); +$match = $matchId ? fetchMatchById((int)$matchId) : null; +$pageTitle = $match ? sprintf('Match #%d • %s', (int)$match['id'], $projectName) : 'Match not found'; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + + +
+ +
+
+
+

Match detail

+

#

+

Recorded on UTC

+
+
+ +
+ score +
+
+
+
Kills
+
Accuracy%
+
Durations
+
Damage taken
+
+
+ +
+
+
+

Combat report

+
+ + + + + + + + + +
Operator
Weapon ()
Shots fired
Shots landed
Accuracy%
Outcome
+
+
+
+
+
+

What to try next

+
    +
  • Run another round with a different weapon profile.
  • +
  • Compare accuracy between shotgun, SMG, and marksman loadouts.
  • +
  • Ask for the next iteration: maps, cover objects, bot teams, or better hit effects.
  • +
+
+
+
+ +
+

Match detail

+

No match found

+

This score card does not exist yet, or the id is invalid.

+ Return to the arena +
+ +
+ +