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…
+
+
+
+
Strike Grid Arena
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
+
+
+
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 2
+
Arena skirmish
+
+
+ Restart
+ Save result
+
+
+
+
+
Operator Operator Nova
+
Weapon VX Carbine
+
Health 100
+
Ammo 30 / 120
+
Kills 0
+
Score 0
+
Time 75s
+
+
+ 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.
+
+
Refresh list
+
+
+
+
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
+
+
+
+
+
+
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
+
#= (int)$match['id'] ?> — = htmlspecialchars($match['player_name']) ?>
+
Recorded on = htmlspecialchars(date('M j, Y • H:i', strtotime((string)$match['created_at']))) ?> UTC
+
+
+
= htmlspecialchars(strtoupper((string)$match['outcome'])) ?>
+
= number_format((int)$match['score']) ?>
+
score
+
+
+
+
Kills = (int)$match['kills'] ?>
+
Accuracy = htmlspecialchars(number_format((float)$match['accuracy'], 1)) ?>%
+
Duration = (int)$match['duration_seconds'] ?>s
+
Damage taken = (int)$match['damage_taken'] ?>
+
+
+
+
+
+
+
Combat report
+
+
+
+ Operator = htmlspecialchars($match['player_name']) ?>
+ Weapon = htmlspecialchars($match['weapon_name']) ?> (= htmlspecialchars($match['weapon_key']) ?>)
+ Shots fired = (int)$match['shots_fired'] ?>
+ Shots landed = (int)$match['shots_hit'] ?>
+ Accuracy = htmlspecialchars(number_format((float)$match['accuracy'], 2)) ?>%
+ Outcome = htmlspecialchars(ucfirst((string)$match['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
+
+
+
+
+