Spaces:
Running
Running
Download core.js from CyberMax-tools/turfbreak: direct link, hf CLI and curl.
- Browser
- Download file 24.5 kB
-
https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/turfbreak/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/turfbreak/resolve/main/core.js
24.5 kB
| // Turfbreak core: seeded city maps of Port Varo (a made-up harbour city), weapons, perks, killstreaks, 3D collision | |
| // (feet height, step-up, mantling onto cover), 3D line of sight and hitscan rays, and the navigation grid. Pure functions only (no DOM, no three.js), so the same file runs in the | |
| // browser, in Node tests and in the trailer recorder. Every map is point-symmetric, so both crews get the same layout. | |
| // (c) 2026 CyberMax. All rights reserved. | |
| export const VERSION = 2; // 2 = first-person (3D bodies, heights, headshots) | |
| export const LAUNCH_DAY = Date.UTC(2026, 9, 16) / 864e5; // 16 Oct 2026 = Contract #1 | |
| // ---------------------------------------------------------------- random | |
| export function rng(seed) { | |
| let a = seed >>> 0; | |
| const f = () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; | |
| f.range = (lo, hi) => lo + (hi - lo) * f(); | |
| f.int = (lo, hi) => Math.floor(lo + (hi - lo + 1) * f()); | |
| f.pick = (arr) => arr[Math.floor(f() * arr.length)]; | |
| f.chance = (p) => f() < p; | |
| return f; | |
| } | |
| export function hashStr(s) { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h >>> 0; } | |
| export const dayNumber = (now = Date.now()) => Math.floor(now / 864e5) - LAUNCH_DAY + 1; | |
| // ---------------------------------------------------------------- crews, weapons, perks, streaks | |
| export const TEAMS = [ | |
| { id: 0, name: 'ASHEN', color: '#FF8A1F', glow: '#FFB25C', dark: '#3A1C08' }, | |
| { id: 1, name: 'COLDWIRE', color: '#2EE6FF', glow: '#8AF4FF', dark: '#06303A' }, | |
| ]; | |
| // Fictional weapons. dmg per bullet, cd = seconds between shots, spread in radians, range in metres. | |
| // First-person fields: hs = headshot multiplier, zoom = aim-down-sights magnification, recoil = upward kick per shot | |
| // (radians, applied to the view by the game), bloom = spread added per shot (decays), sight = 'dot' | 'scope' | 'iron'. | |
| export const WEAPONS = { | |
| vk9: { id: 'vk9', name: 'VK-9 CARBINE', short: 'VK-9', kind: 'Carbine', dmg: 19, cd: 0.105, pellets: 1, spread: 0.035, range: 34, mag: 30, reload: 1.6, speed: 1, auto: true, kick: 0.25, adsT: 0.16, desc: 'Full-auto all-rounder', hs: 1.6, zoom: 1.35, recoil: 0.011, bloom: 0.006, sight: 'dot' }, | |
| wasp: { id: 'wasp', name: 'WASP SMG', short: 'WASP', kind: 'SMG', dmg: 13, cd: 0.068, pellets: 1, spread: 0.06, range: 22, mag: 36, reload: 1.35, speed: 1.08, auto: true, kick: 0.18, adsT: 0.13, desc: 'Fast hands, fast feet', hs: 1.5, zoom: 1.25, recoil: 0.008, bloom: 0.005, sight: 'iron' }, | |
| breach: { id: 'breach', name: 'BREACH-12', short: 'BREACH', kind: 'Shotgun', dmg: 14, cd: 0.78, pellets: 8, spread: 0.11, range: 13, mag: 6, reload: 2.1, speed: 1, auto: true, kick: 0.9, adsT: 0.18, desc: 'Owns every doorway', hs: 1.25, zoom: 1.15, recoil: 0.05, bloom: 0, sight: 'iron' }, | |
| lancer: { id: 'lancer', name: 'LANCER DMR', short: 'LANCER', kind: 'Marksman', dmg: 48, cd: 0.4, pellets: 1, spread: 0.01, range: 50, mag: 10, reload: 2.0, speed: 0.95, auto: true, kick: 0.55, adsT: 0.22, desc: 'Three taps from across the street', hs: 2.1, zoom: 2.6, recoil: 0.035, bloom: 0.012, sight: 'scope' }, | |
| }; | |
| /** Everyone also carries the sidearm (slot 2). Not a loadout pick, so it is kept out of WEAPON_IDS. */ | |
| export const SIDEARM = { id: 'p9', name: 'TALLY P9 SIDEARM', short: 'P9', kind: 'Sidearm', dmg: 24, cd: 0.17, pellets: 1, spread: 0.03, range: 26, mag: 12, reload: 1.15, speed: 1.06, auto: false, kick: 0.3, adsT: 0.12, desc: 'Backup that never jams', hs: 1.8, zoom: 1.2, recoil: 0.02, bloom: 0.008, sight: 'iron' }; | |
| export const ARMS = { ...WEAPONS, p9: SIDEARM }; | |
| export const WEAPON_IDS = Object.keys(WEAPONS); | |
| export const PERKS = { | |
| plated: { id: 'plated', name: 'PLATED', desc: '+30 armour' }, | |
| ghost: { id: 'ghost', name: 'GHOST', desc: 'Hidden from drones, silent dash' }, | |
| quick: { id: 'quick', name: 'QUICKHANDS', desc: 'Faster reload and dash' }, | |
| }; | |
| export const PERK_IDS = Object.keys(PERKS); | |
| export const STREAKS = [ | |
| { at: 3, id: 'recon', name: 'RECON DRONE', desc: 'Every enemy on your map for 12 s' }, | |
| { at: 5, id: 'sentry', name: 'SENTRY', desc: 'An auto-turret holds your ground' }, | |
| { at: 7, id: 'skyfall', name: 'SKYFALL STRIKE', desc: 'A line of strikes down your aim' }, | |
| ]; | |
| // ---------------------------------------------------------------- maps | |
| export const MAPS = { | |
| row: { id: 'row', name: 'NEON ROW', area: 'Port Varo · Market District', fog: '#0B1020' }, | |
| docks: { id: 'docks', name: 'VARO DOCKS', area: 'Port Varo · Container Terminal', fog: '#08131A' }, | |
| yard: { id: 'yard', name: 'SIGNAL YARD', area: 'Port Varo · Rail Depot', fog: '#120D14' }, | |
| }; | |
| export const MAP_IDS = Object.keys(MAPS); | |
| export const BOUND = { x: 46, z: 50 }; | |
| // Bodies in metres (feet at y): eye height, hit cylinder top and head sphere, standing and crouched. | |
| export const BODY = { r: 0.4, eye: 1.62, eyeC: 1.04, top: 1.42, topC: 0.86, head: 1.6, headC: 0.98, headR: 0.18, step: 0.45, mantle: 0.9 }; | |
| const COLS = [-30, 0, 30], ROWS = [-30, 0, 30], HALF = 9; | |
| /** One obstacle: axis-aligned box centred at (x,z), size w (x) by d (z), height h. */ | |
| const box = (x, z, w, d, h, kind, extra = {}) => ({ x, z, w, d, h, kind, ...extra }); | |
| const mirror = (o) => ({ ...o, x: -o.x, z: -o.z, mir: true, rot: o.rot != null ? o.rot + Math.PI : o.rot }); | |
| function overlaps(a, b, pad = 0) { return Math.abs(a.x - b.x) * 2 < a.w + b.w + pad * 2 && Math.abs(a.z - b.z) * 2 < a.d + b.d + pad * 2; } | |
| /** | |
| * Build a map. Same (id, seed) -> same map everywhere. Returns obstacles (collision + render), zones, spawns, lamps, | |
| * signs and decor (render only), plus a nav grid for the bots. | |
| */ | |
| export function genMap(id = 'row', seed = 1) { | |
| const R = rng(seed ^ hashStr(id)); | |
| const half = []; // obstacles for the south half (z > 0) and the east middle (z == 0 row, x > 0), mirrored after | |
| const decorH = []; const signsH = []; const lampsH = []; | |
| const zones = [{ id: 'A', x: -30, z: 0, r: 5.2 }, { id: 'B', x: 0, z: 0, r: 5.2 }, { id: 'C', x: 30, z: 0, r: 5.2 }]; | |
| const keepClear = [...zones.map((z) => ({ x: z.x, z: z.z, w: z.r * 2 + 1.2, d: z.r * 2 + 1.2 })), { x: 0, z: 45, w: 60, d: 6 }]; | |
| const free = (o, pad = 0.6) => !keepClear.some((k) => overlaps(o, k, 0)) && !half.some((h) => overlaps(o, h, pad)); | |
| const add = (o, pad) => { if (free(o, pad)) { half.push(o); return true; } return false; }; | |
| // perimeter: tall buildings around the playable area (backdrop + walls) | |
| // the camera always looks north, so the south edge is a low harbour wall and the other three sides are a tall skyline | |
| const per = [], perN = []; | |
| for (let x = -54; x <= 54; x += 12) { per.push(box(x, BOUND.z + 6, 12, 12, 2.4, 'wall')); perN.push(box(x, -BOUND.z - 6, 12, 12, R.range(14, 30), 'bldg', { facade: R.int(0, 3) })); } | |
| for (let z = -42; z <= 42; z += 12) { const h = R.range(14, 30); perN.push(box(BOUND.x + 6, z, 12, 12, h, 'bldg', { facade: R.int(0, 3) }), box(-BOUND.x - 6, -z, 12, 12, h, 'bldg', { facade: R.int(0, 3) })); } | |
| const blockFill = { | |
| row(cx, cz) { // city block: two or three buildings with an alley through | |
| const vertical = R.chance(0.5); const gap = R.range(2.6, 3.4); const off = R.range(-3, 3); | |
| if (vertical) { | |
| const wl = HALF + off - gap / 2, wr = HALF - off - gap / 2; | |
| add(box(cx - HALF + wl / 2, cz, wl, HALF * 2, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0); | |
| add(box(cx + HALF - wr / 2, cz, wr, HALF * 2, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0); | |
| } else { | |
| const dl = HALF + off - gap / 2, dr = HALF - off - gap / 2; | |
| add(box(cx, cz - HALF + dl / 2, HALF * 2, dl, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0); | |
| add(box(cx, cz + HALF - dr / 2, HALF * 2, dr, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0); | |
| } | |
| }, | |
| docks(cx, cz) { // container stacks with lanes | |
| for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j += 2) { | |
| if (R.chance(0.18)) continue; | |
| const h = R.chance(0.45) ? 5.2 : 2.6; | |
| add(box(cx + i * 6.2, cz + j * 4.6, 2.5, 6.1, h, 'container', { tint: R.int(0, 5) }), 0.2); | |
| } | |
| }, | |
| yard(cx, cz) { // parked rail cars along x with a platform | |
| add(box(cx, cz - 5, 17, 3.1, 3.6, 'train', { tint: R.int(0, 3) }), 0.3); | |
| if (R.chance(0.7)) add(box(cx + R.range(-2, 2), cz + 1.5, 13, 3.1, 3.6, 'train', { tint: R.int(0, 3) }), 0.3); | |
| add(box(cx, cz + 6.5, 16, 2.4, 1.0, 'platform'), 0.3); | |
| }, | |
| }; | |
| const fill = blockFill[id] || blockFill.row; | |
| // south row blocks (z = +30) and the east middle block's flanks | |
| for (const cx of COLS) fill(cx, 30); | |
| // middle row: the lots (A at -30, C at 30) and the plaza (B). Only the east side is generated; mirror makes the west. | |
| const lotCover = (cx, cz) => { | |
| const kinds = id === 'docks' ? ['container', 'crate', 'barrier'] : id === 'yard' ? ['crate', 'barrier', 'pillar'] : ['car', 'barrier', 'dumpster']; | |
| for (let k = 0; k < 7; k++) { | |
| const kind = R.pick(kinds); const a = R.range(0, Math.PI * 2); const r = R.range(6.2, 8.6); | |
| const x = cx + Math.cos(a) * r, z = cz + Math.sin(a) * r; | |
| const along = R.chance(0.5); | |
| const dims = { car: [2.0, 4.4, 1.5], barrier: [3.2, 0.7, 1.2], dumpster: [2.0, 1.4, 1.5], container: [2.5, 6.1, 2.6], crate: [1.4, 1.4, 1.4], pillar: [1.2, 1.2, 4] }[kind]; | |
| add(box(x, z, along ? dims[0] : dims[1], along ? dims[1] : dims[0], dims[2], kind, { tint: R.int(0, 5) }), 0.9); | |
| } | |
| }; | |
| lotCover(30, 0); | |
| // plaza: low cover around zone B (generated for the half x > 0, mirrored) | |
| for (const [x, z, w, d, h, kind] of [[7.6, -3.4, 1.0, 3.8, 1.2, 'planter'], [3.6, 7.4, 3.4, 1.0, 1.2, 'planter'], [8.4, 6.8, 2.2, 2.2, 2.4, 'kiosk']]) add(box(x, z, w, d, h, kind), 0.2); | |
| // street furniture between blocks (south half): cars at the curb, barriers, crates, dumpsters | |
| for (let k = 0; k < 26; k++) { | |
| const vertical = R.chance(0.5); | |
| let x, z; | |
| if (vertical) { x = R.pick([-15, 15, 41.5]) + R.range(-3.2, 3.2); z = R.range(2, 46); } else { z = R.pick([15, 44]) + R.range(-3, 3); x = R.range(-44, 44); } | |
| const kind = id === 'docks' ? R.pick(['container', 'crate', 'crate', 'barrier']) : id === 'yard' ? R.pick(['crate', 'barrier', 'barrier', 'pillar']) : R.pick(['car', 'car', 'barrier', 'dumpster', 'crate']); | |
| const dims = { car: [2.0, 4.4, 1.5], barrier: [3.2, 0.7, 1.2], dumpster: [2.0, 1.4, 1.5], container: [2.5, 6.1, 2.6], crate: [1.4, 1.4, 1.4], pillar: [1.2, 1.2, 4] }[kind]; | |
| const along = vertical ? kind !== 'barrier' : kind === 'barrier'; | |
| add(box(x, z, along ? dims[0] : dims[1], along ? dims[1] : dims[0], dims[2], kind, { tint: R.int(0, 5) }), 1.6); | |
| } | |
| // climbing steps: a crate against some containers and kiosks, so their tops are reachable (jump + mantle) | |
| for (const o of half.slice()) { | |
| if (!['container', 'kiosk'].includes(o.kind) || o.h > 3 || !R.chance(0.65)) continue; | |
| const side = R.int(0, 3); const along = side % 2 === 0; // 0/2: +-z ends, 1/3: +-x sides | |
| const off = along ? (R() - 0.5) * Math.max(0, o.w - 1.4) : (R() - 0.5) * Math.max(0, o.d - 1.4); | |
| const sx = side === 1 ? 1 : side === 3 ? -1 : 0, sz = side === 0 ? 1 : side === 2 ? -1 : 0; | |
| const c = box(o.x + sx * (o.w / 2 + 0.71) + (along ? off : 0), o.z + sz * (o.d / 2 + 0.71) + (along ? 0 : off), 1.4, 1.4, 1.4, 'crate', { tint: R.int(0, 5), step: true }); | |
| if (keepClear.some((k) => overlaps(c, k, 0)) || half.some((h) => h !== o && overlaps(c, h, 0.9)) || overlaps(c, o, -0.01)) continue; | |
| if (Math.abs(c.x) > BOUND.x - 1 || Math.abs(c.z) > BOUND.z - 1) continue; | |
| half.push(c); | |
| } | |
| // street lamps and neon signs (render only) | |
| for (const x of [-15, 15]) for (const z of [8, 24, 40]) lampsH.push({ x: x + (x < 0 ? -4.2 : 4.2), z }); | |
| for (const z of [15]) for (const x of [-36, -6, 24]) lampsH.push({ x, z: z + 4.8 }); | |
| for (const o of half) if (o.kind === 'bldg' && o.sign && R.chance(0.85)) signsH.push({ on: o, side: R.int(0, 3), word: R.int(0, 9), hue: R.int(0, 4), y: Math.min(o.h - 1.2, R.range(2.6, 4.2)) }); | |
| for (let k = 0; k < 18; k++) decorH.push({ kind: R.pick(['puddle', 'puddle', 'puddle', 'vent', 'cone', 'trash']), x: R.range(-44, 44), z: R.range(1, 48), s: R.range(0.6, 1.8), a: R.range(0, 6.28) }); | |
| // mirror everything through the centre | |
| const lamps = [...lampsH, ...lampsH.map((l) => ({ x: -l.x, z: -l.z }))]; | |
| // lamp posts are solid too (thin poles: they stop bodies and bullets; never used as cover by bots) | |
| const obstacles = [...half, ...half.map(mirror), ...per, ...perN, ...lamps.map((l) => box(l.x, l.z, 0.24, 0.24, 5.4, 'lamp'))]; | |
| const signs = [...signsH, ...signsH.map((s) => ({ ...s, on: mirror(s.on), side: (s.side + 2) % 4 }))]; | |
| const decor = [...decorH, ...decorH.map((d) => ({ ...d, x: -d.x, z: -d.z }))].filter((d) => !obstacles.some((o) => inBox(o, d.x, d.z, 0.8))); | |
| const spawns = [[-22, -8, 8, 22, 0].map((x) => ({ x, z: 45.5, yaw: -Math.PI / 2 })), [22, 8, -8, -22, 0].map((x) => ({ x, z: -45.5, yaw: Math.PI / 2 }))]; | |
| const map = { id, name: (MAPS[id] || MAPS.row).name, seed, obstacles, zones, spawns, lamps, signs, decor }; | |
| map.nav = navGrid(map); | |
| return map; | |
| } | |
| // ---------------------------------------------------------------- geometry | |
| export function inBox(o, x, z, pad = 0) { return Math.abs(x - o.x) <= o.w / 2 + pad && Math.abs(z - o.z) <= o.d / 2 + pad; } | |
| /** Push a circle (x, z, r) out of every obstacle and keep it inside the bounds. Returns [x, z]. */ | |
| export function collide(map, x, z, r = 0.45) { | |
| for (let pass = 0; pass < 2; pass++) { | |
| for (const o of map.obstacles) { | |
| const hx = o.w / 2 + r, hz = o.d / 2 + r; const dx = x - o.x, dz = z - o.z; | |
| if (Math.abs(dx) >= hx || Math.abs(dz) >= hz) continue; | |
| const px = hx - Math.abs(dx), pz = hz - Math.abs(dz); | |
| if (px < pz) x += Math.sign(dx || 1) * px; else z += Math.sign(dz || 1) * pz; | |
| } | |
| } | |
| x = Math.max(-BOUND.x + r, Math.min(BOUND.x - r, x)); z = Math.max(-BOUND.z + r, Math.min(BOUND.z - r, z)); | |
| return [x, z]; | |
| } | |
| /** Ray from (ax,az) towards (bx,bz): distance fraction 0..1 to the first obstacle hit, or 1 when clear. */ | |
| export function rayHit(map, ax, az, bx, bz, minH = 0.9) { | |
| const dx = bx - ax, dz = bz - az; let best = 1; | |
| for (const o of map.obstacles) { | |
| if (o.h < minH) continue; | |
| const x0 = o.x - o.w / 2, x1 = o.x + o.w / 2, z0 = o.z - o.d / 2, z1 = o.z + o.d / 2; | |
| let t0 = 0, t1 = best; | |
| if (Math.abs(dx) < 1e-9) { if (ax < x0 || ax > x1) continue; } else { let a = (x0 - ax) / dx, b = (x1 - ax) / dx; if (a > b) [a, b] = [b, a]; t0 = Math.max(t0, a); t1 = Math.min(t1, b); if (t0 > t1) continue; } | |
| if (Math.abs(dz) < 1e-9) { if (az < z0 || az > z1) continue; } else { let a = (z0 - az) / dz, b = (z1 - az) / dz; if (a > b) [a, b] = [b, a]; t0 = Math.max(t0, a); t1 = Math.min(t1, b); if (t0 > t1) continue; } | |
| if (t0 < best) best = t0; | |
| } | |
| return best; | |
| } | |
| export const los = (map, ax, az, bx, bz) => rayHit(map, ax, az, bx, bz) >= 0.999; | |
| /** Distance along a ray (unit dir dx,dz from x,z) to a circle at (cx,cz) radius r, or -1. */ | |
| export function rayCircle(x, z, dx, dz, cx, cz, r) { | |
| const ox = cx - x, oz = cz - z; const t = ox * dx + oz * dz; if (t < 0) return -1; | |
| const d2 = ox * ox + oz * oz - t * t; if (d2 > r * r) return -1; | |
| return t - Math.sqrt(r * r - d2); | |
| } | |
| // ---------------------------------------------------------------- 3D: heights, climbing, rays | |
| /** Highest standable top under a body circle whose feet are at `feet` (tops up to a step above count). */ | |
| export function groundAt(map, x, z, feet = 0, r = BODY.r * 0.8) { | |
| let g = 0; | |
| for (const o of map.obstacles) { | |
| if (o.h > feet + BODY.step || o.h <= g) continue; | |
| if (Math.abs(x - o.x) < o.w / 2 + r && Math.abs(z - o.z) < o.d / 2 + r) g = o.h; | |
| } | |
| return g; | |
| } | |
| /** | |
| * collide() in 3D: boxes whose top is below feet + step are walked over (stood on), not pushed against. | |
| * The body is a circle against each box (closest point), so it slides along walls and rounds corners and thin | |
| * poles instead of snagging on them; a centre that ends up inside a box leaves by the shortest way out. | |
| */ | |
| export function collide3(map, x, z, feet = 0, r = 0.45) { | |
| for (let pass = 0; pass < 3; pass++) { | |
| let moved = false; | |
| for (const o of map.obstacles) { | |
| if (o.h <= feet + BODY.step) continue; | |
| const hw = o.w / 2, hd = o.d / 2; const dx = x - o.x, dz = z - o.z; | |
| if (Math.abs(dx) >= hw + r || Math.abs(dz) >= hd + r) continue; | |
| if (Math.abs(dx) <= hw && Math.abs(dz) <= hd) { // centre inside: shortest axis out | |
| const px = hw + r - Math.abs(dx), pz = hd + r - Math.abs(dz); | |
| if (px < pz) x += Math.sign(dx || 1) * px; else z += Math.sign(dz || 1) * pz; moved = true; continue; | |
| } | |
| const cx = Math.max(-hw, Math.min(hw, dx)), cz = Math.max(-hd, Math.min(hd, dz)); const ex = dx - cx, ez = dz - cz; | |
| const d2 = ex * ex + ez * ez; if (d2 >= r * r) continue; | |
| const d = Math.sqrt(d2) || 1e-6; const push = r - d; x += (ex / d) * push; z += (ez / d) * push; moved = true; | |
| } | |
| if (!moved) break; | |
| } | |
| x = Math.max(-BOUND.x + r, Math.min(BOUND.x - r, x)); z = Math.max(-BOUND.z + r, Math.min(BOUND.z - r, z)); | |
| return [x, z]; | |
| } | |
| /** A box the body (at x,z, feet) is pressing against that it can mantle onto (top within reach), or null. */ | |
| export function mantleTarget(map, x, z, feet, r = 0.5) { | |
| let best = null; | |
| for (const o of map.obstacles) { | |
| if (o.h <= feet + BODY.step || o.h > feet + BODY.mantle || o.kind === 'bldg' || o.kind === 'wall') continue; | |
| if (Math.abs(x - o.x) < o.w / 2 + r && Math.abs(z - o.z) < o.d / 2 + r && (!best || o.h > best.h)) best = o; | |
| } | |
| return best; | |
| } | |
| /** | |
| * 3D ray from A towards B against every box (y from 0 to h) and the ground: { f: fraction 0..1 of the first hit | |
| * (1 = clear), nx, ny, nz: surface normal there }. | |
| */ | |
| export function rayHit3(map, ax, ay, az, bx, by, bz) { | |
| const dx = bx - ax, dy = by - ay, dz = bz - az; let best = 1, n = [0, 0, 0]; | |
| if (dy < -1e-9) { const t = -ay / dy; if (t >= 0 && t < best) { best = t; n = [0, 1, 0]; } } | |
| for (const o of map.obstacles) { | |
| const lo = [o.x - o.w / 2, 0, o.z - o.d / 2], hi = [o.x + o.w / 2, o.h, o.z + o.d / 2]; const p = [ax, ay, az], d = [dx, dy, dz]; | |
| let t0 = 0, t1 = best, axis = -1, sgn = 0, miss = false; | |
| for (let k = 0; k < 3; k++) { | |
| if (Math.abs(d[k]) < 1e-9) { if (p[k] < lo[k] || p[k] > hi[k]) { miss = true; break; } continue; } | |
| let a = (lo[k] - p[k]) / d[k], b = (hi[k] - p[k]) / d[k], s = -1; | |
| if (a > b) { [a, b] = [b, a]; s = 1; } | |
| if (a > t0) { t0 = a; axis = k; sgn = s; } | |
| if (b < t1) t1 = b; | |
| if (t0 > t1) { miss = true; break; } | |
| } | |
| if (miss || axis < 0 || t0 >= best) continue; | |
| best = t0; n = [0, 0, 0]; n[axis] = sgn; | |
| } | |
| return { f: best, nx: n[0], ny: n[1], nz: n[2] }; | |
| } | |
| export const los3 = (map, ax, ay, az, bx, by, bz) => rayHit3(map, ax, ay, az, bx, by, bz).f >= 0.999; | |
| /** Unit view direction for yaw (around y, 0 = +x) and pitch (up positive). */ | |
| export const dir3 = (yaw, pitch) => { const c = Math.cos(pitch); return [Math.cos(yaw) * c, Math.sin(pitch), Math.sin(yaw) * c]; }; | |
| /** | |
| * Ray (origin o, unit dir d) against a standing body (feet at fx,fy,fz; crouch 0..1): distance to the first hit, or -1, | |
| * and whether it is the head. Body = vertical cylinder, head = sphere on top. | |
| */ | |
| export function rayBody(o, d, fx, fy, fz, crouch = 0) { | |
| const top = fy + BODY.top + (BODY.topC - BODY.top) * crouch; const hy = fy + BODY.head + (BODY.headC - BODY.head) * crouch; | |
| let tBody = -1; | |
| { // cylinder (x,z circle), clipped to [fy, top] | |
| const ox = o[0] - fx, oz = o[2] - fz; const a = d[0] * d[0] + d[2] * d[2]; const b = 2 * (ox * d[0] + oz * d[2]); const c = ox * ox + oz * oz - BODY.r * BODY.r; | |
| let lo = -Infinity, hi = Infinity; | |
| if (a > 1e-12) { const D = b * b - 4 * a * c; if (D >= 0) { const s = Math.sqrt(D); lo = (-b - s) / (2 * a); hi = (-b + s) / (2 * a); } else { lo = 1; hi = 0; } } else if (c > 0) { lo = 1; hi = 0; } | |
| if (Math.abs(d[1]) > 1e-12) { let y0 = (fy - o[1]) / d[1], y1 = (top - o[1]) / d[1]; if (y0 > y1) [y0, y1] = [y1, y0]; lo = Math.max(lo, y0); hi = Math.min(hi, y1); } else if (o[1] < fy || o[1] > top) { lo = 1; hi = 0; } | |
| if (lo <= hi && hi >= 0) tBody = Math.max(0, lo); | |
| } | |
| let tHead = -1; | |
| { const ox = o[0] - fx, oy = o[1] - hy, oz = o[2] - fz; const b = ox * d[0] + oy * d[1] + oz * d[2]; const c = ox * ox + oy * oy + oz * oz - BODY.headR * BODY.headR; const D = b * b - c; if (D >= 0) { const t = -b - Math.sqrt(D); if (t >= 0) tHead = t; } } | |
| if (tHead >= 0 && (tBody < 0 || tHead <= tBody + 0.05)) return { t: tHead, head: true }; | |
| return tBody >= 0 ? { t: tBody, head: false } : null; | |
| } | |
| // ---------------------------------------------------------------- navigation (grid BFS, 1 m cells) | |
| export function navGrid(map) { | |
| const W = BOUND.x * 2, H = BOUND.z * 2; const block = new Uint8Array(W * H); | |
| for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) { | |
| const x = i - BOUND.x + 0.5, z = j - BOUND.z + 0.5; | |
| if (map.obstacles.some((o) => inBox(o, x, z, 0.55))) block[j * W + i] = 1; | |
| } | |
| return { W, H, block, cache: new Map() }; | |
| } | |
| const cellOf = (nav, x, z) => { const i = Math.max(0, Math.min(nav.W - 1, Math.floor(x + BOUND.x))); const j = Math.max(0, Math.min(nav.H - 1, Math.floor(z + BOUND.z))); return j * nav.W + i; }; | |
| /** Distance field (in steps) to the target point; cached per target cell. */ | |
| export function field(nav, tx, tz) { | |
| const t = cellOf(nav, tx, tz); let f = nav.cache.get(t); if (f) return f; | |
| const { W, H, block } = nav; f = new Int32Array(W * H).fill(-1); const q = new Int32Array(W * H); let qh = 0, qt = 0; | |
| f[t] = 0; q[qt++] = t; | |
| while (qh < qt) { | |
| const c = q[qh++]; const ci = c % W, cj = (c - ci) / W; const d = f[c] + 1; | |
| if (ci > 0 && f[c - 1] < 0 && !block[c - 1]) { f[c - 1] = d; q[qt++] = c - 1; } | |
| if (ci < W - 1 && f[c + 1] < 0 && !block[c + 1]) { f[c + 1] = d; q[qt++] = c + 1; } | |
| if (cj > 0 && f[c - W] < 0 && !block[c - W]) { f[c - W] = d; q[qt++] = c - W; } | |
| if (cj < H - 1 && f[c + W] < 0 && !block[c + W]) { f[c + W] = d; q[qt++] = c + W; } | |
| } | |
| if (nav.cache.size > 64) nav.cache.clear(); | |
| nav.cache.set(t, f); return f; | |
| } | |
| /** Unit direction that walks down the distance field from (x, z) towards its target; [0,0] when there. */ | |
| export function steer(nav, f, x, z) { | |
| const c = cellOf(nav, x, z); const { W } = nav; const here = f[c]; | |
| if (here === 0) return [0, 0]; | |
| let best = here < 0 ? 1e9 : here, bx = 0, bz = 0; | |
| const ci = c % W, cj = (c - ci) / W; | |
| for (let dj = -2; dj <= 2; dj++) for (let di = -2; di <= 2; di++) { | |
| if (!di && !dj) continue; const i = ci + di, j = cj + dj; if (i < 0 || j < 0 || i >= W || j >= nav.H) continue; | |
| const v = f[j * W + i]; if (v < 0) continue; | |
| const s = v + Math.hypot(di, dj) * 0.01; | |
| if (s < best) { best = s; bx = di; bz = dj; } | |
| } | |
| const l = Math.hypot(bx, bz) || 1; return [bx / l, bz / l]; | |
| } | |
| export function reachable(map, ax, az, bx, bz) { const f = field(map.nav, bx, bz); return f[cellOf(map.nav, ax, az)] >= 0; } | |
| // ---------------------------------------------------------------- the daily contract | |
| const CONTRACTS = [ | |
| { id: 'kills12', text: 'Win with 12+ eliminations', test: (r) => r.win && r.kills >= 12 }, | |
| { id: 'caps4', text: 'Capture 4 zones', test: (r) => r.caps >= 4 }, | |
| { id: 'streak5', text: 'Call in a Sentry (5 in a row)', test: (r) => r.bestStreak >= 5 }, | |
| { id: 'score3k', text: 'Score 3,000 points', test: (r) => r.score >= 3000 }, | |
| { id: 'kd2', text: 'Finish with 2.0 K/D or better', test: (r) => r.kills >= 4 && r.kills >= r.deaths * 2 }, | |
| { id: 'win', text: 'Win the match', test: (r) => r.win }, | |
| { id: 'lancer8', text: 'Win with the Lancer and 8+ eliminations', test: (r) => r.win && r.weapon === 'lancer' && r.kills >= 8 }, | |
| { id: 'breach8', text: '8 eliminations with the Breach-12', test: (r) => r.weapon === 'breach' && r.kills >= 8 }, | |
| ]; | |
| export function contract(day) { | |
| const R = rng(hashStr(`contract-${day}`)); | |
| const map = MAP_IDS[((day % MAP_IDS.length) + MAP_IDS.length) % MAP_IDS.length]; | |
| const c = CONTRACTS[Math.floor(R() * CONTRACTS.length)]; | |
| return { day, map, seed: hashStr(`turf-${day}`), id: c.id, text: c.text, test: c.test }; | |
| } | |
| export const fmt = (n) => Math.round(n).toLocaleString('en-US'); | |