Autosave: 20260330-185433

This commit is contained in:
Flatlogic Bot 2026-03-30 18:54:34 +00:00
parent 7030aad195
commit 95b94e590d
6 changed files with 2175 additions and 505 deletions

107
api/fps_matches.php Normal file
View 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);
}

View File

@ -1,403 +1,429 @@
body { :root {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); --bg: #0b0f14;
background-size: 400% 400%; --bg-alt: #11161d;
animation: gradient 15s ease infinite; --surface: #121821;
color: #212529; --surface-2: #171e28;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --border: #26303d;
font-size: 14px; --text: #eef2f7;
margin: 0; --muted: #9aa4b2;
min-height: 100vh; --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 { html {
display: flex; scroll-behavior: smooth;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
} }
@keyframes gradient { body.app-shell {
0% { font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-position: 0% 50%; background: var(--bg);
} color: var(--text);
50% { line-height: 1.55;
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} }
.chat-container { body.app-shell::selection {
width: 100%; background: rgba(219, 226, 234, 0.2);
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;
} }
.chat-header { .bg-body-tertiary {
padding: 1.5rem; background-color: rgba(11, 15, 20, 0.82) !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.05); backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
} }
.chat-messages { .navbar {
flex: 1; border-color: rgba(255,255,255,0.06) !important;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
} }
/* Custom Scrollbar */ .small-brand {
::-webkit-scrollbar { letter-spacing: 0.16em;
width: 6px; 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; background: transparent;
} }
::-webkit-scrollbar-thumb { .py-lg-6 {
background: rgba(255, 255, 255, 0.3); padding-top: 5rem;
border-radius: 10px; padding-bottom: 5rem;
} }
::-webkit-scrollbar-thumb:hover { .display-title {
background: rgba(255, 255, 255, 0.5); font-size: clamp(2.15rem, 4vw, 4.25rem);
letter-spacing: -0.04em;
line-height: 1.02;
max-width: 11ch;
} }
.message { .hero-copy,
max-width: 85%; .text-secondary,
padding: 0.85rem 1.1rem; .form-text {
border-radius: 16px; color: var(--muted) !important;
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);
} }
@keyframes fadeIn { .eyebrow {
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;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.75rem; letter-spacing: 0.14em;
letter-spacing: 1px; color: var(--muted);
} font-size: 0.78rem;
.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;
font-weight: 600; font-weight: 600;
font-size: 0.9rem;
} }
.form-control { .surface-chip,
width: 100%; .score-pill,
padding: 0.75rem 1rem; .outcome-tag {
border: 1px solid rgba(0, 0, 0, 0.1); display: inline-flex;
border-radius: 12px; align-items: center;
background: #fff; gap: 0.35rem;
transition: all 0.3s ease; border: 1px solid rgba(255,255,255,0.08);
box-sizing: border-box; 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 { .form-control:focus {
outline: none; background: rgba(255,255,255,0.03);
border-color: #23a6d5; border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); 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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
}
.header-links {
display: flex;
gap: 1rem; gap: 1rem;
padding: 1rem 1.25rem;
color: var(--text);
text-decoration: none;
border-top: 1px solid rgba(255,255,255,0.06);
} }
.admin-card { .match-row:first-child {
background: rgba(255, 255, 255, 0.6); border-top: 0;
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);
} }
.admin-card h3 { .match-row:hover,
margin-top: 0; .match-row:focus {
margin-bottom: 1.5rem; background: rgba(255,255,255,0.03);
font-weight: 700; color: var(--text);
} }
.btn-delete { .match-row span {
background: #dc3545; display: block;
color: white; color: var(--muted);
border: none; font-size: 0.88rem;
padding: 0.25rem 0.5rem; margin-top: 0.2rem;
border-radius: 4px;
cursor: pointer;
} }
.btn-add { .match-score-wrap {
background: #212529; display: flex;
color: white; align-items: center;
border: none; gap: 0.7rem;
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%;
white-space: nowrap; white-space: nowrap;
font-size: 0.85em;
color: #555;
} }
.history-table-user { .outcome-tag {
width: 35%; font-size: 0.72rem;
background: rgba(255, 255, 255, 0.3); padding: 0.35rem 0.65rem;
border-radius: 8px;
padding: 8px;
} }
.history-table-ai { .outcome-tag.victory { border-color: rgba(52,211,153,0.3); color: var(--success); }
width: 50%; .outcome-tag.defeat { border-color: rgba(248,113,113,0.3); color: var(--danger); }
background: rgba(255, 255, 255, 0.5); .outcome-tag.timeout { border-color: rgba(251,191,36,0.3); color: var(--warning); }
border-radius: 8px;
padding: 8px; .empty-state {
color: var(--muted);
} }
.no-messages { .stats-grid .metric-card {
text-align: center; height: 100%;
color: #777; }
.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;
} }

File diff suppressed because it is too large Load Diff

67
game_bootstrap.php Normal file
View 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
View File

@ -1,150 +1,265 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/game_bootstrap.php';
$now = date('Y-m-d H:i:s');
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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title><?= htmlspecialchars($projectName) ?> | Browser FPS Arena</title>
<?php <meta name="description" content="<?= htmlspecialchars($projectDescription) ?>" />
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?> <?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php endif; ?>
<?php if ($projectImageUrl): ?> <?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="theme-color" content="#0b0f14" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode($buildStamp) ?>">
<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>
</head> </head>
<body> <body class="app-shell" data-bs-theme="dark">
<main> <nav class="navbar navbar-expand-lg border-bottom border-secondary-subtle bg-body-tertiary bg-opacity-75 sticky-top">
<div class="card"> <div class="container py-2">
<h1>Analyzing your requirements and generating your website…</h1> <a class="navbar-brand fw-semibold text-uppercase small-brand" href="/">Strike Grid Arena</a>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <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="sr-only">Loading…</span> <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> </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> </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> </main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <div class="toast-container position-fixed bottom-0 end-0 p-3">
</footer> <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> </body>
</html> </html>

103
match.php Normal file
View 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>