File size: 18,096 Bytes
4651f6c | 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 | // Taxi jobs and hidden coins.
// Taxi: press J (or Menu → Taxi job). Passengers wave from the kerb near you; stop next to one and they hop in,
// the GPS shows where they're going, and the fare drops the longer you take. Back-to-back fares build a streak.
// Coins: 80 spinning VOID coins: rooftops, piers, the bridge towers, the park, and since the city doubled the south
// supertalls, the Ferris wheel, the El, the stunt park, Brooklyn, the highway and up the mountain trail; found ones stay found.
import * as THREE from "three";
import { GRID } from "./city.js";
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 { } } };
const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
const ord = n => n + (n % 100 > 10 && n % 100 < 14 ? "th" : ["th", "st", "nd", "rd"][n % 10] || "th");
export function createExtras(W, api, nav, sound) {
const { scene } = W, $ = id => document.getElementById(id);
let sd = 77; const rnd = () => (sd = (sd * 16807) % 2147483647) / 2147483647;
// ---- passengers: a little figure with a yellow beacon ----
const personMat = new THREE.MeshStandardMaterial({ color: 0x3060c0, roughness: .7 }), skin = new THREE.MeshStandardMaterial({ color: 0xd9a07a, roughness: .7 });
const beaconMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd23d).multiplyScalar(1.8), transparent: true, opacity: .35, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
const fig = () => { const g = new THREE.Group(); const b = new THREE.Mesh(new THREE.CylinderGeometry(.25, .3, 1.2, 10).translate(0, .9, 0), personMat); const h = new THREE.Mesh(new THREE.SphereGeometry(.17, 12, 8).translate(0, 1.7, 0), skin); const arm = new THREE.Mesh(new THREE.BoxGeometry(.1, .6, .1).translate(0, .3, 0), personMat); arm.position.set(0, 1.35, .3); arm.rotation.x = -2.6; g.add(b, h, arm); const beam = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.4, 30, 16, 1, true).translate(0, 15, 0), beaconMat); g.add(beam); g.userData.arm = arm; scene.add(g); return g; };
const waiting = [fig(), fig(), fig()], dropMark = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 40, 24, 1, true).translate(0, 20, 0), new THREE.MeshBasicMaterial({ color: new THREE.Color(0x3dff8a).multiplyScalar(1.6), transparent: true, opacity: .22, depthWrite: false, toneMapped: false, side: THREE.DoubleSide }));
scene.add(dropMark); dropMark.visible = false; waiting.forEach(w => w.visible = false);
const kerb = (x0, z0, rMin, rMax) => { // a sidewalk spot rMin..rMax metres away
for (let t = 0; t < 40; t++) {
const a = rnd() * 6.28, r = rMin + rnd() * (rMax - rMin); let x = x0 + Math.cos(a) * r, z = z0 + Math.sin(a) * r;
const i = Math.round((x - GRID.X0) / GRID.AV), j = Math.round((z - GRID.Z0) / GRID.ST);
if (i < 0 || i >= GRID.NX || j < 0 || j >= GRID.NZ) continue;
if (rnd() < .5) x = GRID.ax(i) + (GRID.avW(i) / 2 + 2) * (rnd() < .5 ? 1 : -1); else z = GRID.sz(j) + (GRID.stW(j) / 2 + 2) * (rnd() < .5 ? 1 : -1);
if (api.hitBox(x, z, .5, 1.2) || api.isWater(x, z)) continue;
return [x, z];
}
return null;
};
const J = { on: false, fare: null, streak: 0, t: 0, earned: 0 };
function placeWaiting(p) { for (const w of waiting) { const s = kerb(p.x, p.z, 60, 260); if (s) { w.position.set(s[0], 0, s[1]); w.visible = true; } } }
function toggle(on = !J.on) {
const p = api.state; if (!p) return;
J.on = on; J.fare = null; dropMark.visible = false;
if (on) { placeWaiting(p); api.say("🚕 Taxi: pick up a passenger (yellow beams). J to stop", 4); }
else { waiting.forEach(w => w.visible = false); if (nav.dest && nav.dest.taxi) nav.setDest(null); api.say(J.earned ? `🚕 Shift over: $${J.earned} earned` : "🚕 Taxi off", 3); J.earned = 0; J.streak = 0; }
$("gJob").hidden = !on;
}
addEventListener("keydown", e => { if (e.code === "KeyJ" && api.active() && !e.repeat) toggle(); });
// ---- coins ----
const COINS = [], got = new Set(store.get("void.coins") || []); let coinMesh, glowMesh;
const cm = new THREE.Matrix4(), cq = new THREE.Quaternion(), cv = new THREE.Vector3(), cs = new THREE.Vector3(), Y = new THREE.Vector3(0, 1, 0);
{
const spots = [];
for (const h of W.city.helipads.slice(0, 8)) spots.push([h.x + 4, h.y + 1.5, h.z]);
const roofs = W.buildings.filter(b => b.h > 12 && b.h < 60 && b.hw > 4).sort(() => rnd() - .5).slice(0, 14);
for (const b of roofs) spots.push([b.x, b.h + 1.5, b.z]);
for (const [x, y, z] of [[1210, 112, 15], [1570, 112, -15], [-1080, 1.5, -700 + 140 * 2], [-1080, 1.5, -700 + 140 * 7], [W.city.lake.x, 1.5, W.city.lake.z + W.city.lake.rz + 8], [W.city.lawn.x, 1.5, W.city.lawn.z], [-2600, 1.5, 0], [1900, 1.5, 0], [134, 6, 4.2], [16, 18.5, 2]]) spots.push([x, y, z]);
while (spots.length < 40) { const s = kerb(rnd() * 1800 - 900, rnd() * 1300 - 650, 0, 40); if (s) spots.push([s[0], 1.5, s[1]]); }
spots.length = 40;
// the new half of the map: the tallest south roofs, the top of the Ferris wheel, the El platforms, over the stunt park ramps,
// the gas station canopies down south, the highway, Brooklyn's streets, up the mountain trail and the summit
const C = W.city, M = C.mountains;
for (const h of C.helipads.filter(h => h.z > 900).sort((a, b) => b.y - a.y).slice(0, 8)) spots.push([h.x - 4, h.y + 1.5, h.z]);
if (C.wheel) spots.push([C.wheel.x, C.wheel.y + C.wheel.R + 3, C.wheel.z]);
if (C.el) for (const z of C.el.stations) spots.push([C.el.x, C.el.top + 1.6, z]);
spots.push([-680, 11, 1440], [-620, 7, 1350], [-760, 5, 1405]);
for (const s of C.stations) if (s.z > 900) spots.push([s.x, 7.6, s.z]);
spots.push([2150, 1.5, 0], [2420, 1.5, 0]);
for (let n = 0, t = 0; n < 4 && t < 200; t++) { const x = 1760 + rnd() * 700, z = rnd() * 1000 - 500; if (!api.hitBox(x, z, .5, 1.2) && !api.isWater(x, z) && Math.abs(z) > 14) { spots.push([x, 1.5, z]); n++; } }
if (M) { for (let k = 2; k < M.trail.length; k += 2) { const [x, y, z] = M.trail[k]; spots.push([x, y + 1.6, z]); } spots.push([M.peak[0], M.peak[1] + 2, M.peak[2]]); }
for (let t = 0; spots.length < 80 && t < 400; t++) { const s = kerb(rnd() * 1800 - 900, 900 + rnd() * 1150, 0, 40); if (s) spots.push([s[0], 1.5, s[1]]); }
const gold = new THREE.MeshStandardMaterial({ color: 0xffc23d, metalness: 1, roughness: .2, emissive: 0x6a4a00, emissiveIntensity: .6 });
const geo = new THREE.CylinderGeometry(.8, .8, .14, 28).rotateX(Math.PI / 2);
const vTex = (() => { const c = document.createElement("canvas"); c.width = c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#ffc23d"; g.fillRect(0, 0, 64, 64); g.fillStyle = "#7a5200"; g.font = "900 44px sans-serif"; g.textAlign = "center"; g.fillText("V", 32, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
const face = new THREE.MeshStandardMaterial({ map: vTex, metalness: .8, roughness: .3, emissive: 0x6a4a00, emissiveIntensity: .5 });
const all = spots.slice(0, 80);
coinMesh = new THREE.InstancedMesh(geo, [gold, face, face], all.length);
glowMesh = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1.6, 1), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffc23d).multiplyScalar(1.4), transparent: true, opacity: .12, depthWrite: false, toneMapped: false }), all.length);
for (const im of [coinMesh, glowMesh]) { im.frustumCulled = false; im.instanceMatrix.setUsage(THREE.DynamicDrawUsage); scene.add(im); }
all.forEach(([x, y, z], k) => COINS.push({ id: k, x, y, z, on: !got.has(k) }));
for (const c of COINS) { cm.compose(cv.set(c.x, c.y, c.z), cq.identity(), cs.setScalar(c.on ? 1 : 0)); coinMesh.setMatrixAt(c.id, cm); glowMesh.setMatrixAt(c.id, cm); }
}
// ---- boost pads: glowing chevrons on the avenues that kick you forward ----
const PADS = [];
{
const tex = (() => { const c = document.createElement("canvas"); c.width = 64; c.height = 128; const g = c.getContext("2d"); g.clearRect(0, 0, 64, 128); g.strokeStyle = "#3df5ff"; g.lineWidth = 9; g.lineCap = "round"; for (const y of [30, 70, 110]) { g.beginPath(); g.moveTo(10, y); g.lineTo(32, y - 22); g.lineTo(54, y); g.stroke(); } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, color: new THREE.Color(1.8, 1.8, 1.8), polygonOffset: true, polygonOffsetFactor: -4 });
for (let k = 0; k < 14; k++) {
const axis = rnd() < .5, i = 1 + Math.floor(rnd() * (GRID.NX - 2)), j = 1 + Math.floor(rnd() * (GRID.NZ - 2));
const x = axis ? GRID.ax(i) + 4.5 : GRID.ax(i) + 60 + rnd() * 40, z = axis ? GRID.sz(j) + 25 + rnd() * 20 : GRID.sz(j) + 3, yaw = axis ? (rnd() < .5 ? Math.PI / 2 : -Math.PI / 2) : (rnd() < .5 ? 0 : Math.PI);
if (api.isWater(x, z) || api.hitBox(x, z, .5, 2)) continue;
const m = new THREE.Mesh(new THREE.PlaneGeometry(4, 8).rotateX(-Math.PI / 2), mat); m.position.set(x, .07, z); m.rotation.y = -yaw + Math.PI / 2; scene.add(m);
PADS.push({ x, z, yaw, t: 0 });
}
}
// ---- speed cameras: drive past and your speed is recorded; beat your best for points ----
const TRAPS = [], best = store.get("void.traps") || {};
{
const flashM = new THREE.MeshBasicMaterial({ color: new THREE.Color(1, 1, 1).multiplyScalar(4), toneMapped: false, transparent: true, opacity: 0 }), pole = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: .5, roughness: .4 }), boxM = new THREE.MeshStandardMaterial({ color: 0xf7c414, roughness: .5 });
const spots = [[GRID.ax(9), GRID.sz(3), "Midtown"], [GRID.ax(3), GRID.sz(15), "Park Ave"], [GRID.ax(11), GRID.sz(17), "Downtown"], [600, 0, "Broadway"], [GRID.ax(1), GRID.sz(6), "West Side"], [1400, 18, "Bridge"],
[1880, 0, "Highway West"], [2380, 0, "Highway East"], [GRID.ax(5), GRID.sz(27), "South Ave"], [GRID.ax(10), GRID.sz(32), "Harbor"], [GRID.ax(2), GRID.sz(37), "Bay Ridge"], [GRID.ax(7), GRID.sz(22), "Midtown South"], [480, GRID.sz(20), "Under the El"]];
if (W.city.mountains) { const [x, , z] = W.city.mountains.trail[1]; spots.push([x, z, "Hill Climb"]); }
spots.forEach(([x, z, name]) => {
const g = new THREE.Group(); g.position.set(x + 11, 0, z + 9); scene.add(g);
g.add(new THREE.Mesh(new THREE.CylinderGeometry(.12, .15, 5, 8).translate(0, 2.5, 0), pole)); const b = new THREE.Mesh(new THREE.BoxGeometry(.8, .5, .5), boxM); b.position.y = 5; g.add(b);
const f = new THREE.Mesh(new THREE.SphereGeometry(.18, 8, 6), flashM.clone()); f.position.set(-.45, 5, 0); g.add(f);
TRAPS.push({ x, z, name, f, cool: 0 });
});
}
// ---- sky traffic: an airliner crossing high up, a slow blimp with a glowing VOID sign ----
const plane = new THREE.Group(), blimp = new THREE.Group();
{
const w = new THREE.MeshStandardMaterial({ color: 0xe8e8ea, roughness: .4, metalness: .3 });
const body = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.4, 34, 14).rotateZ(Math.PI / 2), w); plane.add(body);
const wing = new THREE.Mesh(new THREE.BoxGeometry(6, .3, 36), w); wing.position.x = 1; plane.add(wing);
const tail = new THREE.Mesh(new THREE.BoxGeometry(4, 6, .3), w); tail.position.set(-15, 3, 0); plane.add(tail);
for (const [z, c] of [[18, 0x22ff66], [-18, 0xff2233]]) { const l = new THREE.Mesh(new THREE.SphereGeometry(.4, 8, 6), new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(3), toneMapped: false })); l.position.set(1, 0, z); plane.add(l); }
scene.add(plane);
const env = new THREE.Mesh(new THREE.SphereGeometry(8, 24, 16), new THREE.MeshStandardMaterial({ color: 0x1a1a20, roughness: .5, metalness: .2 })); env.scale.set(3.4, 1, 1); blimp.add(env);
const signT = (() => { const c = document.createElement("canvas"); c.width = 512; c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#000"; g.fillRect(0, 0, 512, 128); g.fillStyle = "#ff2a3a"; g.shadowColor = "#ff2a3a"; g.shadowBlur = 20; g.font = "900 86px Unbounded, Impact, sans-serif"; g.textAlign = "center"; g.fillText("VOID", 256, 100); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
for (const s of [-1, 1]) { const p = new THREE.Mesh(new THREE.PlaneGeometry(30, 7.5), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6) })); p.position.z = s * 8.1; p.rotation.y = s > 0 ? 0 : Math.PI; blimp.add(p); }
const gond = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 3), new THREE.MeshStandardMaterial({ color: 0x3a3b40 })); gond.position.y = -8.5; blimp.add(gond);
scene.add(blimp);
}
let skyT = 0;
return {
toggle, pads: PADS, get job() { return J; }, coins: () => ({ got: got.size, total: COINS.length }),
fastCams: () => TRAPS.filter(t => (best[t.name] || 0) >= 250).length,
sky(dt, time) {
skyT += dt;
const a = (skyT * .012) % (Math.PI * 2); plane.position.set(Math.cos(a) * 2600, 620, Math.sin(a) * 1600); plane.rotation.y = -Math.atan2(Math.cos(a) * 1600, -Math.sin(a) * 2600);
const b = (skyT * .004) % (Math.PI * 2); blimp.position.set(-200 + Math.cos(b) * 700, 230, -300 + Math.sin(b) * 500); blimp.rotation.y = -Math.atan2(Math.cos(b) * 500, -Math.sin(b) * 700);
},
boosts(dt) {
const p = api.state; if (!p || p.mode !== "drive" || p.y > 1) return;
for (const b of PADS) {
if (b.t > 0) { b.t -= dt; continue; }
if (Math.abs(p.x - b.x) < 2.6 && Math.abs(p.z - b.z) < 4.6) {
b.t = 1.5; const sp = Math.hypot(p.vel.x, p.vel.z) + 18; p.vel.set(Math.cos(p.yaw) * sp, 0, Math.sin(p.yaw) * sp); api.say("⚡ BOOST", 1.2); sound.whoosh && sound.whoosh();
}
}
for (const t of TRAPS) {
t.f.material.opacity = Math.max(0, t.f.material.opacity - dt * 3);
if (t.cool > 0) { t.cool -= dt; continue; }
if (Math.hypot(p.x - t.x, p.z - t.z) < 14) {
const kmh = Math.round(Math.hypot(p.vel.x, p.vel.z) * 3.6); if (kmh < 60) continue;
t.cool = 4; t.f.material.opacity = 1;
if (kmh > (best[t.name] || 0)) { best[t.name] = kmh; store.set("void.traps", best); api.addScore(Math.round(kmh * 2), `📸 ${t.name} speed camera: ${kmh} km/h (new best, ${TRAPS.filter(c => best[c.name]).length}/${TRAPS.length} cameras)`); }
else api.say(`📸 ${t.name}: ${kmh} km/h (best ${best[t.name]})`, 2.5);
}
}
},
update(dt, time) {
const p = api.state; if (!p || !p.started) return;
this.boosts(dt);
// coins
let near = 1e9;
for (const c of COINS) {
if (c.on && Math.abs(p.x - c.x) < 3 && Math.abs(p.z - c.z) < 3 && Math.abs(p.y + .6 - c.y) < 3.5) {
c.on = false; got.add(c.id); store.set("void.coins", [...got]); api.addScore(250, `🪙 Coin ${got.size}/${COINS.length}`); sound.win();
}
if (c.on) { near = Math.min(near, Math.hypot(p.x - c.x, p.z - c.z, (p.y - c.y) * .5)); cv.set(c.x, c.y + Math.sin(time * 2 + c.id) * .2, c.z); cq.setFromAxisAngle(Y, time * 2.4 + c.id); cs.set(1, 1, 1); }
else cs.set(0, 0, 0); // found: shrunk to nothing
cm.compose(cv, cq, cs); coinMesh.setMatrixAt(c.id, cm); glowMesh.setMatrixAt(c.id, cm);
}
coinMesh.instanceMatrix.needsUpdate = glowMesh.instanceMatrix.needsUpdate = true;
$("gCoins").textContent = `🪙 ${got.size}/${COINS.length}` + (near < 150 ? ` · one ${Math.round(near)} m away` : "");
if (!J.on) return;
const sp = Math.hypot(p.vel.x, p.vel.z);
// waving passengers; the nearest one gets in when you stop beside it
for (const w of waiting) {
if (!w.visible) continue;
w.userData.arm.rotation.z = Math.sin(time * 6) * .4;
if (!J.fare && Math.hypot(p.x - w.position.x, p.z - w.position.z) < 9 && sp < 4 && p.mode === "drive") {
const d = kerb(w.position.x, w.position.z, 350, 1100); if (!d) continue;
const dist = Math.hypot(d[0] - w.position.x, d[1] - w.position.z);
const j = Math.round((d[1] - GRID.Z0) / GRID.ST), street = j === 10 ? "Broadway" : `${ord(Math.abs(j - 10) * 7 + 14)} St`;
J.fare = { x: d[0], z: d[1], t: dist / 11 + 25, max: 300 + Math.round(dist * .6), street };
waiting.forEach(o => o.visible = false); dropMark.position.set(d[0], 0, d[1]); dropMark.visible = true;
nav.setDest({ name: `🚕 ${street}`, x: d[0], z: d[1], taxi: true });
api.say(`🚕 "Take me to ${street}, quick!"`, 3); sound.pop();
}
}
if (J.fare) {
const f = J.fare; f.t -= dt;
const pay = Math.max(50, Math.round(f.max * Math.min(1, .35 + f.t / 40)));
$("gJob").textContent = `🚕 To ${f.street} · ${fmt(Math.max(0, f.t))} · $${pay}${J.streak > 1 ? ` · streak ×${J.streak}` : ""}`;
if (Math.hypot(p.x - f.x, p.z - f.z) < 12 && sp < 4) {
J.streak = f.t > 0 ? J.streak + 1 : 0; const total = pay + (J.streak > 1 ? J.streak * 100 : 0); J.earned += total;
api.addScore(total, `🚕 Fare $${total}${J.streak > 1 ? ` (streak ×${J.streak})` : ""}`); J.fare = null; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p);
} else if (f.t < -20) { api.say("🚕 The passenger gave up and got out", 3); J.fare = null; J.streak = 0; dropMark.visible = false; if (nav.dest && nav.dest.taxi) nav.setDest(null); placeWaiting(p); }
} else {
$("gJob").textContent = `🚕 Find a passenger (yellow beams)${J.earned ? ` · $${J.earned} so far` : ""}`;
if (waiting.every(w => !w.visible || Math.hypot(p.x - w.position.x, p.z - w.position.z) > 600)) placeWaiting(p);
}
},
};
}
|