Static site
Browse files- index.html +9 -3
- static/contracts.js +133 -0
- static/game.js +18 -10
- static/main.js +0 -0
- static/net.js +136 -128
- static/npc.js +1 -1
- static/police.js +1 -0
- static/race.js +253 -177
- static/sound.js +6 -0
- static/style.css +16 -0
index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
<meta name="theme-color" content="#10131b">
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
|
| 11 |
-
<link rel="stylesheet" href="/static/style.css?v=
|
| 12 |
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
|
| 13 |
<script type="importmap">
|
| 14 |
{ "imports": {
|
|
@@ -114,6 +114,8 @@
|
|
| 114 |
<div class="hud-top">
|
| 115 |
<div class="g-race" id="gRace" hidden></div>
|
| 116 |
<div class="g-job" id="gJob" hidden></div>
|
|
|
|
|
|
|
| 117 |
<div class="g-gps" id="gGps"></div>
|
| 118 |
<div class="g-hint" id="gHint"></div>
|
| 119 |
<div class="g-target"><i id="gArrow">⬆</i><span id="gTgt"></span></div>
|
|
@@ -153,9 +155,13 @@
|
|
| 153 |
<label class="set">Auto gas<span class="seg" data-set="autogas"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
|
| 154 |
<label class="set">Living city (day/night + weather change)<span class="seg" data-set="cycle"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
|
| 155 |
<label class="set">Touch steering<span class="seg" data-set="steer"><button data-v="stick">Joystick</button><button data-v="buttons">Buttons</button></span></label>
|
|
|
|
|
|
|
| 156 |
</section>
|
|
|
|
|
|
|
| 157 |
<section class="wide"><h4>Challenges</h4><div class="goals" id="gGoals"></div></section>
|
| 158 |
-
<section class="wide"><h4>Controls</h4><p class="small"><b>Drive:</b> W A S D or arrows · Space handbrake/drift · Shift nitro · F fly · L land · C camera · R reset · H horn · T time · M GPS · O autopilot · Enter starts a race at a 🏁 marker. <b>Look:</b> click the game, then move the mouse. <b>Phone:</b> joystick on the left, pedals on the right, drag anywhere to look. <b>Fuel:</b> about 10 min driving, 5 min flying; stop at ⛽ to fill up and repair, or drive through a green 🔧.</p></section>
|
| 159 |
</div>
|
| 160 |
<div class="menu-foot"><button class="pill" id="gExit">Exit to home</button></div>
|
| 161 |
</div>
|
|
@@ -202,6 +208,6 @@
|
|
| 202 |
<div id="toast" role="status"></div>
|
| 203 |
<noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
|
| 204 |
<script src="/static/hub.js" data-app="void" defer></script>
|
| 205 |
-
<script type="module" src="/static/main.js?v=
|
| 206 |
</body>
|
| 207 |
</html>
|
|
|
|
| 8 |
<meta name="theme-color" content="#10131b">
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;800;900&family=Inter:wght@400;500;600;700&family=Noto+Kufi+Arabic:wght@700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="/static/style.css?v=1793029420">
|
| 12 |
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2714%27 fill=%27%23050506%27/%3E%3Ccircle cx=%2732%27 cy=%2732%27 r=%2716%27 fill=%27none%27 stroke=%27%23fff%27 stroke-width=%275%27/%3E%3C/svg%3E">
|
| 13 |
<script type="importmap">
|
| 14 |
{ "imports": {
|
|
|
|
| 114 |
<div class="hud-top">
|
| 115 |
<div class="g-race" id="gRace" hidden></div>
|
| 116 |
<div class="g-job" id="gJob" hidden></div>
|
| 117 |
+
<div class="g-job g-con" id="gCon" hidden></div>
|
| 118 |
+
<div class="g-build" id="gBuild" hidden></div>
|
| 119 |
<div class="g-gps" id="gGps"></div>
|
| 120 |
<div class="g-hint" id="gHint"></div>
|
| 121 |
<div class="g-target"><i id="gArrow">⬆</i><span id="gTgt"></span></div>
|
|
|
|
| 155 |
<label class="set">Auto gas<span class="seg" data-set="autogas"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
|
| 156 |
<label class="set">Living city (day/night + weather change)<span class="seg" data-set="cycle"><button data-v="off">Off</button><button data-v="on">On</button></span></label>
|
| 157 |
<label class="set">Touch steering<span class="seg" data-set="steer"><button data-v="stick">Joystick</button><button data-v="buttons">Buttons</button></span></label>
|
| 158 |
+
<div class="set save-row">Your progress<span><button class="pill" id="gSaveOut">Back up</button><button class="pill" id="gSaveIn">Restore</button></span></div>
|
| 159 |
+
<p class="small">Progress lives in this browser. Back up copies a code you can paste on another device or browser.</p>
|
| 160 |
</section>
|
| 161 |
+
<section class="wide"><h4>Contracts</h4><div class="tiles con-tiles" id="gContracts"></div></section>
|
| 162 |
+
<section class="wide"><h4>Routes and ghosts</h4><div class="routes" id="gRoutes"></div></section>
|
| 163 |
<section class="wide"><h4>Challenges</h4><div class="goals" id="gGoals"></div></section>
|
| 164 |
+
<section class="wide"><h4>Controls</h4><p class="small"><b>Drive:</b> W A S D or arrows · Space handbrake/drift · Shift nitro · F fly · L land · C camera · R reset · H horn · T time · M GPS · O autopilot · Enter starts a race at a 🏁 marker · G drops a gate in the route builder. <b>Look:</b> click the game, then move the mouse. <b>Phone:</b> joystick on the left, pedals on the right, drag anywhere to look. <b>Fuel:</b> about 10 min driving, 5 min flying; stop at ⛽ to fill up and repair, or drive through a green 🔧.</p></section>
|
| 165 |
</div>
|
| 166 |
<div class="menu-foot"><button class="pill" id="gExit">Exit to home</button></div>
|
| 167 |
</div>
|
|
|
|
| 208 |
<div id="toast" role="status"></div>
|
| 209 |
<noscript><p style="position:fixed;inset:auto 0 0;padding:16px;background:#111;color:#fff;text-align:center">VOID needs JavaScript for the 3D car.</p></noscript>
|
| 210 |
<script src="/static/hub.js" data-app="void" defer></script>
|
| 211 |
+
<script type="module" src="/static/main.js?v=1793029420"></script>
|
| 212 |
</body>
|
| 213 |
</html>
|
static/contracts.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Contracts: short jobs that mix driving, flying and chasing, each with a timer and bronze / silver / gold medals.
|
| 2 |
+
// Rooftop Express drive to a pickup on the street, then fly the parcel to a rooftop helipad and land on it
|
| 3 |
+
// Takedown a getaway car flees through the grid: ram it until it gives up
|
| 4 |
+
// Sky Chase a camera drone weaves round the towers: stay close behind it in the air
|
| 5 |
+
// Hot Cargo grab cargo at the docks (three stars at once), then deliver it to a rooftop while the police hunt you
|
| 6 |
+
// Best medals are kept on this device; every medal pays cash for the garage.
|
| 7 |
+
import * as THREE from "three";
|
| 8 |
+
import { GRID } from "./city.js";
|
| 9 |
+
import { makeDriver } from "./police.js";
|
| 10 |
+
|
| 11 |
+
const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { } } };
|
| 12 |
+
const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
|
| 13 |
+
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
| 14 |
+
export const CONTRACTS = [
|
| 15 |
+
{ id: "express", icon: "📦", name: "Rooftop Express", text: "Drive to the pickup, then fly the parcel to a rooftop helipad", limit: 180, medals: [70, 100, 140] },
|
| 16 |
+
{ id: "takedown", icon: "💥", name: "Takedown", text: "Ram the getaway car 4 times before it escapes", limit: 120, medals: [35, 60, 90] },
|
| 17 |
+
{ id: "skychase", icon: "🛸", name: "Sky Chase", text: "Stay within 45 m of the drone for 20 seconds", limit: 100, medals: [35, 55, 80] },
|
| 18 |
+
{ id: "hot", icon: "🔥", name: "Hot Cargo", text: "Grab the cargo at the docks and land it on a rooftop while wanted", limit: 200, medals: [80, 120, 170] },
|
| 19 |
+
];
|
| 20 |
+
const PAY = [0, 1200, 2000, 3200]; // none, bronze, silver, gold
|
| 21 |
+
const MEDAL = ["", "🥉", "🥈", "🥇"];
|
| 22 |
+
|
| 23 |
+
export function createContracts(W, api, nav, sound, police) {
|
| 24 |
+
const { scene } = W, $ = id => document.getElementById(id), D = makeDriver(api);
|
| 25 |
+
const best = store.get("void.contracts") || {};
|
| 26 |
+
// a column of light over the objective
|
| 27 |
+
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 400, 24, 1, true).translate(0, 200, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dfcff).multiplyScalar(1.5), transparent: true, opacity: .14, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
|
| 28 |
+
beacon.visible = false; scene.add(beacon);
|
| 29 |
+
// the getaway car: a low wedge in red with a flashing roof light
|
| 30 |
+
const thief = new THREE.Group();
|
| 31 |
+
{ const m = new THREE.MeshStandardMaterial({ color: 0xb0101c, roughness: .3, metalness: .6 }), dark = new THREE.MeshStandardMaterial({ color: 0x111114, roughness: .4 });
|
| 32 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(4.4, .8, 2).translate(0, .75, 0), m), cab = new THREE.Mesh(new THREE.BoxGeometry(2, .6, 1.7).translate(-.3, 1.4, 0), dark);
|
| 33 |
+
thief.add(body, cab); for (const [x, z] of [[1.4, 1], [1.4, -1], [-1.4, 1], [-1.4, -1]]) { const w = new THREE.Mesh(new THREE.CylinderGeometry(.38, .38, .3, 12).rotateX(Math.PI / 2), dark); w.position.set(x, .38, z); thief.add(w); }
|
| 34 |
+
thief.flash = new THREE.Mesh(new THREE.BoxGeometry(.5, .2, .5), new THREE.MeshBasicMaterial({ color: new THREE.Color(3, .4, .2), toneMapped: false })); thief.flash.position.set(-.3, 1.8, 0); thief.add(thief.flash); }
|
| 35 |
+
thief.visible = false; scene.add(thief);
|
| 36 |
+
// the drone: a disc with four rotors and a light
|
| 37 |
+
const drone = new THREE.Group();
|
| 38 |
+
{ const m = new THREE.MeshStandardMaterial({ color: 0x22262e, roughness: .35, metalness: .7 }), glow = new THREE.MeshBasicMaterial({ color: new THREE.Color(.4, 2.6, 3), toneMapped: false });
|
| 39 |
+
drone.add(new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.9, .6, 20), m)); const eye = new THREE.Mesh(new THREE.SphereGeometry(.5, 12, 8), glow); eye.position.y = -.4; drone.add(eye);
|
| 40 |
+
for (const [x, z] of [[2, 2], [2, -2], [-2, 2], [-2, -2]]) { const arm = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 1.1, .08, 16), glow.clone()); arm.material.color.setRGB(.6, 1.2, 1.4); arm.position.set(x, .4, z); drone.add(arm); }
|
| 41 |
+
drone.scale.setScalar(1.4); }
|
| 42 |
+
drone.visible = false; scene.add(drone);
|
| 43 |
+
|
| 44 |
+
const streetPoint = (p, rmin, rmax) => { // a crossing on the grid at a distance, not in the water or a building
|
| 45 |
+
for (let k = 0; k < 60; k++) {
|
| 46 |
+
const i = Math.floor(Math.random() * GRID.NX), j = Math.floor(Math.random() * GRID.NZ), x = GRID.ax(i), z = GRID.sz(j), d = Math.hypot(x - p.x, z - p.z);
|
| 47 |
+
if (d > rmin && d < rmax && !api.isWater(x, z) && !api.hitBox(x, z, .5, 3)) return { x, z };
|
| 48 |
+
}
|
| 49 |
+
return { x: GRID.ax(Math.floor(GRID.NX / 2)), z: GRID.sz(Math.floor(GRID.NZ / 2)) };
|
| 50 |
+
};
|
| 51 |
+
const padNear = (x, z, rmin, rmax) => { const pads = W.city.helipads.filter(h => { const d = Math.hypot(h.x - x, h.z - z); return d > rmin && d < rmax; }); return pads.length ? pads[Math.floor(Math.random() * pads.length)] : W.city.helipads[0]; };
|
| 52 |
+
const docks = () => { const p = api.state; const opts = [5, 10, 15].map((j, k) => ({ x: -1010, z: GRID.sz(Math.min(GRID.NZ - 1, j)), name: ["Hudson piers north", "Hudson piers", "Hudson piers south"][k] })); return opts.filter(o => !api.isWater(o.x, o.z) && !api.hitBox(o.x, o.z, .5, 3)).sort((a, b) => Math.hypot(a.x - p.x, a.z - p.z) - Math.hypot(b.x - p.x, b.z - p.z))[0] || streetPoint(p, 400, 900); };
|
| 53 |
+
|
| 54 |
+
let C = null; // the contract in progress
|
| 55 |
+
function show(x, z, name, air, y) { beacon.position.set(x, air ? y : 0, z); beacon.visible = true; nav.setDest({ name, x, z, air: !!air, y: y || 0 }); }
|
| 56 |
+
function start(id) {
|
| 57 |
+
const p = api.state; if (!p || !p.started) return;
|
| 58 |
+
const def = CONTRACTS.find(c => c.id === id); if (C) end(false, "Contract cancelled");
|
| 59 |
+
C = { def, t: 0, stage: 0, msg: "" };
|
| 60 |
+
if (id === "express") { const a = streetPoint(p, 250, 650); C.a = a; C.pad = padNear(a.x, a.z, 400, 1100); show(a.x, a.z, "📦 Parcel pickup"); C.msg = "Drive to the 📦 pickup and stop there"; }
|
| 61 |
+
if (id === "takedown") {
|
| 62 |
+
const s = streetPoint(p, 90, 160); Object.assign(C, { car: { x: s.x, z: s.z, yaw: Math.atan2(s.z - p.z, s.x - p.x), v: 18, accel: 15 }, hits: 0, hitT: 0, goal: streetPoint(s, 400, 900) });
|
| 63 |
+
thief.visible = true; beacon.visible = false; nav.setDest(null); C.msg = "Ram the red getaway car";
|
| 64 |
+
}
|
| 65 |
+
if (id === "skychase") {
|
| 66 |
+
const c = { x: p.x + 60, z: p.z, y: Math.max(60, p.y + 40) }; Object.assign(C, { dr: c, a0: Math.random() * 6, close: 0, cx: p.x, cz: p.z });
|
| 67 |
+
drone.visible = true; C.msg = "Take off (F) and stay close to the drone";
|
| 68 |
+
}
|
| 69 |
+
if (id === "hot") { const d = docks(); C.a = d; C.pad = padNear(d.x, d.z, 300, 1000); show(d.x, d.z, "🔥 Cargo at " + (d.name || "the docks")); C.msg = "Drive to the docks for the cargo"; }
|
| 70 |
+
$("gCon").hidden = false; sound.win(); api.say(`${def.icon} ${def.name}: ${def.text}`, 4);
|
| 71 |
+
}
|
| 72 |
+
function end(ok, why) {
|
| 73 |
+
if (!C) return;
|
| 74 |
+
const def = C.def; let medal = 0;
|
| 75 |
+
if (ok) { medal = C.t <= def.medals[0] ? 3 : C.t <= def.medals[1] ? 2 : 1; api.addScore(PAY[medal], `${MEDAL[medal]} ${def.name} in ${fmt(C.t)}`); if (medal > (best[def.id] || 0)) { best[def.id] = medal; store.set("void.contracts", best); } }
|
| 76 |
+
else api.say(`${def.icon} ${why || "Contract failed"}`, 3);
|
| 77 |
+
beacon.visible = thief.visible = drone.visible = false; nav.setDest(null); $("gCon").hidden = true; C = null;
|
| 78 |
+
return medal;
|
| 79 |
+
}
|
| 80 |
+
const onPad = (p, pad) => pad && p.mode === "drive" && Math.hypot(p.x - pad.x, p.z - pad.z) < pad.r + 2 && Math.abs(p.y - pad.y) < 2.5;
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
CONTRACTS, best, get active() { return C; }, start, cancel: () => end(false, "Contract cancelled"),
|
| 84 |
+
update(dt, time) {
|
| 85 |
+
if (!C) return;
|
| 86 |
+
const p = api.state; if (!p || !p.started) return;
|
| 87 |
+
if (p.wreck > 0) { end(false, "The car was wrecked. Contract failed"); return; }
|
| 88 |
+
C.t += dt; const def = C.def, left = def.limit - C.t;
|
| 89 |
+
if (left <= 0) { end(false, "Out of time. Contract failed"); return; }
|
| 90 |
+
beacon.material.opacity = .1 + .06 * Math.sin(time * 4);
|
| 91 |
+
const sp = Math.hypot(p.vel.x, p.vel.z);
|
| 92 |
+
if (def.id === "express" || def.id === "hot") {
|
| 93 |
+
if (C.stage === 0 && Math.hypot(p.x - C.a.x, p.z - C.a.z) < 14 && sp < 4 && p.mode === "drive") {
|
| 94 |
+
C.stage = 1; sound.pop(); show(C.pad.x, C.pad.z, "🛬 Rooftop drop", true, C.pad.y);
|
| 95 |
+
C.msg = "Fly to the rooftop helipad (beam) and land on it";
|
| 96 |
+
if (def.id === "hot") { police.wanted && police.wanted(3, "Hot cargo! The police are on to you"); C.msg = "Wanted! Fly the cargo to the rooftop helipad"; }
|
| 97 |
+
api.say(def.id === "hot" ? "🔥 Cargo loaded. ★★★ Get to the roof!" : "📦 Parcel loaded. Press F to fly to the roof", 3);
|
| 98 |
+
}
|
| 99 |
+
if (C.stage === 1 && onPad(p, C.pad) && sp < 3) { end(true); return; }
|
| 100 |
+
}
|
| 101 |
+
if (def.id === "takedown") {
|
| 102 |
+
const a = C.car;
|
| 103 |
+
// run from the player: pick a far crossing, steer there along the streets, pick a new one when close
|
| 104 |
+
if (Math.hypot(a.x - C.goal.x, a.z - C.goal.z) < 40) C.goal = streetPoint({ x: a.x * 2 - p.x, z: a.z * 2 - p.z }, 0, 700);
|
| 105 |
+
const wp = D.waypoint(a, C.goal.x, C.goal.z), dist = Math.hypot(p.x - a.x, p.z - a.z);
|
| 106 |
+
const flee = 30 + Math.min(14, Math.max(0, 80 - dist) * .25) - C.hits * 2.5;
|
| 107 |
+
D.step(a, wp[0], wp[1], flee, dt);
|
| 108 |
+
if (a.stuck > 1.5) { const s = streetPoint(a, 0, 120); a.x = s.x; a.z = s.z; a.stuck = 0; }
|
| 109 |
+
thief.position.set(a.x, 0, a.z); thief.rotation.y = -a.yaw; thief.flash.visible = Math.sin(time * 18) > 0;
|
| 110 |
+
C.hitT -= dt;
|
| 111 |
+
if (dist < 4.2 && p.y < 2 && C.hitT <= 0) {
|
| 112 |
+
const rel = Math.hypot(p.vel.x - Math.cos(a.yaw) * a.v, p.vel.z - Math.sin(a.yaw) * a.v);
|
| 113 |
+
if (rel > 5) { C.hits++; C.hitT = 1; a.v *= .4; a.yaw += (Math.random() - .5) * .8; sound.crash(); api.impact && api.impact(a.x, a.z, rel); p.shake = 1; api.say(C.hits >= 4 ? "Busted! The getaway car gives up" : `Hit ${C.hits}/4`, 1.6); if (C.hits >= 4) { end(true); return; } }
|
| 114 |
+
else { const dx = p.x - a.x, dz = p.z - a.z; p.x += dx / dist * (4.2 - dist); p.z += dz / dist * (4.2 - dist); }
|
| 115 |
+
}
|
| 116 |
+
if (dist > 650) { end(false, "The getaway car escaped"); return; }
|
| 117 |
+
C.msg = `Ram it: ${C.hits}/4 hits · ${Math.round(dist)} m away`;
|
| 118 |
+
}
|
| 119 |
+
if (def.id === "skychase") {
|
| 120 |
+
// a figure-eight round the towers that drifts slowly across the city
|
| 121 |
+
C.a0 += dt * .22; const r = 180, d = C.dr;
|
| 122 |
+
const tx = C.cx + Math.sin(C.a0) * r, tz = C.cz + Math.sin(C.a0 * 2) * r * .6, ty = 70 + Math.sin(C.a0 * 1.7) * 30;
|
| 123 |
+
d.x += (tx - d.x) * clamp(dt * 1.5); d.z += (tz - d.z) * clamp(dt * 1.5); d.y += (ty - d.y) * clamp(dt * 1.2);
|
| 124 |
+
drone.position.set(d.x, d.y, d.z); drone.rotation.y = -C.a0 * 3; drone.rotation.z = Math.sin(time * 2) * .1;
|
| 125 |
+
const dist = Math.hypot(p.x - d.x, p.y - d.y, p.z - d.z);
|
| 126 |
+
if (dist < 45 && p.mode === "air") { C.close += dt; if (Math.floor(C.close) !== Math.floor(C.close - dt)) sound.pop(); }
|
| 127 |
+
if (C.close >= 20) { end(true); return; }
|
| 128 |
+
C.msg = p.mode !== "air" ? "Take off (F) and chase the drone" : `${dist < 45 ? "Locked on" : "Get closer"} · ${C.close.toFixed(1)}/20 s · ${Math.round(dist)} m`;
|
| 129 |
+
}
|
| 130 |
+
$("gCon").textContent = `${def.icon} ${C.msg} · ${fmt(Math.max(0, left))}`;
|
| 131 |
+
},
|
| 132 |
+
};
|
| 133 |
+
}
|
static/game.js
CHANGED
|
@@ -140,7 +140,7 @@ export function createGame(W, car, sound, life, opts = {}) {
|
|
| 140 |
}
|
| 141 |
function toggleFly() {
|
| 142 |
if (!g || g.wreck > 0 || !g.started) return;
|
| 143 |
-
if (g.mode === "drive") { g.mode = "air"; g.hover = true; g.landing = false; g.vel.y = Math.max(g.vel.y,
|
| 144 |
else if (g.mode === "air" && g.jump) { g.jump = false; g.hover = true; g.landing = false; say("Thrusters on"); }
|
| 145 |
else if (g.mode === "air") { g.landing = !g.landing; if (!g.landing) g.hover = true; say(g.landing ? "Coming down gently" : "Flying"); }
|
| 146 |
}
|
|
@@ -291,6 +291,7 @@ export function createGame(W, car, sound, life, opts = {}) {
|
|
| 291 |
g.vel.set(fx * u + sx * w, 0, fz * u + sz * w);
|
| 292 |
// drift scoring
|
| 293 |
const slip = Math.atan2(Math.abs(w), Math.max(1, Math.abs(u)));
|
|
|
|
| 294 |
if (slip > .22 && Math.abs(u) > 12) { g.drift += slip * Math.abs(u) * dt * 6; g.driftT = .7; }
|
| 295 |
else if (g.driftT > 0) { g.driftT -= dt; if (g.driftT <= 0 && g.drift > 30) { addScore(Math.round(g.drift), "Drift"); g.drift = 0; } else if (g.driftT <= 0) g.drift = 0; }
|
| 296 |
// move, with ramps, rooftops, buildings, water and traffic
|
|
@@ -298,9 +299,14 @@ export function createGame(W, car, sound, life, opts = {}) {
|
|
| 298 |
const hit = hitBox(nx, nz, g.y + .5, 1.15);
|
| 299 |
if (hit) {
|
| 300 |
const vn = g.vel.x * hit.nx + g.vel.z * hit.nz;
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
g.x = nx + hit.nx * hit.d; g.z = nz + hit.nz * hit.d;
|
| 303 |
-
const imp = -vn; impact(g.x - hit.nx * 1.3, .6, g.z - hit.nz * 1.3, imp * 1.5); if (imp >
|
|
|
|
| 304 |
} else { g.x = nx; g.z = nz; }
|
| 305 |
const gr = groundAt(g.x, g.z, g.y), prevY = g.y;
|
| 306 |
if (gr.water) { splash(); return; }
|
|
@@ -433,7 +439,8 @@ export function createGame(W, car, sound, life, opts = {}) {
|
|
| 433 |
const keep = Math.min(112, Math.sqrt(vh2 * vh2 + .6 * down2 * down2));
|
| 434 |
if (vh2 > 2) g.yaw = Math.atan2(g.vel.z, g.vel.x);
|
| 435 |
g.vel.set(Math.cos(g.yaw) * keep, 0, Math.sin(g.yaw) * keep);
|
| 436 |
-
g.mode = "drive"; g.wings = 0; g.hover = false; g.landing = false; g.pitch = 0; g.roll = 0; g.jump = false;
|
|
|
|
| 437 |
if (keep > 25) say(`Touchdown at ${Math.round(keep * 3.6)} km/h. Rolling on`, 2);
|
| 438 |
}
|
| 439 |
}
|
|
@@ -534,16 +541,17 @@ export function createGame(W, car, sound, life, opts = {}) {
|
|
| 534 |
else if (!air && fwd > 8) { let dv = Math.atan2(g.vel.z, g.vel.x) - g.yaw; while (dv > Math.PI) dv -= 2 * Math.PI; while (dv < -Math.PI) dv += 2 * Math.PI; base = g.yaw + clamp(dv, -.6, .6) * .5; }
|
| 535 |
if (rig.yaw == null) rig.yaw = base;
|
| 536 |
let dy = base - rig.yaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI;
|
| 537 |
-
rig.
|
| 538 |
-
|
| 539 |
-
const
|
| 540 |
-
const
|
|
|
|
| 541 |
// walls: pull in at once, ease back out
|
| 542 |
let room = want; for (let t = .8; t <= want; t += .5) { const q2 = pivot.clone().addScaledVector(dir, t); if (q2.y < .3 || hitBox(q2.x, q2.z, q2.y, .35)) { room = Math.max(1.6, t - .6); break; } }
|
| 543 |
rig.dist = room < rig.dist ? room : lerp(rig.dist, room, clamp(dt * 2.5));
|
| 544 |
camPos.copy(pivot).addScaledVector(dir, rig.dist);
|
| 545 |
-
camLook.copy(pivot).add(new THREE.Vector3(fx, 0, fz).multiplyScalar(
|
| 546 |
-
if (
|
| 547 |
if (camPos.y < .4) camPos.y = .4;
|
| 548 |
}
|
| 549 |
cam = { pos: camPos.clone(), look: camLook.clone(), up, fov: lerp(56, 68, clamp(speed / 330)) + nitroFov };
|
|
|
|
| 140 |
}
|
| 141 |
function toggleFly() {
|
| 142 |
if (!g || g.wreck > 0 || !g.started) return;
|
| 143 |
+
if (g.mode === "drive") { g.mode = "air"; g.hover = true; g.landing = false; g.vel.y = Math.max(g.vel.y, 4.5); g.y += .3; sound.whoosh(); say("Thrusters on. Space/E climb · Q/Ctrl dive · A/D bank · W thrust · Shift nitro · F land"); }
|
| 144 |
else if (g.mode === "air" && g.jump) { g.jump = false; g.hover = true; g.landing = false; say("Thrusters on"); }
|
| 145 |
else if (g.mode === "air") { g.landing = !g.landing; if (!g.landing) g.hover = true; say(g.landing ? "Coming down gently" : "Flying"); }
|
| 146 |
}
|
|
|
|
| 291 |
g.vel.set(fx * u + sx * w, 0, fz * u + sz * w);
|
| 292 |
// drift scoring
|
| 293 |
const slip = Math.atan2(Math.abs(w), Math.max(1, Math.abs(u)));
|
| 294 |
+
g.slip = Math.abs(u) > 6 ? clamp((slip - .12) * 3) + (brake && Math.abs(u) > 20 ? .35 : 0) : 0; if (g.thudT > 0) g.thudT -= dt;
|
| 295 |
if (slip > .22 && Math.abs(u) > 12) { g.drift += slip * Math.abs(u) * dt * 6; g.driftT = .7; }
|
| 296 |
else if (g.driftT > 0) { g.driftT -= dt; if (g.driftT <= 0 && g.drift > 30) { addScore(Math.round(g.drift), "Drift"); g.drift = 0; } else if (g.driftT <= 0) g.drift = 0; }
|
| 297 |
// move, with ramps, rooftops, buildings, water and traffic
|
|
|
|
| 299 |
const hit = hitBox(nx, nz, g.y + .5, 1.15);
|
| 300 |
if (hit) {
|
| 301 |
const vn = g.vel.x * hit.nx + g.vel.z * hit.nz;
|
| 302 |
+
// a glancing hit scrapes along the wall and keeps most of its speed; a head-on one stops you with a small bounce
|
| 303 |
+
const sp = Math.hypot(g.vel.x, g.vel.z) || 1, head = vn < 0 ? -vn / sp : 0;
|
| 304 |
+
if (vn < 0) { g.vel.x -= 1.25 * vn * hit.nx; g.vel.z -= 1.25 * vn * hit.nz; g.vel.multiplyScalar(1 - .5 * head); if (head < .5 && sp > 8) g.sparks = Math.max(g.sparks || 0, .8);
|
| 305 |
+
// and the car swings round to run along the wall instead of nosing into it again next frame
|
| 306 |
+
if (head < .75 && sp > 3) { let dv = Math.atan2(g.vel.z, g.vel.x) - g.yaw; while (dv > Math.PI) dv -= 2 * Math.PI; while (dv < -Math.PI) dv += 2 * Math.PI; if (Math.abs(dv) < 1.6) g.yaw += dv * .6; } }
|
| 307 |
g.x = nx + hit.nx * hit.d; g.z = nz + hit.nz * hit.d;
|
| 308 |
+
const imp = -vn; impact(g.x - hit.nx * 1.3, .6, g.z - hit.nz * 1.3, imp * 1.5); if (imp > 3) g.shake = Math.min(1, (g.shake || 0) + imp / 30);
|
| 309 |
+
if (imp > 9) { hurt((imp - 9) * 1.8, "Hit a building"); sound.crash(); g.sparks = 1; } else if (imp > 2 && !(g.thudT > 0)) { sound.thud && sound.thud(imp / 9); g.thudT = .25; }
|
| 310 |
} else { g.x = nx; g.z = nz; }
|
| 311 |
const gr = groundAt(g.x, g.z, g.y), prevY = g.y;
|
| 312 |
if (gr.water) { splash(); return; }
|
|
|
|
| 439 |
const keep = Math.min(112, Math.sqrt(vh2 * vh2 + .6 * down2 * down2));
|
| 440 |
if (vh2 > 2) g.yaw = Math.atan2(g.vel.z, g.vel.x);
|
| 441 |
g.vel.set(Math.cos(g.yaw) * keep, 0, Math.sin(g.yaw) * keep);
|
| 442 |
+
g.mode = "drive"; g.wings = 0; g.hover = false; g.landing = false; g.pitch = -clamp(down2 * .006, 0, .07); g.roll = 0; g.jump = false;
|
| 443 |
+
if (down2 > 2) { sound.thud && sound.thud(down2 / 14); g.shake = Math.min(1, (g.shake || 0) + down2 / 40); }
|
| 444 |
if (keep > 25) say(`Touchdown at ${Math.round(keep * 3.6)} km/h. Rolling on`, 2);
|
| 445 |
}
|
| 446 |
}
|
|
|
|
| 541 |
else if (!air && fwd > 8) { let dv = Math.atan2(g.vel.z, g.vel.x) - g.yaw; while (dv > Math.PI) dv -= 2 * Math.PI; while (dv < -Math.PI) dv += 2 * Math.PI; base = g.yaw + clamp(dv, -.6, .6) * .5; }
|
| 542 |
if (rig.yaw == null) rig.yaw = base;
|
| 543 |
let dy = base - rig.yaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI;
|
| 544 |
+
rig.air = lerp(rig.air ?? (air ? 1 : 0), air ? 1 : 0, clamp(dt * 1.8)); const A = rig.air;
|
| 545 |
+
rig.yaw += dy * clamp(dt * lerp(5, 3, A));
|
| 546 |
+
const yawC = rig.yaw + orbit.yaw, pitC = clamp(lerp(.24, .2, A) + orbit.pitch - (air ? g.vel.y * .004 * A : 0), -.12, 1.25);
|
| 547 |
+
const want = lerp(6.6 + speed * .012, 12 + speed * .01, A) + (orbit.dist || 0) + (g.veh === "plane" || g.veh === "heli" ? 6 : g.veh === "boat" ? 2 : 0);
|
| 548 |
+
const pivot = fr.p.clone().add(new THREE.Vector3(0, 1.3 + .1 * A, 0)), dir = new THREE.Vector3(-Math.cos(yawC) * Math.cos(pitC), Math.sin(pitC), -Math.sin(yawC) * Math.cos(pitC));
|
| 549 |
// walls: pull in at once, ease back out
|
| 550 |
let room = want; for (let t = .8; t <= want; t += .5) { const q2 = pivot.clone().addScaledVector(dir, t); if (q2.y < .3 || hitBox(q2.x, q2.z, q2.y, .35)) { room = Math.max(1.6, t - .6); break; } }
|
| 551 |
rig.dist = room < rig.dist ? room : lerp(rig.dist, room, clamp(dt * 2.5));
|
| 552 |
camPos.copy(pivot).addScaledVector(dir, rig.dist);
|
| 553 |
+
camLook.copy(pivot).add(new THREE.Vector3(fx, 0, fz).multiplyScalar(1.5 + 1.5 * A));
|
| 554 |
+
if (A > .01) up.lerp(fr.u, .15 * A).normalize();
|
| 555 |
if (camPos.y < .4) camPos.y = .4;
|
| 556 |
}
|
| 557 |
cam = { pos: camPos.clone(), look: camLook.clone(), up, fov: lerp(56, 68, clamp(speed / 330)) + nitroFov };
|
static/main.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/net.js
CHANGED
|
@@ -1,128 +1,136 @@
|
|
| 1 |
-
// Online play with real people, no server of our own: WebRTC peer-to-peer through the free PeerJS broker.
|
| 2 |
-
// A room is a well-known peer id; the first player to claim it is the host and relays everyone's car to everyone.
|
| 3 |
-
// If the host leaves, the others race to claim it again. Voice chat is a mesh of audio calls, off until you
|
| 4 |
-
// switch your mic on, and you hear people louder the closer their car is. Chat is quick emotes only (strangers
|
| 5 |
-
// can join the public city), and names are short and filtered.
|
| 6 |
-
const PREFIX = "voidcity-awsaa-v1-";
|
| 7 |
-
const ICE = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }, { urls: "stun:global.stun.twilio.com:3478" }] };
|
| 8 |
-
const BAD = /(fuck|shit|bitch|nigg|cunt|dick|pussy|sex|porn|kill|nazi|hitler|rape|fag)/i;
|
| 9 |
-
export const cleanName = s => { s = String(s || "").replace(/[^\p{L}\p{N} _-]/gu, "").trim().slice(0, 14); return !s || BAD.test(s) ? "Driver" : s; };
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
function
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
});
|
| 35 |
-
p.on("
|
| 36 |
-
|
| 37 |
-
if (
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
}
|
| 59 |
-
function
|
| 60 |
-
function
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if (
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
if (d.t === "
|
| 78 |
-
if (d.t === "
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
function
|
| 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 |
-
const
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Online play with real people, no server of our own: WebRTC peer-to-peer through the free PeerJS broker.
|
| 2 |
+
// A room is a well-known peer id; the first player to claim it is the host and relays everyone's car to everyone.
|
| 3 |
+
// If the host leaves, the others race to claim it again. Voice chat is a mesh of audio calls, off until you
|
| 4 |
+
// switch your mic on, and you hear people louder the closer their car is. Chat is quick emotes only (strangers
|
| 5 |
+
// can join the public city), and names are short and filtered.
|
| 6 |
+
const PREFIX = "voidcity-awsaa-v1-";
|
| 7 |
+
const ICE = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }, { urls: "stun:global.stun.twilio.com:3478" }] };
|
| 8 |
+
const BAD = /(fuck|shit|bitch|nigg|cunt|dick|pussy|sex|porn|kill|nazi|hitler|rape|fag)/i;
|
| 9 |
+
export const cleanName = s => { s = String(s || "").replace(/[^\p{L}\p{N} _-]/gu, "").trim().slice(0, 14); return !s || BAD.test(s) ? "Driver" : s; };
|
| 10 |
+
// what strangers may send: only these events, only sane numbers, and not too often
|
| 11 |
+
const EVENTS = new Set(["race", "emote"]);
|
| 12 |
+
const num = (v, lim) => Number.isFinite(v) && Math.abs(v) <= lim;
|
| 13 |
+
const okState = st => st && Array.isArray(st.p) && st.p.length === 6 && num(st.p[0], 8000) && num(st.p[1], 4000) && num(st.p[2], 8000) && st.p.slice(3).every(v => num(v, 50));
|
| 14 |
+
const flood = c => { const now = performance.now(); if (now - (c._t || 0) > 1000) { c._t = now; c._n = 0; } return ++c._n > 60; };
|
| 15 |
+
export const EMOTES = ["👋 Hi!", "🏁 Race?", "😂", "🔥 Nice!", "GG", "🚓 Cops!", "Follow me", "Wait up"];
|
| 16 |
+
|
| 17 |
+
export function createNet() {
|
| 18 |
+
let Peer = null, peer = null, host = false, me = null, room = null, name = "Driver", hostConn = null, sendT = 0, lastState = null, closing = false;
|
| 19 |
+
const conns = new Map(); // host: everyone's connection
|
| 20 |
+
const players = new Map(); // id -> { id, name, s: [samples], model, paint, race, rp, rf, vc, emote, emoteT }
|
| 21 |
+
const handlers = {};
|
| 22 |
+
const emit = (k, ...a) => (handlers[k] || []).forEach(f => f(...a));
|
| 23 |
+
const status = t => emit("status", t);
|
| 24 |
+
let stream = null, micOn = false, calls = new Map(), audios = new Map();
|
| 25 |
+
|
| 26 |
+
async function lib() { if (!Peer) ({ Peer } = await import("https://cdn.jsdelivr.net/npm/peerjs@1.5.4/+esm")); return Peer; }
|
| 27 |
+
async function join(r, nm) {
|
| 28 |
+
await leave(); closing = false; room = String(r).toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 24) || "public-1"; name = cleanName(nm);
|
| 29 |
+
status("Connecting…"); await lib(); claim();
|
| 30 |
+
}
|
| 31 |
+
function claim() {
|
| 32 |
+
if (closing) return;
|
| 33 |
+
const p = new Peer(PREFIX + room, { config: ICE, debug: 0 }); peer = p;
|
| 34 |
+
p.on("open", id => { host = true; me = id; status(`Online · room ${room} · you are hosting`); emit("change"); });
|
| 35 |
+
p.on("connection", c => {
|
| 36 |
+
c.on("data", d => onData(c, d));
|
| 37 |
+
c.on("open", () => { if (conns.size >= 11) { c.send({ t: "full" }); setTimeout(() => c.close(), 300); return; } conns.set(c.peer, c); emit("change"); });
|
| 38 |
+
c.on("close", () => { conns.delete(c.peer); players.delete(c.peer); dropAudio(c.peer); emit("change"); broadcast({ t: "gone", id: c.peer }); });
|
| 39 |
+
});
|
| 40 |
+
p.on("call", answer);
|
| 41 |
+
p.on("error", e => {
|
| 42 |
+
if (e.type === "unavailable-id") { p.destroy(); client(); }
|
| 43 |
+
else if (e.type === "peer-unavailable") { status("Room not found, retrying…"); setTimeout(() => rejoin(), 800 + Math.random() * 1500); }
|
| 44 |
+
else status("Network: " + e.type);
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
function client() {
|
| 48 |
+
if (closing) return;
|
| 49 |
+
const p = new Peer({ config: ICE, debug: 0 }); peer = p; host = false;
|
| 50 |
+
p.on("open", id => {
|
| 51 |
+
me = id; const c = p.connect(PREFIX + room, { serialization: "json", reliable: false }); hostConn = c;
|
| 52 |
+
c.on("open", () => { status(`Online · room ${room}`); c.send({ t: "hello", name }); emit("change"); });
|
| 53 |
+
c.on("data", d => onData(c, d));
|
| 54 |
+
c.on("close", () => { if (!closing) { status("Host left, reconnecting…"); players.clear(); emit("change"); setTimeout(rejoin, Math.random() * 1500); } });
|
| 55 |
+
});
|
| 56 |
+
p.on("call", answer);
|
| 57 |
+
p.on("error", e => { if (e.type === "peer-unavailable") setTimeout(rejoin, 400 + Math.random() * 1200); else status("Network: " + e.type); });
|
| 58 |
+
}
|
| 59 |
+
function rejoin() { if (closing) return; try { peer && peer.destroy(); } catch { } hostConn = null; conns.clear(); claim(); }
|
| 60 |
+
async function leave() {
|
| 61 |
+
closing = true; try { peer && peer.destroy(); } catch { } peer = null; hostConn = null; host = false; conns.clear(); players.clear();
|
| 62 |
+
for (const id of [...audios.keys()]) dropAudio(id); setMic(false); room = null; emit("change"); status("Offline");
|
| 63 |
+
}
|
| 64 |
+
function broadcast(m, except) { for (const [id, c] of conns) if (id !== except && c.open) try { c.send(m); } catch { } }
|
| 65 |
+
function upsert(id, st) {
|
| 66 |
+
let p = players.get(id), fresh = !p; if (!p) { p = { id, name: "Driver", s: [] }; players.set(id, p); }
|
| 67 |
+
if (st.n && cleanName(st.n) !== p.name) { p.name = cleanName(st.n); fresh = true; }
|
| 68 |
+
Object.assign(p, { model: Number.isInteger(st.m) && st.m >= 0 && st.m < 20 ? st.m : 0, paint: /^#[0-9a-f]{6}$/i.test(st.c) ? st.c : "#888888", race: typeof st.r === "string" ? st.r.slice(0, 20) : 0, rp: num(st.rp, 1e7) ? st.rp : 0, rf: num(st.rf, 1e7) ? st.rf : 0, vc: !!st.vc, wanted: num(st.w, 5) ? st.w | 0 : 0 });
|
| 69 |
+
p.s.push({ t: performance.now(), x: st.p[0], y: st.p[1], z: st.p[2], yaw: st.p[3], pitch: st.p[4], roll: st.p[5], v: num(st.v, 2000) ? st.v : 0, air: !!st.a });
|
| 70 |
+
if (p.s.length > 12) p.s.shift();
|
| 71 |
+
if (fresh) emit("change");
|
| 72 |
+
}
|
| 73 |
+
function onData(c, d) {
|
| 74 |
+
if (!d || typeof d !== "object" || flood(c)) return;
|
| 75 |
+
if (d.t === "ev" && !EVENTS.has(d.k)) return;
|
| 76 |
+
if (host) {
|
| 77 |
+
if (d.t === "hello") { const p = players.get(c.peer) || { id: c.peer, s: [] }; p.name = cleanName(d.name); players.set(c.peer, p); emit("change"); return; }
|
| 78 |
+
if (d.t === "s") { if (okState(d)) upsert(c.peer, d); return; }
|
| 79 |
+
if (d.t === "ev") { broadcast({ ...d, from: c.peer }, c.peer); emit(d.k, d, c.peer); return; }
|
| 80 |
+
} else {
|
| 81 |
+
if (d.t === "w") { if (Array.isArray(d.all)) for (const st of d.all.slice(0, 16)) if (st && typeof st.id === "string" && st.id !== me && okState(st)) upsert(st.id, st); return; }
|
| 82 |
+
if (d.t === "gone") { players.delete(d.id); dropAudio(d.id); emit("change"); return; }
|
| 83 |
+
if (d.t === "full") { status("Room full, trying the next one"); room = room.replace(/(\d+)$/, n => +n + 1); rejoin(); return; }
|
| 84 |
+
if (d.t === "ev") { emit(d.k, d, d.from); return; }
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
// voice: everyone with their mic on calls everyone; people without a mic still hear
|
| 88 |
+
function answer(call) { call.answer(stream || undefined); hook(call); }
|
| 89 |
+
function hook(call) {
|
| 90 |
+
calls.set(call.peer, call);
|
| 91 |
+
call.on("stream", s => { let a = audios.get(call.peer); if (!a) { a = new Audio(); a.autoplay = true; audios.set(call.peer, a); } a.srcObject = s; a.play().catch(() => { }); });
|
| 92 |
+
call.on("close", () => { calls.delete(call.peer); });
|
| 93 |
+
}
|
| 94 |
+
function dropAudio(id) { const a = audios.get(id); if (a) { a.srcObject = null; audios.delete(id); } const c = calls.get(id); if (c) { try { c.close(); } catch { } calls.delete(id); } }
|
| 95 |
+
async function setMic(on) {
|
| 96 |
+
if (on && !stream) { try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); } catch { status("Microphone blocked"); emit("mic", false); return false; } }
|
| 97 |
+
micOn = on;
|
| 98 |
+
if (stream) for (const t of stream.getAudioTracks()) t.enabled = on;
|
| 99 |
+
if (on && peer) for (const p of players.values()) if (!calls.has(p.id)) { try { hook(peer.call(p.id, stream)); } catch { } }
|
| 100 |
+
if (!on && !stream) { }
|
| 101 |
+
emit("mic", on); return on;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// players who went quiet (closed the tab, lost signal) are dropped after 15 s instead of standing still forever
|
| 105 |
+
setInterval(() => { const now = performance.now(); let gone = false; for (const p of [...players.values()]) { const last = p.s.length ? p.s[p.s.length - 1].t : p.seen || (p.seen = now); if (now - last > 15000) { players.delete(p.id); dropAudio(p.id); gone = true; } } if (gone) emit("change"); }, 3000);
|
| 106 |
+
return {
|
| 107 |
+
join, leave, setMic, EMOTES,
|
| 108 |
+
on(k, f) { (handlers[k] = handlers[k] || []).push(f); },
|
| 109 |
+
online: () => !!(peer && me && (host || (hostConn && hostConn.open))),
|
| 110 |
+
get host() { return host; }, get room() { return room; }, get me() { return me; }, get mic() { return micOn; }, get name() { return name; },
|
| 111 |
+
players: () => [...players.values()],
|
| 112 |
+
nameOf: id => (players.get(id) || {}).name || "Someone",
|
| 113 |
+
send(m) { // a one-off event for everyone (race start, emote)
|
| 114 |
+
const ev = { ...m, t: "ev", k: m.t };
|
| 115 |
+
if (host) broadcast({ ...ev, from: me }); else if (hostConn && hostConn.open) hostConn.send(ev);
|
| 116 |
+
},
|
| 117 |
+
// ~12 times a second: my car to the host; the host sends everyone to everyone
|
| 118 |
+
tick(dt, mine) {
|
| 119 |
+
if (!peer || !me) return;
|
| 120 |
+
lastState = mine; sendT += dt; if (sendT < 1 / 12) return; sendT = 0;
|
| 121 |
+
const st = mine && { t: "s", n: name, ...mine, vc: micOn };
|
| 122 |
+
if (host) { const all = [...(st ? [{ id: me, ...st }] : []), ...[...players.values()].filter(p => p.s.length).map(p => { const q = p.s[p.s.length - 1]; return { id: p.id, n: p.name, p: [q.x, q.y, q.z, q.yaw, q.pitch, q.roll], m: p.model, c: p.paint, r: p.race, rp: p.rp, rf: p.rf, vc: p.vc, v: q.v, a: q.air, w: p.wanted }; })]; broadcast({ t: "w", all }); }
|
| 123 |
+
else if (st && hostConn && hostConn.open) try { hostConn.send(st); } catch { }
|
| 124 |
+
},
|
| 125 |
+
// where a player's car should be drawn now: 120 ms behind, between two samples
|
| 126 |
+
sample(p) {
|
| 127 |
+
const t = performance.now() - 120, s = p.s; if (!s.length) return null;
|
| 128 |
+
let a = s[0], b = s[s.length - 1];
|
| 129 |
+
for (let k = 0; k < s.length - 1; k++) if (s[k].t <= t && s[k + 1].t >= t) { a = s[k]; b = s[k + 1]; break; }
|
| 130 |
+
const u = b.t > a.t ? Math.max(0, Math.min(1.5, (t - a.t) / (b.t - a.t))) : 1, L = (x, y) => x + (y - x) * u;
|
| 131 |
+
const ay = (x, y) => { let d = y - x; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return x + d * u; };
|
| 132 |
+
return { x: L(a.x, b.x), y: L(a.y, b.y), z: L(a.z, b.z), yaw: ay(a.yaw, b.yaw), pitch: L(a.pitch, b.pitch), roll: L(a.roll, b.roll), v: b.v, air: b.air, stale: performance.now() - s[s.length - 1].t > 4000 };
|
| 133 |
+
},
|
| 134 |
+
setVolume(id, v) { const a = audios.get(id); if (a) a.volume = Math.max(0, Math.min(1, v)); },
|
| 135 |
+
};
|
| 136 |
+
}
|
static/npc.js
CHANGED
|
@@ -99,7 +99,7 @@ export function createLife(scene, roads, walkways, COARSE) {
|
|
| 99 |
const cross = (axis, k) => axis === 0 ? ax(k) : sz(k); // the coordinate of the k-th crossing road along this axis
|
| 100 |
const crossW = (axis, k) => axis === 0 ? avW(k) : stW(k);
|
| 101 |
const roadC = (axis, road) => axis === 0 ? sz(road) : ax(road);
|
| 102 |
-
const N = COARSE ? 500 : 1100, NP = COARSE ? 900 : 2000;
|
| 103 |
|
| 104 |
// ---- vehicles ----
|
| 105 |
const carMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: .32, metalness: .55, envMapIntensity: 1.1 });
|
|
|
|
| 99 |
const cross = (axis, k) => axis === 0 ? ax(k) : sz(k); // the coordinate of the k-th crossing road along this axis
|
| 100 |
const crossW = (axis, k) => axis === 0 ? avW(k) : stW(k);
|
| 101 |
const roadC = (axis, road) => axis === 0 ? sz(road) : ax(road);
|
| 102 |
+
const LOWMEM = (navigator.deviceMemory || 8) <= 4, N = COARSE ? (LOWMEM ? 320 : 500) : 1100, NP = COARSE ? (LOWMEM ? 500 : 900) : 2000;
|
| 103 |
|
| 104 |
// ---- vehicles ----
|
| 105 |
const carMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: .32, metalness: .55, envMapIntensity: 1.1 });
|
static/police.js
CHANGED
|
@@ -91,6 +91,7 @@ export function createPolice(scene, api, sound, life) {
|
|
| 91 |
}
|
| 92 |
return {
|
| 93 |
get stars() { return stars; }, get bust() { return bust; }, cops, clear,
|
|
|
|
| 94 |
event(kind, extra) { // things the game tells us about
|
| 95 |
if (kind === "smash") addHeat(extra === "police" ? 3 : 1, extra === "police" ? "You smashed a police car!" : "Wanted for smashing cars");
|
| 96 |
if (kind === "cop") addHeat(3, "Police officer down");
|
|
|
|
| 91 |
}
|
| 92 |
return {
|
| 93 |
get stars() { return stars; }, get bust() { return bust; }, cops, clear,
|
| 94 |
+
wanted(n, why) { if (stars < n) { heat = Math.max(heat, HEAT[n]); addHeat(0, why); } },
|
| 95 |
event(kind, extra) { // things the game tells us about
|
| 96 |
if (kind === "smash") addHeat(extra === "police" ? 3 : 1, extra === "police" ? "You smashed a police car!" : "Wanted for smashing cars");
|
| 97 |
if (kind === "cop") addHeat(3, "Police officer down");
|
static/race.js
CHANGED
|
@@ -1,177 +1,253 @@
|
|
| 1 |
-
// Street races round VOID City: drive into a start marker and press Enter (or tap Race). Gates on the real streets,
|
| 2 |
-
// three AI rivals in sports cars (solo), or everybody in your online room. A countdown, a timer, live positions,
|
| 3 |
-
// best times kept on this device. The Sky Rings race is a flying time trial through gates in the air.
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
import
|
| 7 |
-
import {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
{ id: "
|
| 14 |
-
{ id: "
|
| 15 |
-
{ id: "
|
| 16 |
-
{ id: "
|
| 17 |
-
]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
const
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
const
|
| 34 |
-
const
|
| 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 |
-
$("gRace").hidden =
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
if (!R)
|
| 100 |
-
const
|
| 101 |
-
|
| 102 |
-
if (
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Street races round VOID City: drive into a start marker and press Enter (or tap Race). Gates on the real streets,
|
| 2 |
+
// three AI rivals in sports cars (solo), or everybody in your online room. A countdown, a timer, live positions,
|
| 3 |
+
// best times kept on this device. The Sky Rings race is a flying time trial through gates in the air.
|
| 4 |
+
// Ghosts: your best run on every route is recorded and races beside you next time, with a split at every gate.
|
| 5 |
+
// Your own routes: drop gates while you drive or fly, save them, race them against your ghost, share them as a link.
|
| 6 |
+
import * as THREE from "three";
|
| 7 |
+
import { GRID } from "./city.js";
|
| 8 |
+
import { KINDS, vehicle } from "./npc.js";
|
| 9 |
+
import { makeDriver } from "./police.js";
|
| 10 |
+
|
| 11 |
+
const G = (i, j) => [GRID.ax(i), 0, GRID.sz(j)];
|
| 12 |
+
export const ROUTES = [
|
| 13 |
+
{ id: "midtown", name: "Midtown Sprint", pts: [G(7, 8), G(7, 4), G(9, 4), G(9, 1), G(5, 1), G(5, 6), G(8, 6)] },
|
| 14 |
+
{ id: "park", name: "Park Loop ×2", pts: [G(2, 12), G(5, 12), G(5, 18), G(2, 18), G(2, 12), G(5, 12), G(5, 18), G(2, 18), G(2, 13)] },
|
| 15 |
+
{ id: "bridge", name: "Bridge Run", pts: [G(7, 10), G(12, 10), [1400, 0, 0], [1690, 0, 0]] },
|
| 16 |
+
{ id: "downtown", name: "Downtown Dash", pts: [G(8, 13), G(12, 13), G(12, 19), G(9, 19), G(9, 15), G(11, 15)] },
|
| 17 |
+
{ id: "drag", name: "Runway Drag", pts: [[-2600, 0, 0], [-2350, 0, 0], [-2100, 0, 0], [-1960, 0, 0]] },
|
| 18 |
+
{ id: "sky", name: "Sky Rings", air: true, pts: [[420, 0, -40], [420, 90, -160], [520, 120, -300], [430, 150, -450], [250, 170, -520], [60, 150, -460], [-60, 120, -330], [-120, 90, -180], [-260, 70, 60], [-400, 60, 250]] },
|
| 19 |
+
];
|
| 20 |
+
const RIVALS = [["VIPER", 0xd21f2b], ["BLAZE", 0xff6a00], ["NOVA", 0x1f6fe0]];
|
| 21 |
+
const fmt = t => `${Math.floor(t / 60)}:${(t % 60).toFixed(1).padStart(4, "0")}`;
|
| 22 |
+
const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { } } };
|
| 23 |
+
|
| 24 |
+
export function createRaces(scene, api, sound, canvasTex, net) {
|
| 25 |
+
const $ = id => document.getElementById(id);
|
| 26 |
+
const D = makeDriver(api);
|
| 27 |
+
const label = (text, col) => { const t = canvasTex(512, 128, (g, w, h) => { g.fillStyle = "rgba(0,0,0,.6)"; g.fillRect(0, 0, w, h); g.strokeStyle = col; g.lineWidth = 6; g.strokeRect(4, 4, w - 8, h - 8); g.fillStyle = col; g.font = "900 54px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText(text, w / 2, 84); }); return new THREE.Sprite(new THREE.SpriteMaterial({ map: t, toneMapped: false, depthTest: true })); };
|
| 28 |
+
// start markers: a column of light and a floating name
|
| 29 |
+
const custom = (store.get("void.routes") || []).filter(r => r && Array.isArray(r.pts) && r.pts.length >= 3);
|
| 30 |
+
const routes = [...ROUTES, ...custom];
|
| 31 |
+
const makeStart = r => {
|
| 32 |
+
const [x, , z] = r.pts[0], [nx, , nz] = r.pts[1], back = Math.atan2(nz - z, nx - x);
|
| 33 |
+
const sx = x - Math.cos(back) * 35, sz = z - Math.sin(back) * 35;
|
| 34 |
+
const col = r.custom ? "#c07dff" : r.air ? "#3dfcff" : "#ffd23d";
|
| 35 |
+
const pillar = new THREE.Mesh(new THREE.CylinderGeometry(6, 6, 60, 32, 1, true).translate(0, 30, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(col).multiplyScalar(1.6), transparent: true, opacity: .12, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
|
| 36 |
+
pillar.position.set(sx, 0, sz); scene.add(pillar);
|
| 37 |
+
const ring = new THREE.Mesh(new THREE.RingGeometry(5.4, 6, 48).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ color: new THREE.Color(col).multiplyScalar(2), toneMapped: false })); ring.position.set(sx, .15, sz); scene.add(ring);
|
| 38 |
+
const s = label("🏁 " + r.name.toUpperCase(), col); s.scale.set(16, 4, 1); s.position.set(sx, 9, sz); scene.add(s);
|
| 39 |
+
return { r, x: sx, z: sz, yaw: back, pillar, ring, s };
|
| 40 |
+
};
|
| 41 |
+
const starts = routes.map(makeStart);
|
| 42 |
+
// gates: two posts and a banner across the road
|
| 43 |
+
const gateMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd23d).multiplyScalar(2), toneMapped: false }), dimMat = new THREE.MeshBasicMaterial({ color: 0x555555 });
|
| 44 |
+
const checker = canvasTex(256, 32, (g, w, h) => { for (let x = 0; x < w; x += 16) for (let y = 0; y < h; y += 16) { g.fillStyle = ((x + y) / 16) % 2 ? "#111" : "#fff"; g.fillRect(x, y, 16, 16); } });
|
| 45 |
+
const gates = [];
|
| 46 |
+
function buildGates(r) {
|
| 47 |
+
clearGates();
|
| 48 |
+
r.pts.forEach((p, k) => {
|
| 49 |
+
const q = r.pts[Math.max(0, k - 1)], o = r.pts[Math.min(r.pts.length - 1, k + 1)], dir = k ? Math.atan2(p[2] - q[2], p[0] - q[0]) : Math.atan2(o[2] - p[2], o[0] - p[0]);
|
| 50 |
+
const g = new THREE.Group(); g.position.set(p[0], p[1], p[2]); g.rotation.y = -dir;
|
| 51 |
+
const fin = k === r.pts.length - 1;
|
| 52 |
+
if (r.air) { const ring = new THREE.Mesh(new THREE.TorusGeometry(10, .7, 10, 40), gateMat.clone()); ring.rotation.y = Math.PI / 2; g.add(ring); g.userData.parts = [ring]; }
|
| 53 |
+
else {
|
| 54 |
+
const W = 11, parts = [];
|
| 55 |
+
for (const s of [-1, 1]) { const post = new THREE.Mesh(new THREE.BoxGeometry(.6, 7, .6).translate(0, 3.5, 0), gateMat.clone()); post.position.z = s * W; g.add(post); parts.push(post); }
|
| 56 |
+
const banner = new THREE.Mesh(new THREE.BoxGeometry(.3, 1.4, W * 2 + .6), fin ? new THREE.MeshBasicMaterial({ map: checker, toneMapped: false }) : gateMat.clone()); banner.position.y = 7; g.add(banner); if (!fin) parts.push(banner);
|
| 57 |
+
g.userData.parts = parts;
|
| 58 |
+
}
|
| 59 |
+
scene.add(g); gates.push(g);
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
function clearGates() { for (const g of gates) scene.remove(g); gates.length = 0; }
|
| 63 |
+
// rivals: sports cars with their own colours
|
| 64 |
+
const sport = KINDS.find(k => k.id === "sports"), sg = vehicle(sport);
|
| 65 |
+
const rivals = RIVALS.map(([name, col]) => {
|
| 66 |
+
const g = new THREE.Group(); g.add(new THREE.Mesh(sg.body, new THREE.MeshStandardMaterial({ vertexColors: true, color: col, roughness: .25, metalness: .6 })), new THREE.Mesh(sg.lights, new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, color: new THREE.Color(2, 2, 2) })));
|
| 67 |
+
const tag = label(name, "#" + col.toString(16).padStart(6, "0")); tag.scale.set(5, 1.25, 1); tag.position.y = 3.4; g.add(tag);
|
| 68 |
+
g.visible = false; scene.add(g); return { name, g, x: 0, z: 0, yaw: 0, v: 0, cp: 1, done: 0, skill: 1 };
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// the ghost: a see-through copy of your best run on this route
|
| 72 |
+
const ghost = new THREE.Group(); ghost.add(new THREE.Mesh(sg.body, new THREE.MeshBasicMaterial({ color: new THREE.Color(.3, 1.6, 2), transparent: true, opacity: .28, depthWrite: false, toneMapped: false })));
|
| 73 |
+
{ const gt = label("GHOST", "#3dfcff"); gt.scale.set(5, 1.25, 1); gt.position.y = 3.4; ghost.add(gt); } ghost.visible = false; scene.add(ghost);
|
| 74 |
+
const ghostOf = id => { const gh = store.get("void.ghost." + id); return gh && Array.isArray(gh.p) && gh.p.length >= 8 ? gh : null; };
|
| 75 |
+
let R = null; // the race in progress
|
| 76 |
+
function begin(route, opts = {}) {
|
| 77 |
+
const p = api.state; if (!p) return;
|
| 78 |
+
api.policeClear && api.policeClear();
|
| 79 |
+
const st = starts.find(s => s.r === route);
|
| 80 |
+
buildGates(route);
|
| 81 |
+
const slot = opts.slot ?? 0, side = [0, 1, -1, 2, -2, 3, -3][slot % 7] * 4.2, row = Math.floor(slot / 7) * 8;
|
| 82 |
+
const fx = Math.cos(st.yaw), fz = Math.sin(st.yaw);
|
| 83 |
+
api.teleport(st.x + fx * (20 - row) - fz * side, st.z + fz * (20 - row) + fx * side, st.yaw);
|
| 84 |
+
api.freeze(opts.wait ?? 3.2);
|
| 85 |
+
// bots: in an online race every player runs the same bots from the same seed on a fixed clock, so they match
|
| 86 |
+
let sd = (opts.seed || Math.floor(Math.random() * 1e9)) % 2147483647 || 7; const rng = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
|
| 87 |
+
R = { route, t: -(opts.wait ?? 3.2), cp: 1, done: 0, online: !!opts.online, results: [], lastBeep: 4, rng, acc: 0, bt: 0, rec: [], recT: 0, splits: [], ghost: ghostOf(route.id) };
|
| 88 |
+
ghost.visible = !!R.ghost;
|
| 89 |
+
rivals.forEach((r, k) => {
|
| 90 |
+
r.g.visible = !route.air && !route.custom && (!R.online || opts.bots !== false);
|
| 91 |
+
const sd = [-1, 1, 2][k] * 4.2, rw = k === 2 ? 8 : 0;
|
| 92 |
+
Object.assign(r, { x: st.x + fx * (20 - rw) - fz * sd, z: st.z + fz * (20 - rw) + fx * sd, yaw: st.yaw, v: 0, cp: 1, done: 0, skill: .9 + rng() * .16, accel: 13, stuck: 0 });
|
| 93 |
+
});
|
| 94 |
+
$("gRace").hidden = false; $("gRaceRes").hidden = true;
|
| 95 |
+
}
|
| 96 |
+
function finish(msg) {
|
| 97 |
+
const p = api.state, best = store.get("void.race." + R.route.id);
|
| 98 |
+
const me = R.done, rank = standings().findIndex(s => s.me) + 1;
|
| 99 |
+
if (me && (!best || me < best)) { store.set("void.race." + R.route.id, me); if (R.rec.length >= 8) store.set("void.ghost." + R.route.id, { t: me, s: R.splits, p: R.rec.slice(0, 12000) }); }
|
| 100 |
+
const gdiff = me && R.ghost ? me - R.ghost.t : null;
|
| 101 |
+
const reward = me ? [0, 1500, 800, 400, 200][Math.min(4, rank)] || 100 : 0;
|
| 102 |
+
if (reward) api.addScore(reward, `${R.route.name}: ${["", "1st", "2nd", "3rd"][rank] || rank + "th"}`);
|
| 103 |
+
$("gRaceRes").innerHTML = `<h3>${R.route.name}</h3><p>${msg || (me ? `You finished ${["", "1st", "2nd", "3rd"][rank] || rank + "th"} in ${fmt(me)}` : "Race over")}</p><p class="small">${best ? `Best: ${fmt(Math.min(best, me || 1e9))}` : me ? "New best time!" : ""}${gdiff != null ? ` · vs ghost ${gdiff <= 0 ? "−" : "+"}${Math.abs(gdiff).toFixed(2)} s${gdiff < 0 ? " (new ghost saved)" : ""}` : me && !R.ghost ? " · ghost saved" : ""}</p><ol>${standings().map(s => `<li${s.me ? ' class="me"' : ""}>${s.name}<span>${s.done ? fmt(s.done) : "DNF"}</span></li>`).join("")}</ol><button class="pill solid" id="gRaceOk">OK</button>`;
|
| 104 |
+
$("gRaceRes").hidden = false; $("gRaceOk").onclick = () => { $("gRaceRes").hidden = true; };
|
| 105 |
+
$("gRace").hidden = true; clearGates(); rivals.forEach(r => r.g.visible = false); ghost.visible = false; R = null; void p;
|
| 106 |
+
}
|
| 107 |
+
// everyone's progress: checkpoints passed plus how far along to the next
|
| 108 |
+
const progOf = (x, y, z, cp, route) => { const a = route.pts[cp - 1], b = route.pts[Math.min(route.pts.length - 1, cp)]; const L = Math.hypot(b[0] - a[0], b[2] - a[2]) || 1, d = Math.hypot(b[0] - x, b[2] - z); return cp + Math.max(0, 1 - d / L); };
|
| 109 |
+
function standings() {
|
| 110 |
+
if (!R) return [];
|
| 111 |
+
const p = api.state, out = [{ name: "YOU", me: true, prog: R.done ? 1e6 - R.done : progOf(p.x, p.y, p.z, R.cp, R.route), done: R.done }];
|
| 112 |
+
for (const r of rivals) if (r.g.visible) out.push({ name: r.name, prog: r.done ? 1e6 - r.done : progOf(r.x, 0, r.z, r.cp, R.route), done: r.done });
|
| 113 |
+
if (R.online && net) for (const q of net.players()) if (q.race === R.route.id) out.push({ name: q.name, prog: q.rf ? 1e6 - q.rf : q.rp || 0, done: q.rf || 0 });
|
| 114 |
+
return out.sort((a, b) => b.prog - a.prog);
|
| 115 |
+
}
|
| 116 |
+
function nearStart() { const p = api.state; if (!p || R || B) return null; return starts.find(s => Math.hypot(p.x - s.x, p.z - s.z) < 14) || null; }
|
| 117 |
+
addEventListener("keydown", e => { if (e.code === "Enter" && api.active()) { const s = nearStart(); if (s) start(s.r); } });
|
| 118 |
+
$("gRaceGo").onclick = () => { const s = nearStart(); if (s) start(s.r); };
|
| 119 |
+
function start(route) {
|
| 120 |
+
if (net && net.online() && !route.custom) { const seed = Math.floor(Math.random() * 1e9); net.send({ t: "race", id: route.id, wait: 6, seed }); begin(route, { online: true, wait: 6, slot: 0, seed }); }
|
| 121 |
+
else begin(route);
|
| 122 |
+
}
|
| 123 |
+
// ---- the route builder ----
|
| 124 |
+
let B = null;
|
| 125 |
+
const saveCustom = () => store.set("void.routes", custom.map(({ id, name, air, pts }) => ({ id, name, air, pts, custom: true })));
|
| 126 |
+
function addRoute(r) { r.custom = true; custom.push(r); routes.push(r); starts.push(makeStart(r)); while (custom.length > 12) removeRoute(custom[0].id); saveCustom(); return r; }
|
| 127 |
+
function removeRoute(id) {
|
| 128 |
+
const k = custom.findIndex(r => r.id === id); if (k < 0) return; const r = custom[k]; custom.splice(k, 1); routes.splice(routes.indexOf(r), 1);
|
| 129 |
+
const s = starts.findIndex(st => st.r === r); if (s >= 0) { const st = starts[s]; scene.remove(st.pillar, st.ring, st.s); starts.splice(s, 1); }
|
| 130 |
+
try { localStorage.removeItem("void.ghost." + id); localStorage.removeItem("void.race." + id); } catch { } saveCustom();
|
| 131 |
+
}
|
| 132 |
+
const buildMarks = [];
|
| 133 |
+
function builder(on) {
|
| 134 |
+
if (on && R) return api.say("Finish the race first", 2);
|
| 135 |
+
for (const m of buildMarks) scene.remove(m); buildMarks.length = 0;
|
| 136 |
+
B = on ? { pts: [] } : null; $("gBuild").hidden = !on; if (on) { dropGate(); api.say("Route builder: drive or fly your route, press G (or the button) to drop each gate", 4); }
|
| 137 |
+
renderBuild();
|
| 138 |
+
}
|
| 139 |
+
function dropGate() {
|
| 140 |
+
const p = api.state; if (!B || !p) return;
|
| 141 |
+
const pt = [Math.round(p.x), p.mode === "air" && p.y > 6 ? Math.round(p.y) : 0, Math.round(p.z)], last = B.pts[B.pts.length - 1];
|
| 142 |
+
if (last && Math.hypot(pt[0] - last[0], pt[1] - last[1], pt[2] - last[2]) < 30) return api.say("Too close to the last gate (30 m)", 1.5);
|
| 143 |
+
B.pts.push(pt); sound.pop();
|
| 144 |
+
const m = new THREE.Mesh(new THREE.TorusGeometry(pt[1] ? 10 : 6, .5, 8, 32), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xc07dff).multiplyScalar(2), toneMapped: false })); m.position.set(pt[0], pt[1] || 6, pt[2]); m.rotation.y = -p.yaw + Math.PI / 2; scene.add(m); buildMarks.push(m);
|
| 145 |
+
renderBuild();
|
| 146 |
+
}
|
| 147 |
+
function saveBuild() {
|
| 148 |
+
if (!B || B.pts.length < 3) return api.say("A route needs a start and at least 2 more gates", 2);
|
| 149 |
+
const r = addRoute({ id: "c" + Date.now().toString(36), name: "My route " + (custom.length + 1), air: B.pts.some(q => q[1] > 0), pts: B.pts });
|
| 150 |
+
builder(false); api.say(`Saved "${r.name}" and copied its link. The purple marker is its start`, 3.5); renderList(); share(r, true);
|
| 151 |
+
}
|
| 152 |
+
function renderBuild() { if (!B) return; $("gBuild").innerHTML = `<b>🛠 Route builder</b><span>${B.pts.length === 1 ? "Start set" : `${B.pts.length - 1} gate${B.pts.length > 2 ? "s" : ""}`}</span><button class="pill" id="gBDrop">Drop gate (G)</button><button class="pill solid" id="gBSave">Save</button><button class="pill" id="gBCancel">Cancel</button>`; $("gBDrop").onclick = dropGate; $("gBSave").onclick = saveBuild; $("gBCancel").onclick = () => builder(false); }
|
| 153 |
+
addEventListener("keydown", e => { if (e.code === "KeyG" && B && api.active() && !e.repeat) dropGate(); });
|
| 154 |
+
// share a route as a link: everything is in the address, nothing is uploaded
|
| 155 |
+
const b64 = o => btoa(unescape(encodeURIComponent(JSON.stringify(o)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
| 156 |
+
async function share(r, quiet) {
|
| 157 |
+
const url = location.origin + location.pathname + "#r=" + b64({ n: r.name, p: r.pts });
|
| 158 |
+
try { await navigator.clipboard.writeText(url); if (!quiet) api.say("Route link copied: send it to a friend", 2.5); } catch { if (!quiet) prompt("Copy this route link", url); }
|
| 159 |
+
}
|
| 160 |
+
try {
|
| 161 |
+
const h = new URLSearchParams(location.hash.slice(1)).get("r");
|
| 162 |
+
if (h) {
|
| 163 |
+
const o = JSON.parse(decodeURIComponent(escape(atob(h.replace(/-/g, "+").replace(/_/g, "/")))));
|
| 164 |
+
const pts = (o.p || []).slice(0, 40).map(q => [+q[0] || 0, Math.max(0, Math.min(400, +q[1] || 0)), +q[2] || 0]).filter(q => q.every(Number.isFinite) && Math.abs(q[0]) < 4000 && Math.abs(q[2]) < 4000);
|
| 165 |
+
if (pts.length >= 3 && !custom.some(r => JSON.stringify(r.pts) === JSON.stringify(pts))) addRoute({ id: "c" + Date.now().toString(36), name: String(o.n || "Shared route").replace(/[<>&"]/g, "").slice(0, 28), air: pts.some(q => q[1] > 0), pts });
|
| 166 |
+
history.replaceState(null, "", location.pathname + location.search);
|
| 167 |
+
}
|
| 168 |
+
} catch { }
|
| 169 |
+
// the Routes list in the Menu: best time, ghost, go there, share, delete
|
| 170 |
+
function renderList() {
|
| 171 |
+
const el = $("gRoutes"); if (!el) return;
|
| 172 |
+
el.innerHTML = routes.map(r => { const b = store.get("void.race." + r.id), gh = !!ghostOf(r.id); return `<div class="route${r.custom ? " mine" : ""}"><div><span>${r.air ? "✈️" : "🏁"} ${r.name}</span><small>${b ? `Best ${fmt(b)}` : "No time yet"}${gh ? " · 👻 ghost" : ""}${r.custom ? " · yours" : ""}</small></div><button class="pill" data-rgo="${r.id}">Go</button>${r.custom ? `<button class="pill" data-share="${r.id}">Share</button><button class="pill" data-del="${r.id}">✕</button>` : ""}</div>`; }).join("") + `<button class="tile wide-tile" id="gBuildBtn"><b>🛠</b>Make your own route</button>`;
|
| 173 |
+
}
|
| 174 |
+
document.addEventListener("click", e => {
|
| 175 |
+
const t = e.target.closest("[data-rgo],[data-share],[data-del],#gBuildBtn"); if (!t || !t.closest("#gRoutes")) return;
|
| 176 |
+
if (t.id === "gBuildBtn") return builder(true);
|
| 177 |
+
const r = routes.find(x => x.id === (t.dataset.rgo || t.dataset.share || t.dataset.del)); if (!r) return;
|
| 178 |
+
if (t.dataset.rgo) { const st = starts.find(s => s.r === r); if (st && !R) { api.teleport(st.x, st.z, st.yaw); api.say(`${r.name}: press Enter (or Start) at the marker`, 3); } }
|
| 179 |
+
if (t.dataset.share) share(r);
|
| 180 |
+
if (t.dataset.del && confirm(`Delete "${r.name}" and its ghost?`)) { removeRoute(r.id); renderList(); }
|
| 181 |
+
});
|
| 182 |
+
if (net) net.on("race", (m, from) => { const r = ROUTES.find(x => x.id === m.id); if (r && !R && !B) { begin(r, { online: true, wait: Math.max(2, Math.min(10, +m.wait || 6)) - .3, seed: Math.abs(m.seed | 0) || 7, slot: 1 + (net.players().findIndex(q => q.id === from) + 1) % 6 }); api.say(`${net.nameOf(from)} started ${r.name}. Get ready!`, 3); } });
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
get race() { return R; }, rivals, renderList, get building() { return !!B; },
|
| 186 |
+
progress() { return R ? { race: R.route.id, rp: R.done ? 0 : progOf(api.state.x, api.state.y, api.state.z, R.cp, R.route), rf: R.done } : null; },
|
| 187 |
+
starts,
|
| 188 |
+
update(dt, time) {
|
| 189 |
+
const p = api.state;
|
| 190 |
+
for (const s of starts) { s.pillar.material.opacity = .1 + .05 * Math.sin(time * 3); s.s.visible = s.ring.visible = s.pillar.visible = !R; }
|
| 191 |
+
const ns = nearStart(); $("gRaceGo").hidden = !ns; if (ns) $("gRaceGo").textContent = `Start ${ns.r.name} (Enter)`;
|
| 192 |
+
if (!R || !p) return;
|
| 193 |
+
R.t += dt;
|
| 194 |
+
// countdown
|
| 195 |
+
if (R.t < 0) { const n = Math.min(3, Math.ceil(-R.t)); $("gCount").textContent = n; $("gCount").hidden = false; if (n < R.lastBeep) { R.lastBeep = n; sound.pop(); } }
|
| 196 |
+
else if (R.t < .8) { $("gCount").textContent = "GO!"; $("gCount").hidden = false; if (R.lastBeep > 0) { R.lastBeep = 0; sound.win(); } }
|
| 197 |
+
else $("gCount").hidden = true;
|
| 198 |
+
const route = R.route, N = route.pts.length;
|
| 199 |
+
// gates: the next one bright, the one after dim, the rest hidden
|
| 200 |
+
gates.forEach((g, k) => { g.visible = k >= R.cp && k <= R.cp + 1; for (const m of g.userData.parts || []) m.material.color.set(k === R.cp ? 0xffd23d : 0x666666).multiplyScalar(k === R.cp ? 2 : 1); });
|
| 201 |
+
if (R.t > 0 && !R.done) {
|
| 202 |
+
const c = route.pts[R.cp], d = route.air ? Math.hypot(p.x - c[0], p.y - c[1], p.z - c[2]) : Math.hypot(p.x - c[0], p.z - c[2]);
|
| 203 |
+
if (d < (route.air ? (route.custom ? 14 : 11) : 13)) { R.splits[R.cp] = +R.t.toFixed(2); const gs = R.ghost && R.ghost.s && R.ghost.s[R.cp]; if (gs && R.cp < N - 1) { const df = R.t - gs; api.say(`Gate ${R.cp} ${df <= 0 ? "−" : "+"}${Math.abs(df).toFixed(2)} s vs ghost`, 1.6); } R.cp++; sound.pop(); if (R.cp >= N) { R.done = R.t; sound.win(); api.say(`Finished in ${fmt(R.t)}!`, 3); R.endAt = R.t + (R.online ? 15 : 25); } }
|
| 204 |
+
}
|
| 205 |
+
if (R.t > 0 && !R.done && (R.recT += dt) >= .1) { R.recT -= .1; R.rec.push(Math.round(p.x * 10) / 10, Math.round(p.y * 10) / 10, Math.round(p.z * 10) / 10, Math.round(p.yaw * 100) / 100); }
|
| 206 |
+
if (R.ghost) {
|
| 207 |
+
const gp = R.ghost.p, n = gp.length / 4, f = Math.max(0, R.t) * 10, i = Math.min(n - 2, Math.floor(f)), k = Math.min(1, f - i), a = i * 4, b = a + 4;
|
| 208 |
+
if (f >= n - 1) ghost.visible = false;
|
| 209 |
+
else { let dy = gp[b + 3] - gp[a + 3]; if (dy > Math.PI) dy -= 2 * Math.PI; if (dy < -Math.PI) dy += 2 * Math.PI; ghost.position.set(gp[a] + (gp[b] - gp[a]) * k, gp[a + 1] + (gp[b + 1] - gp[a + 1]) * k, gp[a + 2] + (gp[b + 2] - gp[a + 2]) * k); ghost.rotation.y = -(gp[a + 3] + dy * k); }
|
| 210 |
+
}
|
| 211 |
+
// rivals: stepped on a fixed 1/60 s clock from the green light (the same on every player's screen when online)
|
| 212 |
+
if (!route.air && !route.custom && R.t > 0) {
|
| 213 |
+
R.acc += dt;
|
| 214 |
+
while (R.acc >= 1 / 60 && R.bt < R.t) {
|
| 215 |
+
R.acc -= 1 / 60; R.bt += 1 / 60;
|
| 216 |
+
for (const r of rivals) {
|
| 217 |
+
if (!r.g.visible || r.done) continue;
|
| 218 |
+
const c = route.pts[Math.min(N - 1, r.cp)];
|
| 219 |
+
let band = 1;
|
| 220 |
+
if (!R.online) { const mine = progOf(p.x, 0, p.z, R.cp, route), theirs = progOf(r.x, 0, r.z, r.cp, route); band = mine - theirs > .8 ? 1.12 : theirs - mine > .8 ? .9 : 1; }
|
| 221 |
+
// brake for the corner at the next gate, and get back on the road if it ever ends up stuck
|
| 222 |
+
const prev = route.pts[Math.max(0, r.cp - 1)], nxt = route.pts[Math.min(N - 1, r.cp + 1)];
|
| 223 |
+
const a1 = Math.atan2(c[2] - prev[2], c[0] - prev[0]), a2 = Math.atan2(nxt[2] - c[2], nxt[0] - c[0]);
|
| 224 |
+
let turnA = Math.abs(a2 - a1); if (turnA > Math.PI) turnA = 2 * Math.PI - turnA;
|
| 225 |
+
const dc = Math.hypot(c[0] - r.x, c[2] - r.z), top = 62 * r.skill * band;
|
| 226 |
+
const want = turnA > .5 && r.cp < N - 1 ? Math.min(top, 13 + Math.max(0, dc - 8) * .9) : top;
|
| 227 |
+
D.step(r, c[0], c[2], want, 1 / 60, R.rng);
|
| 228 |
+
if (r.stuck > 1.2) {
|
| 229 |
+
const dx = c[0] - prev[0], dz = c[2] - prev[2], L2 = dx * dx + dz * dz || 1, t = Math.max(0, Math.min(1, ((r.x - prev[0]) * dx + (r.z - prev[2]) * dz) / L2));
|
| 230 |
+
r.x = prev[0] + dx * t; r.z = prev[2] + dz * t; r.yaw = Math.atan2(c[2] - r.z, c[0] - r.x); r.v = 6; r.stuck = 0;
|
| 231 |
+
}
|
| 232 |
+
if (Math.hypot(r.x - c[0], r.z - c[2]) < 12) { r.cp++; if (r.cp >= N) r.done = R.bt; }
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
for (const r of rivals) {
|
| 237 |
+
if (!r.g.visible) continue;
|
| 238 |
+
r.g.position.set(r.x, 0, r.z); r.g.rotation.y = -r.yaw;
|
| 239 |
+
// they can't drive through you (you get pushed; they keep their line so every screen agrees)
|
| 240 |
+
const dx = p.x - r.x, dz = p.z - r.z, dd = Math.hypot(dx, dz);
|
| 241 |
+
if (dd < 3.4 && dd > .01 && p.y < 2) { p.x += dx / dd * (3.4 - dd); p.z += dz / dd * (3.4 - dd); if (!R.online) r.v *= .9; }
|
| 242 |
+
}
|
| 243 |
+
const st = standings(), pos = st.findIndex(s => s.me) + 1;
|
| 244 |
+
// (the banner is built once per race and only its text changes, so the Quit button stays clickable)
|
| 245 |
+
if (!$("gRaceT")) { $("gRace").innerHTML = `<b id="gRaceN"></b><span id="gRaceT"></span><span id="gRaceG"></span><span id="gRaceP"></span><button class="pill" id="gRaceQuit2">Quit</button>`; $("gRaceQuit2").onclick = () => R && finish("You left the race"); }
|
| 246 |
+
$("gRaceN").textContent = route.name; $("gRaceT").textContent = R.t < 0 ? "Get ready" : fmt(Math.max(0, R.done || R.t)); $("gRaceG").textContent = `Gate ${Math.min(R.cp, N - 1)}/${N - 1}`; $("gRaceP").textContent = `${pos}/${st.length}`;
|
| 247 |
+
const next = route.pts[Math.min(N - 1, R.cp)]; R.next = next;
|
| 248 |
+
if (R.done && (R.t > R.endAt || (!R.online && rivals.every(r => r.done || !r.g.visible)))) { finish(); return; }
|
| 249 |
+
if (R && R.t > 600) finish("Out of time");
|
| 250 |
+
},
|
| 251 |
+
cancel() { if (R) finish("Race cancelled"); },
|
| 252 |
+
};
|
| 253 |
+
}
|
static/sound.js
CHANGED
|
@@ -94,6 +94,9 @@ export class Sound {
|
|
| 94 |
this.rumble = C.createOscillator(); this.rumble.frequency.value = 42; this.rumbleG = C.createGain(); this.rumbleG.gain.value = 0; this.rumble.connect(this.rumbleG).connect(this.sfx); this.rumble.start();
|
| 95 |
this.windF = C.createBiquadFilter(); this.windF.type = "bandpass"; this.windF.frequency.value = 900; this.windF.Q.value = .6; this.windG = C.createGain(); this.windG.gain.value = 0;
|
| 96 |
this.loopNoise().connect(this.windF).connect(this.windG).connect(this.sfx);
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
this.next = C.currentTime + .05;
|
| 99 |
setInterval(() => this.schedule(), 25);
|
|
@@ -196,6 +199,8 @@ export class Sound {
|
|
| 196 |
this.env(g, t, .004, 1.1, 1.6); o.connect(g).connect(this.musicIn); o.start(t); o.stop(t + 1.7);
|
| 197 |
this.noiseHit(t, "lowpass", 6000, .5, .55, 1.5, this.musicIn);
|
| 198 |
}
|
|
|
|
|
|
|
| 199 |
whoosh() { if (this.on) this.noiseHit(this.ctx.currentTime, "bandpass", 700, .8, .5, .9, this.sfx); }
|
| 200 |
pop() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "bandpass", 300, 1, .9, .07, this.sfx); this.noiseHit(t + .06, "bandpass", 260, 1, .6, .06, this.sfx); } }
|
| 201 |
crash() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "lowpass", 2500, .4, 1, 1.2, this.sfx); this.noiseHit(t, "bandpass", 180, 1, 1, .6, this.sfx); } }
|
|
@@ -233,6 +238,7 @@ export class Sound {
|
|
| 233 |
this.thrG.gain.setTargetAtTime(s.thrust * .45, t, .08); this.thrF.frequency.setTargetAtTime(500 + s.thrust * 1500, t, .1);
|
| 234 |
this.rumbleG.gain.setTargetAtTime(s.thrust * .3, t, .1);
|
| 235 |
this.windG.gain.setTargetAtTime(Math.min(1, s.speed / 320) * .16, t, .2);
|
|
|
|
| 236 |
this.sfxLP.frequency.setTargetAtTime(s.inside ? 1100 : 16000, t, .3);
|
| 237 |
}
|
| 238 |
}
|
|
|
|
| 94 |
this.rumble = C.createOscillator(); this.rumble.frequency.value = 42; this.rumbleG = C.createGain(); this.rumbleG.gain.value = 0; this.rumble.connect(this.rumbleG).connect(this.sfx); this.rumble.start();
|
| 95 |
this.windF = C.createBiquadFilter(); this.windF.type = "bandpass"; this.windF.frequency.value = 900; this.windF.Q.value = .6; this.windG = C.createGain(); this.windG.gain.value = 0;
|
| 96 |
this.loopNoise().connect(this.windF).connect(this.windG).connect(this.sfx);
|
| 97 |
+
// tyres: a narrow band of noise that rises when the car slides
|
| 98 |
+
this.sqF = C.createBiquadFilter(); this.sqF.type = "bandpass"; this.sqF.frequency.value = 1500; this.sqF.Q.value = 9; this.sqG = C.createGain(); this.sqG.gain.value = 0;
|
| 99 |
+
this.loopNoise().connect(this.sqF).connect(this.sqG).connect(this.sfx);
|
| 100 |
|
| 101 |
this.next = C.currentTime + .05;
|
| 102 |
setInterval(() => this.schedule(), 25);
|
|
|
|
| 199 |
this.env(g, t, .004, 1.1, 1.6); o.connect(g).connect(this.musicIn); o.start(t); o.stop(t + 1.7);
|
| 200 |
this.noiseHit(t, "lowpass", 6000, .5, .55, 1.5, this.musicIn);
|
| 201 |
}
|
| 202 |
+
// a body thud scaled by how hard you hit (walls, landings)
|
| 203 |
+
thud(p = .5) { if (!this.on) return; const t = this.ctx.currentTime, k = Math.min(1, p); this.noiseHit(t, "lowpass", 400 + k * 1600, .7, .35 + k * .65, .15 + k * .4, this.sfx); if (k > .5) this.noiseHit(t + .01, "bandpass", 2600, 1.2, (k - .5) * .7, .25, this.sfx); }
|
| 204 |
whoosh() { if (this.on) this.noiseHit(this.ctx.currentTime, "bandpass", 700, .8, .5, .9, this.sfx); }
|
| 205 |
pop() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "bandpass", 300, 1, .9, .07, this.sfx); this.noiseHit(t + .06, "bandpass", 260, 1, .6, .06, this.sfx); } }
|
| 206 |
crash() { if (this.on) { const t = this.ctx.currentTime; this.noiseHit(t, "lowpass", 2500, .4, 1, 1.2, this.sfx); this.noiseHit(t, "bandpass", 180, 1, 1, .6, this.sfx); } }
|
|
|
|
| 238 |
this.thrG.gain.setTargetAtTime(s.thrust * .45, t, .08); this.thrF.frequency.setTargetAtTime(500 + s.thrust * 1500, t, .1);
|
| 239 |
this.rumbleG.gain.setTargetAtTime(s.thrust * .3, t, .1);
|
| 240 |
this.windG.gain.setTargetAtTime(Math.min(1, s.speed / 320) * .16, t, .2);
|
| 241 |
+
if (this.sqG) { const sl = Math.min(1, s.slip || 0); this.sqG.gain.setTargetAtTime(sl * .5, t, .05); this.sqF.frequency.setTargetAtTime(1300 + sl * 700, t, .1); }
|
| 242 |
this.sfxLP.frequency.setTargetAtTime(s.inside ? 1100 : 16000, t, .3);
|
| 243 |
}
|
| 244 |
}
|
static/style.css
CHANGED
|
@@ -406,3 +406,19 @@ body[data-mode="play"] .hub-chip { display: none !important; }
|
|
| 406 |
.up-row em { font: 800 11px var(--body); font-style: normal; color: #ffd23d; }
|
| 407 |
#gCash { color: #9dffb0; }
|
| 408 |
.up-note { margin: 10px 0 0; color: var(--mute); font-size: 11.5px; line-height: 1.4; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
.up-row em { font: 800 11px var(--body); font-style: normal; color: #ffd23d; }
|
| 407 |
#gCash { color: #9dffb0; }
|
| 408 |
.up-note { margin: 10px 0 0; color: var(--mute); font-size: 11.5px; line-height: 1.4; }
|
| 409 |
+
|
| 410 |
+
/* contracts, routes, the route builder, backups */
|
| 411 |
+
.con-tiles { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
| 412 |
+
.tile.con { justify-items: start; text-align: left; padding: 12px; gap: 3px; }
|
| 413 |
+
.tile.con small { color: var(--mute); font-size: 11px; line-height: 1.3; font-weight: 500; }
|
| 414 |
+
.tile.con i { font-style: normal; font-size: 11px; color: #ffd23d; margin-top: 3px; }
|
| 415 |
+
.g-con { background: rgba(0,40,52,.82); border-color: rgba(61,252,255,.6); color: #aaf6ff; }
|
| 416 |
+
.routes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
| 417 |
+
.route { display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; gap: 6px; padding: 8px 10px; border-radius: 12px; background: rgba(0,0,0,.25); border: 1px solid rgba(255,255,255,.08); }
|
| 418 |
+
.route span { display: block; font: 700 13px var(--body); } .route small { color: var(--mute); font-size: 11px; }
|
| 419 |
+
.route .pill { padding: 5px 10px; font-size: 11px; } .route.mine { border-color: rgba(192,125,255,.45); }
|
| 420 |
+
.wide-tile { grid-column: 1 / -1; grid-auto-flow: column; justify-content: center; gap: 8px; }
|
| 421 |
+
.g-build { display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 14px; border-radius: 14px; background: rgba(40,14,60,.85); border: 1px solid rgba(192,125,255,.6); color: #e6d2ff; font: 600 13px var(--body); pointer-events: auto; flex-wrap: wrap; }
|
| 422 |
+
.g-build[hidden] { display: none; } .g-build .pill { padding: 6px 12px; font-size: 12px; }
|
| 423 |
+
.save-row { display: flex; justify-content: space-between; align-items: center; } .save-row span { display: flex; gap: 6px; }
|
| 424 |
+
@media (max-width: 820px) { .routes { grid-template-columns: minmax(0, 1fr); } }
|