Autosave: 20260330-185433
This commit is contained in:
parent
7030aad195
commit
95b94e590d
107
api/fps_matches.php
Normal file
107
api/fps_matches.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../game_bootstrap.php';
|
||||
|
||||
function jsonResponse(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function safeLength(string $value): int
|
||||
{
|
||||
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
|
||||
}
|
||||
|
||||
try {
|
||||
ensureFpsMatchesTable();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
if (isset($_GET['id'])) {
|
||||
$id = filter_var($_GET['id'], FILTER_VALIDATE_INT, ['options' => ['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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
1302
assets/js/main.js
1302
assets/js/main.js
File diff suppressed because it is too large
Load Diff
67
game_bootstrap.php
Normal file
67
game_bootstrap.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
function ensureFpsMatchesTable(): void
|
||||
{
|
||||
static $ensured = false;
|
||||
if ($ensured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS fps_matches (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
player_name VARCHAR(80) NOT NULL,
|
||||
weapon_key VARCHAR(40) NOT NULL,
|
||||
weapon_name VARCHAR(80) NOT NULL,
|
||||
kills INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
shots_fired INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
shots_hit INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
accuracy DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
damage_taken INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
duration_seconds INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
outcome VARCHAR(16) NOT NULL DEFAULT 'defeat',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_score (score)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL;
|
||||
|
||||
db()->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;
|
||||
}
|
||||
369
index.php
369
index.php
@ -1,150 +1,265 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/game_bootstrap.php';
|
||||
|
||||
ensureFpsMatchesTable();
|
||||
$recentMatches = fetchRecentMatches(6);
|
||||
|
||||
$projectName = trim((string)($_SERVER['PROJECT_NAME'] ?? 'Strike Grid Arena'));
|
||||
$projectDescription = trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? 'Browser FPS prototype with weapon switching, moving bots, round summaries, and stored match reports.'));
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
$buildStamp = (string)max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<title><?= htmlspecialchars($projectName) ?> | Browser FPS Arena</title>
|
||||
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<meta name="theme-color" content="#0b0f14" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode($buildStamp) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<body class="app-shell" data-bs-theme="dark">
|
||||
<nav class="navbar navbar-expand-lg border-bottom border-secondary-subtle bg-body-tertiary bg-opacity-75 sticky-top">
|
||||
<div class="container py-2">
|
||||
<a class="navbar-brand fw-semibold text-uppercase small-brand" href="/">Strike Grid Arena</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
<li class="nav-item"><a class="nav-link" href="#loadout">Loadout</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#arena">Arena</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#history">Recent matches</a></li>
|
||||
<li class="nav-item"><a class="btn btn-light btn-sm ms-lg-2" href="#arena">Play round</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="hero-section border-bottom border-secondary-subtle">
|
||||
<div class="container py-5 py-lg-6">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<p class="eyebrow mb-3">Browser FPS MVP</p>
|
||||
<h1 class="display-title mb-3">Pick a gun, drop into the arena, and fight moving bots in your browser.</h1>
|
||||
<p class="hero-copy mb-4">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.</p>
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<span class="surface-chip">4 weapon profiles</span>
|
||||
<span class="surface-chip">Moving bot AI</span>
|
||||
<span class="surface-chip">Round summary + saved scores</span>
|
||||
<span class="surface-chip">Canvas gameplay + PHP history</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<a class="btn btn-light" href="#loadout">Configure operator</a>
|
||||
<a class="btn btn-outline-light" href="#history">View score cards</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="surface-panel p-4">
|
||||
<div class="mini-report mb-4">
|
||||
<div>
|
||||
<span class="mini-label">Mode</span>
|
||||
<strong>Skirmish vs. bots</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mini-label">Round length</span>
|
||||
<strong>75 seconds</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-report mb-4">
|
||||
<div>
|
||||
<span class="mini-label">Objective</span>
|
||||
<strong>Clear the squad or outscore the timer</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mini-label">Controls</span>
|
||||
<strong>WASD • Mouse • R • Space</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-note">
|
||||
<div class="status-dot"></div>
|
||||
<div>
|
||||
<strong>Live prototype</strong>
|
||||
<p class="mb-0 text-secondary">Built as a thin MVP slice so you can test feel, weapons, and bot movement before adding bigger maps.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loadout" class="section-block border-bottom border-secondary-subtle">
|
||||
<div class="container py-5">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-xl-4">
|
||||
<div class="section-header sticky-lg-top top-offset">
|
||||
<p class="eyebrow mb-2">Step 1</p>
|
||||
<h2 class="h3 mb-3">Set your operator profile</h2>
|
||||
<p class="text-secondary mb-0">Choose a call sign and weapon preset. The selected loadout feeds directly into the live arena and the saved match record.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-8">
|
||||
<div class="surface-panel p-4 p-lg-5">
|
||||
<form id="loadout-form" class="row g-4" novalidate>
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="playerName" class="form-label">Call sign</label>
|
||||
<input type="text" class="form-control form-control-lg" id="playerName" name="player_name" maxlength="40" placeholder="Operator Nova" value="Operator Nova" required>
|
||||
<div class="form-text">Used in score history and match detail pages.</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<label class="form-label d-block">Weapon loadout</label>
|
||||
<div class="weapon-grid" id="weaponGrid" role="radiogroup" aria-label="Weapon presets">
|
||||
<button type="button" class="weapon-card active" data-weapon="carbine" aria-pressed="true">
|
||||
<span class="weapon-title">VX Carbine</span>
|
||||
<span class="weapon-meta">Balanced • 30 rounds • stable recoil</span>
|
||||
</button>
|
||||
<button type="button" class="weapon-card" data-weapon="smg" aria-pressed="false">
|
||||
<span class="weapon-title">Mako SMG</span>
|
||||
<span class="weapon-meta">Fast fire • close range • 36 rounds</span>
|
||||
</button>
|
||||
<button type="button" class="weapon-card" data-weapon="shotgun" aria-pressed="false">
|
||||
<span class="weapon-title">Breach-8</span>
|
||||
<span class="weapon-meta">Heavy spread • burst damage • 8 shells</span>
|
||||
</button>
|
||||
<button type="button" class="weapon-card" data-weapon="marksman" aria-pressed="false">
|
||||
<span class="weapon-title">Atlas DMR</span>
|
||||
<span class="weapon-meta">High damage • precise • 12 rounds</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="weaponInput" name="weapon_key" value="carbine">
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-wrap gap-2 align-items-center">
|
||||
<button type="submit" class="btn btn-light">Start round</button>
|
||||
<span class="text-secondary small">Round starts instantly in the arena panel below.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="arena" class="section-block border-bottom border-secondary-subtle">
|
||||
<div class="container py-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<div class="surface-panel p-3 p-lg-4 h-100">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<p class="eyebrow mb-1">Step 2</p>
|
||||
<h2 class="h4 mb-0">Arena skirmish</h2>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-light btn-sm" id="restartButton" type="button">Restart</button>
|
||||
<button class="btn btn-light btn-sm" id="saveButton" type="button" disabled>Save result</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="gameCanvas" width="1200" height="720" aria-label="FPS game arena" role="img" tabindex="0"></canvas>
|
||||
<div class="hud-strip mt-3" id="hudStrip">
|
||||
<div><span>Operator</span><strong id="hudName">Operator Nova</strong></div>
|
||||
<div><span>Weapon</span><strong id="hudWeapon">VX Carbine</strong></div>
|
||||
<div><span>Health</span><strong id="hudHealth">100</strong></div>
|
||||
<div><span>Ammo</span><strong id="hudAmmo">30 / 120</strong></div>
|
||||
<div><span>Kills</span><strong id="hudKills">0</strong></div>
|
||||
<div><span>Score</span><strong id="hudScore">0</strong></div>
|
||||
<div><span>Time</span><strong id="hudTime">75s</strong></div>
|
||||
</div>
|
||||
<div class="controls-note mt-3">
|
||||
<span>Click inside the arena to lock the mouse. Use <strong>WASD</strong> to move and climb the hills, move the <strong>mouse</strong> to look <strong>up / down / left / right</strong>, use <strong>← →</strong> plus <strong>↑ ↓</strong> as keyboard fallback, <strong>hold click/space</strong> for rapid fire, <strong>R</strong> to reload, and <strong>Esc</strong> to release aim.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="d-grid gap-4 h-100">
|
||||
<div class="surface-panel p-4">
|
||||
<p class="eyebrow mb-2">Step 3</p>
|
||||
<h2 class="h5 mb-3">Round summary</h2>
|
||||
<div id="summaryPanel" class="summary-state empty">
|
||||
<p class="summary-title">No round finished yet.</p>
|
||||
<p class="summary-copy mb-0 text-secondary">Start a round to generate a combat report, then save it into recent matches.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface-panel p-4">
|
||||
<h2 class="h5 mb-3">Combat cues</h2>
|
||||
<ul class="list-unstyled compact-list mb-0">
|
||||
<li>The arena now has climbable hills, greener grass, and a brighter island-style sky palette.</li>
|
||||
<li>Human-like enemy silhouettes still strafe, dodge, retreat, and chase across the new terrain.</li>
|
||||
<li>Clearing all bots awards a bonus score and marks the round as victory.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="history" class="section-block">
|
||||
<div class="container py-5">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Step 4</p>
|
||||
<h2 class="h3 mb-2">Recent match reports</h2>
|
||||
<p class="text-secondary mb-0">Stored server-side with PHP and MariaDB so each round becomes a reviewable score card.</p>
|
||||
</div>
|
||||
<button class="btn btn-outline-light btn-sm" id="refreshMatchesButton" type="button">Refresh list</button>
|
||||
</div>
|
||||
<div class="surface-panel p-0 overflow-hidden">
|
||||
<div id="matchesList" class="matches-list">
|
||||
<?php if (!$recentMatches): ?>
|
||||
<div class="empty-state p-5 text-center">
|
||||
<h3 class="h5 mb-2">No matches saved yet</h3>
|
||||
<p class="text-secondary mb-0">Finish a round and use “Save result” to create the first combat report.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentMatches as $match): ?>
|
||||
<a class="match-row" href="/match.php?id=<?= (int)$match['id'] ?>">
|
||||
<div>
|
||||
<strong>#<?= (int)$match['id'] ?> • <?= htmlspecialchars((string)$match['player_name']) ?></strong>
|
||||
<span><?= htmlspecialchars((string)$match['weapon_name']) ?> • <?= (int)$match['kills'] ?> kills • <?= htmlspecialchars(date('M j, H:i', strtotime((string)$match['created_at']))) ?> UTC</span>
|
||||
</div>
|
||||
<div class="match-score-wrap">
|
||||
<span class="outcome-tag <?= htmlspecialchars((string)$match['outcome']) ?>"><?= htmlspecialchars(strtoupper((string)$match['outcome'])) ?></span>
|
||||
<strong><?= number_format((int)$match['score']) ?></strong>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="appToast" class="toast align-items-center text-bg-dark border border-secondary-subtle" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="toastMessage">Ready.</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__FPS_BOOTSTRAP__ = {
|
||||
apiUrl: '/api/fps_matches.php',
|
||||
initialMatches: <?= json_encode($recentMatches, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
<script src="/assets/js/main.js?v=<?= urlencode($buildStamp) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
103
match.php
Normal file
103
match.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/game_bootstrap.php';
|
||||
|
||||
ensureFpsMatchesTable();
|
||||
|
||||
$projectName = trim((string)($_SERVER['PROJECT_NAME'] ?? 'Strike Grid Arena'));
|
||||
$projectDescription = trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? 'Browser FPS prototype with multiple guns, moving bots, and score tracking.'));
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
|
||||
$matchId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
$match = $matchId ? fetchMatchById((int)$matchId) : null;
|
||||
$pageTitle = $match ? sprintf('Match #%d • %s', (int)$match['id'], $projectName) : 'Match not found';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||
<?php if ($projectDescription): ?>
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode((string)@filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
|
||||
</head>
|
||||
<body class="app-shell" data-bs-theme="dark">
|
||||
<nav class="navbar navbar-expand-lg border-bottom border-secondary-subtle bg-body-tertiary bg-opacity-75 sticky-top">
|
||||
<div class="container py-2">
|
||||
<a class="navbar-brand fw-semibold text-uppercase small-brand" href="/">Strike Grid Arena</a>
|
||||
<a class="btn btn-outline-light btn-sm" href="/">Back to arena</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container py-5">
|
||||
<?php if ($match): ?>
|
||||
<section class="surface-panel p-4 p-lg-5 mb-4">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Match detail</p>
|
||||
<h1 class="h2 mb-2">#<?= (int)$match['id'] ?> — <?= htmlspecialchars($match['player_name']) ?></h1>
|
||||
<p class="text-secondary mb-0">Recorded on <?= htmlspecialchars(date('M j, Y • H:i', strtotime((string)$match['created_at']))) ?> UTC</p>
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<span class="score-pill d-inline-flex mb-2"><?= htmlspecialchars(strtoupper((string)$match['outcome'])) ?></span>
|
||||
<div class="display-6 fw-semibold mb-0"><?= number_format((int)$match['score']) ?></div>
|
||||
<small class="text-secondary">score</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 stats-grid">
|
||||
<div class="col-6 col-md-3"><div class="metric-card"><span>Kills</span><strong><?= (int)$match['kills'] ?></strong></div></div>
|
||||
<div class="col-6 col-md-3"><div class="metric-card"><span>Accuracy</span><strong><?= htmlspecialchars(number_format((float)$match['accuracy'], 1)) ?>%</strong></div></div>
|
||||
<div class="col-6 col-md-3"><div class="metric-card"><span>Duration</span><strong><?= (int)$match['duration_seconds'] ?>s</strong></div></div>
|
||||
<div class="col-6 col-md-3"><div class="metric-card"><span>Damage taken</span><strong><?= (int)$match['damage_taken'] ?></strong></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="surface-panel p-4 h-100">
|
||||
<h2 class="h5 mb-3">Combat report</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-borderless align-middle mb-0">
|
||||
<tbody>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Operator</th><td><?= htmlspecialchars($match['player_name']) ?></td></tr>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Weapon</th><td><?= htmlspecialchars($match['weapon_name']) ?> <span class="text-secondary">(<?= htmlspecialchars($match['weapon_key']) ?>)</span></td></tr>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Shots fired</th><td><?= (int)$match['shots_fired'] ?></td></tr>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Shots landed</th><td><?= (int)$match['shots_hit'] ?></td></tr>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Accuracy</th><td><?= htmlspecialchars(number_format((float)$match['accuracy'], 2)) ?>%</td></tr>
|
||||
<tr><th scope="row" class="text-secondary fw-medium">Outcome</th><td><?= htmlspecialchars(ucfirst((string)$match['outcome'])) ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="surface-panel p-4 h-100">
|
||||
<h2 class="h5 mb-3">What to try next</h2>
|
||||
<ul class="list-unstyled compact-list mb-0">
|
||||
<li>Run another round with a different weapon profile.</li>
|
||||
<li>Compare accuracy between shotgun, SMG, and marksman loadouts.</li>
|
||||
<li>Ask for the next iteration: maps, cover objects, bot teams, or better hit effects.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="surface-panel p-5 text-center">
|
||||
<p class="eyebrow mb-2">Match detail</p>
|
||||
<h1 class="h3 mb-3">No match found</h1>
|
||||
<p class="text-secondary mb-4">This score card does not exist yet, or the id is invalid.</p>
|
||||
<a class="btn btn-light" href="/">Return to the arena</a>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user