Spaces:
Running
Running
Download game.js from CyberMax-tools/turfbreak: direct link, hf CLI and curl.
- Browser
- Download file 83.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/turfbreak/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/game.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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.'; }); | |