39366-vm/assets/js/main.js
2026-03-30 18:54:34 +00:00

1292 lines
55 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const boot = window.__FPS_BOOTSTRAP__ || {};
const apiUrl = boot.apiUrl || '/api/fps_matches.php';
const initialMatches = Array.isArray(boot.initialMatches) ? boot.initialMatches : [];
const loadoutForm = document.getElementById('loadout-form');
const playerNameInput = document.getElementById('playerName');
const weaponInput = document.getElementById('weaponInput');
const weaponButtons = Array.from(document.querySelectorAll('.weapon-card'));
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const restartButton = document.getElementById('restartButton');
const saveButton = document.getElementById('saveButton');
const refreshMatchesButton = document.getElementById('refreshMatchesButton');
const summaryPanel = document.getElementById('summaryPanel');
const matchesList = document.getElementById('matchesList');
const hudName = document.getElementById('hudName');
const hudWeapon = document.getElementById('hudWeapon');
const hudHealth = document.getElementById('hudHealth');
const hudAmmo = document.getElementById('hudAmmo');
const hudKills = document.getElementById('hudKills');
const hudScore = document.getElementById('hudScore');
const hudTime = document.getElementById('hudTime');
const toastElement = document.getElementById('appToast');
const toastMessage = document.getElementById('toastMessage');
const appToast = toastElement ? new bootstrap.Toast(toastElement, { delay: 2400 }) : null;
const FOV = Math.PI / 2.7;
const MAX_PITCH = Math.PI * 0.33;
const ROUND_DURATION = 75;
const WORLD = { width: 1800, height: 1200 };
const TERRAIN_HILLS = [
{ x: 260, y: 220, radiusX: 290, radiusY: 210, height: 42 },
{ x: 620, y: 360, radiusX: 300, radiusY: 220, height: 68 },
{ x: 1120, y: 320, radiusX: 360, radiusY: 260, height: 78 },
{ x: 1480, y: 250, radiusX: 260, radiusY: 210, height: 48 },
{ x: 420, y: 760, radiusX: 320, radiusY: 240, height: 64 },
{ x: 930, y: 700, radiusX: 360, radiusY: 280, height: 92 },
{ x: 1450, y: 790, radiusX: 310, radiusY: 230, height: 72 },
{ x: 740, y: 1010, radiusX: 300, radiusY: 220, height: 58 },
{ x: 1240, y: 980, radiusX: 250, radiusY: 200, height: 44 }
];
const keys = {};
const pointer = { active: false, locked: false, sensitivity: 0.0028 };
const weapons = {
carbine: { key: 'carbine', name: 'VX Carbine', damage: 28, fireRate: 180, range: 760, spread: 0.02, magSize: 30, reserve: 120, reloadTime: 1500, pellets: 1, recoil: 0.014 },
smg: { key: 'smg', name: 'Mako SMG', damage: 16, fireRate: 95, range: 520, spread: 0.038, magSize: 36, reserve: 180, reloadTime: 1350, pellets: 1, recoil: 0.021 },
shotgun: { key: 'shotgun', name: 'Breach-8', damage: 14, fireRate: 520, range: 340, spread: 0.12, magSize: 8, reserve: 40, reloadTime: 1800, pellets: 6, recoil: 0.06 },
marksman: { key: 'marksman', name: 'Atlas DMR', damage: 56, fireRate: 420, range: 960, spread: 0.012, magSize: 12, reserve: 48, reloadTime: 1700, pellets: 1, recoil: 0.01 }
};
const state = {
player: null,
weapon: weapons.carbine,
bots: [],
props: [],
running: false,
roundEnded: false,
startTime: 0,
endTime: 0,
lastTick: performance.now(),
nextShotAt: 0,
reloadingUntil: 0,
recoil: 0,
flashUntil: 0,
noticeUntil: 0,
message: 'Choose a weapon and press Start round.',
matchSummary: null,
saveInFlight: false,
currentName: playerNameInput?.value.trim() || 'Operator Nova',
renderMatches: initialMatches,
mouseTriggerHeld: false,
spaceTriggerHeld: false
};
function showToast(message) {
if (!toastMessage || !appToast) return;
toastMessage.textContent = message;
appToast.show();
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function normalizeAngle(angle) {
while (angle > Math.PI) angle -= Math.PI * 2;
while (angle < -Math.PI) angle += Math.PI * 2;
return angle;
}
function randomBetween(min, max) {
return min + Math.random() * (max - min);
}
function mix(start, end, amount) {
return start + (end - start) * amount;
}
function terrainHeightAt(x, y) {
const px = clamp(x, 0, WORLD.width);
const py = clamp(y, 0, WORLD.height);
const nx = px / WORLD.width - 0.5;
const ny = py / WORLD.height - 0.5;
const islandMask = clamp(1 - (nx * nx * 2.3 + ny * ny * 2), 0, 1);
const shorelineLift = Math.pow(islandMask, 0.72) * 18;
const rollingNoise = (Math.sin(px / 108) + Math.cos(py / 132) + Math.sin((px + py) / 168)) * 6;
let hillHeight = shorelineLift + rollingNoise;
TERRAIN_HILLS.forEach((hill) => {
const dx = (px - hill.x) / hill.radiusX;
const dy = (py - hill.y) / hill.radiusY;
const falloff = Math.max(0, 1 - dx * dx - dy * dy);
hillHeight += hill.height * falloff * falloff;
});
return Math.max(0, hillHeight);
}
function getViewMetrics() {
const elevation = state.player ? (state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y)) : 18;
const climbFactor = clamp(elevation / 150, 0, 1);
const pitch = state.player?.pitch || 0;
const pitchFactor = clamp(pitch / MAX_PITCH, -1, 1);
const pitchOffset = pitchFactor * canvas.height * 0.16;
const horizonBase = canvas.height * (0.45 - climbFactor * 0.065);
const floorBase = canvas.height * (0.72 - climbFactor * 0.055);
return {
elevation,
pitch,
pitchFactor,
pitchOffset,
horizonY: clamp(horizonBase + pitchOffset, canvas.height * 0.16, canvas.height * 0.72),
floorY: clamp(floorBase + pitchOffset * 1.05, canvas.height * 0.42, canvas.height * 0.92)
};
}
function resetKeys() {
Object.keys(keys).forEach((code) => {
keys[code] = false;
});
state.mouseTriggerHeld = false;
state.spaceTriggerHeld = false;
}
function activatePointerLock() {
if (!canvas) return;
canvas.focus();
if (document.pointerLockElement === canvas) {
pointer.active = true;
pointer.locked = true;
return;
}
if (typeof canvas.requestPointerLock === 'function') {
canvas.requestPointerLock();
return;
}
pointer.active = true;
pointer.locked = false;
showToast('Arena focused. Move mouse to look around.');
}
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function setWeapon(key) {
if (!weapons[key]) return;
weaponButtons.forEach((button) => {
const active = button.dataset.weapon === key;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
weaponInput.value = key;
state.weapon = weapons[key];
hudWeapon.textContent = state.weapon.name;
}
function createBot(id, index) {
const edgePadding = 140;
const spawnEdges = [
{ x: randomBetween(edgePadding, WORLD.width - edgePadding), y: edgePadding },
{ x: WORLD.width - edgePadding, y: randomBetween(edgePadding, WORLD.height - edgePadding) },
{ x: randomBetween(edgePadding, WORLD.width - edgePadding), y: WORLD.height - edgePadding },
{ x: edgePadding, y: randomBetween(edgePadding, WORLD.height - edgePadding) }
];
const silhouettes = [
{ jacket: '#d9685f', accent: '#7f1d1d', pants: '#1f2937', skin: '#f1c27d' },
{ jacket: '#ef4444', accent: '#7c2d12', pants: '#111827', skin: '#d6a07a' },
{ jacket: '#fb7185', accent: '#881337', pants: '#1e293b', skin: '#c58c61' },
{ jacket: '#f97316', accent: '#9a3412', pants: '#0f172a', skin: '#8d5b3d' }
];
const point = spawnEdges[index % spawnEdges.length];
return {
id,
x: point.x,
y: point.y,
radius: 24,
hp: 70 + Math.round(randomBetween(0, 25)),
speed: 84 + randomBetween(0, 36),
wanderAngle: randomBetween(0, Math.PI * 2),
turnTimer: randomBetween(0.6, 2.1),
attackCooldown: randomBetween(0.4, 1.2),
hurtUntil: 0,
shootFlashUntil: 0,
angle: randomBetween(0, Math.PI * 2),
strideOffset: randomBetween(0, Math.PI * 2),
bodyScale: 0.94 + randomBetween(-0.08, 0.12),
silhouette: silhouettes[index % silhouettes.length],
preferredRange: randomBetween(180, 250),
strafeDir: Math.random() < 0.5 ? -1 : 1,
strafeTimer: randomBetween(0.45, 1.2),
dodgeCooldown: randomBetween(0.8, 1.7),
aggression: randomBetween(0.92, 1.18),
state: 'patrol'
};
}
function generateProps() {
return [
{ x: 280, y: 250, size: 64, type: 'pine' },
{ x: 520, y: 360, size: 74, type: 'rock' },
{ x: 790, y: 320, size: 70, type: 'pine' },
{ x: 1100, y: 360, size: 84, type: 'rock' },
{ x: 1420, y: 270, size: 66, type: 'pine' },
{ x: 360, y: 760, size: 78, type: 'rock' },
{ x: 680, y: 910, size: 68, type: 'pine' },
{ x: 980, y: 760, size: 86, type: 'rock' },
{ x: 1290, y: 860, size: 72, type: 'pine' },
{ x: 1520, y: 980, size: 74, type: 'rock' }
];
}
function spawnRound() {
const name = (playerNameInput.value || '').trim();
if (!name) {
playerNameInput.focus();
showToast('Add a call sign before starting.');
return;
}
resetKeys();
state.currentName = name;
state.player = {
x: WORLD.width / 2,
y: WORLD.height * 0.62,
angle: -Math.PI / 2,
pitch: 0,
health: 100,
speed: 220,
ammo: state.weapon.magSize,
reserve: state.weapon.reserve,
damageTaken: 0,
terrainHeight: terrainHeightAt(WORLD.width / 2, WORLD.height * 0.62)
};
state.bots = Array.from({ length: 7 }, (_, index) => createBot(index + 1, index));
state.props = generateProps();
state.running = true;
state.roundEnded = false;
state.startTime = performance.now();
state.endTime = 0;
state.lastTick = performance.now();
state.nextShotAt = 0;
state.reloadingUntil = 0;
state.recoil = 0;
state.flashUntil = 0;
state.noticeUntil = performance.now() + 2200;
state.message = 'Round live. Push up the hills and clear the arena.';
state.matchSummary = null;
state.saveInFlight = false;
saveButton.disabled = true;
hudName.textContent = name;
hudWeapon.textContent = state.weapon.name;
updateSummary();
updateHud();
canvas.focus();
showToast(`${state.weapon.name} equipped. Round started.`);
}
function updateHud() {
if (!state.player) return;
const elapsed = state.running ? (performance.now() - state.startTime) / 1000 : (state.endTime - state.startTime) / 1000;
const remaining = Math.max(0, ROUND_DURATION - elapsed);
const kills = 7 - state.bots.length;
const score = Math.max(0, kills * 140 + Math.round(remaining * 4) - state.player.damageTaken * 2);
hudName.textContent = state.currentName;
hudWeapon.textContent = state.weapon.name;
hudHealth.textContent = `${Math.max(0, Math.round(state.player.health))}`;
hudAmmo.textContent = `${state.player.ammo} / ${state.player.reserve}`;
hudKills.textContent = String(kills);
hudScore.textContent = String(score);
hudTime.textContent = `${Math.ceil(remaining)}s`;
}
function getScoreSnapshot() {
const elapsedMs = (state.roundEnded ? state.endTime : performance.now()) - state.startTime;
const seconds = Math.max(1, Math.round(elapsedMs / 1000));
const kills = 7 - state.bots.length;
const shotsFired = state.player?.shotsFired || 0;
const shotsHit = state.player?.shotsHit || 0;
const score = Math.max(0, kills * 140 + Math.max(0, ROUND_DURATION - seconds) * 4 - (state.player?.damageTaken || 0) * 2 + (state.bots.length === 0 ? 250 : 0));
let outcome = 'defeat';
if (state.bots.length === 0) outcome = 'victory';
else if (seconds >= ROUND_DURATION && state.player.health > 0) outcome = 'timeout';
return {
player_name: state.currentName,
weapon_key: state.weapon.key,
weapon_name: state.weapon.name,
kills,
shots_fired: shotsFired,
shots_hit: shotsHit,
damage_taken: Math.round(state.player?.damageTaken || 0),
duration_seconds: seconds,
score,
outcome
};
}
function updateSummary(savedMatch = null) {
if (!summaryPanel) return;
if (!state.matchSummary) {
summaryPanel.className = 'summary-state empty';
summaryPanel.innerHTML = `
<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>
`;
return;
}
const s = state.matchSummary;
summaryPanel.className = 'summary-state ready';
summaryPanel.innerHTML = `
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<p class="summary-title mb-1">${s.outcome === 'victory' ? 'Squad cleared' : s.outcome === 'timeout' ? 'Timer expired' : 'Operator down'}</p>
<p class="summary-copy mb-0 text-secondary">${s.weapon_name}${s.player_name}</p>
</div>
<span class="outcome-tag ${s.outcome}">${s.outcome.toUpperCase()}</span>
</div>
<div class="summary-grid">
<div class="metric-card"><span>Score</span><strong>${s.score}</strong></div>
<div class="metric-card"><span>Kills</span><strong>${s.kills}</strong></div>
<div class="metric-card"><span>Accuracy</span><strong>${s.shots_fired ? ((s.shots_hit / s.shots_fired) * 100).toFixed(1) : '0.0'}%</strong></div>
<div class="metric-card"><span>Duration</span><strong>${s.duration_seconds}s</strong></div>
</div>
<p class="summary-copy mb-0 text-secondary">${savedMatch ? `Saved as match #${savedMatch.id}.` : 'Use “Save result” to store this run.'}</p>
`;
}
function finishRound(reason) {
if (!state.running || state.roundEnded || !state.player) return;
state.running = false;
state.roundEnded = true;
state.endTime = performance.now();
state.matchSummary = getScoreSnapshot();
saveButton.disabled = false;
updateHud();
updateSummary();
const messages = {
victory: 'Victory. Arena cleared.',
timeout: 'Round complete. Timer expired.',
defeat: 'Round failed. Restart to try again.'
};
state.message = messages[reason] || 'Round complete.';
state.noticeUntil = performance.now() + 3200;
showToast(messages[reason] || 'Round complete.');
}
function shoot(now) {
if (!state.running || !state.player) return;
if (now < state.nextShotAt) return;
if (now < state.reloadingUntil) return;
if (state.player.ammo <= 0) {
reload();
return;
}
state.player.ammo -= 1;
state.nextShotAt = now + state.weapon.fireRate;
state.recoil = Math.min(0.11, state.recoil + state.weapon.recoil);
state.flashUntil = now + 55;
state.player.shotsFired = (state.player.shotsFired || 0) + 1;
const pellets = state.weapon.pellets || 1;
for (let i = 0; i < pellets; i += 1) {
const offset = randomBetween(-state.weapon.spread, state.weapon.spread);
const rayAngle = state.player.angle + offset;
let bestTarget = null;
let bestDistance = Infinity;
state.bots.forEach((bot) => {
const dx = bot.x - state.player.x;
const dy = bot.y - state.player.y;
const dist = Math.hypot(dx, dy);
if (dist > state.weapon.range) return;
const diff = Math.abs(normalizeAngle(Math.atan2(dy, dx) - rayAngle));
const threshold = Math.atan2(bot.radius, Math.max(dist, 1)) * 1.2 + state.weapon.spread * 0.4;
if (diff < threshold && dist < bestDistance) {
bestTarget = bot;
bestDistance = dist;
}
});
if (bestTarget) {
bestTarget.hp -= state.weapon.damage;
bestTarget.hurtUntil = now + 90;
state.player.shotsHit = (state.player.shotsHit || 0) + 1;
if (bestTarget.hp <= 0) {
state.bots = state.bots.filter((bot) => bot !== bestTarget);
state.message = 'Target down.';
state.noticeUntil = now + 900;
if (state.bots.length === 0) {
finishRound('victory');
}
}
}
}
updateHud();
}
function reload() {
if (!state.player || !state.running) return;
if (state.player.ammo >= state.weapon.magSize) return;
if (state.player.reserve <= 0) return;
if (performance.now() < state.reloadingUntil) return;
state.reloadingUntil = performance.now() + state.weapon.reloadTime;
state.message = 'Reloading…';
state.noticeUntil = performance.now() + 1000;
showToast('Reloading.');
}
function completeReload(now) {
if (!state.player || !state.running) return;
if (state.reloadingUntil && now >= state.reloadingUntil) {
const needed = state.weapon.magSize - state.player.ammo;
const transfer = Math.min(needed, state.player.reserve);
state.player.ammo += transfer;
state.player.reserve -= transfer;
state.reloadingUntil = 0;
updateHud();
}
}
function getBotAvoidance(bot) {
let avoidX = 0;
let avoidY = 0;
state.bots.forEach((other) => {
if (other === bot) return;
const dx = bot.x - other.x;
const dy = bot.y - other.y;
const dist = Math.hypot(dx, dy) || 1;
const desired = bot.radius + other.radius + 34;
if (dist < desired) {
const push = (desired - dist) / desired;
avoidX += (dx / dist) * push;
avoidY += (dy / dist) * push;
}
});
state.props.forEach((prop) => {
const dx = bot.x - prop.x;
const dy = bot.y - prop.y;
const dist = Math.hypot(dx, dy) || 1;
const desired = prop.size * 0.85 + bot.radius + 36;
if (dist < desired) {
const push = (desired - dist) / desired;
avoidX += (dx / dist) * push * 1.35;
avoidY += (dy / dist) * push * 1.35;
}
});
return { x: avoidX, y: avoidY };
}
function getPrimaryThreat() {
if (!state.player || !state.bots.length) return null;
let best = null;
let bestScore = -Infinity;
const centerX = canvas.width * 0.5;
state.bots.forEach((bot) => {
const projection = projectObject(bot);
if (!projection) return;
const screenOffset = Math.abs(projection.screenX - centerX) / centerX;
const rangeFactor = 1 - clamp(projection.distance / Math.max(state.weapon.range, 1), 0, 1);
const score = (1 - screenOffset) * 0.72 + rangeFactor * 0.28;
if (score > bestScore) {
bestScore = score;
best = { bot, projection, score };
}
});
return best;
}
function attemptAutoFire(now) {
if (!state.running || !state.player) return;
if (!(state.mouseTriggerHeld || state.spaceTriggerHeld)) return;
if (!pointer.active && !state.spaceTriggerHeld) return;
shoot(now);
}
function updateBots(delta, now) {
if (!state.player) return;
state.bots.forEach((bot) => {
const dx = state.player.x - bot.x;
const dy = state.player.y - bot.y;
const dist = Math.hypot(dx, dy);
const targetAngleToPlayer = Math.atan2(dy, dx);
const dirX = dx / (dist || 1);
const dirY = dy / (dist || 1);
const sideX = -dirY;
const sideY = dirX;
const avoidance = getBotAvoidance(bot);
bot.attackCooldown -= delta;
bot.turnTimer -= delta;
bot.strafeTimer -= delta;
bot.dodgeCooldown -= delta;
let targetAngle = bot.wanderAngle;
let moveX = 0;
let moveY = 0;
let speedFactor = 0.5;
if (dist < 620) {
if (bot.strafeTimer <= 0) {
bot.strafeTimer = randomBetween(0.45, 1.25);
bot.strafeDir = Math.random() < 0.5 ? -1 : 1;
}
let forwardBias = 0;
if (dist > bot.preferredRange + 65) {
forwardBias = 1;
bot.state = 'chase';
} else if (dist < bot.preferredRange - 35) {
forwardBias = -0.78;
bot.state = 'retreat';
} else {
forwardBias = 0.18;
bot.state = 'strafe';
}
let strafeBias = bot.strafeDir * (dist < 300 ? 0.92 : 0.6);
if (bot.dodgeCooldown <= 0 && dist < 320) {
bot.dodgeCooldown = randomBetween(1.1, 2.1);
bot.strafeDir = Math.random() < 0.5 ? -1 : 1;
strafeBias = bot.strafeDir * 1.35;
bot.state = 'dodge';
}
moveX = dirX * forwardBias + sideX * strafeBias + avoidance.x * 1.8;
moveY = dirY * forwardBias + sideY * strafeBias + avoidance.y * 1.8;
speedFactor = bot.state === 'dodge' ? 1.18 : dist > bot.preferredRange ? 1.02 : 0.88;
targetAngle = targetAngleToPlayer;
} else {
bot.state = 'patrol';
if (bot.turnTimer <= 0) {
bot.turnTimer = randomBetween(0.8, 2.4);
bot.wanderAngle += randomBetween(-1.15, 1.15);
}
moveX = Math.cos(bot.wanderAngle) * 0.65 + avoidance.x * 1.4;
moveY = Math.sin(bot.wanderAngle) * 0.65 + avoidance.y * 1.4;
speedFactor = 0.42;
targetAngle = bot.wanderAngle;
}
const currentTerrain = bot.terrainHeight ?? terrainHeightAt(bot.x, bot.y);
const moveLength = Math.hypot(moveX, moveY) || 1;
const intendedSpeed = bot.speed * bot.aggression * speedFactor;
const trialX = bot.x + (moveX / moveLength) * intendedSpeed * delta;
const trialY = bot.y + (moveY / moveLength) * intendedSpeed * delta;
const nextTerrain = terrainHeightAt(trialX, trialY);
const climbDelta = nextTerrain - currentTerrain;
const slopeFactor = climbDelta > 0 ? clamp(1 - climbDelta / 150, 0.7, 1) : clamp(1 + Math.abs(climbDelta) / 320, 1, 1.06);
bot.x += (moveX / moveLength) * intendedSpeed * slopeFactor * delta;
bot.y += (moveY / moveLength) * intendedSpeed * slopeFactor * delta;
if (bot.x < 80 || bot.x > WORLD.width - 80) bot.wanderAngle = Math.PI - bot.wanderAngle;
if (bot.y < 80 || bot.y > WORLD.height - 80) bot.wanderAngle = -bot.wanderAngle;
bot.x = clamp(bot.x, 80, WORLD.width - 80);
bot.y = clamp(bot.y, 80, WORLD.height - 80);
bot.terrainHeight = mix(currentTerrain, terrainHeightAt(bot.x, bot.y), Math.min(1, delta * 7));
bot.angle = normalizeAngle(bot.angle + normalizeAngle(targetAngle - bot.angle) * Math.min(1, delta * 8.4));
const aimError = Math.abs(normalizeAngle(targetAngleToPlayer - bot.angle));
const canShoot = dist < 320 && aimError < 0.44;
if (canShoot && bot.attackCooldown <= 0 && state.running) {
const damage = randomBetween(4, 9);
state.player.health = clamp(state.player.health - damage, 0, 100);
state.player.damageTaken += damage;
bot.attackCooldown = randomBetween(0.42, 0.95);
bot.shootFlashUntil = now + 90;
state.message = bot.state === 'dodge' ? 'Enemy strafe fire incoming.' : 'Taking fire.';
state.noticeUntil = now + 700;
updateHud();
if (state.player.health <= 0) {
finishRound('defeat');
}
}
});
}
function updatePlayer(delta) {
if (!state.player || !state.running) return;
const rotationSpeed = 2.2;
const pitchSpeed = 1.55;
if (keys.ArrowLeft) state.player.angle -= rotationSpeed * delta;
if (keys.ArrowRight) state.player.angle += rotationSpeed * delta;
if (keys.ArrowUp) state.player.pitch = clamp(state.player.pitch + pitchSpeed * delta, -MAX_PITCH, MAX_PITCH);
if (keys.ArrowDown) state.player.pitch = clamp(state.player.pitch - pitchSpeed * delta, -MAX_PITCH, MAX_PITCH);
let moveX = 0;
let moveY = 0;
const forwardX = Math.cos(state.player.angle);
const forwardY = Math.sin(state.player.angle);
const sideX = Math.cos(state.player.angle + Math.PI / 2);
const sideY = Math.sin(state.player.angle + Math.PI / 2);
if (keys.KeyW) {
moveX += forwardX;
moveY += forwardY;
}
if (keys.KeyS) {
moveX -= forwardX;
moveY -= forwardY;
}
if (keys.KeyA) {
moveX -= sideX;
moveY -= sideY;
}
if (keys.KeyD) {
moveX += sideX;
moveY += sideY;
}
const length = Math.hypot(moveX, moveY);
if (length > 0) {
const currentTerrain = state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y);
const stepX = (moveX / length) * state.player.speed * delta;
const stepY = (moveY / length) * state.player.speed * delta;
const trialX = clamp(state.player.x + stepX, 70, WORLD.width - 70);
const trialY = clamp(state.player.y + stepY, 70, WORLD.height - 70);
const nextTerrain = terrainHeightAt(trialX, trialY);
const climbDelta = nextTerrain - currentTerrain;
const slopeFactor = climbDelta > 0 ? clamp(1 - climbDelta / 160, 0.68, 1) : clamp(1 + Math.abs(climbDelta) / 340, 1, 1.08);
state.player.x = clamp(state.player.x + stepX * slopeFactor, 70, WORLD.width - 70);
state.player.y = clamp(state.player.y + stepY * slopeFactor, 70, WORLD.height - 70);
state.player.terrainHeight = mix(currentTerrain, terrainHeightAt(state.player.x, state.player.y), Math.min(1, delta * 8));
} else {
state.player.terrainHeight = mix(state.player.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y), terrainHeightAt(state.player.x, state.player.y), Math.min(1, delta * 5));
}
}
function drawBackground(width, height) {
ctx.clearRect(0, 0, width, height);
const view = getViewMetrics();
const horizonY = view.horizonY;
const floorY = view.floorY;
const skyGradient = ctx.createLinearGradient(0, 0, 0, horizonY);
skyGradient.addColorStop(0, '#67b7ff');
skyGradient.addColorStop(0.52, '#8fd3ff');
skyGradient.addColorStop(1, '#d8f2ff');
ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, width, horizonY);
const sunX = width * 0.82;
const sunY = horizonY * 0.25;
const sunGlow = ctx.createRadialGradient(sunX, sunY, 8, sunX, sunY, 86);
sunGlow.addColorStop(0, 'rgba(255, 244, 167, 0.95)');
sunGlow.addColorStop(0.45, 'rgba(255, 225, 124, 0.42)');
sunGlow.addColorStop(1, 'rgba(255, 225, 124, 0)');
ctx.fillStyle = sunGlow;
ctx.beginPath();
ctx.arc(sunX, sunY, 86, 0, Math.PI * 2);
ctx.fill();
const waterGradient = ctx.createLinearGradient(0, horizonY - 16, 0, floorY - 8);
waterGradient.addColorStop(0, '#7fd7e6');
waterGradient.addColorStop(1, '#4aa8bc');
ctx.fillStyle = waterGradient;
ctx.fillRect(0, horizonY - 8, width, Math.max(12, floorY - horizonY - 22));
const farHillLayers = [
{ base: horizonY + 8, ampA: 16, ampB: 11, freqA: 170, freqB: 95, color: '#7dbb78' },
{ base: horizonY + 36, ampA: 22, ampB: 16, freqA: 138, freqB: 82, color: '#5e9d57' },
{ base: horizonY + 74, ampA: 28, ampB: 18, freqA: 116, freqB: 70, color: '#4d8447' }
];
farHillLayers.forEach((layer) => {
ctx.fillStyle = layer.color;
ctx.beginPath();
ctx.moveTo(0, height);
for (let x = 0; x <= width; x += 22) {
const ridgeY = layer.base - Math.sin(x / layer.freqA) * layer.ampA - Math.cos((x + view.elevation * 5) / layer.freqB) * layer.ampB;
ctx.lineTo(x, ridgeY);
}
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
});
const grassGradient = ctx.createLinearGradient(0, floorY - 10, 0, height);
grassGradient.addColorStop(0, '#8fd162');
grassGradient.addColorStop(0.42, '#5fb141');
grassGradient.addColorStop(1, '#2f6d26');
ctx.fillStyle = grassGradient;
ctx.fillRect(0, floorY - 10, width, height - floorY + 10);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1.3;
for (let i = 0; i < 7; i += 1) {
const stripeY = floorY + i * 38;
const stripeCurve = Math.sin((i + 1) * 0.7 + view.elevation * 0.015) * 28;
ctx.beginPath();
ctx.moveTo(0, stripeY + stripeCurve);
ctx.quadraticCurveTo(width * 0.45, stripeY - 18, width, stripeY + 10 - stripeCurve * 0.2);
ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,0.45)';
[[0.18, 0.16, 0.11], [0.54, 0.22, 0.14], [0.72, 0.11, 0.1]].forEach(([x, y, s]) => {
const cloudX = width * x;
const cloudY = horizonY * y + 18;
ctx.beginPath();
ctx.ellipse(cloudX, cloudY, width * s * 0.34, 18, 0, 0, Math.PI * 2);
ctx.ellipse(cloudX + 28, cloudY - 8, width * s * 0.24, 15, 0, 0, Math.PI * 2);
ctx.ellipse(cloudX - 26, cloudY - 4, width * s * 0.21, 13, 0, 0, Math.PI * 2);
ctx.fill();
});
}
function projectObject(obj) {
const dx = obj.x - state.player.x;
const dy = obj.y - state.player.y;
const distanceToPlayer = Math.hypot(dx, dy);
const relAngle = normalizeAngle(Math.atan2(dy, dx) - state.player.angle);
if (distanceToPlayer < 1 || Math.abs(relAngle) > FOV * 0.75) return null;
const perspective = Math.tan(relAngle) / Math.tan(FOV / 2);
const screenX = canvas.width * 0.5 + perspective * canvas.width * 0.5;
const view = getViewMetrics();
const objectTerrain = obj.terrainHeight ?? terrainHeightAt(obj.x, obj.y);
const playerTerrain = state.player?.terrainHeight ?? terrainHeightAt(state.player.x, state.player.y);
const elevationDelta = objectTerrain - playerTerrain;
const verticalFactor = clamp(540 / Math.max(distanceToPlayer, 120), 0.24, 1.7);
const floorY = view.floorY - elevationDelta * verticalFactor;
const size = clamp((32000 / distanceToPlayer) * (1 + elevationDelta / 1400), 28, 360);
return { screenX, size, distance: distanceToPlayer, relAngle, floorY, elevationDelta };
}
function drawProps() {
const projected = state.props
.map((prop) => ({ prop, p: projectObject(prop) }))
.filter((item) => item.p)
.sort((a, b) => b.p.distance - a.p.distance);
projected.forEach(({ prop, p }) => {
const floorY = p.floorY;
if (prop.type === 'pine') {
const trunkHeight = p.size * 0.34;
const crownHeight = p.size * 0.82;
const crownWidth = p.size * 0.58;
drawGroundShadow(p.screenX, floorY + p.size * 0.05, p.size * 0.2, p.size * 0.06, 0.18);
ctx.fillStyle = '#6b4f2a';
ctx.fillRect(p.screenX - p.size * 0.05, floorY - trunkHeight, p.size * 0.1, trunkHeight);
ctx.fillStyle = '#205e2b';
ctx.beginPath();
ctx.moveTo(p.screenX, floorY - crownHeight);
ctx.lineTo(p.screenX - crownWidth * 0.55, floorY - trunkHeight * 0.32);
ctx.lineTo(p.screenX + crownWidth * 0.55, floorY - trunkHeight * 0.32);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#2d7a33';
ctx.beginPath();
ctx.moveTo(p.screenX, floorY - crownHeight * 0.74);
ctx.lineTo(p.screenX - crownWidth * 0.42, floorY - trunkHeight * 0.04);
ctx.lineTo(p.screenX + crownWidth * 0.42, floorY - trunkHeight * 0.04);
ctx.closePath();
ctx.fill();
} else {
const rockWidth = p.size * 0.62;
const rockHeight = p.size * 0.4;
const rockY = floorY - rockHeight;
const rockGradient = ctx.createLinearGradient(p.screenX, rockY, p.screenX, floorY);
rockGradient.addColorStop(0, '#94a3b8');
rockGradient.addColorStop(1, '#475569');
drawGroundShadow(p.screenX, floorY + p.size * 0.04, rockWidth * 0.62, p.size * 0.06, 0.18);
ctx.fillStyle = rockGradient;
ctx.beginPath();
ctx.moveTo(p.screenX - rockWidth * 0.52, floorY);
ctx.quadraticCurveTo(p.screenX - rockWidth * 0.66, rockY + rockHeight * 0.44, p.screenX - rockWidth * 0.22, rockY + rockHeight * 0.08);
ctx.quadraticCurveTo(p.screenX - rockWidth * 0.02, rockY - rockHeight * 0.06, p.screenX + rockWidth * 0.2, rockY + rockHeight * 0.04);
ctx.quadraticCurveTo(p.screenX + rockWidth * 0.68, rockY + rockHeight * 0.28, p.screenX + rockWidth * 0.52, floorY);
ctx.closePath();
ctx.fill();
}
});
}
function drawLimb(x1, y1, x2, y2, width, color) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawGroundShadow(x, y, rx, ry, alpha = 0.2) {
ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`;
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawBots(now) {
const projected = state.bots
.map((bot) => ({ bot, p: projectObject(bot) }))
.filter((item) => item.p)
.sort((a, b) => b.p.distance - a.p.distance);
projected.forEach(({ bot, p }) => {
const floorY = p.floorY;
const scale = bot.bodyScale || 1;
const headRadius = p.size * 0.1 * scale;
const shoulderY = floorY - p.size * 0.46 * scale;
const torsoHeight = p.size * 0.28 * scale;
const torsoWidth = p.size * 0.23 * scale;
const hipY = shoulderY + torsoHeight * 0.92;
const movingAggressively = ['chase', 'strafe', 'dodge', 'retreat'].includes(bot.state);
const stride = Math.sin(now / (movingAggressively ? 88 : 148) + bot.strideOffset) * p.size * (bot.state === 'dodge' ? 0.11 : movingAggressively ? 0.07 : 0.04);
const facing = Math.sin(bot.angle - state.player.angle);
const centerX = p.screenX + facing * p.size * 0.04;
const legSpread = p.size * 0.08;
const footY = floorY + p.size * 0.12;
const leftFootX = centerX - legSpread - stride;
const rightFootX = centerX + legSpread + stride;
const legWidth = Math.max(4, p.size * 0.05);
const armWidth = Math.max(4, p.size * 0.045);
const hurt = now < bot.hurtUntil;
const skinTone = bot.silhouette?.skin || '#d6a07a';
const jacket = hurt ? '#fca5a5' : (bot.silhouette?.jacket || '#d9685f');
const jacketAccent = hurt ? '#ef4444' : (bot.silhouette?.accent || '#7f1d1d');
const pants = hurt ? '#7f1d1d' : (bot.silhouette?.pants || '#1f2937');
const headY = shoulderY - headRadius * 0.62;
const aimDirX = Math.cos(bot.angle) * p.size * 0.26;
const aimDirY = Math.sin(bot.angle) * p.size * 0.08;
const rifleBaseX = centerX + torsoWidth * 0.12;
const rifleBaseY = shoulderY + torsoHeight * 0.42;
const muzzleX = rifleBaseX + aimDirX;
const muzzleY = rifleBaseY + aimDirY;
const leftHandX = rifleBaseX - torsoWidth * 0.36;
const leftHandY = rifleBaseY + torsoHeight * 0.14;
const rightHandX = rifleBaseX + torsoWidth * 0.14;
const rightHandY = rifleBaseY - torsoHeight * 0.04;
drawGroundShadow(centerX, footY + p.size * 0.02, p.size * 0.18, p.size * 0.05, 0.18);
drawLimb(centerX - legSpread * 0.45, hipY, leftFootX, footY, legWidth, pants);
drawLimb(centerX + legSpread * 0.45, hipY, rightFootX, footY, legWidth, pants);
drawLimb(leftFootX - p.size * 0.02, footY, leftFootX + p.size * 0.03, footY, Math.max(4, p.size * 0.042), '#020617');
drawLimb(rightFootX - p.size * 0.02, footY, rightFootX + p.size * 0.03, footY, Math.max(4, p.size * 0.042), '#020617');
const torsoGradient = ctx.createLinearGradient(centerX, shoulderY, centerX, shoulderY + torsoHeight);
torsoGradient.addColorStop(0, jacket);
torsoGradient.addColorStop(1, jacketAccent);
ctx.fillStyle = torsoGradient;
ctx.fillRect(centerX - torsoWidth / 2, shoulderY, torsoWidth, torsoHeight);
ctx.fillStyle = 'rgba(255,255,255,0.14)';
ctx.fillRect(centerX - torsoWidth * 0.18, shoulderY + torsoHeight * 0.18, torsoWidth * 0.36, torsoHeight * 0.3);
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = Math.max(1, p.size * 0.008);
ctx.strokeRect(centerX - torsoWidth / 2, shoulderY, torsoWidth, torsoHeight);
drawLimb(centerX - torsoWidth * 0.42, shoulderY + torsoHeight * 0.22, leftHandX, leftHandY, armWidth, jacket);
drawLimb(centerX + torsoWidth * 0.38, shoulderY + torsoHeight * 0.18, rightHandX, rightHandY, armWidth, jacket);
ctx.fillStyle = skinTone;
ctx.beginPath();
ctx.arc(leftHandX, leftHandY, Math.max(3, p.size * 0.03), 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(rightHandX, rightHandY, Math.max(3, p.size * 0.03), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = skinTone;
ctx.beginPath();
ctx.arc(centerX, headY, headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath();
ctx.arc(centerX, headY - headRadius * 0.18, headRadius * 0.98, Math.PI, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(centerX - headRadius * 0.56, headY - headRadius * 0.12, headRadius * 1.12, headRadius * 0.2);
ctx.fillStyle = '#475569';
ctx.fillRect(centerX - torsoWidth * 0.12, shoulderY - headRadius * 0.18, torsoWidth * 0.24, headRadius * 0.3);
ctx.strokeStyle = '#020617';
ctx.lineWidth = Math.max(4, p.size * 0.055);
ctx.beginPath();
ctx.moveTo(rifleBaseX - torsoWidth * 0.42, rifleBaseY + torsoHeight * 0.08);
ctx.lineTo(muzzleX, muzzleY);
ctx.stroke();
ctx.strokeStyle = '#64748b';
ctx.lineWidth = Math.max(3, p.size * 0.03);
ctx.beginPath();
ctx.moveTo(rifleBaseX - torsoWidth * 0.04, rifleBaseY - torsoHeight * 0.04);
ctx.lineTo(rifleBaseX + torsoWidth * 0.34, rifleBaseY + torsoHeight * 0.02);
ctx.stroke();
if (now < bot.shootFlashUntil) {
ctx.fillStyle = '#fde68a';
ctx.beginPath();
ctx.arc(muzzleX, muzzleY, p.size * 0.06, 0, Math.PI * 2);
ctx.fill();
}
const hpWidth = torsoWidth * 1.2;
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(centerX - hpWidth / 2, headY - headRadius - 16, hpWidth, 5);
ctx.fillStyle = '#34d399';
ctx.fillRect(centerX - hpWidth / 2, headY - headRadius - 16, hpWidth * clamp(bot.hp / 95, 0, 1), 5);
});
}
function drawWeapon(now) {
const width = canvas.width;
const height = canvas.height;
const moving = Boolean(keys.KeyW || keys.KeyA || keys.KeyS || keys.KeyD);
const bob = state.running ? Math.sin(now / (moving ? 112 : 190)) * (moving ? 7 : 2.4) : 0;
const sway = state.running ? Math.cos(now / (moving ? 148 : 240)) * (moving ? 10 : 4) : 0;
const recoilLift = state.recoil * 240;
const target = getPrimaryThreat();
const targetBias = target ? clamp(-target.projection.relAngle / (FOV * 0.45), -1, 1) : 0;
const targetLift = target ? clamp((1 - target.projection.distance / Math.max(state.weapon.range, 1)) * 14, 0, 14) : 0;
const gunX = width * 0.54 + sway - recoilLift * 0.28 + targetBias * 34;
const gunY = height * 0.83 + bob + recoilLift * 0.2 - targetLift;
const sleeveColor = '#334155';
const skinTone = '#d6a07a';
const gloveColor = '#0f172a';
const barrelLengthByWeapon = { carbine: 116, smg: 92, shotgun: 102, marksman: 144 };
const barrelLength = barrelLengthByWeapon[state.weapon.key] || 112;
const stockLength = state.weapon.key === 'marksman' ? 42 : 34;
const movingHandLift = moving ? Math.sin(now / 140) * 4 : 0;
const pitchFactor = state.player ? clamp((state.player.pitch || 0) / MAX_PITCH, -1, 1) : 0;
const pitchLift = pitchFactor * 46;
const weaponRotation = targetBias * 0.14 - state.recoil * 0.3 - pitchFactor * 0.08;
drawGroundShadow(width * 0.5 + targetBias * 24, height * 0.965, width * 0.2, height * 0.02, 0.12);
ctx.save();
ctx.translate(gunX, gunY + pitchLift);
ctx.rotate(weaponRotation);
drawLimb(-276, 188, -128, 86, 28, sleeveColor);
drawLimb(-128, 86, -64, 24 + movingHandLift, 24, skinTone);
drawLimb(204, 188, 58, 68, 28, sleeveColor);
drawLimb(58, 68, 18, 10, 24, skinTone);
ctx.fillStyle = gloveColor;
ctx.beginPath();
ctx.arc(-62, 20 + movingHandLift, 13, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(18, 8, 13, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#dbe2ea';
ctx.fillRect(-36, -34, 98, 30);
ctx.fillStyle = '#9aa4b2';
ctx.fillRect(-stockLength, -22, stockLength, 22);
ctx.fillStyle = '#cbd5e1';
ctx.fillRect(20, -20, barrelLength, 12);
ctx.fillStyle = '#64748b';
ctx.fillRect(2, -46, 34, 12);
ctx.fillStyle = '#0f172a';
ctx.fillRect(-6, -4, 24, 48);
ctx.fillRect(52, -12, 18, 18);
ctx.fillStyle = '#475569';
ctx.fillRect(-12, 8, 18, 48);
ctx.fillStyle = '#1e293b';
ctx.fillRect(barrelLength - 6, -22, 16, 10);
ctx.fillRect(4, -44, 10, 18);
if (now < state.flashUntil) {
ctx.fillStyle = '#fde68a';
ctx.beginPath();
ctx.moveTo(barrelLength + 6, -16);
ctx.lineTo(barrelLength + 38, -2);
ctx.lineTo(barrelLength + 10, 8);
ctx.lineTo(barrelLength + 26, 26);
ctx.lineTo(barrelLength - 2, 12);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawCrosshair() {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.78)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx - 12, cy);
ctx.lineTo(cx - 4, cy);
ctx.moveTo(cx + 4, cy);
ctx.lineTo(cx + 12, cy);
ctx.moveTo(cx, cy - 12);
ctx.lineTo(cx, cy - 4);
ctx.moveTo(cx, cy + 4);
ctx.lineTo(cx, cy + 12);
ctx.stroke();
}
function drawRadar() {
const size = 130;
const padding = 18;
const x = canvas.width - size - padding;
const y = padding;
ctx.fillStyle = 'rgba(11,15,20,0.7)';
ctx.fillRect(x, y, size, size);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.strokeRect(x, y, size, size);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.beginPath();
ctx.moveTo(x + size / 2, y);
ctx.lineTo(x + size / 2, y + size);
ctx.moveTo(x, y + size / 2);
ctx.lineTo(x + size, y + size / 2);
ctx.stroke();
ctx.fillStyle = '#dbe2ea';
ctx.beginPath();
ctx.arc(x + size / 2, y + size / 2, 4, 0, Math.PI * 2);
ctx.fill();
state.bots.forEach((bot) => {
const relX = (bot.x - state.player.x) / 12;
const relY = (bot.y - state.player.y) / 12;
const dotX = clamp(x + size / 2 + relX, x + 8, x + size - 8);
const dotY = clamp(y + size / 2 + relY, y + 8, y + size - 8);
ctx.fillStyle = '#f87171';
ctx.beginPath();
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
ctx.fill();
});
}
function drawStatus(now) {
if (now < state.noticeUntil) {
ctx.fillStyle = 'rgba(11,15,20,0.66)';
ctx.fillRect(16, 16, 260, 42);
ctx.fillStyle = '#eef2f7';
ctx.font = '600 16px Inter, system-ui, sans-serif';
ctx.fillText(state.message, 30, 42);
}
if (performance.now() < state.reloadingUntil) {
ctx.fillStyle = '#fbbf24';
ctx.font = '600 14px Inter, system-ui, sans-serif';
ctx.fillText('Reloading…', canvas.width * 0.5 - 34, canvas.height - 110);
}
}
function render(now) {
drawBackground(canvas.width, canvas.height);
if (!state.player) {
ctx.fillStyle = '#eef2f7';
ctx.font = '600 30px Inter, system-ui, sans-serif';
ctx.fillText('Configure your loadout to begin.', 54, 120);
ctx.font = '400 18px Inter, system-ui, sans-serif';
ctx.fillStyle = '#9aa4b2';
ctx.fillText('The first delivery includes gun selection, moving bots, round summaries, and saved match reports.', 54, 156);
return;
}
drawProps();
drawBots(now);
drawCrosshair();
drawRadar();
drawWeapon(now);
drawStatus(now);
if (state.roundEnded) {
ctx.fillStyle = 'rgba(11,15,20,0.46)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#eef2f7';
ctx.font = '700 34px Inter, system-ui, sans-serif';
const title = state.matchSummary?.outcome === 'victory' ? 'Victory' : state.matchSummary?.outcome === 'timeout' ? 'Time up' : 'Defeat';
ctx.fillText(title, canvas.width / 2 - 60, canvas.height / 2 - 20);
ctx.font = '400 18px Inter, system-ui, sans-serif';
ctx.fillStyle = '#c7d0db';
ctx.fillText('Review the summary on the right and save the result.', canvas.width / 2 - 170, canvas.height / 2 + 16);
}
}
function tick(now) {
const delta = Math.min(0.032, (now - state.lastTick) / 1000);
state.lastTick = now;
if (state.recoil > 0) state.recoil = Math.max(0, state.recoil - delta * 0.14);
if (state.player && state.running) {
updatePlayer(delta);
updateBots(delta, now);
completeReload(now);
attemptAutoFire(now);
const elapsed = (now - state.startTime) / 1000;
if (elapsed >= ROUND_DURATION) finishRound('timeout');
updateHud();
}
render(now);
requestAnimationFrame(tick);
}
async function fetchMatches() {
try {
const response = await fetch(`${apiUrl}?limit=8`, { headers: { 'Accept': 'application/json' } });
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Unable to load matches.');
state.renderMatches = data.matches || [];
renderMatchesList();
} catch (error) {
showToast(error.message || 'Unable to refresh matches.');
}
}
function renderMatchesList() {
if (!matchesList) return;
const matches = state.renderMatches || [];
if (!matches.length) {
matchesList.innerHTML = `
<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>
`;
return;
}
matchesList.innerHTML = matches.map((match) => `
<a class="match-row" href="/match.php?id=${match.id}">
<div>
<strong>#${match.id}${escapeHtml(match.player_name)}</strong>
<span>${escapeHtml(match.weapon_name)}${match.kills} kills • ${formatDate(match.created_at)}</span>
</div>
<div class="match-score-wrap">
<span class="outcome-tag ${escapeHtml(match.outcome)}">${escapeHtml(match.outcome.toUpperCase())}</span>
<strong>${Number(match.score).toLocaleString()}</strong>
</div>
</a>
`).join('');
}
function formatDate(value) {
const date = new Date(value.replace(' ', 'T') + 'Z');
return Number.isNaN(date.getTime()) ? value : date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' UTC';
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
async function saveMatch() {
if (!state.matchSummary || state.saveInFlight) return;
state.saveInFlight = true;
saveButton.disabled = true;
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(state.matchSummary)
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Unable to save match.');
updateSummary(data.match);
showToast(`Saved match #${data.match.id}.`);
await fetchMatches();
} catch (error) {
saveButton.disabled = false;
showToast(error.message || 'Unable to save result.');
} finally {
state.saveInFlight = false;
}
}
weaponButtons.forEach((button) => {
button.addEventListener('click', () => setWeapon(button.dataset.weapon));
});
loadoutForm?.addEventListener('submit', (event) => {
event.preventDefault();
setWeapon(weaponInput.value || 'carbine');
spawnRound();
document.getElementById('arena')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
restartButton?.addEventListener('click', spawnRound);
saveButton?.addEventListener('click', saveMatch);
refreshMatchesButton?.addEventListener('click', fetchMatches);
canvas?.addEventListener('click', () => {
activatePointerLock();
});
canvas?.addEventListener('mousemove', (event) => {
if (!state.player || !pointer.active) return;
state.player.angle = normalizeAngle(state.player.angle + event.movementX * pointer.sensitivity);
state.player.pitch = clamp(state.player.pitch - event.movementY * pointer.sensitivity, -MAX_PITCH, MAX_PITCH);
});
canvas?.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
if (!pointer.active) return;
state.mouseTriggerHeld = true;
shoot(performance.now());
});
window.addEventListener('mouseup', (event) => {
if (event.button === 0) {
state.mouseTriggerHeld = false;
}
});
canvas?.addEventListener('contextmenu', (event) => event.preventDefault());
document.addEventListener('pointerlockchange', () => {
const locked = document.pointerLockElement === canvas;
pointer.locked = locked;
pointer.active = locked;
if (!locked) {
state.mouseTriggerHeld = false;
}
if (locked) {
showToast('Mouse locked. Use WASD to move and mouse to look around.');
} else if (state.running) {
showToast('Mouse released. Click the arena to aim again.');
}
});
document.addEventListener('pointerlockerror', () => {
pointer.locked = false;
pointer.active = true;
showToast('Mouse lock unavailable. Aim while the arena stays focused.');
});
window.addEventListener('blur', resetKeys);
document.addEventListener('visibilitychange', () => {
if (document.hidden) resetKeys();
});
window.addEventListener('keydown', (event) => {
if (["KeyW","KeyA","KeyS","KeyD","ArrowLeft","ArrowRight","Space","KeyR"].includes(event.code)) {
event.preventDefault();
}
keys[event.code] = true;
if (event.code === 'Space') {
state.spaceTriggerHeld = true;
shoot(performance.now());
}
if (event.code === 'KeyR') reload();
});
window.addEventListener('keyup', (event) => {
keys[event.code] = false;
if (event.code === 'Space') {
state.spaceTriggerHeld = false;
}
});
renderMatchesList();
updateSummary();
setWeapon('carbine');
requestAnimationFrame(tick);
});