turfbreak / game.js
CyberMax-tools's picture
Deploy turfbreak
217da1a verified
Raw History Blame Contribute Delete
83.8 kB
// Turfbreak: a first-person tactical shooter in Port Varo, a made-up rain-soaked harbour city. Two crews (ASHEN vs
// COLDWIRE) fight over three zones; eliminations chain into killstreaks (Recon Drone, Sentry, Skyfall Strike).
// Desktop: pointer-lock mouse look, WASD, Shift sprint, Space jump, C crouch (slide while sprinting), R reload, right
// button aims down sights, 1/2 or the wheel switch weapons, G charge, F killstreak, Tab scoreboard, Esc pause.
// Phone: left thumb stick, drag the right half to look, fire / aim / reload / jump / crouch buttons, optional auto-fire
// and aim assist. Bots on both teams, a daily Contract (same map + objective for everyone), a kill-cam from your
// killer's eyes, a FINAL KILLCAM, an automatic "Play of the match" replay recorded on the device as a clip, a score
// card, challenge-a-friend links and a Telegram Mini App. Rules in sim.js, city in core.js, looks in render.js + fpv.js.
// (c) 2026 CyberMax. All rights reserved.
import { count, store, toast, share, intents, canvasBlob, download, pwa } from './cmx.js';
import * as FP from './funpass.js';
import * as skins from './skins.js';
import * as TGM from './tg.js';
import { genMap, MAPS, MAP_IDS, WEAPONS, WEAPON_IDS, ARMS, PERKS, PERK_IDS, STREAKS, TEAMS, BODY, contract, dayNumber, fmt } from './core.js';
import * as SIM from './sim.js';
const GAME = 'turfbreak';
const SITE = 'https://cybermaxtools.com';
const PATH = '/games/turfbreak/';
const $ = (id) => document.getElementById(id);
const Q = new URLSearchParams(location.search);
const CAP = Q.has('cap'); // trailer / screenshot capture: frame-stepped from outside
const COARSE = matchMedia('(pointer: coarse)').matches || /[?&]touch=1/.test(location.search);
if (COARSE) document.body.classList.add('coarse');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const angD = (a, b) => { let d = b - a; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; };
// ------------------------------------------------------------------ cosmetics (Operator Pack, $1.99 once, cosmetic only)
let SKINS = []; // filled from render.js after the dynamic import
skins.capture({ game: GAME, name: 'Turfbreak', skins: 6, price: '$1.99', preview: ['#B36BFF', '#FF5A1F', '#7FE3FF', '#FFE08A', '#4CFFB0', '#FF2E4C'],
perks: ['6 operator looks: Nightshade, Ember, Arctic, Gilded, Static and Carbon', 'Matching sleeves, weapon glow and tracer colour in first person', 'Shown in your kill-cams, clips and score cards'] });
// The full game ($2.99 once, Stripe "Turfbreak: full game, one payment"): exactly what it opens. Six lines, so the shared
// sheet adds no generic ones. Free for everyone: daily Contract, quick matches, every weapon and perk, challenge links.
const FULL_PERKS = ['SHOWDOWN mode: 3 vs 3, one life per round, first to 4', 'Veteran bots (faster, sharper)', 'Pick any map for Quick Match', 'Contract archive: replay every past daily Contract', 'Yours for good on any device (receipt code)', 'Free stays free: daily Contract, quick matches, every weapon'];
FP.init({ game: GAME, name: 'Turfbreak', perks: FULL_PERKS, exact: true, title: 'Turfbreak full game', short: 'Full game', free: 'The daily Contract, quick matches, every weapon and perk, and challenge links stay free for everyone.', extra: 'Turfbreak full game, $2.99 once: Showdown (3 vs 3, one life per round), Veteran bots, any map on demand and the Contract archive.' });
// ------------------------------------------------------------------ saved stats + settings (on this device only)
const S = Object.assign({ plays: 0, best: {}, streak: 0, lastDay: 0, name: '', weapon: 'vk9', perk: 'plated', skin: 0, vet: false, coached: false, map: 'daily', kills: 0,
fov: 90, sens: 1, tsens: 1, adsSens: 1, invert: false, autofire: true, assist: true, adsToggle: false, portraitOk: false, bob: true, skillAdj: 0, wins: 0 }, store('turfbreak:s') || {});
// saves from the first first-person build (17 Oct): new mouse scale (sens 1 now = 0.0012 rad per count, half the old
// speed) and the new 90 degree default field of view; everything else in the save is kept as it was
if ((S.feelV || 1) < 2) { if (S.fov === 84) S.fov = 90; S.sens = Math.max(0.1, Math.min(4, S.sens || 1)); S.feelV = 2; }
if (S.adsSens == null) S.adsSens = 1; if (S.bob == null) S.bob = true;
if (!S.name) S.name = ['VIPER', 'GHOST', 'NOVA', 'REAPER', 'SHADE', 'TALON', 'BLITZ', 'COBRA'][Math.floor(Math.random() * 8)] + '-' + (10 + Math.floor(Math.random() * 89));
const save = () => store('turfbreak:s', S);
const TODAY = dayNumber();
const DAY = Math.max(1, Number(Q.get('day')) || TODAY);
let chal = null; // challenge from a friend: { d, n, s, k, w }
try { const c = Q.get('c'); if (c) { const j = JSON.parse(decodeURIComponent(escape(atob(c.replace(/-/g, '+').replace(/_/g, '/'))))); if (j && j.d && j.s != null) chal = { d: Math.max(1, j.d | 0), n: String(j.n || 'A FRIEND').toUpperCase().replace(/[^A-Z0-9-]/g, '').slice(0, 12), s: Math.max(0, j.s | 0), k: Math.max(0, j.k | 0), w: WEAPONS[j.w] ? j.w : 'vk9' }; } } catch { chal = null; }
if (!chal && Q.get('cd') && Q.get('cs')) chal = { d: Math.max(1, Number(Q.get('cd')) | 0), n: 'A FRIEND', s: Math.max(0, Number(Q.get('cs')) | 0), k: 0, w: 'vk9' }; // Telegram challenge deep link (t.me/<bot>?start=tb_d<day>_vs<score>)
// ------------------------------------------------------------------ audio (WebAudio, all synthesised, original)
let AC = null, master = null, noiseB = null, muted = store('turfbreak:mute') === true, musicT = null, rainG = null;
function audio() {
if (AC || CAP) return;
try {
AC = new (window.AudioContext || window.webkitAudioContext)(); master = AC.createGain(); master.gain.value = muted ? 0 : 0.8; master.connect(AC.destination);
noiseB = AC.createBuffer(1, AC.sampleRate, AC.sampleRate); const d = noiseB.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
const n = AC.createBufferSource(); n.buffer = noiseB; n.loop = true; const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 1600; const hp = AC.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 500;
rainG = AC.createGain(); rainG.gain.value = 0.05; n.connect(lp); lp.connect(hp); hp.connect(rainG); rainG.connect(master); n.start();
} catch { AC = null; }
}
function env(node, t, a, d, peak) { const g = AC.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(0.0001, t + a + d); node.connect(g); return g; }
function out(node, pan = 0) { if (AC.createStereoPanner) { const p = AC.createStereoPanner(); p.pan.value = clamp(pan, -1, 1); node.connect(p); p.connect(master); } else node.connect(master); }
const noise = (t, f, q, a, d, peak, pan = 0, type = 'bandpass') => { const n = AC.createBufferSource(); n.buffer = noiseB; const bp = AC.createBiquadFilter(); bp.type = type; bp.frequency.value = f; bp.Q.value = q; n.connect(bp); out(env(bp, t, a, d, peak), pan); n.start(t, Math.random() * 0.5); n.stop(t + a + d + 0.05); };
const tone = (t, f0, f1, type, a, d, peak, pan = 0) => { const o = AC.createOscillator(); o.type = type; o.frequency.setValueAtTime(f0, t); if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + a + d); out(env(o, t, a, d, peak), pan); o.start(t); o.stop(t + a + d + 0.05); };
const SFX = {
shot(w, vol = 1, pan = 0, near = false) {
if (!AC || vol < 0.03) return; const t = AC.currentTime;
const f = { vk9: 1900, wasp: 2600, breach: 900, lancer: 1400, p9: 2200, sentry: 3000 }[w] || 1800; const dec = { breach: 0.34, lancer: 0.3, p9: 0.12 }[w] || 0.11;
noise(t, near ? f : f * 0.6, 0.7, 0.002, dec, 0.55 * vol, pan);
tone(t, w === 'breach' ? 140 : w === 'lancer' ? 120 : 180, 40, 'sine', 0.002, w === 'breach' || w === 'lancer' ? 0.2 : 0.08, 0.6 * vol, pan);
if (near) noise(t + 0.01, 6200, 1.2, 0.001, 0.03, 0.12 * vol, pan, 'highpass'); // mechanical snap
if (!near && vol > 0.1) noise(t + 0.06, 500, 0.5, 0.02, 0.4, 0.08 * vol, pan); // street echo
},
hit(kind) {
if (!AC) return; const t = AC.currentTime;
if (kind === 'kill') { // confirm: a heavy thud, then a bright two-note chime
tone(t, 150, 55, 'sine', 0.002, 0.16, 0.5); noise(t, 900, 0.8, 0.001, 0.06, 0.22);
tone(t + 0.05, 1568, 1568, 'triangle', 0.002, 0.16, 0.16); tone(t + 0.05, 2093, 2093, 'sine', 0.002, 0.22, 0.12); tone(t + 0.13, 2637, 2637, 'sine', 0.002, 0.26, 0.1);
} else if (kind === 'head') { tone(t, 3300, 2900, 'triangle', 0.001, 0.1, 0.2); tone(t, 1650, 1650, 'square', 0.001, 0.035, 0.06); noise(t, 5000, 2, 0.001, 0.02, 0.1); }
else { tone(t, 2400, 2100, 'triangle', 0.001, 0.045, 0.13); noise(t, 3800, 2.5, 0.001, 0.018, 0.08); tone(t, 180, 120, 'sine', 0.001, 0.05, 0.12); }
},
hurt() { if (!AC) return; const t = AC.currentTime; tone(t, 160, 70, 'sine', 0.002, 0.18, 0.4); noise(t, 400, 0.6, 0.002, 0.12, 0.25); },
boom(vol = 1, pan = 0) { if (!AC || vol < 0.03) return; const t = AC.currentTime; const n = AC.createBufferSource(); n.buffer = noiseB; n.loop = true; const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(120, t + 0.9); n.connect(lp); out(env(lp, t, 0.004, 1.0, 0.9 * vol), pan); n.start(t); n.stop(t + 1.2); tone(t, 70, 30, 'sine', 0.005, 0.6, 0.8 * vol, pan); },
cap(good) { if (!AC) return; const t = AC.currentTime; (good ? [523, 659, 784] : [392, 330, 262]).forEach((f, i) => { const o = AC.createOscillator(); o.type = 'sawtooth'; const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2200; o.frequency.value = f; o.connect(lp); out(env(lp, t + i * 0.09, 0.01, 0.35, 0.09)); o.start(t + i * 0.09); o.stop(t + i * 0.09 + 0.5); }); },
streak() { if (!AC) return; const t = AC.currentTime; const o = AC.createOscillator(); o.type = 'sawtooth'; o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(900, t + 0.5); const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 3000; o.connect(lp); out(env(lp, t, 0.02, 0.6, 0.14)); o.start(t); o.stop(t + 0.7); },
ui() { if (!AC) return; tone(AC.currentTime, 660, 660, 'triangle', 0.002, 0.05, 0.08); },
dash() { if (!AC) return; const t = AC.currentTime; const n = AC.createBufferSource(); n.buffer = noiseB; const bp = AC.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(600, t); bp.frequency.exponentialRampToValueAtTime(2400, t + 0.2); n.connect(bp); out(env(bp, t, 0.01, 0.25, 0.2)); n.start(t); n.stop(t + 0.35); },
step(vol = 1) { if (!AC) return; const t = AC.currentTime; noise(t, 220 + Math.random() * 80, 1.4, 0.002, 0.07, 0.11 * vol); noise(t, 2600, 2, 0.001, 0.025, 0.03 * vol); },
jump() { if (!AC) return; noise(AC.currentTime, 300, 1, 0.003, 0.08, 0.12); },
land(v = 1) { if (!AC) return; const t = AC.currentTime; noise(t, 160, 1, 0.002, 0.14, 0.25 * v); tone(t, 90, 50, 'sine', 0.002, 0.1, 0.2 * v); },
click() { if (!AC) return; noise(AC.currentTime, 4000, 3, 0.001, 0.02, 0.12); },
magOut() { if (!AC) return; const t = AC.currentTime; noise(t, 1800, 4, 0.001, 0.04, 0.14); noise(t + 0.05, 900, 3, 0.001, 0.05, 0.08); },
magIn() { if (!AC) return; const t = AC.currentTime; noise(t, 1300, 5, 0.001, 0.05, 0.18); noise(t + 0.12, 2600, 5, 0.001, 0.03, 0.14); },
swap() { if (!AC) return; const t = AC.currentTime; noise(t, 1500, 3, 0.002, 0.06, 0.1); noise(t + 0.09, 3200, 4, 0.001, 0.03, 0.1); },
};
function music(on) {
if (!AC) return; clearInterval(musicT); musicT = null; if (!on || muted) return;
const notes = [55, 55, 65.4, 55, 49, 55, 73.4, 65.4]; let i = 0; const step = 60 / 104 / 2;
musicT = setInterval(() => { if (!AC) return; const t = AC.currentTime + 0.02; const f = notes[i % notes.length]; i++;
const o = AC.createOscillator(); o.type = 'sawtooth'; o.frequency.value = f; const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(700, t); lp.frequency.exponentialRampToValueAtTime(140, t + step); o.connect(lp); out(env(lp, t, 0.01, step * 0.9, 0.035)); o.start(t); o.stop(t + step);
}, step * 1000);
}
function setMute(m) { muted = m; store('turfbreak:mute', m); $('mute').textContent = m ? 'MUTED' : 'SND'; if (master) master.gain.value = m ? 0 : 0.8; music(!m && state === 'play'); }
// ------------------------------------------------------------------ boot
const cv = $('c');
let World = null, world = null, G = null, attract = null, mapCache = new Map(), builtKey = '', state = 'loading';
let mode = 'daily', con = contract(DAY), curMap = null, seen = new Set(), seenT = 0;
const lb = (p) => { const b = $('lbar'); if (b) b.style.width = `${p}%`; };
function getMap(id, seed) { const k = `${id}:${seed}`; let m = mapCache.get(k); if (!m) { m = genMap(id, seed); mapCache.set(k, m); if (mapCache.size > 4) mapCache.delete(mapCache.keys().next().value); } return m; }
function ensureWorld(map) { const k = `${map.id}:${map.seed}`; if (builtKey !== k) { world.build(map); builtKey = k; } }
async function boot() {
lb(10); const BT = window.__turf.boot = {}; const mk = (k) => { BT[k] = Math.round(performance.now()); };
let ok = false; try { const c = document.createElement('canvas'); ok = !!c.getContext('webgl2'); } catch { ok = false; }
if (!ok) { $('loading').hidden = true; $('nogl').hidden = false; count(GAME, 'nogl'); return; }
mk('start'); const mod = await import('./render.js'); lb(45); World = mod.World; SKINS = mod.SKINS; mk('import');
world = new World(cv, { record: CAP, tier: Q.get('hq') ? 'high' : Q.get('lq') ? 'low' : undefined });
fit(); lb(60); mk('stage');
if (TGM.inTG) { await TGM.init({ onResize: fit, onBack: () => { if (state === 'play') pause(); else if (state === 'pause') toMenu(); } }); if (TGM.app()) { claimTG(); TGM.hello().then((r) => { tgInfo = { ...tgInfo, ...r }; renderMenu(); }); } }
con = contract(chal ? chal.d : DAY);
curMap = getMap(con.map, con.seed); mk('map'); ensureWorld(curMap); lb(85); mk('city');
startAttract(curMap); mk('attract');
// compile every shader program (the first-person ones too: your weapon in hand, bullet marks) so DEPLOY never
// stalls. Where the GPU compiles in parallel this is quick and is awaited; where it cannot (software GL, some older
// phones) the menu shows first and the compile runs right behind it, so the player is never staring at a loading bar.
const warm = () => { try { world.vm.use(S.weapon); world.vm.root.visible = true; world.decal(0, -50, 0, 0, 1, 0); world.draw(liveView(attract, { x: 0, z: 0, mode: 'orbit', r: 64, h: 52 }, true), 0); world.vm.root.visible = true; world.stage.r.compile(world.vm.root, world.cam, world.scene); } catch { /* compiles on first draw */ } world.vm.root.visible = false; };
const par = world.stage.r.extensions.has('KHR_parallel_shader_compile');
if (par) { warm(); try { await world.stage.r.compileAsync(world.scene, world.cam); } catch { /* */ } }
mk('compile');
lb(95);
$('loading').hidden = true; toMenu(); lb(100);
window.__turf.ready = true; window.__turf.readyAt = performance.now();
if (!par) setTimeout(() => { if (state === 'menu') warm(); mk('compileLate'); }, 60);
if (!CAP) requestAnimationFrame(loop);
pwa(GAME, $('install'));
count(GAME, 'view', TGM.app() ? 'telegram' : chal ? 'challenge' : '');
}
function fit() { if (world) world.stage.fit(); }
window.addEventListener('resize', () => { fit(); rotateCheck(); });
// ------------------------------------------------------------------ input: keyboard + pointer-lock mouse look
const keys = new Set(); const mouse = { down: false, rdown: false, dx: 0, dy: 0, in: false };
const pend = { dash: false, nade: false, streak: false, reload: false, jump: false, slot: null, slide: false };
const look = { yaw: -Math.PI / 2, pitch: 0, rc: 0, ry: 0, fdx: 0, fdy: 0 }; // rc/ry: recoil offset that settles back
let adsOn = false; // toggle mode state (and the touch ADS button)
const lockInfo = { requested: 0, locked: false, error: '', changes: 0, at: 0, raw: false };
const typing = () => /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName);
const locked = () => document.pointerLockElement === cv;
function requestLock() {
if (COARSE || CAP || !cv.requestPointerLock) return;
lockInfo.requested++;
try { const p = cv.requestPointerLock({ unadjustedMovement: true }); if (p && p.then) p.then(() => { lockInfo.raw = true; }, () => {}); if (p && p.catch) p.catch(() => { try { const p2 = cv.requestPointerLock(); if (p2 && p2.catch) p2.catch((e) => { lockInfo.error = String(e && e.name || e); }); } catch (e) { lockInfo.error = String(e && e.name || e); } }); } catch (e) { lockInfo.error = String(e && e.name || e); }
}
document.addEventListener('pointerlockchange', () => {
lockInfo.changes++; lockInfo.locked = locked(); lockInfo.at = performance.now(); document.body.classList.toggle('locked', lockInfo.locked);
if (!lockInfo.locked && state === 'play' && !CAP) pause(); // Esc releases the mouse: that is a pause
});
document.addEventListener('pointerlockerror', () => { lockInfo.error = 'pointerlockerror'; });
window.addEventListener('keydown', (e) => {
if (typing()) return;
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'KeyC', 'ControlLeft'].includes(e.code) && (state === 'play' || isCine())) e.preventDefault();
const first = !keys.has(e.code); keys.add(e.code);
if (isCine() && (e.code === 'Space' || e.code === 'Enter' || e.code === 'Escape')) { skipCine(); return; }
if (state === 'play') {
if (!first) return;
if (e.code === 'Space') pend.jump = true;
if (e.code === 'KeyC' || e.code === 'ControlLeft') { if (G && (G.you.sprint || G.you.moving > 5.6)) pend.slide = true; }
if (e.code === 'KeyV') pend.dash = true;
if (e.code === 'KeyG') pend.nade = true;
if (e.code === 'KeyF' || e.code === 'KeyQ') pend.streak = true;
if (e.code === 'KeyR') pend.reload = true;
if (e.code === 'Digit1') pend.slot = 0;
if (e.code === 'Digit2') pend.slot = 1;
if (e.code === 'Escape' || e.code === 'KeyP') pause();
if (e.code === 'Tab') board(true);
} else if (state === 'pause') { if (e.code === 'Escape' || e.code === 'KeyP') resume(); if (e.code === 'KeyR') restart(); }
else if (state === 'over' && e.code === 'KeyR') restart();
else if (state === 'menu' && e.code === 'Enter') startMatch('daily');
if (e.code === 'KeyM') setMute(!muted);
});
window.addEventListener('keyup', (e) => { keys.delete(e.code); if (e.code === 'Tab') board(false); });
window.addEventListener('blur', () => { keys.clear(); mouse.down = mouse.rdown = false; if (state === 'play' && !CAP) pause(); });
document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'play' && !CAP) pause(); });
document.addEventListener('mousemove', (e) => {
if (state !== 'play' || COARSE) return;
// locked: raw movement (unadjustedMovement where the browser has it: no OS acceleration, no smoothing);
// not locked (lock refused, e.g. inside some embeds): the cursor over the game still turns.
// No clamping, so fast flicks land where your hand went; only the known one-off spike some browsers send right
// after the lock is taken (hundreds of px in one event while the lock settles) is dropped.
if (!(locked() || mouse.in)) return;
const mx = e.movementX || 0, my = e.movementY || 0;
if (performance.now() - lockInfo.at < 120 && (Math.abs(mx) > 250 || Math.abs(my) > 250)) return;
if (Math.abs(mx) > 1500 || Math.abs(my) > 1500) return;
mouse.dx += mx; mouse.dy += my;
});
cv.addEventListener('mouseenter', () => { mouse.in = true; });
cv.addEventListener('mouseleave', () => { mouse.in = false; });
cv.addEventListener('mousedown', (e) => {
if (isCine()) { skipCine(); return; } audio();
if (state === 'play' && !locked()) requestLock();
if (e.button === 0) mouse.down = true; if (e.button === 2) { mouse.rdown = true; if (S.adsToggle) adsOn = !adsOn; }
cv.focus({ preventScroll: true });
});
window.addEventListener('mouseup', (e) => { if (e.button === 0) mouse.down = false; if (e.button === 2) mouse.rdown = false; });
document.addEventListener('mousedown', (e) => { if (locked() && e.target !== cv) { if (e.button === 0) mouse.down = true; if (e.button === 2) { mouse.rdown = true; if (S.adsToggle) adsOn = !adsOn; } } });
cv.addEventListener('contextmenu', (e) => e.preventDefault());
cv.addEventListener('wheel', (e) => { if (state === 'play' && G) { e.preventDefault(); pend.slot = G.you.slot ? 0 : 1; } }, { passive: false });
// ------------------------------------------------------------------ input: touch (left stick, right-half look, buttons)
const touch = { stick: null, look: null, fire: null, firing: false, crouch: false, lookDX: 0, lookDY: 0 };
const STICK_R = 52;
const rel = (t) => { const r = cv.getBoundingClientRect(); return { x: t.clientX - r.left, y: t.clientY - r.top, w: r.width, h: r.height }; };
cv.addEventListener('touchstart', (e) => {
e.preventDefault(); audio(); if (isCine()) { skipCine(); return; }
for (const t of e.changedTouches) {
const p = rel(t);
if (p.x < p.w * 0.42 && !touch.stick) { touch.stick = { id: t.identifier, ox: p.x, oy: p.y, dx: 0, dy: 0 }; const el = $('stickL'); el.style.left = `${p.x}px`; el.style.top = `${p.y}px`; el.classList.add('on'); el.firstChild.style.transform = ''; }
else if (!touch.look) touch.look = { id: t.identifier, x: t.clientX, y: t.clientY };
}
}, { passive: false });
window.addEventListener('touchmove', (e) => {
if (state === 'play') e.preventDefault();
for (const t of e.changedTouches) {
const s = touch.stick;
if (s && s.id === t.identifier) {
// floating stick: 12 % dead zone (no drift), full speed at 52 px, and the base follows a thumb that slides past the rim
const p = rel(t); let dx = (p.x - s.ox) / STICK_R, dy = (p.y - s.oy) / STICK_R; let l = Math.hypot(dx, dy);
if (l > 1.25) { s.ox += (dx / l) * (l - 1.25) * STICK_R; s.oy += (dy / l) * (l - 1.25) * STICK_R; const el = $('stickL'); el.style.left = `${s.ox}px`; el.style.top = `${s.oy}px`; dx /= l / 1.25; dy /= l / 1.25; l = 1.25; }
const vis = Math.min(1, l); $('stickL').firstChild.style.transform = `translate(${(dx / (l || 1)) * vis * 44}px,${(dy / (l || 1)) * vis * 44}px)`;
const k = l < 0.12 ? 0 : Math.min(1, (l - 0.12) / 0.88) / (l || 1); s.dx = dx * k; s.dy = dy * k;
}
for (const k of ['look', 'fire']) { const L = touch[k]; if (L && L.id === t.identifier) { touch.lookDX += t.clientX - L.x; touch.lookDY += t.clientY - L.y; L.x = t.clientX; L.y = t.clientY; } }
}
}, { passive: false });
const endTouch = (e) => { for (const t of e.changedTouches) { if (touch.stick && touch.stick.id === t.identifier) { touch.stick = null; $('stickL').classList.remove('on'); } if (touch.look && touch.look.id === t.identifier) touch.look = null; if (touch.fire && touch.fire.id === t.identifier) { touch.fire = null; touch.firing = false; $('t-fire').classList.remove('on'); } } };
window.addEventListener('touchend', endTouch); window.addEventListener('touchcancel', endTouch);
$('t-fire').addEventListener('touchstart', (e) => { e.preventDefault(); audio(); const t = e.changedTouches[0]; touch.fire = { id: t.identifier, x: t.clientX, y: t.clientY }; touch.firing = true; $('t-fire').classList.add('on'); }, { passive: false });
const tbtn = (id, fn) => { const el = $(id); el.addEventListener('touchstart', (e) => { e.preventDefault(); e.stopPropagation(); audio(); fn(); }, { passive: false }); el.addEventListener('click', fn); };
tbtn('t-ads', () => { adsOn = !adsOn; $('t-ads').classList.toggle('on', adsOn); });
tbtn('t-reload', () => { pend.reload = true; });
tbtn('t-jump', () => { pend.jump = true; });
tbtn('t-crouch', () => { if (G && (G.you.sprint || G.you.moving > 5.6)) pend.slide = true; else { touch.crouch = !touch.crouch; $('t-crouch').classList.toggle('on', touch.crouch); } });
tbtn('t-swap', () => { if (G) pend.slot = G.you.slot ? 0 : 1; });
tbtn('t-nade', () => { pend.nade = true; });
tbtn('t-streak', () => { pend.streak = true; });
// ------------------------------------------------------------------ per-step input for your operator
let assistT = null;
/** Mouse: radians per raw count at sensitivity 1.0 (~16 cm per 360 at 800 DPI; Krunker-like casual default). */
const MOUSE_K = 0.0012, TOUCH_K = 0.0046;
/**
* Look, applied every rendered frame (not only on 60 Hz sim steps), so the view answers the mouse on the very next
* frame at any refresh rate. No smoothing or acceleration for the mouse. While aiming down sights the turn rate scales
* with the zoom (the target moves the same distance under the crosshair), times the ADS sensitivity setting.
*/
function applyLook(dt) {
if (!G || state !== 'play') { mouse.dx = mouse.dy = 0; touch.lookDX = touch.lookDY = 0; return; }
const y = G.you; const W = SIM.armOf(y);
const z = 1 + (W.zoom - 1) * y.ads; const adsK = y.ads > 0.01 ? lerpN(1, S.adsSens / z, Math.min(1, y.ads * 1.4)) : 1;
const inv = S.invert ? -1 : 1;
let dyaw = mouse.dx * MOUSE_K * S.sens * adsK, dpit = -mouse.dy * MOUSE_K * S.sens * adsK * inv;
// touch: a gentle speed curve (slow drags stay precise, quick swipes turn fast)
const tv = Math.hypot(touch.lookDX, touch.lookDY) / Math.max(0.004, dt) / 1000; // px per ms
const curve = 0.8 + Math.min(0.9, tv * 0.55);
let tx = touch.lookDX * TOUCH_K * S.tsens * adsK * curve, ty = -touch.lookDY * TOUCH_K * S.tsens * adsK * inv * curve;
// touch aim assist: slow the look near an enemy and pull gently towards it while you turn, move or fire
assistT = null;
if (COARSE && S.assist) {
const best = assistTarget(y, 0.085);
if (best) {
assistT = best.ent; tx *= 0.5; ty *= 0.5;
const active = Math.abs(tx) + Math.abs(ty) > 0.0003 || touch.stick || touch.firing;
if (active) { look.yaw += angD(look.yaw, best.yaw) * Math.min(1, dt * 6); look.pitch += (best.pitch - look.pitch - look.rc) * Math.min(1, dt * 4.5); }
}
}
dyaw += tx; dpit += ty;
look.fdx += (mouse.dx + touch.lookDX); look.fdy += (mouse.dy + touch.lookDY);
mouse.dx = mouse.dy = 0; touch.lookDX = touch.lookDY = 0;
look.yaw += dyaw; look.pitch = clamp(look.pitch + dpit, -1.45, 1.45);
}
const lerpN = (a, b, t) => a + (b - a) * t;
const smooth01 = (t) => t * t * (3 - 2 * t);
function readInput(dt) {
const y = G.you; const W = ARMS[SIM.armOf(y).id];
// recoil settles back
look.rc *= Math.exp(-dt * 7.5); look.ry *= Math.exp(-dt * 9);
const inp = { mx: 0, mz: 0, yaw: look.yaw + look.ry, pitch: clamp(look.pitch + look.rc, -1.45, 1.45), fire: false };
let fwd = 0, str = 0;
if (keys.has('KeyW') || keys.has('ArrowUp')) fwd += 1; if (keys.has('KeyS') || keys.has('ArrowDown')) fwd -= 1;
if (keys.has('KeyD') || keys.has('ArrowRight')) str += 1; if (keys.has('KeyA') || keys.has('ArrowLeft')) str -= 1;
if (touch.stick) { fwd = -touch.stick.dy; str = touch.stick.dx; }
const cy = Math.cos(look.yaw), sy = Math.sin(look.yaw);
inp.mx = cy * fwd - sy * str; inp.mz = sy * fwd + cy * str;
inp.sprint = keys.has('ShiftLeft') || keys.has('ShiftRight') || (touch.stick && -touch.stick.dy > 0.9 && Math.abs(touch.stick.dx) < 0.45);
inp.crouch = keys.has('KeyC') || keys.has('ControlLeft') || touch.crouch;
inp.ads = S.adsToggle || COARSE ? adsOn : mouse.rdown;
inp.fire = mouse.down || touch.firing;
let held = touch.firing;
if (COARSE && S.autofire && !inp.fire) { const t = SIM.aimTarget(G, { ...y, yaw: inp.yaw, pitch: inp.pitch }, W.range * 1.5); if (t) { inp.fire = true; held = true; } }
if (held && !W.auto) inp.fire = (G.tick % 10) < 5; // a held touch button keeps a semi-auto sidearm firing (about 6 shots a second)
if (howtoOn && (fwd || str || inp.fire)) hideHowto(); // the how-to card closes the moment you move or shoot
inp.jump = pend.jump; inp.reload = pend.reload; inp.nade = pend.nade; inp.streak = pend.streak; inp.dash = pend.dash; inp.slide = pend.slide;
if (pend.slot != null) inp.slot = pend.slot;
pend.jump = pend.reload = pend.nade = pend.streak = pend.dash = pend.slide = false; pend.slot = null;
return inp;
}
/** The enemy closest to the crosshair (angle under `cone`) that you can see, with the yaw/pitch to its chest. */
function assistTarget(y, cone) {
const eye = SIM.eyeOf(y); let best = null, bd = cone;
for (const o of G.ents) {
if (o.team === y.team || !o.alive || !seen.has(o.id)) continue;
const dx = o.x - eye[0], dz = o.z - eye[2], d = Math.hypot(dx, dz); if (d > 45 || d < 0.5) continue;
const yw = Math.atan2(dz, dx), pt = Math.atan2(o.y + 1.1 - 0.3 * o.crouch - eye[1], d);
const a = Math.hypot(angD(look.yaw, yw), pt - look.pitch - look.rc) * Math.max(1, 6 / d);
if (a < bd) { bd = a; best = { ent: o, yaw: yw, pitch: pt }; }
}
return best;
}
// ------------------------------------------------------------------ matches
/**
* How sharp the enemy bots are (0..1): the first match is a rookie lobby, then it ramps over about 6 matches to
* normal (0.5) and adapts a little to how you do (S.skillAdj moves +-0.05 after each match). Veteran = 1.
*/
function botSkill() {
const base = Math.min(0.5, 0.1 + S.plays * 0.07);
return clamp(base + (S.skillAdj || 0), 0.05, 0.7);
}
function newMatch(m) {
mode = m; let id, seed;
if (m === 'daily') { con = contract(chal ? chal.d : DAY); id = con.map; seed = con.seed; }
else { const pick = FP.isPro() && S.map !== 'daily' ? S.map : null; id = pick || MAP_IDS[Math.floor(Math.random() * MAP_IDS.length)]; seed = (Math.random() * 1e9) >>> 0; if (S.map === 'daily' || !FP.isPro()) { if (m === 'quick') { id = MAP_IDS[Math.floor(Math.random() * MAP_IDS.length)]; } } }
if (m === 'showdown' && S.map === 'daily') { id = contract(DAY).map; }
curMap = getMap(id, seed); ensureWorld(curMap);
const skin = S.skin > 0 && skins.owned() ? S.skin : 0;
G = SIM.createMatch({ map: curMap, seed: m === 'daily' ? con.seed : seed, mode: m === 'showdown' ? 'showdown' : 'turf', hard: S.vet && FP.isPro(), skill: botSkill(), rookie: S.plays === 0 && m !== 'showdown', you: { name: S.name, weapon: S.weapon, perk: S.perk, skin } });
prevPos = null;
world.setOps(G.ents, skin);
look.yaw = G.you.yaw; look.pitch = 0; look.rc = look.ry = 0; adsOn = false; touch.crouch = false; for (const id2 of ['t-ads', 't-crouch']) $(id2).classList.remove('on');
seen = new Set(); seenT = 0; feedRows = []; kc = null; potm = null; clip = null; frameShot = null; lastHud = {}; nextCine = null; deathT = 0; bob = { ph: 0, land: 0, step: 0 };
buildZonesHud(); drawMiniBase();
}
function startMatch(m) {
audio(); SFX.ui();
if (m === 'showdown' && !FP.isPro()) { openFull('mode-showdown'); return; }
if (!world) return;
const t0 = performance.now();
newMatch(m);
for (const id of ['menu', 'over', 'pause', 'settings']) $(id).hidden = true;
$('hud').hidden = false; document.body.classList.add('playing'); $('cine').hidden = true; fit();
state = 'play'; backdrop(false); G.startedMs = performance.now() - t0; if (!window.__turf.playAt) window.__turf.playAt = performance.now();
requestLock(); goFull();
const map = MAPS[curMap.id]; banner(mode === 'daily' ? `CONTRACT #${con.day}` : mode === 'showdown' ? 'SHOWDOWN' : 'QUICK MATCH', `${map.name} · ${mode === 'showdown' ? 'FIRST TO 4 ROUNDS' : 'TAKE THE ZONES'}`);
if ((S.howto || 0) < 3 && !CAP) showHowto();
S.plays++; save(); updUnder(); rotateCheck();
if (!muted) music(true);
cv.focus({ preventScroll: true });
count(GAME, 'play', mode === 'daily' ? (chal ? 'challenge' : DAY !== TODAY ? 'archive' : 'daily') : mode);
try { window.CMXPortal && window.CMXPortal.gameplayStart && window.CMXPortal.gameplayStart(); } catch { /* */ }
}
// one-screen how-to (first 3 matches): every control on one card; it closes on your first move or shot
let howtoOn = false, howtoT = 0;
function showHowto() {
const k = (key, what) => `<span><b>${key}</b>${what}</span>`;
$('coach').innerHTML = COARSE
? `<p class="ht-t">HOW TO PLAY</p><div class="ht-g">${k('LEFT THUMB', 'move (push up to sprint)')}${k('DRAG RIGHT', 'aim')}${k('FIRE', 'or just aim: auto-fire is on')}${k('ADS', 'aim down sights')}${k('JUMP · CROUCH', 'crouch while running = slide')}${k('ZONES A B C', 'stand in one to take it')}</div><p class="ht-k">Move to start</p>`
: `<p class="ht-t">HOW TO PLAY</p><div class="ht-g">${k('WASD', 'move')}${k('MOUSE', 'aim')}${k('LEFT CLICK', 'fire')}${k('RIGHT CLICK', 'aim down sights')}${k('SHIFT', 'sprint')}${k('SPACE', 'jump · climb crates')}${k('C', 'crouch · slide while sprinting')}${k('R · 1 2 · G · F', 'reload · weapons · charge · streak')}</div><p class="ht-k">Take the zones A B C · move to start</p>`;
$('coach').classList.add('show'); howtoOn = true; howtoT = 0; // closes on the first move or shot, or after 14 s of play
}
function hideHowto() { if (!howtoOn) return; howtoOn = false; $('coach').classList.remove('show'); S.howto = (S.howto || 0) + 1; save(); }
function goFull() { // phones: full screen + landscape when the browser allows it (silently ignored otherwise)
if (!COARSE || CAP || TGM.app() || document.querySelector('meta[name="cmx-portal"]')) return; // portals run their own fullscreen (CrazyGames forbids a custom one)
try { const el = document.documentElement; if (!document.fullscreenElement && el.requestFullscreen) el.requestFullscreen({ navigationUI: 'hide' }).then(() => { try { screen.orientation && screen.orientation.lock && screen.orientation.lock('landscape').catch(() => {}); } catch { /* */ } }).catch(() => {}); } catch { /* */ }
}
function rotateCheck() { const r = $('rotate'); if (!r) return; r.hidden = !(COARSE && state === 'play' && innerHeight > innerWidth * 1.05 && !S.portraitOk && !CAP); }
$('rot-ok').onclick = () => { S.portraitOk = true; save(); rotateCheck(); };
function restart() { const t0 = performance.now(); startMatch(mode); window.__turf.restartMs = performance.now() - t0; }
function pause() { if (state !== 'play') return; state = 'pause'; $('pause').hidden = false; music(false); mouse.down = mouse.rdown = false; keys.clear(); board(false); if (locked()) try { document.exitPointerLock(); } catch { /* */ } }
function resume() { state = 'play'; $('pause').hidden = true; $('settings').hidden = true; if (!muted) music(true); requestLock(); cv.focus({ preventScroll: true }); }
function backdrop(on) { if (!world || CAP) return; const st = world.stage; if (on) { st.auto = false; if (st.q.scale !== 0.7) st.setQuality(0.7); } else { st.auto = !/[?&](fixedq|3d)=1/.test(location.search); if (st.q.scale < 0.85) st.setQuality(world.low ? 0.85 : 1); } }
function toMenu() {
state = 'menu'; backdrop(true); for (const id of ['over', 'pause', 'hud', 'cine', 'settings']) $(id).hidden = true; $('menu').hidden = false; document.body.classList.remove('playing'); fit(); music(false); rotateCheck();
if (locked()) try { document.exitPointerLock(); } catch { /* */ }
if (attract && curMap && attract.map !== curMap) startAttract(curMap);
renderMenu();
}
function startAttract(map) { attract = SIM.createMatch({ map, seed: map.seed + 3, size: 4 }); for (let i = 0; i < 60 * 9; i++) SIM.step(attract, {}); world.setOps(attract.ents, 0); } // 9 s in: bots already moving up, cheap to simulate on load
// ------------------------------------------------------------------ cinematics: kill-cam (killer's eyes), final kill-cam, play of the match
let kc = null, potm = null, nextCine = null, clip = null, frameShot = null, deathT = 0;
const isCine = () => state === 'killcam' || state === 'final' || state === 'potm';
function beginCine(kind, t0, t1, follow, title, sub, slowAt = -1) {
if (t1 - t0 < 30) return false;
const f0 = SIM.histAt(G, t0); if (!G.hist[f0]) return false;
kc = { kind, t0, t1, p: t0, follow, slowAt, last: t0, rec: null, cy: null };
state = kind; world.stage.parts.clear(); mouse.down = false; touch.firing = false;
const f = G.ents[follow]; const sk = f.you ? (SKINS[G.you.skin] || SKINS[0]) : null;
world.vm.setLook(sk && sk.jacket ? sk.jacket : f.team === 0 ? '#45473A' : '#34465A', sk && sk.visor ? sk.visor : TEAMS[f.team].color);
$('hud').hidden = true; $('cine').hidden = false; const T = $('cine-t'); T.textContent = title; T.className = `cine-t${kind === 'potm' ? ' potm' : ''}`; $('cine-s').innerHTML = sub;
if (kind === 'potm' && !CAP) startRecording();
return true;
}
function skipCine() { if (!kc) return; kc.p = kc.t1; }
function endCine() {
const k = kc; kc = null; $('cine').hidden = true;
const sk = SKINS[G.you.skin] || SKINS[0]; world.vm.setLook(sk.jacket || '#45473A', sk.visor || TEAMS[0].color);
if (k.rec) stopRecording(k.rec);
if (k.kind === 'killcam') { state = 'play'; $('hud').hidden = false; if (G.you.respawn < 1e8) G.you.respawn = Math.min(G.you.respawn, 1.0); return; }
if (k.kind === 'final') { if (!startPotm()) showOver(); return; }
if (k.kind === 'potm') showOver();
}
function startPotm() {
const b = SIM.bestMoment(G); if (!b) return false;
potm = b; const w = weaponName(b.w);
const what = b.multi >= 3 ? 'TRIPLE BREAK' : b.multi === 2 ? 'DOUBLE BREAK' : b.streak >= 5 ? `${b.streak} STREAK` : b.hs ? 'HEADSHOT' : 'ELIMINATION';
return beginCine('potm', b.t0, b.t1, G.you.id, 'PLAY OF THE MATCH', `${esc(G.you.name)} <em>·</em> ${what} <em>·</em> ${w}`, -1);
}
function stepCine(dt) {
const k = kc; const near = k.slowAt > 0 && Math.abs(k.p - k.slowAt) < 40;
const speed = k.kind === 'final' ? (near ? 0.35 : 1) : k.kind === 'potm' ? 0.85 : 0.8;
const prev = k.p; k.p = Math.min(k.t1, k.p + dt * 60 * speed);
const view = histView(k.p, k.follow, k);
for (const e of SIM.histEvents(G, prev, k.p)) { world.fx(e, view); cineSound(e, view); }
world.draw(view, dt * speed);
if (k.rec) recFrame(k.rec);
if (k.kind === 'potm' && !frameShot && k.p > (k.t0 + k.t1) / 2) grabFrame();
if (k.p >= k.t1) endCine();
}
/** A view of the match at a (fractional) tick from history, seen through the eyes of `follow` (interpolated). */
function histView(tick, follow, k) {
const i1 = Math.min(SIM.histAt(G, tick), G.hist.length - 1), i0 = Math.max(0, i1 - 1); const A = G.hist[i0], B = G.hist[i1];
const f = B.tick > A.tick ? clamp((tick - A.tick) / (B.tick - A.tick), 0, 1) : 1;
const n = G.ents.length; const ents = []; const H = SIM.HS;
const L = (o) => A.s[o] + (B.s[o] - A.s[o]) * f;
for (let i = 0; i < n; i++) {
const o = i * H; const ya = A.s[o + 2], yb = B.s[o + 2];
ents.push({ x: L(o), z: L(o + 1), yaw: ya + angD(ya, yb) * f, alive: B.s[o + 3] > 0.5, moving: L(o + 4), firing: B.s[o + 5], hp: B.s[o + 6], dash: B.s[o + 7] > 0.5, kick: B.s[o + 8], y: L(o + 9), pitch: L(o + 10), crouch: L(o + 11), slot: B.s[o + 12] % 2 >= 1 ? 1 : 0, reload: B.s[o + 12] >= 2, team: G.ents[i].team, you: G.ents[i].you, show: true, mark: 0 });
}
const e = ents[follow] || ents[0]; const ent = G.ents[follow];
if (k) { if (k.cy == null) { k.cy = e.yaw; k.cp = e.pitch; } k.cy += angD(k.cy, e.yaw) * 0.5; k.cp += (e.pitch - k.cp) * 0.5; }
const yaw = k ? k.cy : e.yaw, pitch = k ? k.cp : e.pitch;
const w = e.slot ? 'p9' : ent.weapon;
return { ents, zones: B.zones.map((z) => [z[0], z[1], false]), sentries: B.sentries, charges: B.charges,
cam: { mode: 'fps', follow, team: e.team, x: e.x, y: e.y + BODY.eye + (BODY.eyeC - BODY.eye) * e.crouch, z: e.z, yaw, pitch, fov: S.fov, zoom: 1, weapon: e.alive ? w : null, alive: e.alive, moving: e.moving, ground: true, crouch: e.crouch, ads: 0, reload: e.reload ? 0.5 : -1 } };
}
function cineSound(e) { if (e.k === 'shot') SFX.shot(e.w, 0.35, 0, kc && e.from === kc.follow); else if (e.k === 'boom') SFX.boom(0.6); else if (e.k === 'kill') SFX.hit('kill'); }
// on-device clip of the Play of the match (watermarked); nothing is uploaded
function startRecording() {
try {
if (!window.MediaRecorder || !HTMLCanvasElement.prototype.captureStream) return;
const types = ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
const type = types.find((t) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)); if (!type) return;
const src = world.stage.canvas; const sc = Math.min(1, 1280 / Math.max(src.width, src.height)); const c = document.createElement('canvas'); c.width = Math.round(src.width * sc / 2) * 2; c.height = Math.round(src.height * sc / 2) * 2;
const stream = c.captureStream(30); const chunks = []; const r = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 5_000_000 });
r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };
const g0 = G; r.onstop = () => { if (chunks.length && G === g0) { clip = { blob: new Blob(chunks, { type: type.split(';')[0] }), ext: type.includes('mp4') ? 'mp4' : 'webm' }; if (state === 'over') showClip(); } };
r.start(); kc.rec = { r, c, g: c.getContext('2d'), sub: $('cine-s').textContent };
} catch { /* recording is optional */ }
}
function recFrame(rec) {
const g = rec.g, c = rec.c, W = c.width, H = c.height;
g.drawImage(world.stage.canvas, 0, 0, W, H);
g.fillStyle = 'rgba(0,0,0,.85)'; g.fillRect(0, 0, W, H * 0.09); g.fillRect(0, H * 0.91, W, H * 0.09);
g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#FF8A1F'; g.font = `italic 800 ${Math.round(H * 0.055)}px TB, "Arial Narrow", sans-serif`; g.fillText('PLAY OF THE MATCH', W / 2, H * 0.047);
g.fillStyle = '#FFFFFF'; g.font = `800 ${Math.round(H * 0.035)}px TB, "Arial Narrow", sans-serif`; g.fillText(rec.sub.toUpperCase(), W / 2, H * 0.955);
g.textAlign = 'right'; g.font = `800 ${Math.round(H * 0.026)}px TB, "Arial Narrow", sans-serif`; g.fillStyle = 'rgba(255,255,255,.75)'; g.fillText('TURFBREAK · cybermaxtools.com/games/turfbreak', W - 12, H * 0.87);
}
function stopRecording(rec) { try { if (rec.r.state !== 'inactive') rec.r.stop(); } catch { /* */ } }
function grabFrame() { try { const src = world.stage.canvas; const c = document.createElement('canvas'); c.width = src.width; c.height = src.height; c.getContext('2d').drawImage(src, 0, 0); frameShot = c; } catch { frameShot = null; } }
function showClip() {
const v = $('clipv'), b = $('clip'); if (!clip) { b.hidden = true; v.hidden = true; return; }
const u = URL.createObjectURL(clip.blob); v.src = u; v.hidden = false; b.hidden = false; v.play().catch(() => {});
}
// ------------------------------------------------------------------ the loop
let lastT = 0, acc = 0, attractAcc = 0, bob = { ph: 0, land: 0, step: 0 }, autoCam = null, deadCam = null;
// render interpolation: bodies and your eyes are drawn between the last two 60 Hz sim steps, so motion is smooth on
// 90/120/144 Hz screens and never judders when a frame runs 0 or 2 steps
let prevPos = null, camY = null;
function snapPrev() {
const n = G.ents.length; if (!prevPos || prevPos.length !== n * 4) prevPos = new Float32Array(n * 4);
G.ents.forEach((e, i) => { prevPos[i * 4] = e.x; prevPos[i * 4 + 1] = e.y; prevPos[i * 4 + 2] = e.z; prevPos[i * 4 + 3] = e.crouch; });
}
/** Interpolated [x, y, z, crouch] of an operator for drawing (falls back to the sim value after a teleport). */
function ipos(e, i) {
if (!prevPos || G.over || state !== 'play' || CAP) return [e.x, e.y, e.z, e.crouch];
const a = clamp(acc / SIM.DT, 0, 1); const o = i * 4;
if (Math.abs(e.x - prevPos[o]) + Math.abs(e.z - prevPos[o + 2]) > 3) return [e.x, e.y, e.z, e.crouch];
return [prevPos[o] + (e.x - prevPos[o]) * a, prevPos[o + 1] + (e.y - prevPos[o + 1]) * a, prevPos[o + 2] + (e.z - prevPos[o + 2]) * a, prevPos[o + 3] + (e.crouch - prevPos[o + 3]) * a];
}
function loop(t) { const dt = lastT ? Math.min(0.1, (t - lastT) / 1000) : 1 / 60; lastT = t; frame(dt); requestAnimationFrame(loop); }
function frame(dt) {
if (!world) return;
if (state === 'menu' || state === 'loading') {
attractAcc += dt; let n = 0;
while (attractAcc >= SIM.DT && n < 6) { SIM.step(attract, {}); attractAcc -= SIM.DT; n++; for (const e of attract.events) world.fx(e, { ents: attract.ents, cam: { x: 0, z: 0 } }); if (attract.over) startAttract(attract.map); }
if (attractAcc > SIM.DT * 6) attractAcc = 0;
world.draw(liveView(attract, { x: 0, z: 0, mode: 'orbit', r: 64, h: 52 }, true), dt); return;
}
if (isCine()) { stepCine(dt); return; }
if (state === 'play') {
if (!G.you.bot) applyLook(dt);
if (howtoOn && (howtoT += dt) > 14) hideHowto();
acc += dt; let n = 0;
while (acc >= SIM.DT && n < 6) {
const inp = G.you.bot ? {} : readInput(SIM.DT);
snapPrev();
SIM.step(G, inp); acc -= SIM.DT; n++;
handleEvents();
if (state !== 'play') break;
}
if (acc > SIM.DT * 6) acc = 0;
if (state === 'play' && G.over && !nextCine) { nextCine = 'final'; deathT = 0.9; }
if (nextCine) { deathT -= dt; if (deathT <= 0) { const c = nextCine; nextCine = null; if (c === 'kill') startKillcam(); else startFinal(); } }
}
if (!G) return;
const view = liveView(G, fpsCam(dt));
world.draw(view, state === 'pause' ? 0 : dt);
look.fdx = look.fdy = 0;
if (state === 'play' || state === 'pause') hud(view);
}
/** First-person camera for the live match: your eyes (bob, landing dip), a teammate's when you are out in Showdown. */
function fpsCam(dt) {
const y = G.you;
if (state === 'over') return { x: y.x, z: y.z, mode: 'orbit', r: 40, h: 34 };
let f = y;
if (!y.alive && G.mode === 'showdown') { const m = G.ents.find((e) => e.team === y.team && e.alive); if (m) f = m; }
const W = SIM.armOf(f); const mv = f.ground ? Math.min(1.3, f.moving / 6.4) : 0;
bob.ph += dt * (7.2 + (f.sprint ? 3 : 0)) * (mv > 0.05 ? 1 : 0); bob.land *= Math.exp(-dt * 8);
const ip = ipos(f, f.id); const eye = [ip[0], ip[1] + BODY.eye + (BODY.eyeC - BODY.eye) * ip[3], ip[2]];
// ease the eyes over small steps (kerbs, platforms) instead of popping; jumps, falls and crouch stay 1:1
if (camY == null || !f.ground || Math.abs(eye[1] - camY) > 0.6) camY = eye[1]; else camY = eye[1] + (camY - eye[1]) * Math.exp(-dt * 22);
eye[1] = camY;
let yaw, pitch;
if (f === y && !y.bot) { yaw = look.yaw + look.ry; pitch = look.pitch + look.rc; }
else { // autopilot / spectating: a smoothed view of the bot's aim (steady for videos)
if (!autoCam || autoCam.id !== f.id) autoCam = { id: f.id, yaw: f.yaw, pitch: f.pitch };
autoCam.yaw += angD(autoCam.yaw, f.yaw) * Math.min(1, dt * 7); autoCam.pitch += (f.pitch - autoCam.pitch) * Math.min(1, dt * 6);
yaw = autoCam.yaw; pitch = autoCam.pitch;
}
const dead = !f.alive;
if (dead) { // redeploy view: raised behind where you fell, turned towards whoever took you out
const k = G.ents[f.killer]; const tx = k && k.alive ? k.x : f.x + Math.cos(yaw) * 10, tz = k && k.alive ? k.z : f.z + Math.sin(yaw) * 10;
const a = Math.atan2(tz - f.z, tx - f.x); const cx = f.x - Math.cos(a) * 2.2, cz = f.z - Math.sin(a) * 2.2, cyy = f.y + 3.1;
if (!deadCam) deadCam = { yaw, pitch }; deadCam.yaw += angD(deadCam.yaw, a) * Math.min(1, dt * 3); deadCam.pitch += (Math.atan2((k ? k.y + 1.2 : 1) - cyy, Math.hypot(tx - cx, tz - cz)) - deadCam.pitch) * Math.min(1, dt * 3);
return { mode: 'fps', follow: f.id, team: f.team, x: cx, y: cyy, z: cz, yaw: deadCam.yaw, pitch: deadCam.pitch, fov: S.fov, zoom: 1, weapon: null, alive: false, deadView: true };
}
deadCam = null;
return {
mode: 'fps', follow: f.id, team: f.team, x: eye[0], y: eye[1] + (S.bob ? Math.sin(bob.ph * 2) * 0.012 * mv - bob.land * 0.08 : 0), z: eye[2],
yaw, pitch, fov: S.fov, zoom: 1 + (W.zoom - 1) * smooth01(f.ads), weapon: W.id, alive: true, bob: S.bob, slide: f.slide > 0,
moving: f.moving, sprint: f.sprint, ads: f.ads, ground: f.ground, crouch: f.crouch, reload: f.reload > 0 ? 1 - f.reload / (f.reloadMax || 1) : -1,
lookDX: look.fdx, lookDY: look.fdy,
};
}
function liveView(g, cam, all = false) {
if (!all && g === G && (G.tick - seenT >= 6 || G.tick < seenT)) { seenT = G.tick; seen = new Set(G.ents.filter((e) => e.team === G.you.team || SIM.spotted(G, G.you.team, e)).map((e) => e.id)); }
const recon = !all && g === G && G.recon[G.you.team] > 0;
return {
ents: g.ents.map((e, i) => { const p = g === G ? ipos(e, i) : [e.x, e.y, e.z, e.crouch]; return { x: p[0], y: p[1], z: p[2], yaw: e.yaw, pitch: e.pitch, crouch: p[3], hpAbs: e.hp, name: e.name, prot: g.t < e.prot, flinch: g.t - e.lastHit < 0.12, alive: e.alive, moving: e.moving, firing: e.firing, hp: e.hp / e.maxHp, dash: e.dash > 0, kick: e.kick, team: e.team, you: e.you, show: true,
mark: all ? 0 : e.team === G.you.team ? 1 : recon && seen.has(e.id) ? 2 : 0 }; }),
zones: g.zones.map((z) => [z.owner, z.prog, z.contest]), sentries: g.sentries.map((s) => [s.x, s.z, s.yaw, s.team, s.firing]), charges: g.charges.map((c) => [c.x0, c.z0, c.x, c.z, c.t, c.fly, c.y0, c.y]),
cam: cam || { x: 0, z: 0, mode: 'orbit' },
};
}
function startKillcam() {
const y = G.you; const killer = G.ents[y.killer]; if (!killer || G.over) return;
const t1 = G.tick; const t0 = Math.max(0, t1 - 60 * 3.6);
const last = G.feed.length ? G.feed[G.feed.length - 1] : null; const w = last ? last.w : killer.weapon;
if (!beginCine('killcam', t0, t1, killer.id, 'KILLCAM', `${esc(killer.name)} <em>·</em> ${weaponName(w)}${last && last.hs ? ' <em>·</em> HEADSHOT' : ''} <em>·</em> ${killer.hp > 0 ? Math.ceil(killer.hp) : 0} HP LEFT`)) return;
}
function startFinal() {
music(false);
const lk = G.lastKill;
if (lk && G.tick - lk.tick < 60 * 30) {
const t1 = Math.min(G.tick, lk.tick + 60); const t0 = Math.max(0, lk.tick - 60 * 2.6);
const k = G.ents[lk.by]; if (beginCine('final', t0, t1, lk.by, 'FINAL KILLCAM', `${esc(k.name)} <em>·</em> ${weaponName(lk.w)}${lk.hs ? ' <em>·</em> HEADSHOT' : ''} <em>→</em> ${esc(G.ents[lk.victim].name)}`, lk.tick)) return;
}
if (!startPotm()) showOver();
}
const weaponName = (w) => (w === 'skyfall' ? 'SKYFALL STRIKE' : w === 'sentry' ? 'SENTRY' : w === 'charge' ? 'CHARGE' : ARMS[w] ? ARMS[w].short : '');
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// ------------------------------------------------------------------ events -> fx, sound, HUD
let feedRows = [];
function handleEvents() {
const y = G.you; const camF = { mode: 'fps', follow: y.id, x: y.x, z: y.z };
const view = { ents: G.ents, cam: camF };
for (const e of G.events) {
world.fx(e, view);
const rel = (x, z) => angD(look.yaw, Math.atan2(z - y.z, x - y.x));
const near = (x, z) => Math.max(0, 1 - Math.hypot(x - y.x, z - y.z) / 45); const pan = (x, z) => clamp(Math.sin(rel(x, z)), -1, 1) * 0.85;
if (e.k === 'shot') {
const src = e.from >= 0 ? G.ents[e.from] : null; const mine = src && src.you;
SFX.shot(e.w, mine ? 0.7 : near(e.x0, e.z0) * 0.6, mine ? 0 : pan(e.x0, e.z0), mine);
if (mine && !y.bot) { const W = ARMS[e.w] || WEAPONS.vk9; const k = (1 - 0.35 * y.ads) * (y.crouch > 0.5 ? 0.8 : 1); look.rc += W.recoil * k / (W.pellets > 1 ? W.pellets : 1); look.ry += (Math.random() - 0.5) * W.recoil * 0.5 * k / (W.pellets > 1 ? W.pellets : 1); bloomKick = 1; }
// bullets cracking past your head
if (!mine && src && src.team !== y.team && e.hit !== y.id) { const d = distToSeg(y.x, y.y + 1.5, y.z, e.x0, e.y0, e.z0, e.x1, e.y1, e.z1); if (d < 1.2 && Math.random() < 0.5) SFX.click(); }
}
else if (e.k === 'boom') SFX.boom(near(e.x, e.z), pan(e.x, e.z));
else if ((e.k === 'dash' || e.k === 'slide') && G.ents[e.id].you) { SFX.dash(); if (e.k === 'slide') world.stage.shake(0.05); }
else if (e.k === 'jump' && G.ents[e.id].you) SFX.jump();
else if (e.k === 'mantle' && G.ents[e.id].you) { SFX.jump(); bob.land = 0.4; }
else if (e.k === 'land' && G.ents[e.id].you) { SFX.land(Math.min(1, -e.v / 10)); bob.land = Math.min(1, -e.v / 9); }
else if (e.k === 'reloaded' && G.ents[e.id].you) SFX.magIn();
else if (e.k === 'hit') {
if (e.you) { hitMark(e.hs ? 'head' : 'body'); SFX.hit(e.hs ? 'head' : 'body'); }
if (e.id === y.id) { const d = $('dmg'); d.classList.add('on'); setTimeout(() => d.classList.remove('on'), 60); world.stage.shake(0.12); dmgDir(Math.atan2(e.fz - y.z, e.fx - y.x)); SFX.hurt(); look.rc += 0.012; }
} else if (e.k === 'kill') {
const k = G.ents[e.by], v = G.ents[e.victim];
feed(k, v, e.w, e.hs);
if (e.by === y.id) {
hitMark('kill'); SFX.hit('kill'); killConfirm(v, e.hs);
pop(e.hs ? `HEADSHOT · ${esc(v.name)}` : e.multi >= 3 ? 'TRIPLE BREAK' : e.multi === 2 ? 'DOUBLE BREAK' : `ELIMINATED · ${esc(v.name)}`, (e.multi >= 2 ? 100 + 50 * (e.multi - 1) : 100) + (e.hs ? 25 : 0));
if (e.multi >= 2) banner(e.multi >= 3 ? 'TRIPLE BREAK' : 'DOUBLE BREAK', `${e.streak} IN A ROW`);
world.stage.punch(0.003);
}
if (e.victim === y.id && !G.over) { nextCine = 'kill'; deathT = 0.75; world.stage.flash('#200000', 0.4); }
} else if (e.k === 'streak') {
const s = STREAKS.find((q) => q.id === e.s); const who = G.ents[e.id];
if (who.you) { SFX.streak(); banner(s.name, e.s === 'recon' ? 'ONLINE · ENEMIES MARKED' : COARSE ? 'TAP STREAK TO CALL IT IN' : 'PRESS F TO CALL IT IN'); }
else if (who.team !== y.team && e.s !== 'recon') banner(`ENEMY ${s.name}`, '', true);
} else if (e.k === 'recon' && e.team !== y.team && y.perk !== 'ghost') banner('ENEMY RECON DRONE', 'YOU ARE ON THEIR MAP', true);
else if (e.k === 'skyfall') { SFX.streak(); if (G.ents[e.id].team !== y.team) banner('INCOMING SKYFALL', 'MOVE', true); }
else if (e.k === 'cap') {
const good = e.team === y.team; SFX.cap(good);
const zz = G.zones.find((z) => z.id === e.zone); const mine = Math.hypot(y.x - zz.x, y.z - zz.z) < 6 && good;
if (mine) pop(`ZONE ${e.zone} CAPTURED`, 150);
const all = G.zones.every((z) => z.owner === e.team);
if (G.mode === 'turf') banner(all ? (good ? 'TURFBREAK' : 'TURF LOST') : `ZONE ${e.zone} ${good ? 'TAKEN' : 'LOST'}`, all ? (good ? 'ALL THREE ZONES ARE YOURS' : 'THEY HOLD ALL THREE') : '', !good);
} else if (e.k === 'round') { const good = e.team === y.team; SFX.cap(good); banner(good ? 'ROUND WON' : 'ROUND LOST', `${G.rounds[y.team]} - ${G.rounds[1 - y.team]}`, !good); }
else if (e.k === 'roundStart') { banner(`ROUND ${e.round}`, 'ONE LIFE · TAKE B OR WIPE THEM'); look.yaw = y.yaw; look.pitch = 0; }
else if (e.k === 'spawn' && e.id === y.id) { look.yaw = y.yaw; look.pitch = 0; look.rc = look.ry = 0; }
}
// footsteps + reload start sound
if (y.alive && y.ground && y.moving > 1.5 && !y.bot) { bob.step -= SIM.DT * (y.moving / 2.4); if (bob.step <= 0) { bob.step = 1; SFX.step(y.crouch > 0.5 ? 0.4 : y.sprint ? 1.2 : 0.8); } }
if (y.reload > 0 && !reloadWas) SFX.magOut(); reloadWas = y.reload > 0;
if (y.slot !== slotWas) { if (slotWas != null) SFX.swap(); slotWas = y.slot; }
}
let reloadWas = false, slotWas = null, bloomKick = 0;
function distToSeg(px, py, pz, ax, ay, az, bx, by, bz) { const dx = bx - ax, dy = by - ay, dz = bz - az; const L = dx * dx + dy * dy + dz * dz || 1; const t = clamp(((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / L, 0, 1); return Math.hypot(ax + dx * t - px, ay + dy * t - py, az + dz * t - pz); }
function feed(k, v, w, hs) {
const cls = (e) => (e.team === 0 ? 'a' : 'c'); const me = k.you || v.you;
feedRows.push({ t: performance.now(), html: `<p class="${me ? 'me' : ''}"><span class="${cls(k)}">${esc(k.name)}</span><em>${weaponName(w)}${hs ? '<i class="hsi" title="Headshot"></i>' : ''}</em><span class="${cls(v)}">${esc(v.name)}</span></p>` });
feedRows = feedRows.slice(-5); renderFeed();
}
function renderFeed() { const now = performance.now(); feedRows = feedRows.filter((r) => now - r.t < 6000); $('feed').innerHTML = feedRows.map((r) => r.html).join(''); }
function pop(text, pts) { const p = document.createElement('p'); p.innerHTML = `${text}<b>+${pts}</b>`; $('pops').appendChild(p); setTimeout(() => p.remove(), 1650); }
let banT = 0;
function banner(t, sub = '', bad = false) { const b = $('banner'); b.innerHTML = `${esc(t)}${sub ? `<small>${esc(sub)}</small>` : ''}`; b.style.color = bad ? '#FF6A5A' : '#FFFFFF'; b.classList.remove('show'); void b.offsetWidth; b.classList.add('show'); clearTimeout(banT); banT = setTimeout(() => b.classList.remove('show'), 2100); }
/** Kill confirm: a red skull-free "X" badge that punches in under the crosshair with the match kill count. */
let kcT = 0;
function killConfirm(v, hs) {
const el = $('kconf'); if (!el) return; el.innerHTML = `<i></i><b>${G.you.kills}</b><small>${hs ? 'HEADSHOT' : 'KILL'}</small>`;
el.className = `kconf${hs ? ' hs' : ''}`; void el.offsetWidth; el.classList.add('on'); clearTimeout(kcT); kcT = setTimeout(() => el.classList.remove('on'), 900);
world.stage.flash(hs ? '#3A2A00' : '#2A0400', 0.12);
}
let hitT = 0;
function hitMark(kind) { const h = $('hitm'); h.className = `hitm on ${kind}`; clearTimeout(hitT); hitT = setTimeout(() => { h.className = `hitm ${kind}`; }, kind === 'kill' ? 260 : 110); }
function dmgDir(a) {
const host = $('dmgdir'); const el = document.createElement('i'); el.dataset.a = String(a); host.appendChild(el);
setTimeout(() => el.remove(), 1100); if (host.children.length > 6) host.firstChild.remove();
}
function board(on) {
const b = $('board'); if (!b) return; if (!on || !G) { b.hidden = true; return; }
const rows = G.ents.slice().sort((p, q) => q.score - p.score);
b.innerHTML = `<table><tr class="hd"><td>OPERATOR</td><td class="n">K</td><td class="n">D</td><td class="n">SCORE</td></tr>${rows.map((e) => `<tr class="t${e.team}${e.you ? ' me' : ''}"><td>${esc(e.name)}</td><td class="n">${e.kills}</td><td class="n">${e.deaths}</td><td class="n">${fmt(e.score)}</td></tr>`).join('')}</table>`;
b.hidden = false;
}
// ------------------------------------------------------------------ HUD
let lastHud = {};
function buildZonesHud() {
$('zones').innerHTML = G.zones.map((z) => `<span data-z="${z.id}">${z.id}<i></i></span>`).join('');
$('zmark').innerHTML = G.zones.map((z) => `<div class="zm" data-z="${z.id}"><b>${z.id}</b><small></small></div>`).join('');
}
function hud(view) {
const y = G.you; const W = SIM.armOf(y);
const set = (id, v, f) => { if (lastHud[id] !== v) { lastHud[id] = v; f(v); } };
if (G.mode === 'turf') { set('sc0', Math.floor(G.score[0]), (v) => { $('sc0').textContent = v; }); set('sc1', Math.floor(G.score[1]), (v) => { $('sc1').textContent = v; }); }
else { set('sc0', G.rounds[0], (v) => { $('sc0').textContent = v; }); set('sc1', G.rounds[1], (v) => { $('sc1').textContent = v; }); }
const sec = Math.ceil(G.left); set('tm', sec, (v) => { $('timer').textContent = `${Math.floor(v / 60)}:${String(v % 60).padStart(2, '0')}`; });
G.zones.forEach((z, i) => { const el = $('zones').children[i]; if (!el) return; const k = `${z.owner}${z.contest ? 'c' : ''}${Math.round(z.prog * 20)}`; set(`z${i}`, k, () => { el.className = `${z.owner >= 0 ? `o${z.owner}` : ''}${z.contest ? ' ct' : ''}`; const bar = el.firstElementChild; bar.style.width = `${Math.abs(z.prog) * 100}%`; bar.style.background = z.prog > 0 ? TEAMS[0].color : TEAMS[1].color; }); });
set('hp', Math.ceil(y.hp), (v) => { $('hpf').style.width = `${(v / y.maxHp) * 100}%`; $('hpn').textContent = Math.max(0, v); $('hpf').parentElement.classList.toggle('low', v < y.maxHp * 0.35); });
set('ammo', `${y.ammo}|${y.reload > 0}`, () => { $('ammo').textContent = y.reload > 0 ? '--' : y.ammo; $('ammo').classList.toggle('low', y.ammo <= Math.ceil(W.mag * 0.25) && y.reload <= 0); });
$('rlf').style.width = y.reload > 0 ? `${(1 - y.reload / (y.reloadMax || 1)) * 100}%` : '0%';
set('wn', `${W.id}`, () => { $('wname').textContent = W.name; $('mag').textContent = `/ ${W.mag}`; $('callsign').textContent = y.name; $('slots').innerHTML = [WEAPONS[y.weapon], ARMS.p9].map((w, i) => `<span class="${i === y.slot ? 'on' : ''}"><b>${i + 1}</b>${w.short}</span>`).join(''); });
set('pips', `${y.streak}|${y.owned.join()}`, () => { $('pips').innerHTML = [1, 2, 3, 4, 5, 6, 7].map((i) => `<span class="${i <= y.streak ? 'f' : ''}${STREAKS.some((s) => s.at === i) ? ' m' : ''}" style="${STREAKS.some((s) => s.at === i) && i > y.streak ? 'background:rgba(255,255,255,.35)' : ''}"></span>`).join(''); const top = y.owned[y.owned.length - 1]; const s = STREAKS.find((q) => q.id === top); $('k-streak').hidden = !s; $('t-streak').hidden = !s; if (s) { $('k-streak').textContent = `F · ${s.name}`; $('t-streak').textContent = s.id === 'skyfall' ? 'SKYFALL' : s.name; } });
set('nade', y.nades, (v) => { $('k-nade').textContent = `G · CHARGE ${v}`; $('t-nade').textContent = `CHG ${v}`; });
let c = '';
if (!y.alive) c = G.mode === 'showdown' ? 'ELIMINATED · SPECTATING' : `REDEPLOYING ${Math.max(0, y.respawn).toFixed(1)}`;
else if (G.roundOver > 0) c = 'NEXT ROUND';
else if (G.left < 10.5 && G.mode === 'turf') c = `${Math.ceil(G.left)}`;
set('center', c, (v) => { $('center').textContent = v; });
// crosshair: gap follows the real spread (it blooms while you fire, move or jump); hidden while aiming down sights
const H = cv.clientHeight || 600; const vf = (world.cam.fov * Math.PI) / 180;
bloomKick *= 0.82;
const gap = clamp((Math.tan(SIM.spreadOf(y)) / Math.tan(vf / 2)) * (H / 2) + bloomKick * 3, 3, 90);
const xh = $('xh'); xh.style.setProperty('--g', `${gap.toFixed(1)}px`);
const hideX = y.ads > 0.55 || !y.alive || y.sprint; xh.classList.toggle('off', hideX);
const at = y.alive ? SIM.aimTarget(G, { ...y, yaw: view.cam.yaw, pitch: view.cam.pitch }, W.range * 1.6) : null;
xh.classList.toggle('foe', !!at);
// target info under the crosshair: who you are on, how much health they have left, and how far
const tg = $('tgt'); const tk = at ? `${at.ent.id}|${Math.ceil(at.ent.hp)}|${Math.round(at.dist)}` : '';
set('tgt', tk, () => { if (!at) { tg.classList.remove('on'); return; } tg.classList.add('on'); tg.innerHTML = `<b>${esc(at.ent.name)}</b><i style="--h:${Math.max(0, at.ent.hp / at.ent.maxHp) * 100}%"></i><small>${Math.round(at.dist)} M</small>`; });
$('scope').hidden = !(W.sight === 'scope' && y.ads > 0.86 && y.alive);
// damage direction arcs (relative to where you look now)
for (const el of $('dmgdir').children) el.style.transform = `rotate(${(angD(look.yaw, Number(el.dataset.a)) * 180) / Math.PI}deg)`;
// zone markers: projected on screen, clamped to the edges when off screen, with distance
const Wd = cv.clientWidth || 800;
G.zones.forEach((z, i) => {
const el = $('zmark').children[i]; if (!el) return; const p = world.toScreen(z.x, 3.2, z.z, Wd, H);
const d = Math.hypot(z.x - y.x, z.z - y.z); let px = p.x, py = p.y; const behind = !p.on || p.behind;
if (behind) { const a = angD(look.yaw, Math.atan2(z.z - y.z, z.x - y.x)); px = Wd / 2 + Math.sin(a) * Wd * 0.46; py = H * 0.5 - Math.cos(a) * H * 0.3; }
px = clamp(px, 26, Wd - 26); py = clamp(py, 70, COARSE ? H * 0.6 : H - 90);
el.style.transform = `translate(${px.toFixed(0)}px,${py.toFixed(0)}px)`; el.className = `zm${z.owner >= 0 ? ` o${z.owner}` : ''}${z.contest ? ' ct' : ''}${d < z.r ? ' in' : ''}`;
el.lastChild.textContent = d < z.r ? (z.contest ? 'CONTESTED' : z.owner === y.team ? 'HOLDING' : 'CAPTURING') : `${Math.round(d)}M`;
});
if ((G.tick & 3) === 0) { drawMini(); renderFeed(); if (!$('board').hidden) board(true); }
}
let miniBase = null;
function drawMiniBase() {
const c = document.createElement('canvas'); c.width = c.height = 520; const g = c.getContext('2d'); const s = 5; // 5 px per metre, map centred at 260
g.fillStyle = 'rgba(10,12,18,.92)'; g.fillRect(0, 0, 520, 520);
for (const o of curMap.obstacles) { if (Math.abs(o.x) > 60 || Math.abs(o.z) > 60) continue; g.fillStyle = o.kind === 'bldg' ? '#2C3140' : o.h > 1.7 ? '#475062' : '#3A4150'; g.fillRect((o.x - o.w / 2) * s + 260, (o.z - o.d / 2) * s + 260, o.w * s, o.d * s); }
miniBase = c;
}
function drawMini() {
const g = $('mini').getContext('2d'); const y = G.you; const R = 150; const s = 5 * 0.55; // radar: 30 m around you, forward is up
g.save(); g.clearRect(0, 0, 300, 300); g.beginPath(); g.arc(R, R, R - 2, 0, 7); g.clip(); g.fillStyle = 'rgba(8,10,16,.9)'; g.fillRect(0, 0, 300, 300);
g.translate(R, R); g.rotate(-Math.PI / 2 - look.yaw); g.scale(s / 5, s / 5); g.translate(-y.x * 5, -y.z * 5);
g.drawImage(miniBase, -260, -260);
for (const z of G.zones) { g.strokeStyle = z.owner >= 0 ? TEAMS[z.owner].color : '#AAB'; g.lineWidth = 8; g.beginPath(); g.arc(z.x * 5, z.z * 5, z.r * 5, 0, 7); g.stroke(); }
for (const e of G.ents) {
if (!e.alive || e.you || (e.team !== y.team && !seen.has(e.id))) continue;
g.fillStyle = e.team === 0 ? TEAMS[0].color : TEAMS[1].color; g.beginPath(); g.arc(e.x * 5, e.z * 5, 11, 0, 7); g.fill();
}
g.restore();
g.save(); g.translate(R, R); g.fillStyle = '#FFFFFF'; g.beginPath(); g.moveTo(0, -13); g.lineTo(-9, 9); g.lineTo(0, 4); g.lineTo(9, 9); g.fill();
g.strokeStyle = 'rgba(255,255,255,.22)'; g.lineWidth = 2; g.beginPath(); g.moveTo(0, 0); g.lineTo(-R * 0.7, -R * 0.75); g.moveTo(0, 0); g.lineTo(R * 0.7, -R * 0.75); g.stroke(); g.restore();
g.font = '800 26px TB, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
G.zones.forEach((z) => { const a = Math.atan2(z.z - y.z, z.x - y.x) - look.yaw; const d = Math.min(R - 22, Math.hypot(z.x - y.x, z.z - y.z) * s); g.fillStyle = z.owner >= 0 ? TEAMS[z.owner].color : '#E8EAF0'; g.fillText(z.id, R + Math.sin(a) * d, R - Math.cos(a) * d); });
}
// ------------------------------------------------------------------ results, share, challenge
let R = null;
function showOver() {
state = 'over'; music(false); $('hud').hidden = true; $('cine').hidden = true; board(false);
if (locked()) try { document.exitPointerLock(); } catch { /* */ }
R = SIM.result(G);
{ // difficulty follows you a little: a clear win nudges the bots up, a rough loss nudges them down
const kd = R.kills / Math.max(1, R.deaths);
if (R.win) S.wins = (S.wins || 0) + 1;
if (mode !== 'showdown') S.skillAdj = clamp((S.skillAdj || 0) + (R.win && kd >= 1.5 ? 0.05 : !R.win && kd < 0.7 ? -0.05 : 0), -0.2, 0.2);
}
const passed = mode === 'daily' && con.test(R);
if (mode === 'daily') {
const d = chal ? chal.d : DAY; S.best[d] = Math.max(S.best[d] || 0, R.score);
if (d === TODAY) { if (S.lastDay === TODAY - 1) S.streak++; else if (S.lastDay !== TODAY) S.streak = 1; S.lastDay = TODAY; }
const keys2 = Object.keys(S.best).map(Number).sort((a, b) => b - a).slice(0, 40); S.best = Object.fromEntries(keys2.map((k) => [k, S.best[k]]));
}
S.kills += R.kills; if (!S.coached) S.coached = true; save(); updUnder();
const res = R.draw ? 'DRAW' : R.win ? 'VICTORY' : 'DEFEAT';
$('o-res').textContent = res; $('o-res').className = `o-res ${R.win ? 'w' : 'l'}`;
$('o-sub').textContent = `${mode === 'daily' ? `CONTRACT #${con.day} · ` : mode === 'showdown' ? 'SHOWDOWN · ' : ''}${MAPS[curMap.id].name} · ${G.mode === 'turf' ? `${R.team[0]} - ${R.team[1]}` : `${R.rounds[0]} - ${R.rounds[1]} ROUNDS`}`;
const kd = R.deaths ? (R.kills / R.deaths).toFixed(2) : R.kills.toFixed(2);
$('o-stats').innerHTML = `<div class="big"><b>${fmt(R.score)}</b><small>SCORE${R.mvp ? ' · MVP' : ` · #${R.rank} OF ${R.board.length}`}</small></div><div><b>${R.kills}</b><small>ELIMS</small></div><div><b>${R.deaths}</b><small>DEATHS</small></div><div><b>${kd}</b><small>K/D</small></div><div><b>${R.heads}</b><small>HEADSHOTS</small></div><div><b>${R.bestStreak}</b><small>BEST STREAK</small></div><div><b>${R.caps}</b><small>CAPTURES</small></div>`;
$('o-board').innerHTML = '<tr class="hd"><td>OPERATOR</td><td class="n">K</td><td class="n">D</td><td class="n">SCORE</td></tr>' + R.board.map((b) => `<tr class="t${b.team}${b.you ? ' me' : ''}"><td>${esc(b.name)}</td><td class="n">${b.k}</td><td class="n">${b.d}</td><td class="n">${fmt(b.s)}</td></tr>`).join('');
const oc = $('o-con'); if (mode === 'daily') { oc.hidden = false; oc.textContent = `CONTRACT: ${con.text} · ${passed ? 'COMPLETE' : 'NOT YET'}`; oc.className = `o-con ${passed ? 'ok' : 'no'}`; } else oc.hidden = true;
const vs = $('o-vs'); if (chal && mode === 'daily') { vs.hidden = false; vs.textContent = R.score > chal.s ? `YOU BEAT ${chal.n} BY ${fmt(R.score - chal.s)}` : `${chal.n} STILL LEADS BY ${fmt(chal.s - R.score)} · RUN IT BACK`; } else vs.hidden = true;
const text = shareText(); const url = challengeUrl(); const it = intents(GAME, text, url.replace(SITE, ''));
$('sx').href = it.x || `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`; $('sw').href = it.whatsapp || `https://wa.me/?text=${encodeURIComponent(`${text} ${url}`)}`;
$('over').hidden = false; showClip();
FP.afterWin($('over-fp'), 'match');
count(GAME, 'done', `${mode}-${R.win ? 'win' : 'loss'}`);
if (TGM.app() && S.plays === 1) TGM.played().then((r) => { tgInfo = { ...tgInfo, ...r }; });
try { window.CMXPortal && window.CMXPortal.gameplayStop && window.CMXPortal.gameplayStop(); } catch { /* */ }
}
function shareText() {
const res = R.draw ? 'DRAW' : R.win ? 'VICTORY' : 'DEFEAT';
return `TURFBREAK ${mode === 'daily' ? `Contract #${con.day}` : mode === 'showdown' ? 'Showdown' : 'Quick match'} · ${MAPS[curMap.id].name}: ${res} · ${R.kills} elims · ${R.heads} headshots · best streak ${R.bestStreak} · ${fmt(R.score)} pts. Beat me:`;
}
function challengeUrl() {
const j = { d: chal ? chal.d : DAY, n: S.name, s: R ? R.score : 0, k: R ? R.kills : 0, w: S.weapon };
const b = btoa(unescape(encodeURIComponent(JSON.stringify(j)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return `${SITE}${PATH}?c=${b}`;
}
async function scoreCard() {
const W = 1080, H = 1350, c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d');
g.fillStyle = '#05060A'; g.fillRect(0, 0, W, H);
const src = frameShot || world.stage.canvas; const s = Math.max(W / src.width, (H * 0.62) / src.height);
try { g.drawImage(src, (W - src.width * s) / 2, 0, src.width * s, src.height * s); } catch { /* */ }
const gr = g.createLinearGradient(0, H * 0.3, 0, H * 0.68); gr.addColorStop(0, 'rgba(5,6,10,0)'); gr.addColorStop(1, 'rgba(5,6,10,1)'); g.fillStyle = gr; g.fillRect(0, 0, W, H);
g.fillStyle = '#05060A'; g.fillRect(0, H * 0.68, W, H);
const F = (w, sz, it = false) => `${it ? 'italic ' : ''}${w} ${sz}px TB, "Arial Narrow", sans-serif`;
g.textAlign = 'left'; g.fillStyle = '#F2F2F0'; g.font = F(800, 92, true); g.fillText('TURF', 60, 120); g.fillStyle = '#FF8A1F'; g.fillText('BREAK', 60 + g.measureText('TURF').width, 120);
g.fillStyle = '#AEB4C2'; g.font = F(700, 30); g.fillText(`${mode === 'daily' ? `CONTRACT #${con.day}` : mode.toUpperCase()} · ${MAPS[curMap.id].name}`, 64, 168);
const res = R.draw ? 'DRAW' : R.win ? 'VICTORY' : 'DEFEAT';
g.fillStyle = R.win ? '#FF8A1F' : '#D8DCE4'; g.font = F(800, 170, true); g.fillText(res, 56, H * 0.66);
const stats = [[fmt(R.score), 'SCORE'], [R.kills, 'ELIMS'], [R.heads, 'HEADSHOTS'], [R.bestStreak, 'STREAK']];
stats.forEach(([v, l], i) => { const x = 60 + i * 250; g.fillStyle = '#FFFFFF'; g.font = F(800, 84); g.fillText(String(v), x, H * 0.79); g.fillStyle = '#8A90A0'; g.font = F(700, 28); g.fillText(l, x + 2, H * 0.825); });
g.fillStyle = '#FFFFFF'; g.font = F(800, 38); g.fillText(`${S.name}${R.mvp ? ' · MVP' : ''}`, 60, H * 0.885);
if (mode === 'daily') { g.fillStyle = con.test(R) ? '#5CFFB0' : '#8A90A0'; g.font = F(700, 30); g.fillText(`CONTRACT: ${con.text.toUpperCase()} · ${con.test(R) ? 'COMPLETE' : 'NOT YET'}`, 60, H * 0.925); }
g.fillStyle = '#FF8A1F'; g.fillRect(0, H - 70, W, 70); g.fillStyle = '#120A02'; g.font = F(800, 34); g.textAlign = 'center'; g.fillText('PLAY FREE · CYBERMAXTOOLS.COM/GAMES/TURFBREAK', W / 2, H - 24);
return canvasBlob(c);
}
$('retry').onclick = () => restart();
$('o-menu').onclick = () => toMenu();
$('p-resume').onclick = () => resume();
$('p-menu').onclick = () => toMenu();
$('h-pause').addEventListener('click', () => pause());
$('mute').onclick = () => setMute(!muted);
$('share').onclick = async () => { const blob = await scoreCard(); if (TGM.app()) { TGM.share(shareText(), challengeUrl()); count(GAME, 'share', 'telegram'); return; } share(GAME, shareText(), challengeUrl().replace(SITE, ''), blob, `turfbreak-${mode === 'daily' ? `contract-${con.day}` : 'match'}.png`); };
$('card').onclick = async () => { const blob = await scoreCard(); download(blob, `turfbreak-${mode === 'daily' ? `contract-${con.day}` : 'match'}.png`); count(GAME, 'share', 'card'); toast('Score card saved.'); };
$('clip').onclick = () => { if (!clip) return; download(clip.blob, `turfbreak-play-of-the-match.${clip.ext}`); count(GAME, 'share', 'clip'); toast('Clip saved. Post it anywhere.'); };
$('beat').onclick = async () => {
const url = challengeUrl(); const text = `${S.name} scored ${fmt(R.score)} on Turfbreak Contract #${chal ? chal.d : DAY}. Beat it:`; count(GAME, 'share', 'challenge');
if (TGM.app()) { TGM.challenge(chal ? chal.d : DAY, R.score, text); return; }
if (navigator.share) { try { await navigator.share({ title: 'Turfbreak challenge', text, url }); return; } catch { /* cancelled */ } }
try { await navigator.clipboard.writeText(`${text} ${url}`); toast('Challenge link copied. Send it to a friend.'); } catch { prompt('Copy your challenge link:', url); }
};
// ------------------------------------------------------------------ settings (field of view, sensitivity, invert, touch assists)
function renderSettings() {
const row = (id, label, html) => `<label class="st-row" for="${id}"><span>${label}</span>${html}</label>`;
const tog = (k) => `<button type="button" class="chip${S[k] ? ' on' : ''}" data-t="${k}">${S[k] ? 'ON' : 'OFF'}</button>`;
$('st-body').innerHTML = [
row('st-fov', `FIELD OF VIEW <b id="st-fov-v">${S.fov}°</b>`, `<input id="st-fov" type="range" min="70" max="105" step="1" value="${S.fov}">`),
row('st-sens', `MOUSE SENSITIVITY <b id="st-sens-v">${S.sens.toFixed(2)}</b>`, `<input id="st-sens" type="range" min="0.1" max="4" step="0.05" value="${S.sens}">`),
row('st-ads', `AIM-DOWN-SIGHTS SENSITIVITY <b id="st-ads-v">${S.adsSens.toFixed(2)}</b>`, `<input id="st-ads" type="range" min="0.4" max="1.6" step="0.05" value="${S.adsSens}">`),
row('st-tsens', `TOUCH LOOK SPEED <b id="st-tsens-v">${S.tsens.toFixed(2)}</b>`, `<input id="st-tsens" type="range" min="0.3" max="2.5" step="0.05" value="${S.tsens}">`),
`<div class="st-row"><span>INVERT LOOK UP/DOWN</span>${tog('invert')}</div>`,
`<div class="st-row"><span>HEAD BOB</span>${tog('bob')}</div>`,
`<div class="st-row"><span>AIM DOWN SIGHTS: TOGGLE (OFF = HOLD)</span>${tog('adsToggle')}</div>`,
`<div class="st-row"><span>TOUCH: AUTO-FIRE ON TARGET</span>${tog('autofire')}</div>`,
`<div class="st-row"><span>TOUCH: AIM ASSIST</span>${tog('assist')}</div>`,
].join('');
for (const [id, k, f] of [['st-fov', 'fov', (v) => `${v}°`], ['st-sens', 'sens', (v) => Number(v).toFixed(2)], ['st-ads', 'adsSens', (v) => Number(v).toFixed(2)], ['st-tsens', 'tsens', (v) => Number(v).toFixed(2)]]) {
$(id).oninput = (e) => { S[k] = Number(e.target.value); $(`${id}-v`).textContent = f(S[k]); save(); };
}
for (const b of $('st-body').querySelectorAll('[data-t]')) b.onclick = () => { S[b.dataset.t] = !S[b.dataset.t]; save(); SFX.ui(); renderSettings(); };
}
function openSettings() { renderSettings(); $('settings').hidden = false; }
$('m-set').onclick = () => openSettings();
$('p-set').onclick = () => openSettings();
$('st-done').onclick = () => { $('settings').hidden = true; };
// ------------------------------------------------------------------ buying: web checkout or, inside Telegram, Stars
let tgInfo = {};
function openFull(from) { if (TGM.app()) TGM.sheet('turfbreak-full', { perks: FULL_PERKS, onPaid: claimTG }); else FP.open(from); }
function openSkins(from) { if (TGM.app()) TGM.sheet('turfbreak-skins', { perks: ['6 operator looks with their own visor glow', 'Matching sleeves, weapon glow and tracers in first person', 'Cosmetic only: same maps, same scores'], onPaid: claimTG }); else skins.open(from); }
async function claimTG() {
tgInfo = { ...tgInfo, ...(await TGM.status()) }; const codes = tgInfo.codes || {};
if (codes['turfbreak-full'] && !FP.ownsGame()) await FP.unlockGame(codes['turfbreak-full']);
if (codes['turfbreak-skins'] && !skins.owned()) await skins.unlock(codes['turfbreak-skins']);
renderMenu();
}
$('pass-btn').onclick = () => openFull('header');
$('skins-btn').onclick = () => openSkins('under');
$('tg-invite').onclick = () => { if (tgInfo.invite) TGM.invite(tgInfo.invite); };
FP.onChange(() => renderMenu());
skins.onChange(() => renderMenu());
// ------------------------------------------------------------------ menu
function renderMenu() {
if (!world || !SKINS.length) return;
const c = contract(chal ? chal.d : DAY); const pro = FP.isPro();
$('m-contract').innerHTML = `<b>CONTRACT #${c.day}${c.day === TODAY ? ' · TODAY' : ''}</b><span>${MAPS[c.map].name} · ${MAPS[c.map].area} · same map for everyone today</span><span class="obj">OBJECTIVE: ${esc(c.text)}</span>`;
const ch = $('m-chal'); if (chal) { ch.hidden = false; ch.textContent = `${chal.n} SCORED ${fmt(chal.s)} (${chal.k} ELIMS) ON CONTRACT #${chal.d} · BEAT IT`; } else ch.hidden = true;
$('m-show').innerHTML = `SHOWDOWN <em>3v3</em>${pro ? '' : '<span class="lock">FULL GAME</span>'}`;
$('m-weap').innerHTML = WEAPON_IDS.map((id) => { const w = WEAPONS[id]; const bars = (v) => [0, 1, 2, 3, 4].map((i) => `<i class="${i < v ? 'f' : ''}"></i>`).join(''); const dmg = Math.round(Math.min(5, (w.dmg * w.pellets / w.cd) / 60)), rng2 = Math.round(w.range / 10); return `<button class="wp${S.weapon === id ? ' on' : ''}" data-w="${id}" type="button"><b>${w.short}</b><small>${w.desc}</small><span class="bars">${bars(Math.max(1, dmg))}</span><span class="bars">${bars(Math.max(1, rng2))}</span></button>`; }).join('');
$('m-perk').innerHTML = PERK_IDS.map((id) => `<button class="chip${S.perk === id ? ' on' : ''}" data-p="${id}" type="button" title="${PERKS[id].desc}">${PERKS[id].name}</button>`).join('');
const own = skins.owned();
$('m-skin').innerHTML = SKINS.map((k, i) => `<button class="chip${S.skin === i && (i === 0 || own) ? ' on' : ''}${i > 0 && !own ? ' lock' : ''}" data-s="${i}" type="button"><span class="sw" style="color:${k.visor || '#FF8A1F'};background:${k.visor || '#FF8A1F'}"></span>${k.name}</button>`).join('');
$('m-name').value = S.name;
$('m-lo').innerHTML = `LOADOUT <b>${WEAPONS[S.weapon].short}</b> + <b>P9</b> · <b>${PERKS[S.perk].name}</b> · <b>${(SKINS[S.skin] || SKINS[0]).name.toUpperCase()}</b><i>${$('m-load').hidden ? 'EDIT' : 'DONE'}</i>`;
$('m-vet').classList.toggle('on', S.vet && pro); $('m-vet').classList.toggle('lock', !pro);
$('m-map').textContent = `MAP: ${S.map === 'daily' || !pro ? 'RANDOM' : MAPS[S.map].name}`; $('m-map').classList.toggle('lock', !pro);
$('m-stats').textContent = `${S.name} · ${S.plays} matches · ${S.kills} eliminations${S.streak ? ` · ${S.streak}-day contract streak` : ''}`;
$('tg-row').hidden = !(TGM.app() && tgInfo.invite);
for (const b of $('m-weap').querySelectorAll('[data-w]')) b.onclick = () => { S.weapon = b.dataset.w; save(); SFX.ui(); renderMenu(); };
for (const b of $('m-perk').querySelectorAll('[data-p]')) b.onclick = () => { S.perk = b.dataset.p; save(); SFX.ui(); renderMenu(); };
for (const b of $('m-skin').querySelectorAll('[data-s]')) b.onclick = () => { const i = Number(b.dataset.s); if (i > 0 && !skins.owned()) { openSkins('menu'); return; } S.skin = i; save(); SFX.ui(); renderMenu(); };
updUnder();
}
$('m-play').onclick = () => startMatch('daily');
$('m-lo').onclick = () => { const l = $('m-load'); l.hidden = !l.hidden; $('m-lo').setAttribute('aria-expanded', String(!l.hidden)); SFX.ui(); renderMenu(); };
$('m-quick').onclick = () => startMatch('quick');
$('m-show').onclick = () => startMatch('showdown');
$('m-vet').onclick = () => { if (!FP.isPro()) { openFull('veteran'); return; } S.vet = !S.vet; save(); renderMenu(); };
$('m-map').onclick = () => { if (!FP.isPro()) { openFull('maps'); return; } const order = ['daily', ...MAP_IDS]; S.map = order[(order.indexOf(S.map) + 1) % order.length]; save(); renderMenu(); };
$('m-name').addEventListener('input', (e) => { S.name = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '').slice(0, 10) || S.name; save(); });
function updUnder() { $('streak').textContent = S.streak || 0; $('best').textContent = S.best[DAY] ? fmt(S.best[DAY]) : '-'; $('plays').textContent = S.plays; }
// ------------------------------------------------------------------ test + trailer hooks
window.__turf = {
ready: false, restartMs: 0,
get state() { return state; },
G: () => G, attract: () => attract, world: () => world, look: () => ({ ...look }), lock: () => ({ ...lockInfo, locked: locked() }), settings: () => ({ fov: S.fov, sens: S.sens, adsSens: S.adsSens, invert: S.invert, autofire: S.autofire, assist: S.assist, bob: S.bob, mouseK: MOUSE_K, skill: G ? G.skill : botSkill(), plays: S.plays }),
howto: () => howtoOn,
start: (m = 'daily') => startMatch(m),
auto: (on = true) => { if (G) { G.you.bot = on; autoCam = null; } },
advance: (n = 1) => { for (let i = 0; i < n; i++) frame(1 / 30); },
fast: (sec) => { const n = Math.round(sec * 60); for (let i = 0; i < n && !G.over; i++) { SIM.step(G, G.you.bot ? {} : {}); } world.stage.parts.clear(); seenT = -99; autoCam = null; },
stats: () => world.stats(),
showCaption: (h) => { $('cap').innerHTML = h; },
endCard: (h) => { const e = $('endcard'); e.innerHTML = h; e.hidden = !h; if (h) { $('cine').hidden = true; $('hud').hidden = true; } },
hideUi: (on) => { document.body.classList.toggle('noui', on); for (const id of ['menu']) $(id).style.opacity = on ? 0 : ''; },
skip: () => skipCine(),
full: (on = true) => { document.body.classList.toggle('capfull', on); fit(); },
callStreak: (id) => { const y = G.you; let best = null, bd = 40; for (const o of G.ents) if (o.team !== y.team && o.alive) { const d = Math.hypot(o.x - y.x, o.z - y.z); if (d < bd && d > 8) { bd = d; best = o; } } if (best) { y.yaw = Math.atan2(best.z - y.z, best.x - y.x); look.yaw = y.yaw; if (autoCam) autoCam.yaw = y.yaw; } y.owned.push(id); SIM.useStreak(G, y, id); },
menuOff: (on = true) => { $('menu').style.visibility = on ? 'hidden' : ''; },
face: (maxD = 40) => { const y = G.you; const eye = SIM.eyeOf(y); let best = null, bd = maxD; for (const o of G.ents) if (o.team !== y.team && o.alive && SIM.canSee(G, y, o)) { const d = Math.hypot(o.x - y.x, o.z - y.z); if (d < bd) { bd = d; best = o; } } if (!best) return null; look.yaw = Math.atan2(best.z - y.z, best.x - y.x); look.pitch = Math.atan2(best.y + 1.2 - eye[1], bd); y.yaw = look.yaw; y.pitch = look.pitch; autoCam = null; return { d: bd, id: best.id }; },
aimOn: () => { const y = G.you; const t = SIM.aimTarget(G, { ...y, yaw: look.yaw, pitch: look.pitch }); return t ? { d: +t.dist.toFixed(1), head: t.head } : null; },
setLook: (yaw, pitch) => { look.yaw = yaw; look.pitch = pitch; autoCam = null; },
zoom: () => {},
killcam: () => { if (G && G.you.killer != null) { G.you.alive = false; G.you.respawn = 3; startKillcam(); } return state; },
finish: () => { if (G) { G.left = 1 / 60; G.target = Math.min(G.target, Math.max(G.score[0], G.score[1]) + 0.01); } },
over: () => showOver(),
};
boot().catch((e) => { console.error(e); $('loading').querySelector('.hint').textContent = 'Could not start the 3D city. Reload the page to try again.'; });