Spaces:
Running
Running
File size: 24,493 Bytes
217da1a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | // 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');
|