Spaces:
Running
Running
Download sim.js from CyberMax-tools/turfbreak: direct link, hf CLI and curl.
- Browser
- Download file 39.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/sim.js
- Command line
-
hf download hf://spaces/CyberMax-tools/turfbreak/sim.js
-
curl -L -o sim.js https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/sim.js
39.8 kB
| // Turfbreak simulation: operators with real 3D bodies (feet height, gravity, jump, mantling onto cover, crouch, | |
| // slide, sprint, aim-down-sights), hitscan rays in 3D with head and body hitboxes (headshot multipliers), a sidearm | |
| // slot, bots that take cover, crouch behind low walls, peek and shoot only along a real line of sight, charges, zones | |
| // (Turf = Domination), killstreaks (Recon Drone, Sentry, Skyfall Strike), scoring and a 30 Hz history of the whole | |
| // match for kill-cams from the killer's eyes and the "play of the match" replay. Pure and deterministic for a seed + | |
| // inputs (fixed 60 Hz steps), no DOM, no three.js. | |
| // Mature tone, no gore: a tagged-out operator breaks into sparks and redeploys at the crew's spawn. | |
| // (c) 2026 CyberMax. All rights reserved. | |
| import { rng, WEAPONS, WEAPON_IDS, ARMS, PERK_IDS, STREAKS, TEAMS, BODY, collide, collide3, groundAt, mantleTarget, rayHit3, los3, dir3, rayBody, field, steer, inBox } from './core.js'; | |
| export const DT = 1 / 60; | |
| export const HS = 13; // floats per operator per history frame | |
| // Movement feel (see FEEL.md): linear ground acceleration and friction, Quake-style air control that keeps momentum, | |
| // a snappy 1.25 m jump with coyote time and a jump buffer, and a sprint-slide you can hop out of. | |
| const GRAV = 20, JUMP = 7.05, WALK = 6.4, ACC_G = 70, FRIC = 64, ACC_A = 22, COYOTE = 0.1, JBUF = 0.12; | |
| const SLIDE_V = 10.8, SLIDE_T = 0.75, SLIDE_FRIC = 5.5, SLIDE_CD = 0.9; | |
| /** Spawn protection (s): no damage, bots ignore you; it ends early the moment you fire. */ | |
| export const PROT = 2.0; | |
| const lerp = (a, b, t) => a + (b - a) * t; | |
| const approach = (v, t, step) => (v < t ? Math.min(t, v + step) : Math.max(t, v - step)); | |
| const CALLSIGNS = ['RAZOR', 'VANTA', 'NOMAD', 'HEX', 'KILO', 'RUNE', 'SABLE', 'ONYX', 'DRIFT', 'VOLT', 'ASH', 'CIPHER', 'MOTH', 'FLINT', 'JINX', 'NOVA', 'RIFT', 'STATIC', 'WRAITH', 'BISHOP', 'ECHO', 'GRIT', 'HAVOC', 'LYNX']; | |
| const angDiff = (a, b) => { let d = b - a; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; }; | |
| /** Eye position of an operator (world). */ | |
| export const eyeOf = (e) => [e.x, e.y + BODY.eye + (BODY.eyeC - BODY.eye) * e.crouch, e.z]; | |
| const chestOf = (e) => [e.x, e.y + 1.05 - 0.32 * e.crouch, e.z]; | |
| const headOf = (e) => [e.x, e.y + BODY.head + (BODY.headC - BODY.head) * e.crouch, e.z]; | |
| /** The weapon an operator holds right now. */ | |
| export const armOf = (e) => ARMS[e.slot === 1 ? 'p9' : e.weapon]; | |
| /** | |
| * opts: { map, seed, mode: 'turf'|'showdown', you: {name, weapon, perk, skin}, hard, size (per team), time (s), target, | |
| * skill (0..1, how sharp the bots against you are; 0.5 = normal, hard = 1), rookie (first match ever: one enemy | |
| * walks straight to the middle zone so the new player meets an easy first fight) } | |
| */ | |
| export function createMatch(opts) { | |
| const { map } = opts; | |
| const R = rng((opts.seed || 1) ^ 0x9E3779B9); | |
| const mode = opts.mode || 'turf'; | |
| const size = opts.size || (mode === 'showdown' ? 3 : 4); | |
| const skill = opts.hard ? 1 : Math.max(0, Math.min(1, opts.skill != null ? opts.skill : 0.5)); | |
| const G = { | |
| map, mode, R, t: 0, tick: 0, over: false, winner: -1, hard: !!opts.hard, skill, | |
| time: opts.time || (mode === 'showdown' ? 60 : 180), left: 0, target: opts.target || 200, | |
| score: [0, 0], rounds: [0, 0], round: 1, roundOver: 0, toWin: 4, | |
| zones: map.zones.map((z) => ({ ...z, owner: -1, prog: 0, contest: false, n: [0, 0] })), | |
| ents: [], charges: [], strikes: [], sentries: [], recon: [0, 0], events: [], hist: [], histEv: [], feed: [], | |
| you: null, lastKill: null, cover: coverSpots(map), | |
| }; | |
| G.left = G.time; | |
| const used = new Set(); const names = CALLSIGNS.slice(); | |
| const pickName = () => { let n; do { n = names[Math.floor(R() * names.length)]; } while (used.has(n) && used.size < names.length); used.add(n); return n; }; | |
| if (opts.you) used.add(opts.you.name); | |
| let id = 0; | |
| for (const team of [0, 1]) for (let k = 0; k < size; k++) { | |
| const isYou = team === 0 && k === 0 && !!opts.you; | |
| const w = isYou ? (opts.you.weapon || 'vk9') : WEAPON_IDS[Math.floor(R() * WEAPON_IDS.length)]; | |
| const perk = isYou ? (opts.you.perk || 'plated') : PERK_IDS[Math.floor(R() * PERK_IDS.length)]; | |
| const e = { | |
| id: id++, team, bot: !isYou, you: isYou, name: isYou ? opts.you.name : pickName(), skin: isYou ? (opts.you.skin || 0) : Math.floor(R() * 3), | |
| weapon: w, perk, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, yaw: 0, pitch: 0, crouch: 0, ground: true, sprint: false, ads: 0, | |
| hp: 100, maxHp: 100, alive: true, respawn: 0, spawnT: 0, slot: 0, mags: [WEAPONS[w].mag, ARMS.p9.mag], bloom: 0, | |
| ammo: WEAPONS[w].mag, reload: 0, cd: 0, firing: 0, dash: 0, dashCd: 0, nades: 2, lastHit: -9, lastFire: -9, lastHitBy: -1, trig: false, | |
| streak: 0, bestStreak: 0, kills: 0, deaths: 0, caps: 0, assists: 0, heads: 0, score: 0, dmgBy: {}, owned: [], | |
| ai: { zone: null, target: null, react: 0, err: 0, errP: 0, strafe: 1, strafeT: 0, think: 0, retreat: 0, nadeT: 2 + R() * 4, wander: 0, cover: null, coverT: 0, peekT: 0, lostT: 0, lastSeen: null, burst: 0 }, | |
| killer: null, moving: 0, kick: 0, revealT: 0, slide: 0, slideCd: 0, airT: 0, jumpBuf: 0, jumped: false, prot: 0, | |
| // bots against you get the match skill; your own teammates are never worse than normal | |
| skill: !opts.you ? skill : team === 0 ? Math.max(skill, 0.55) : skill, | |
| }; | |
| G.ents.push(e); | |
| if (isYou) G.you = e; | |
| } | |
| if (opts.you && opts.rookie) { // first match: the nearest enemy heads straight for B, a little slower on the trigger | |
| const r = G.ents.find((e) => e.team === 1); r.skill = Math.min(r.skill, 0.05); r.ai.zone = G.zones[1]; r.ai.think = 9; r.rookie = true; | |
| } | |
| for (const e of G.ents) spawn(G, e, true); | |
| return G; | |
| } | |
| /** Candidate cover spots: points hugging every box tall enough to hide a crouched body, on its four sides. */ | |
| function coverSpots(map) { | |
| const out = []; | |
| for (const o of map.obstacles) { | |
| if (o.h < 1.0 || Math.abs(o.x) > 46 || Math.abs(o.z) > 50 || o.kind === 'wall' || o.kind === 'lamp') continue; | |
| const low = o.h < 1.7; | |
| const pts = []; | |
| for (const s of [-1, 1]) { | |
| for (const f of (o.w > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + f * o.w, o.z + s * (o.d / 2 + 0.75)]); | |
| for (const f of (o.d > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + s * (o.w / 2 + 0.75), o.z + f * o.d]); | |
| } | |
| for (const [x, z] of pts) { | |
| if (Math.abs(x) > 45 || Math.abs(z) > 49) continue; | |
| if (map.obstacles.some((q) => inBox(q, x, z, 0.5))) continue; | |
| out.push({ x, z, low, o }); | |
| } | |
| } | |
| return out; | |
| } | |
| function spawn(G, e, first) { | |
| const pts = G.map.spawns[e.team]; const k = G.ents.filter((o) => o.team === e.team).indexOf(e); | |
| let p = pts[k % pts.length]; | |
| if (!first) { // redeploy at the spawn point farthest from every living enemy (no spawn-camping) | |
| let best = -1; | |
| for (const q of pts) { let near = 1e9; for (const o of G.ents) if (o.team !== e.team && o.alive) near = Math.min(near, Math.hypot(o.x - q.x, o.z - q.z)); near += G.R() * 3; if (near > best) { best = near; p = q; } } | |
| } | |
| e.x = p.x + (G.R() - 0.5) * 2; e.z = p.z + (G.R() - 0.5) * 1.5; e.yaw = p.yaw; e.pitch = 0; e.vx = e.vz = e.vy = 0; e.y = 0; e.ground = true; e.crouch = 0; | |
| e.maxHp = e.perk === 'plated' ? 130 : 100; e.hp = e.maxHp; e.alive = true; e.spawnT = G.t; e.prot = G.t + PROT; e.dmgBy = {}; | |
| e.slot = 0; e.mags = [WEAPONS[e.weapon].mag, ARMS.p9.mag]; e.ammo = e.mags[0]; e.reload = 0; e.cd = 0.3; e.nades = 2; e.streak = 0; e.dash = 0; e.dashCd = 0; e.bloom = 0; e.ads = 0; | |
| e.slide = 0; e.slideCd = 0; e.airT = 0; e.jumpBuf = 0; e.jumped = false; | |
| const keepZone = first && e.rookie; | |
| e.ai.target = null; e.ai.react = 0; if (!keepZone) { e.ai.zone = null; e.ai.think = 0; } e.ai.cover = null; e.ai.lastSeen = null; | |
| } | |
| const ev = (G, e) => { e.tick = G.tick; G.events.push(e); G.histEv.push(e); }; | |
| /** Can `m` see operator `o`? (eye to head or chest, real 3D line of sight) */ | |
| function sees(G, m, o) { | |
| const a = eyeOf(m); const h = headOf(o); if (los3(G.map, a[0], a[1], a[2], h[0], h[1], h[2])) return 'head'; | |
| const c = chestOf(o); if (los3(G.map, a[0], a[1], a[2], c[0], c[1], c[2])) return 'chest'; | |
| return null; | |
| } | |
| export const canSee = (G, m, o) => !!sees(G, m, o); | |
| /** Is enemy `o` visible to team `team`? (line of sight from any alive teammate, a fresh shot, or the recon drone) */ | |
| export function spotted(G, team, o) { | |
| if (!o.alive) return false; | |
| if (G.recon[team] > 0 && o.perk !== 'ghost') return true; | |
| if (G.t - o.lastFire < 0.7) return true; | |
| for (const m of G.ents) if (m.team === team && m.alive && Math.hypot(m.x - o.x, m.z - o.z) < 48 && sees(G, m, o)) return true; | |
| return false; | |
| } | |
| /** Current spread (radians, half-angle) of an operator's weapon: hip vs aimed, moving, airborne, crouched, bloom. */ | |
| export function spreadOf(e) { | |
| const W = armOf(e); const mv = Math.hypot(e.vx, e.vz); | |
| if (W.id === 'breach') return W.spread * (1 - 0.35 * e.ads) + e.bloom; | |
| // the first aimed shot from a steady stance goes exactly where the crosshair / dot is | |
| if (e.ads > 0.95 && mv < 1 && e.ground && e.bloom < 0.002) return 0; | |
| const s = W.spread * (1 - 0.88 * e.ads) * (mv > 1 ? 1 + 0.6 * Math.min(1, mv / WALK) : 0.8) * (e.ground ? 1 : 2.2) * (1 - 0.22 * e.crouch); | |
| return s + e.bloom * (1 - 0.5 * e.ads); | |
| } | |
| /** What the operator's crosshair is on: { ent, dist, head } for the first enemy along the exact view ray, else null. */ | |
| export function aimTarget(G, e, maxD = 80) { | |
| const o = eyeOf(e); const d = dir3(e.yaw, e.pitch); | |
| const wall = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * maxD, o[1] + d[1] * maxD, o[2] + d[2] * maxD).f * maxD; | |
| let best = null; | |
| for (const q of G.ents) { if (q.team === e.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < wall && (!best || h.t < best.dist)) best = { ent: q, dist: h.t, head: h.head }; } | |
| return best; | |
| } | |
| // ---------------------------------------------------------------- shooting | |
| function fire(G, e) { | |
| const W = armOf(e); | |
| e.cd = W.cd; e.ammo -= 1; e.mags[e.slot] = e.ammo; e.lastFire = G.t; e.firing = 0.08; e.kick = W.kick; e.prot = 0; // firing ends spawn protection | |
| const o = eyeOf(e); const sp = spreadOf(e); | |
| for (let p = 0; p < W.pellets; p++) { | |
| // spread: uniform in a cone; bots add their aim error | |
| const a = G.R() * Math.PI * 2, r = Math.sqrt(G.R()) * sp; | |
| const yaw = e.yaw + Math.cos(a) * r + (e.bot ? e.ai.err : 0), pitch = e.pitch + Math.sin(a) * r + (e.bot ? e.ai.errP : 0); | |
| const d = dir3(yaw, pitch); const L = W.range * 2.4; | |
| const wh = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * L, o[1] + d[1] * L, o[2] + d[2] * L); let best = wh.f * L; let hit = null, head = false; | |
| for (const q of G.ents) { | |
| if (q.team === e.team || !q.alive) continue; | |
| const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); | |
| if (h && h.t < best) { best = h.t; hit = q; head = h.head; } | |
| } | |
| let hitS = null; | |
| for (const s of G.sentries) { | |
| if (s.team === e.team || s.hp <= 0) continue; | |
| const h = rayBody(o, d, s.x, 0, s.z, 0.55); if (h && h.t < best) { best = h.t; hit = null; head = false; hitS = s; } | |
| } | |
| const x1 = o[0] + d[0] * best, y1 = o[1] + d[1] * best, z1 = o[2] + d[2] * best; | |
| ev(G, { k: 'shot', from: e.id, w: W.id, x0: o[0], y0: o[1] - 0.12, z0: o[2], x1, y1, z1, hit: hit ? hit.id : hitS ? -2 : -1, hs: head, nx: hit || hitS ? 0 : wh.nx, ny: hit || hitS ? 0 : wh.ny, nz: hit || hitS ? 0 : wh.nz, team: e.team, world: !hit && !hitS && wh.f < 1 }); | |
| if (hit) { | |
| const fall = W.id === 'breach' ? Math.max(0.3, 1 - best / W.range) : best > W.range ? 0.72 : best > W.range * 0.7 ? 0.86 : 1; | |
| // bots ease off a human while the match skill is low (new players), full damage from skill 1 | |
| const easy = e.bot && hit.you ? lerp(0.55, 1, Math.min(1, e.skill * 1.6)) : 1; | |
| damage(G, hit, W.dmg * fall * (head ? W.hs : 1) * easy, e, W.id, head, [x1, y1, z1]); | |
| } else if (hitS) { hitS.hp -= W.dmg; if (hitS.hp <= 0) ev(G, { k: 'boom', x: hitS.x, y: 0.8, z: hitS.z, r: 2, small: true }); } | |
| } | |
| e.bloom = Math.min(W.spread * 2.5 + 0.03, e.bloom + W.bloom); | |
| if (e.ammo <= 0) startReload(e); | |
| } | |
| function startReload(e) { const W = armOf(e); if (e.reload > 0 || e.ammo >= W.mag) return; e.reload = W.reload * (e.perk === 'quick' ? 0.7 : 1); e.reloadMax = e.reload; } | |
| function damage(G, o, dmg, by, weapon, head = false, at = null) { | |
| if (!o.alive || G.t < o.prot) return; // spawn protection | |
| o.hp -= dmg; o.lastHit = G.t; o.lastHitBy = by.id; o.dmgBy[by.id] = (o.dmgBy[by.id] || 0) + dmg; | |
| ev(G, { k: 'hit', id: o.id, by: by.id, dmg, x: o.x, z: o.z, y: at ? at[1] : o.y + 1, hx: at ? at[0] : o.x, hz: at ? at[2] : o.z, fx: by.x, fz: by.z, hs: head, you: by.you }); | |
| if (o.bot && by.team !== o.team && (o.ai.target == null || G.ents[o.ai.target] && !G.ents[o.ai.target].alive)) { o.ai.target = by.id; o.ai.react = lerp(0.6, 0.2, o.skill); o.ai.lastSeen = { x: by.x, z: by.z, t: G.t }; } | |
| if (o.hp <= 0) kill(G, o, by, weapon, head); | |
| } | |
| function kill(G, o, by, weapon, head = false) { | |
| o.alive = false; o.hp = 0; o.deaths++; o.streak = 0; o.respawn = G.mode === 'showdown' ? 1e9 : 4.2; o.killer = by.id; | |
| by.kills++; by.streak++; by.bestStreak = Math.max(by.bestStreak, by.streak); by.score += 100; | |
| if (head) { by.heads++; by.score += 25; } | |
| const inZone = G.zones.find((z) => Math.hypot(o.x - z.x, o.z - z.z) < z.r + 1 && z.owner === by.team); | |
| if (inZone) by.score += 50; | |
| for (const [aid, d] of Object.entries(o.dmgBy)) { const a = G.ents[aid]; if (a && a !== by && a.team === by.team && d >= 35) { a.assists++; a.score += 50; } } | |
| const recent = G.feed.filter((f) => f.by === by.id && G.t - f.t < 4).length; | |
| const multi = recent + 1; | |
| if (multi >= 2) by.score += 50 * (multi - 1); | |
| G.feed.push({ t: G.t, by: by.id, victim: o.id, w: weapon, multi, hs: head }); | |
| G.lastKill = { tick: G.tick, by: by.id, victim: o.id, w: weapon, multi, team: by.team, hs: head }; | |
| ev(G, { k: 'kill', by: by.id, victim: o.id, w: weapon, x: o.x, y: o.y, z: o.z, multi, streak: by.streak, team: by.team, hs: head, fx: by.x, fz: by.z }); | |
| // killstreaks (per life) | |
| const s = STREAKS.find((k) => k.at === by.streak); | |
| if (s && by.alive) { by.owned.push(s.id); ev(G, { k: 'streak', id: by.id, s: s.id }); if (by.bot || s.id === 'recon') useStreak(G, by, s.id); } | |
| } | |
| // ---------------------------------------------------------------- killstreaks | |
| export function useStreak(G, e, id = null) { | |
| const sid = id || e.owned[e.owned.length - 1]; if (!sid) return false; | |
| const i = e.owned.lastIndexOf(sid); if (i < 0) return false; e.owned.splice(i, 1); | |
| if (sid === 'recon') { G.recon[e.team] = 12; ev(G, { k: 'recon', team: e.team, id: e.id }); } | |
| if (sid === 'sentry') { const s = { team: e.team, by: e.id, x: e.x + Math.cos(e.yaw) * 1.2, z: e.z + Math.sin(e.yaw) * 1.2, yaw: e.yaw, hp: 140, life: 22, cd: 0, firing: 0 }; [s.x, s.z] = collide(G.map, s.x, s.z, 0.7); G.sentries.push(s); ev(G, { k: 'sentry', team: e.team, x: s.x, z: s.z, id: e.id }); } | |
| if (sid === 'skyfall') { | |
| let a = e.yaw; | |
| if (e.bot) { // aim at the crowd | |
| let best = 0; for (const o of G.ents) if (o.team !== e.team && o.alive) { const n = G.ents.filter((p) => p.team === o.team && p.alive && Math.hypot(p.x - o.x, p.z - o.z) < 8).length; if (n > best) { best = n; a = Math.atan2(o.z - e.z, o.x - e.x); } } | |
| } | |
| const pts = []; for (let k = 0; k < 7; k++) { const d = 7 + k * 4.4; pts.push({ x: e.x + Math.cos(a) * d, z: e.z + Math.sin(a) * d, t: 1.6 + k * 0.17 }); } | |
| G.strikes.push({ team: e.team, by: e.id, pts, t: 0 }); | |
| ev(G, { k: 'skyfall', team: e.team, id: e.id, pts: pts.map((p) => [p.x, p.z]) }); | |
| } | |
| return true; | |
| } | |
| function explode(G, x, z, r, dmg, by, weapon, y = 0.3) { | |
| ev(G, { k: 'boom', x, y, z, r, by: by ? by.id : -1, w: weapon }); | |
| for (const o of G.ents) { | |
| if (!o.alive || (by && o.team === by.team && o !== by)) continue; | |
| const d = Math.hypot(o.x - x, o.z - z, (o.y + 0.9 - y) * 0.6); if (d > r) continue; | |
| if (!los3(G.map, x, y + 0.4, z, o.x, o.y + 1.0, o.z) && !los3(G.map, x, y + 0.4, z, o.x, o.y + 1.5, o.z)) continue; | |
| damage(G, o, dmg * (1 - (d / r) * 0.6) * (o === by ? 0.4 : 1), by, weapon); | |
| } | |
| } | |
| // ---------------------------------------------------------------- bots | |
| const PREF = { breach: 6, lancer: 24, wasp: 10, vk9: 15 }; | |
| function think(G, e) { | |
| const A = e.ai; | |
| // target: an enemy this bot can actually see (3D line of sight, in its field of view or very close / just fired) | |
| let tgt = A.target != null ? G.ents[A.target] : null; | |
| if (tgt && (!tgt.alive || G.t < tgt.prot || Math.hypot(tgt.x - e.x, tgt.z - e.z) > 52)) { tgt = null; A.target = null; } | |
| if (tgt) { if (sees(G, e, tgt)) { A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0; } else { A.lostT += 1 / 15; if (A.lostT > 1.4) { tgt = null; A.target = null; } } } | |
| if (!tgt) { | |
| let bd = 44; | |
| for (const o of G.ents) { | |
| if (o.team === e.team || !o.alive || G.t < o.prot) continue; const d = Math.hypot(o.x - e.x, o.z - e.z); | |
| if (d >= bd) continue; | |
| const fov = Math.abs(angDiff(e.yaw, Math.atan2(o.z - e.z, o.x - e.x))) < 1.25; | |
| const noticed = G.recon[e.team] > 0 || fov || d < 7 || G.t - o.lastFire < 0.6; | |
| if (noticed && sees(G, e, o)) { bd = d; tgt = o; } | |
| } | |
| if (tgt) { | |
| A.target = tgt.id; A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0; | |
| // reaction time and first-aim error follow the bot's skill (0 = rookie lobby, 0.5 = normal, 1 = Veteran) | |
| const k = e.skill; | |
| A.react = lerp(0.82, 0.22, k) + G.R() * lerp(0.3, 0.1, k) + (tgt.crouch > 0.5 ? 0.1 : 0) + (tgt.you && k < 0.3 ? 0.12 : 0); | |
| A.err = (G.R() - 0.5) * lerp(0.46, 0.14, k); A.errP = (G.R() - 0.5) * lerp(0.22, 0.07, k); | |
| } | |
| } | |
| // objective: a zone we don't hold (nearest), sometimes hold one we do | |
| if (!A.zone || A.think <= 0) { | |
| A.think = 2 + G.R() * 2; | |
| const zs = G.zones.slice().sort((a, b) => Math.hypot(a.x - e.x, a.z - e.z) - Math.hypot(b.x - e.x, b.z - e.z)); | |
| const want = zs.filter((z) => z.owner !== e.team); | |
| A.zone = G.mode === 'showdown' ? G.zones[1] : (want.length && G.R() < 0.82 ? want[Math.min(want.length - 1, G.R() < 0.7 ? 0 : 1)] : zs[Math.floor(G.R() * zs.length)]); | |
| A.wander = G.R() * Math.PI * 2; | |
| } | |
| return tgt; | |
| } | |
| /** The nearest cover spot that blocks the threat's view of a (crouched) body there, and that we can walk to. */ | |
| function findCover(G, e, th) { | |
| let best = null, bd = 13; | |
| const te = eyeOf(th); | |
| for (const c of G.cover) { | |
| const d = Math.hypot(c.x - e.x, c.z - e.z); if (d > bd) continue; | |
| // the box must sit between the spot and the threat | |
| if ((c.o.x - c.x) * (th.x - c.x) + (c.o.z - c.z) * (th.z - c.z) <= 0) continue; | |
| const y = groundAt(G.map, c.x, c.z, 0); | |
| if (los3(G.map, te[0], te[1], te[2], c.x, y + (c.low ? 0.95 : 1.4), c.z)) continue; | |
| if (G.ents.some((m) => m !== e && m.alive && m.ai.cover === c)) continue; | |
| bd = d; best = c; | |
| } | |
| return best; | |
| } | |
| function botStep(G, e, dt) { | |
| const A = e.ai; const W = armOf(e); | |
| A.think -= dt; A.strafeT -= dt; A.nadeT -= dt; A.coverT -= dt; A.peekT -= dt; | |
| const tgt = (G.tick + e.id) % 4 === 0 || A.target == null ? think(G, e) : (A.target != null ? G.ents[A.target] : null); | |
| let mx = 0, mz = 0, fire = false, crouch = false, jump = false; | |
| const hurt = e.hp < e.maxHp * 0.45 && G.t - e.lastHit < 2.5; | |
| // switch to the sidearm when the main weapon is empty in a fight at close range, back when safe | |
| if (tgt && e.slot === 0 && e.ammo === 0 && Math.hypot(tgt.x - e.x, tgt.z - e.z) < 12 && e.mags[1] > 0) setSlot(e, 1); | |
| else if (!tgt && e.slot === 1 && e.reload <= 0) setSlot(e, 0); | |
| // want cover? (hurt, reloading or under fire from a target we can't beat in the open) | |
| if (tgt && !A.cover && A.coverT <= 0 && (hurt || e.reload > 0 || (G.t - e.lastHit < 0.6 && G.R() < 0.35))) { | |
| A.cover = findCover(G, e, tgt); A.coverT = A.cover ? 4 + G.R() * 2 : 1.5; A.peekT = 1.2 + G.R() * 1.4; | |
| } | |
| if (A.cover && (A.coverT <= 0 || !tgt && G.R() < 0.01)) { A.cover = null; A.coverT = 2.5 + G.R() * 2; } | |
| let lookAt = null; | |
| if (tgt) { | |
| const seen = A.lostT === 0; | |
| const aimP = seen ? (sees(G, e, tgt) === 'head' && G.R() < 0.4 ? headOf(tgt) : chestOf(tgt)) : [A.lastSeen.x, 1.2, A.lastSeen.z]; | |
| const dx = aimP[0] - e.x, dz = aimP[2] - e.z; const d = Math.hypot(dx, dz); | |
| const eye = eyeOf(e); const wantY = Math.atan2(dz, dx), wantP = Math.atan2(aimP[1] - eye[1], d); | |
| const k = Math.min(1, dt * lerp(4.5, 13, e.skill)); const settle = lerp(0.45, 1.7, e.skill); | |
| e.yaw += angDiff(e.yaw, wantY + A.err) * k; e.pitch += (wantP + A.errP - e.pitch) * k; | |
| A.err *= Math.exp(-dt * settle); A.errP *= Math.exp(-dt * settle); | |
| A.react -= dt; | |
| const onTarget = Math.abs(angDiff(e.yaw, wantY)) < 0.22 && Math.abs(e.pitch - wantP) < 0.2; | |
| // fire in bursts (auto weapons) when it can see the target | |
| if (A.react <= 0 && seen && d < W.range * 1.5 && onTarget) { | |
| if (A.burst <= 0) A.burst = W.auto ? 0.35 + G.R() * 0.5 : 0.15; fire = true; | |
| } | |
| if (A.burst > 0) { A.burst -= dt; if (A.burst <= 0) { A.react = W.auto ? 0.12 + G.R() * 0.22 : 0.05; } } | |
| if (A.cover) { | |
| // move to cover, crouch behind low cover, reload there, then peek out (stand / strafe) to shoot | |
| const c = A.cover; const cd = Math.hypot(c.x - e.x, c.z - e.z); | |
| if (cd > 0.6) { const f = field(G.map.nav, c.x, c.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); if (sx || sz) { mx = sx; mz = sz; } else { mx = (c.x - e.x) / cd; mz = (c.z - e.z) / cd; } fire = fire && cd < 4; } | |
| else { | |
| const peeking = A.peekT < 0 && A.peekT > -1.1 && e.reload <= 0 && !hurt; | |
| if (A.peekT < -1.1) A.peekT = 0.9 + G.R() * 1.6; | |
| if (c.low) crouch = !peeking; else if (peeking) { const ux = dx / (d || 1), uz = dz / (d || 1); mx = -uz * A.strafe * 0.9; mz = ux * A.strafe * 0.9; } | |
| if (!peeking) fire = false; | |
| if (e.reload <= 0 && e.ammo < W.mag * 0.5 && !peeking) startReload(e); | |
| } | |
| } else { | |
| if (A.strafeT <= 0) { A.strafe = G.R() < 0.5 ? -1 : 1; A.strafeT = 0.5 + G.R() * 1.0; if (G.R() < 0.12 && e.ground) jump = true; if (G.R() < 0.2) A.crouchT = 0.6; } | |
| const pref = PREF[W.id] || 12; | |
| const toward = !seen ? 1 : d > pref + 3 ? 1 : d < pref - 4 ? -0.8 : 0; | |
| const ux = dx / (d || 1), uz = dz / (d || 1); | |
| if (seen) { mx = ux * toward + -uz * A.strafe * 0.85; mz = uz * toward + ux * A.strafe * 0.85; } | |
| else { const f = field(G.map.nav, A.lastSeen.x, A.lastSeen.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); mx = sx; mz = sz; } | |
| if ((A.crouchT || 0) > 0) { A.crouchT -= dt; crouch = true; } | |
| if (A.retreat > 0) { mx = -ux; mz = -uz; } | |
| } | |
| // charge at a target hiding behind cover | |
| if (A.nadeT <= 0 && e.nades > 0 && d > 7 && d < 15 && (!seen || tgt.crouch > 0.5) && G.R() < 0.5) { throwCharge(G, e, tgt.x + (G.R() - 0.5) * 2, tgt.z + (G.R() - 0.5) * 2); A.nadeT = 6 + G.R() * 8; } | |
| if (G.t - e.lastHit < 0.3 && e.dashCd <= 0 && G.R() < 0.06 && !A.cover) e.dash = 0.3; | |
| if (e.reload <= 0 && e.ammo === 0) startReload(e); | |
| } else { | |
| if (e.reload <= 0 && e.ammo < armOf(e).mag * 0.6) startReload(e); | |
| const z = A.zone; let gx = z.x, gz = z.z; | |
| const inZone = Math.hypot(e.x - z.x, e.z - z.z) < z.r - 0.5; | |
| if (inZone) { A.wander += dt * 0.6; gx = z.x + Math.cos(A.wander + e.id) * z.r * 0.5; gz = z.z + Math.sin(A.wander + e.id) * z.r * 0.5; } | |
| if (A.retreat > 0) { const sp = G.map.spawns[e.team][0]; gx = sp.x; gz = sp.z; } | |
| // heard a shot or got hit from somewhere: turn to look | |
| if (G.t - e.lastHit < 1.2 && e.lastHitBy >= 0) { const b = G.ents[e.lastHitBy]; lookAt = [b.x, b.z]; } | |
| const f = field(G.map.nav, gx, gz); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); | |
| const dd = Math.hypot(gx - e.x, gz - e.z); | |
| if (sx || sz) { mx = sx; mz = sz; } else if (dd > 0.6) { mx = (gx - e.x) / dd; mz = (gz - e.z) / dd; } | |
| const la = lookAt ? Math.atan2(lookAt[1] - e.z, lookAt[0] - e.x) : (mx || mz) ? Math.atan2(mz, mx) : e.yaw; | |
| e.yaw += angDiff(e.yaw, la) * Math.min(1, dt * 6); e.pitch += (0 - e.pitch) * Math.min(1, dt * 4); | |
| } | |
| if (e.hp < e.maxHp * 0.3 && G.t - e.lastHit < 2 && G.R() < 0.02 && !A.cover) A.retreat = 2.5; | |
| A.retreat -= dt; | |
| return { mx, mz, fire, crouch, jump }; | |
| } | |
| function setSlot(e, s) { if (e.slot === s) return; e.mags[e.slot] = e.ammo; e.slot = s; e.ammo = e.mags[s]; e.reload = 0; e.cd = Math.max(e.cd, 0.32); e.bloom = 0; } | |
| // ---------------------------------------------------------------- charges | |
| function throwCharge(G, e, tx, tz) { | |
| if (e.nades <= 0) return; e.nades--; | |
| let dx = tx - e.x, dz = tz - e.z; const d = Math.hypot(dx, dz) || 1; const L = Math.min(16, d); dx /= d; dz /= d; | |
| // stop at walls (anything taller than a thrown arc clears) | |
| const f = rayHit3(G.map, e.x, e.y + 1.6, e.z, e.x + dx * L, e.y + 1.6, e.z + dz * L).f; const land = Math.max(1, L * f - 0.6); | |
| const lx = e.x + dx * land, lz = e.z + dz * land; | |
| G.charges.push({ by: e.id, team: e.team, x0: e.x, z0: e.z, y0: e.y + 1.5, x: lx, z: lz, y: groundAt(G.map, lx, lz, 9, 0.1), t: 0, fuse: 1.15, fly: 0.55 }); | |
| ev(G, { k: 'throw', id: e.id }); | |
| } | |
| // ---------------------------------------------------------------- the step | |
| /** | |
| * input (for your operator): { mx, mz (world move, |m| <= 1), yaw, pitch (absolute view), fire, ads, sprint, crouch, | |
| * jump, slot (0 main | 1 sidearm), dash, nade, streak, reload }. Legacy top-down aim (ax, az) still works. | |
| */ | |
| export function step(G, input = {}) { | |
| const dt = DT; G.events.length = 0; if (G.over) return G; | |
| G.t += dt; G.tick++; | |
| if (G.roundOver > 0) { G.roundOver -= dt; if (G.roundOver <= 0) nextRound(G); record(G); return G; } | |
| G.left = Math.max(0, G.left - dt); | |
| G.recon[0] = Math.max(0, G.recon[0] - dt); G.recon[1] = Math.max(0, G.recon[1] - dt); | |
| for (const e of G.ents) { | |
| e.firing = Math.max(0, e.firing - dt); e.kick = Math.max(0, e.kick - dt * 6); | |
| if (!e.alive) { e.respawn -= dt; if (e.respawn <= 0 && G.mode === 'turf') { spawn(G, e); ev(G, { k: 'spawn', id: e.id }); } continue; } | |
| let mx = 0, mz = 0, wantFire = false, ads = false, sprint = false, crouch = false, jump = false, slide = false; | |
| if (e.bot) { const b = botStep(G, e, dt); mx = b.mx; mz = b.mz; wantFire = b.fire; crouch = b.crouch; jump = b.jump; ads = b.fire && armOf(e).id === 'lancer'; } | |
| else { | |
| mx = input.mx || 0; mz = input.mz || 0; const l = Math.hypot(mx, mz); if (l > 1) { mx /= l; mz /= l; } | |
| if (input.ax != null) e.yaw = Math.atan2(input.az - e.z, input.ax - e.x); else if (input.yaw != null) e.yaw = input.yaw; | |
| if (input.pitch != null) e.pitch = Math.max(-1.45, Math.min(1.45, input.pitch)); | |
| wantFire = !!input.fire; ads = !!input.ads; sprint = !!input.sprint; crouch = !!input.crouch; jump = !!input.jump; slide = !!input.slide; | |
| if (input.slot != null) setSlot(e, input.slot ? 1 : 0); | |
| if (input.dash && e.dashCd <= 0) e.dash = 0.32; | |
| if (input.nade && e.nades > 0 && G.charges.filter((c) => c.by === e.id).length === 0) { | |
| if (input.ax != null) throwCharge(G, e, input.ax, input.az); | |
| else { const L = Math.max(4, Math.min(16, 9 + e.pitch * 18)); throwCharge(G, e, e.x + Math.cos(e.yaw) * L, e.z + Math.sin(e.yaw) * L); } | |
| } | |
| if (input.streak) useStreak(G, e); | |
| if (input.reload) startReload(e); | |
| } | |
| const W = armOf(e); | |
| // slide: sprint (or run fast) + crouch on the ground -> a burst of speed that glides out over 0.75 s | |
| const hv = Math.hypot(e.vx, e.vz); | |
| if (slide && e.ground && e.slideCd <= 0 && e.slide <= 0 && hv > 4.5) { | |
| const ux = e.vx / hv, uz = e.vz / hv; const v0 = Math.max(hv, SLIDE_V * W.speed); | |
| e.vx = ux * v0; e.vz = uz * v0; e.slide = SLIDE_T; e.slideCd = SLIDE_CD + SLIDE_T; ev(G, { k: 'slide', id: e.id }); | |
| } | |
| e.slideCd = Math.max(0, e.slideCd - dt); | |
| // stance (linear, so crouch and aim-down-sights take a fixed, predictable time) | |
| e.crouch = approach(e.crouch, crouch || e.dash > 0 || e.slide > 0 ? 1 : 0, dt / 0.11); | |
| e.ads = approach(e.ads, ads && e.reload <= 0 && e.dash <= 0 && e.slide <= 0 ? 1 : 0, dt / (W.adsT || 0.16)); | |
| // sprint only forward-ish, never while firing or aiming | |
| const fwd = mx * Math.cos(e.yaw) + mz * Math.sin(e.yaw); | |
| e.sprint = sprint && fwd > 0.5 && !wantFire && e.ads < 0.3 && e.crouch < 0.5 && e.slide <= 0; | |
| // movement | |
| let sp = WALK * W.speed * (wantFire && W.id === 'lancer' ? 0.8 : 1) * (e.sprint ? 1.34 : 1) * (1 - 0.45 * e.crouch) * (1 - 0.3 * e.ads); | |
| if (e.dash > 0) { if (e.dashCd <= 0) { e.dashCd = e.perk === 'quick' ? 2.2 : 3.2; ev(G, { k: 'dash', id: e.id }); } e.dash -= dt; sp = 13.5; if (!(mx || mz)) { mx = Math.cos(e.yaw); mz = Math.sin(e.yaw); } } | |
| e.dashCd = Math.max(0, e.dashCd - dt); | |
| const tvx = mx * sp, tvz = mz * sp; | |
| if (e.dash > 0) { const a = Math.min(1, 40 * dt); e.vx += (tvx - e.vx) * a; e.vz += (tvz - e.vz) * a; } | |
| else if (e.slide > 0 && e.ground) { // glide: low friction, a little steering, never faster | |
| e.slide -= dt; const v = Math.hypot(e.vx, e.vz); const nv = Math.max(0, v - SLIDE_FRIC * dt); | |
| if (mx || mz) { e.vx += mx * 9 * dt; e.vz += mz * 9 * dt; } | |
| const v2 = Math.hypot(e.vx, e.vz) || 1; e.vx *= nv / v2; e.vz *= nv / v2; | |
| if (nv < 3.2) e.slide = 0; | |
| } else if (e.ground) { // linear acceleration towards the wished velocity (friction when there is no input) | |
| let dvx = tvx - e.vx, dvz = tvz - e.vz; const dl = Math.hypot(dvx, dvz); const step = (mx || mz ? ACC_G : FRIC) * dt; | |
| if (dl > step) { dvx *= step / dl; dvz *= step / dl; } e.vx += dvx; e.vz += dvz; | |
| } else if (mx || mz) { // air control: steer freely, keep the momentum you had (slide-hops stay fast) | |
| const cur = Math.hypot(e.vx, e.vz); e.vx += mx * ACC_A * dt; e.vz += mz * ACC_A * dt; | |
| const cap = Math.max(cur, sp), now = Math.hypot(e.vx, e.vz); if (now > cap) { e.vx *= cap / now; e.vz *= cap / now; } | |
| } | |
| if (e.slide > 0 && !e.ground) e.slide = Math.max(0, e.slide - dt); | |
| // jump (with coyote time after leaving a ledge and a short buffer before landing), gravity, mantle, steps | |
| e.airT = e.ground ? 0 : e.airT + dt; if (jump) e.jumpBuf = JBUF; else e.jumpBuf = Math.max(0, e.jumpBuf - dt); | |
| const canJump = (e.ground || (e.airT < COYOTE && e.vy <= 0 && !e.jumped)) && (e.crouch < 0.6 || e.slide > 0); | |
| let jumpedNow = false; | |
| if (e.jumpBuf > 0 && canJump) { e.vy = JUMP; e.ground = false; e.jumped = true; e.jumpBuf = 0; e.slide = 0; jumpedNow = true; ev(G, { k: 'jump', id: e.id }); } | |
| const nx = e.x + e.vx * dt, nz = e.z + e.vz * dt; | |
| if (!e.ground || jumpedNow) { | |
| const m = mantleTarget(G.map, nx, nz, e.y + 0.05); | |
| if (m && e.vy < 3.2 && (mx || mz) && (Math.hypot(mx, mz) > 0.3)) { e.y = m.h; e.vy = 0; e.ground = true; e.jumped = false; ev(G, { k: 'mantle', id: e.id }); } | |
| } | |
| [e.x, e.z] = collide3(G.map, nx, nz, e.y, 0.45); | |
| // the part of the velocity pointing into what we hit is removed (the push direction is the contact normal); the | |
| // part along the surface is kept, so you slide along walls and around poles and corners instead of sticking | |
| { const px = e.x - nx, pz = e.z - nz, pl = Math.hypot(px, pz); | |
| if (pl > 1e-7) { const ux = px / pl, uz = pz / pl; const vn = e.vx * ux + e.vz * uz; if (vn < 0) { e.vx -= vn * ux; e.vz -= vn * uz; } } } | |
| const g = groundAt(G.map, e.x, e.z, e.y); | |
| if (e.ground && e.y > g + 0.01) { if (e.y - g <= BODY.step + 0.01 && e.vy <= 0) e.y = g; else e.ground = false; } | |
| if (!e.ground) { e.vy -= GRAV * dt; e.y += e.vy * dt; if (e.y <= g) { if (e.vy < -7.4) ev(G, { k: 'land', id: e.id, v: e.vy }); e.y = g; e.vy = 0; e.ground = true; e.jumped = false; } } | |
| else if (e.y < g) e.y = g; | |
| e.moving = Math.hypot(e.vx, e.vz); | |
| // weapon | |
| e.cd -= dt; e.bloom = Math.max(0, e.bloom - dt * (W.bloom * 9 + 0.02)); | |
| const trigger = wantFire && (W.auto || !e.trig); e.trig = wantFire; | |
| if (e.reload > 0) { e.reload -= dt; if (e.reload <= 0) { e.ammo = W.mag; e.mags[e.slot] = e.ammo; ev(G, { k: 'reloaded', id: e.id }); } } | |
| else if (trigger && e.cd <= 0 && e.ammo > 0 && G.t - e.spawnT > 0.3 && !e.sprint) fire(G, e); | |
| else if (wantFire && e.ammo <= 0 && e.reload <= 0) startReload(e); | |
| // regen | |
| if (G.t - e.lastHit > 3.6 && e.hp < e.maxHp) e.hp = Math.min(e.maxHp, e.hp + 34 * dt); | |
| } | |
| // charges | |
| for (const c of G.charges) { c.t += dt; if (c.t >= c.fuse && !c.done) { c.done = true; explode(G, c.x, c.z, 4.4, 96, G.ents[c.by], 'charge', (c.y || 0) + 0.2); } } | |
| G.charges = G.charges.filter((c) => !c.done); | |
| // skyfall | |
| for (const s of G.strikes) { s.t += dt; for (const p of s.pts) if (!p.done && s.t >= p.t) { p.done = true; explode(G, p.x, p.z, 4.8, 130, G.ents[s.by], 'skyfall'); } } | |
| G.strikes = G.strikes.filter((s) => s.pts.some((p) => !p.done)); | |
| // sentries | |
| for (const s of G.sentries) { | |
| s.life -= dt; s.cd -= dt; s.firing = Math.max(0, s.firing - dt); if (s.hp <= 0 || s.life <= 0) continue; | |
| let tgt = null, bd = 20; | |
| for (const o of G.ents) if (o.team !== s.team && o.alive) { const d = Math.hypot(o.x - s.x, o.z - s.z); const c = chestOf(o); if (d < bd && los3(G.map, s.x, 0.9, s.z, c[0], c[1], c[2])) { bd = d; tgt = o; } } | |
| if (tgt) { | |
| const want = Math.atan2(tgt.z - s.z, tgt.x - s.x); s.yaw += angDiff(s.yaw, want) * Math.min(1, dt * 8); | |
| if (s.cd <= 0 && Math.abs(angDiff(s.yaw, want)) < 0.2) { | |
| s.cd = 0.13; s.firing = 0.06; const c = chestOf(tgt); const p = Math.atan2(c[1] - 0.9, bd); | |
| const d = dir3(s.yaw + (G.R() - 0.5) * 0.08, p + (G.R() - 0.5) * 0.06); const o = [s.x + d[0] * 0.6, 0.9, s.z + d[2] * 0.6]; | |
| let best = 22 * rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * 22, o[1] + d[1] * 22, o[2] + d[2] * 22).f; let hit = null; | |
| for (const q of G.ents) { if (q.team === s.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < best) { best = h.t; hit = q; } } | |
| ev(G, { k: 'shot', from: -1, w: 'sentry', x0: o[0], y0: o[1], z0: o[2], x1: o[0] + d[0] * best, y1: o[1] + d[1] * best, z1: o[2] + d[2] * best, hit: hit ? hit.id : -1, team: s.team, nx: 0, ny: 0, nz: 0 }); | |
| if (hit) damage(G, hit, 10, G.ents[s.by], 'sentry'); | |
| } | |
| } | |
| } | |
| G.sentries = G.sentries.filter((s) => s.hp > 0 && s.life > 0); | |
| // zones | |
| for (const z of G.zones) { | |
| z.n = [0, 0]; | |
| if (G.mode === 'showdown' && z.id !== 'B') continue; | |
| for (const e of G.ents) if (e.alive && Math.hypot(e.x - z.x, e.z - z.z) < z.r && e.y < 3) z.n[e.team]++; | |
| z.contest = z.n[0] > 0 && z.n[1] > 0; | |
| if (z.contest) continue; | |
| const t = z.n[0] > 0 ? 0 : z.n[1] > 0 ? 1 : -1; if (t < 0) continue; | |
| const dir = t === 0 ? 1 : -1; const rate = 0.3 * Math.min(3, z.n[t]) ** 0.6; | |
| const before = z.prog; z.prog = Math.max(-1, Math.min(1, z.prog + dir * rate * dt)); | |
| if (z.owner !== t && Math.sign(before) !== Math.sign(z.prog) && z.owner !== -1 && Math.abs(before) > 0) { z.owner = -1; ev(G, { k: 'neutral', zone: z.id }); } | |
| if (Math.abs(z.prog) >= 1 && z.owner !== t) { | |
| z.owner = t; ev(G, { k: 'cap', zone: z.id, team: t }); | |
| for (const e of G.ents) if (e.alive && e.team === t && Math.hypot(e.x - z.x, e.z - z.z) < z.r) { e.caps++; e.score += 150; } | |
| if (G.mode === 'showdown') { endRound(G, t); break; } | |
| } | |
| } | |
| // scoring | |
| if (G.mode === 'turf') { | |
| for (const z of G.zones) if (z.owner >= 0) G.score[z.owner] += dt; | |
| for (const e of G.ents) if (e.alive) for (const z of G.zones) if (z.owner === e.team && Math.hypot(e.x - z.x, e.z - z.z) < z.r) e.score += 10 * dt; | |
| if (G.score[0] >= G.target || G.score[1] >= G.target || G.left <= 0) finish(G, G.score[0] > G.score[1] ? 0 : G.score[1] > G.score[0] ? 1 : -1); | |
| } else if (!G.roundOver) { | |
| const alive = [0, 1].map((t) => G.ents.some((e) => e.team === t && e.alive)); | |
| if (!alive[0] || !alive[1]) endRound(G, alive[0] ? 0 : 1); | |
| else if (G.left <= 0) { const z = G.zones[1]; endRound(G, z.owner >= 0 ? z.owner : z.prog > 0.05 ? 0 : z.prog < -0.05 ? 1 : (G.ents.filter((e) => e.team === 0 && e.alive).length >= G.ents.filter((e) => e.team === 1 && e.alive).length ? 0 : 1)); } | |
| } | |
| record(G); | |
| return G; | |
| } | |
| function endRound(G, t) { | |
| if (G.roundOver > 0 || G.over) return; | |
| G.rounds[t]++; ev(G, { k: 'round', team: t, rounds: G.rounds.slice() }); | |
| for (const e of G.ents) if (e.team === t) e.score += 100; | |
| if (G.rounds[t] >= G.toWin) finish(G, t); else G.roundOver = 3; | |
| } | |
| function nextRound(G) { | |
| G.round++; G.left = G.time; G.charges = []; G.strikes = []; G.sentries = []; G.recon = [0, 0]; | |
| for (const z of G.zones) { z.owner = -1; z.prog = 0; } | |
| for (const e of G.ents) { spawn(G, e, true); e.owned = []; } | |
| ev(G, { k: 'roundStart', round: G.round }); | |
| } | |
| function finish(G, winner) { | |
| if (G.over) return; G.over = true; G.winner = winner; | |
| for (const e of G.ents) if (e.team === winner) e.score += 500; | |
| ev(G, { k: 'end', winner }); | |
| } | |
| // ---------------------------------------------------------------- history (30 Hz) for kill-cams and replays | |
| function record(G) { | |
| if (G.tick % 2) return; | |
| const s = new Float32Array(G.ents.length * HS); | |
| G.ents.forEach((e, i) => { | |
| const o = i * HS; s[o] = e.x; s[o + 1] = e.z; s[o + 2] = e.yaw; s[o + 3] = e.alive ? 1 : 0; s[o + 4] = e.moving; s[o + 5] = e.firing; s[o + 6] = e.hp / e.maxHp; s[o + 7] = e.dash > 0 ? 1 : 0; s[o + 8] = e.kick; | |
| s[o + 9] = e.y; s[o + 10] = e.pitch; s[o + 11] = e.crouch; s[o + 12] = e.slot + (e.reload > 0 ? 2 : 0) + e.ads * 0.5; | |
| }); | |
| G.hist.push({ tick: G.tick, t: G.t, s, sentries: G.sentries.map((q) => [q.x, q.z, q.yaw, q.team, q.firing]), zones: G.zones.map((z) => [z.owner, z.prog]), charges: G.charges.map((c) => [c.x0, c.z0, c.x, c.z, c.t, c.fly, c.y0 || 1.5, c.y || 0]) }); | |
| } | |
| /** History frame index at or after tick. */ | |
| export function histAt(G, tick) { let lo = 0, hi = G.hist.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (G.hist[m].tick < tick) lo = m + 1; else hi = m; } return lo; } | |
| /** Events from the history between two ticks (for replays). */ | |
| export function histEvents(G, t0, t1) { return G.histEv.filter((e) => e.tick > t0 && e.tick <= t1); } | |
| /** The player's best moment: the window around their biggest multi-kill / streak (else the last kill they made). */ | |
| export function bestMoment(G, who = G.you) { | |
| if (!who) return null; | |
| const kills = G.histEv.filter((e) => e.k === 'kill' && e.by === who.id); | |
| if (!kills.length) return null; | |
| let best = kills[kills.length - 1], score = -1; | |
| for (const k of kills) { const s = k.multi * 10 + (k.streak >= 5 ? 6 : k.streak >= 3 ? 3 : 0) + (k.w === 'skyfall' ? 4 : 0) + (k.hs ? 2 : 0); if (s >= score) { score = s; best = k; } } | |
| const first = kills.filter((k) => k.tick <= best.tick && best.tick - k.tick < 60 * 4.5)[0] || best; | |
| return { t0: Math.max(0, first.tick - 60 * 2.2), t1: best.tick + 60 * 1.4, multi: best.multi, streak: best.streak, w: best.w, hs: !!best.hs }; | |
| } | |
| /** Result summary for the player (score card, contract check, challenge links). */ | |
| export function result(G) { | |
| const y = G.you; if (!y) return null; | |
| const mates = G.ents.slice().sort((a, b) => b.score - a.score); | |
| return { | |
| mode: G.mode, map: G.map.id, win: G.winner === y.team, draw: G.winner < 0, score: Math.round(y.score), kills: y.kills, deaths: y.deaths, assists: y.assists, heads: y.heads, | |
| caps: y.caps, bestStreak: y.bestStreak, weapon: y.weapon, perk: y.perk, team: [Math.round(G.score[0]), Math.round(G.score[1])], rounds: G.rounds.slice(), | |
| mvp: mates[0].id === y.id, rank: mates.indexOf(y) + 1, board: mates.map((e) => ({ name: e.name, team: e.team, you: e.you, k: e.kills, d: e.deaths, s: Math.round(e.score) })), | |
| }; | |
| } | |
| export { TEAMS }; | |