1292 lines
55 KiB
JavaScript
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
async function saveMatch() {
|
|
if (!state.matchSummary || state.saveInFlight) return;
|
|
state.saveInFlight = true;
|
|
saveButton.disabled = true;
|
|
try {
|
|
const response = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', '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);
|
|
});
|