turfbreak / core.js
CyberMax-tools's picture
Deploy turfbreak
217da1a verified
Raw History Blame Contribute Delete
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');