Skip to content

Instantly share code, notes, and snippets.

@tarruda
Last active August 29, 2026 12:58
Show Gist options
  • Select an option

  • Save tarruda/0f9fc3dd2b69e3a827ef54701f440434 to your computer and use it in GitHub Desktop.

Select an option

Save tarruda/0f9fc3dd2b69e3a827ef54701f440434 to your computer and use it in GitHub Desktop.
Low poly vice-city like game created by Qwen 3.8 Flash Next
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VICE CITY — Ocean Drive (three.js)</title>
<style>
/* ============================================================================
* VICE CITY CLONE — UI skin. Sunset palette: magenta #ff2f8e, cyan #22e8ff,
* amber #ffd166, indigo #241a4a.
* ==========================================================================*/
:root {
--pink: #ff2f8e;
--cyan: #22e8ff;
--amber: #ffd166;
--ink: #150f28;
--hud-font: "Impact", "Haettenschweiler", "Arial Black", "Franklin Gothic Bold", sans-serif;
--ui-font: "Trebuchet MS", "Segoe UI", Verdana, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden; background: #06060d;
font-family: var(--ui-font); color: #fff;
-webkit-font-smoothing: antialiased; user-select: none;
}
canvas#gl { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: none; }
/* ------------------------------------------------------------------ HUD -- */
#hud { position: fixed; inset: 0; display: none; pointer-events: none; z-index: 10; }
#radarWrap { position: absolute; left: 22px; bottom: 22px; width: 240px; height: 240px; }
#radar {
width: 240px; height: 240px; border-radius: 50%;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .18), 0 10px 40px rgba(0, 0, 0, .6),
inset 0 0 40px rgba(10, 4, 24, .8);
background: #071a33;
}
#radarRing {
position: absolute; inset: -6px; border-radius: 50%;
background: conic-gradient(from 210deg, var(--pink), var(--cyan), #ffd166, var(--pink));
filter: blur(9px); opacity: .55; z-index: -1;
}
#stationTag { position: absolute; left: 8px; bottom: -20px; font: 400 11px/1 var(--ui-font); letter-spacing: .22em; color: #ffffff70; }
#right { position: absolute; right: 26px; top: 20px; text-align: right; }
#stars { font-size: 26px; letter-spacing: 3px; line-height: 1; margin-bottom: 2px; height: 30px; }
#stars i { font-style: normal; color: #ffffff1f; text-shadow: 0 2px 6px #000; transition: color .2s; }
#stars i.on { color: var(--amber); text-shadow: 0 0 12px #ff9d2f, 0 0 26px #ff2f8e; }
#stars.live i.on { animation: starpulse 1.1s ease-in-out infinite; }
#stars.live i.on:nth-child(2) { animation-delay: .1s }
#stars.live i.on:nth-child(3) { animation-delay: .2s }
#stars.live i.on:nth-child(4) { animation-delay: .3s }
#stars.live i.on:nth-child(5) { animation-delay: .4s }
#stars.live i.on:nth-child(6) { animation-delay: .5s }
@keyframes starpulse { 50% { filter: brightness(1.6) } }
#cash {
font: italic 900 44px/1 var(--hud-font); letter-spacing: 1px;
color: #d8ffe9; text-shadow: 0 0 14px #22e8ff88, 0 3px 0 #00000090, 0 0 40px #ff2f8e55;
margin-bottom: 2px;
}
#wepRow { font: italic 700 17px/1.1 var(--hud-font); letter-spacing: 1.6px; color: #fff; text-shadow: 0 2px 6px #000; }
#wepRow #ammo { color: var(--amber); margin-left: 12px; }
#bars { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; align-items: flex-end; }
.bar {
width: 210px; height: 11px; border-radius: 8px; background: #ffffff17;
box-shadow: inset 0 0 0 1px #ffffff30, 0 2px 10px #0007; overflow: hidden;
}
.bar i { display: block; height: 100%; width: 100%; border-radius: 8px; transition: width .18s ease-out; }
.bar i#hpfill { background: linear-gradient(90deg, #ff2f8e, #ff8fb8); box-shadow: 0 0 14px #ff2f8eaa inset; }
.bar.armor i#arfill { background: linear-gradient(90deg, #0bb7d6, #9df3ff); width: 0%; }
#reloadbar { display: none; width: 210px; height: 5px; margin-top: 8px; background: #ffffff22; border-radius: 4px; overflow: hidden; }
#reloadbar i { display: block; height: 100%; background: var(--amber); width: 0; }
#reloadbar.run i { animation: reload var(--dur, 1.5s) linear forwards; }
@keyframes reload { to { width: 100% } }
#objective {
position: absolute; top: 18px; left: 50%; transform: translateX(-50%);
font: italic 700 15px/1 var(--hud-font); letter-spacing: 3px; color: #ffffffb0;
text-shadow: 0 2px 8px #000; padding: 8px 16px; border-radius: 20px;
background: #12081f50; backdrop-filter: blur(3px);
}
#objective.hot { color: var(--amber); }
#radio {
position: absolute; right: 26px; bottom: 26px; text-align: right;
padding: 8px 14px; border-radius: 12px; background: #16092b66;
border: 1px solid #ffffff1f; backdrop-filter: blur(4px);
}
#radio b { display: block; font: italic 900 19px/1 var(--hud-font); letter-spacing: 2px; color: #fff; text-shadow: 0 0 14px var(--pink); }
#radio span { font: 400 10px/1.6 var(--ui-font); letter-spacing: .3em; color: #ffffff80; }
#radio.flash { animation: radioflash .7s ease-out; }
@keyframes radioflash { 0% { background: #ff2f8e66; transform: scale(1.08) } 100% { background: #16092b66; transform: none } }
#popups { position: absolute; left: 50%; top: 20%; transform: translateX(-50%); text-align: center; }
.pop {
font: italic 800 21px/1.35 var(--hud-font); letter-spacing: 2px;
text-shadow: 0 3px 0 #00000080, 0 0 26px currentColor;
animation: popin .32s cubic-bezier(.2, 1.6, .4, 1); opacity: 1; transition: opacity .5s, transform .5s;
}
.pop.out { opacity: 0; transform: translateY(-14px); }
@keyframes popin { from { opacity: 0; transform: translateY(16px) scale(.86) } }
#bigmsg {
position: absolute; left: 50%; top: 34%; transform: translate(-50%, -50%);
font: italic 900 78px/1 var(--hud-font); letter-spacing: 5px; opacity: 0;
transition: opacity .5s; text-shadow: 0 0 40px currentColor, 0 6px 0 #00000060;
}
#fps { position: absolute; left: 24px; top: 18px; font: 400 11px/1 var(--ui-font); letter-spacing: .18em; color: #ffffff4d; }
/* crosshair */
#cross { position: absolute; left: 50%; top: 50%; width: 40px; height: 40px; margin: -20px 0 0 -20px; opacity: .8; transition: opacity .15s, transform .12s; }
#cross i { position: absolute; background: #fff; box-shadow: 0 0 8px #ff2f8e, 0 0 3px #000; border-radius: 1px; }
#cross .c.t { left: 19px; top: 2px; width: 2px; height: 9px; }
#cross .c.b { left: 19px; top: 29px; width: 2px; height: 9px; }
#cross .c.l { left: 2px; top: 19px; width: 9px; height: 2px; }
#cross .c.r { left: 29px; top: 19px; width: 9px; height: 2px; }
#cross .dot { left: 19px; top: 19px; width: 2px; height: 2px; }
#cross.ads { transform: scale(.62); }
/* ------------------------------------------------------------- overlays -- */
#wasted, #pause {
position: fixed; inset: 0; display: none; z-index: 40; flex-direction: column;
align-items: center; justify-content: center; cursor: pointer;
background: radial-gradient(circle at 50% 50%, #4b0016b0, #05000ae0 70%);
backdrop-filter: grayscale(.7) blur(2px);
}
#wasted .wtitle, #pause .ptitle {
font: italic 900 132px/1 var(--hud-font); letter-spacing: 8px;
color: #ff2f4d; text-shadow: 0 0 60px #ff002e, 0 8px 0 #2a0008;
}
#pause .ptitle { color: var(--cyan); text-shadow: 0 0 60px #22e8ff, 0 8px 0 #03222a; }
#wasted .wsub, #pause .psub { margin-top: 10px; font: 400 13px/1 var(--ui-font); letter-spacing: .3em; color: #ffffff90; }
/* ---------------------------------------------------------------- intro -- */
#intro {
position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-end;
justify-content: center; overflow: hidden; cursor: pointer;
background: linear-gradient(#1a0f3d 0%, #4a1660 38%, #b52a6b 62%, #ff6a3d 78%, #ffb347 88%);
}
.sunset { position: absolute; inset: 0; }
.sun {
position: absolute; left: 50%; top: 46%; width: 46vmin; height: 46vmin; margin: -23vmin 0 0 -23vmin;
border-radius: 50%;
background: linear-gradient(#fff2b0 0%, #ffd166 28%, #ff6a3d 62%, #ff2f8e 100%);
box-shadow: 0 0 130px #ff5ea899, 0 0 300px #ff2f8e55;
mask-image: repeating-linear-gradient(#000 0 14px, transparent 14px 20px);
-webkit-mask-image: repeating-linear-gradient(#000 0 14px, transparent 14px 20px);
}
.grid {
position: absolute; left: -30%; right: -30%; bottom: -10%; height: 46%;
background:
repeating-linear-gradient(90deg, #ff2f8e55 0 2px, transparent 2px 74px),
repeating-linear-gradient(0deg, #22e8ff44 0 2px, transparent 2px 54px);
transform: perspective(58% ) rotateX(74deg);
transform-origin: bottom center;
mask-image: linear-gradient(#0000 0%, #000 40%, #000 100%);
-webkit-mask-image: linear-gradient(#0000 0%, #000 40%, #000 100%);
}
.palm { position: absolute; bottom: -4%; width: 34vmin; height: 52vmin; fill: #180a2c; opacity: .93; }
.palm.left { left: -4vmin; transform: scaleX(-1); }
.palm.right { right: -4vmin; }
.ibody { position: relative; text-align: center; padding-bottom: 7vh; width: 100%; }
.logo { margin: 0; font: italic 900 clamp(56px, 13vw, 168px)/.82 var(--hud-font); letter-spacing: 4px; }
.logo .v {
display: block; color: #a9f7ff;
text-shadow: 0 0 8px #fff, 0 0 26px #22e8ff, 0 0 60px #22e8ff, 4px 4px 0 #ff2f8e, -2px -2px 0 #1b0f33;
}
.logo .c {
display: block; color: #ffd6ec;
text-shadow: 0 0 8px #fff, 0 0 26px #ff2f8e, 0 0 70px #ff2f8e, 4px 4px 0 #22e8ff, -2px -2px 0 #1b0f33;
}
.tag { margin: 6px 0 26px; font: 400 12px/1 var(--ui-font); letter-spacing: .48em; color: #ffe9f7c0; }
#loadwrap { margin: 0 auto; width: min(420px, 76vw); }
#loadtext { font: italic 700 13px/1 var(--hud-font); letter-spacing: .28em; color: #fff; margin-bottom: 10px; }
#loadtrack { height: 7px; border-radius: 6px; background: #00000055; box-shadow: inset 0 0 0 1px #ffffff33; overflow: hidden; }
#loadbar { height: 100%; width: 0%; border-radius: 6px; background: linear-gradient(90deg, #22e8ff, #ff2f8e); transition: width .25s; box-shadow: 0 0 18px #ff2f8e; }
#playBtn {
pointer-events: auto; cursor: pointer; border: 0; border-radius: 40px; padding: 18px 46px;
font: italic 900 26px/1 var(--hud-font); letter-spacing: 4px; color: #22062a;
background: linear-gradient(90deg, #22e8ff, #ff2f8e 60%, #ffd166);
box-shadow: 0 0 40px #ff2f8e88, 0 10px 30px #00000060;
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe { 50% { transform: scale(1.045); filter: brightness(1.12) } }
.keys { margin: 22px auto 0; max-width: 620px; }
.keys div { font: 400 12.5px/1.9 var(--ui-font); color: #ffe9f7d0; letter-spacing: .06em; }
.keys b { color: #fff; background: #ffffff1f; border-radius: 5px; padding: 1px 7px; font-weight: 700; }
@media (max-width: 720px) {
#radarWrap { transform: scale(.78); transform-origin: bottom left; }
#cash { font-size: 34px } #bars .bar { width: 150px }
}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<!-- ============================== HUD ============================== -->
<div id="hud">
<div id="radarWrap">
<canvas id="radar" width="240" height="240"></canvas>
<div id="radarRing"></div>
<div id="stationTag"></div>
</div>
<div id="right">
<div id="stars"></div>
<div id="cash" class="neon">$0</div>
<div id="wepRow"><span id="wep"></span><span id="ammo"></span></div>
<div id="bars">
<div class="bar"><i id="hpfill"></i></div>
<div class="bar armor"><i id="arfill"></i></div>
</div>
<div id="reloadbar"><i></i></div>
</div>
<div id="objective">REACH THE MARKER</div>
<div id="radio"><b>FLASH FM</b><span>RETROWAVE</span></div>
<div id="popups"></div>
<div id="bigmsg"></div>
<div id="fps"></div>
<div id="cross">
<i class="c t"></i><i class="c b"></i><i class="c l"></i><i class="c r"></i>
<i class="dot"></i>
</div>
</div>
<!-- ============================ WASTED ============================= -->
<div id="wasted">
<div class="wtitle">WASTED</div>
<div class="wsub">click to respawn at the hospital</div>
</div>
<!-- ============================= PAUSE ============================= -->
<div id="pause">
<div class="ptitle">PAUSED</div>
<div class="psub">click or press P to resume &nbsp;·&nbsp; Esc releases the mouse</div>
</div>
<!-- ============================= INTRO ============================= -->
<div id="intro">
<div class="sunset">
<div class="sun"></div>
<div class="grid"></div>
<svg class="palm left" viewBox="0 0 200 300"><path d="M96 300 C 100 210 108 150 128 104 c -30 -34 -66 -44 -96 -34 30 -22 66 -18 96 12 -6 -46 -30 -80 -62 -96 42 6 70 42 78 88 34 -34 76 -42 112 -26 -38 -8 -76 4 -104 34 44 8 78 34 96 72 -34 -28 -74 -38 -116 -30 -18 44 -24 110 -28 174z"/></svg>
<svg class="palm right" viewBox="0 0 200 300"><path d="M96 300 C 100 210 108 150 128 104 c -30 -34 -66 -44 -96 -34 30 -22 66 -18 96 12 -6 -46 -30 -80 -62 -96 42 6 70 42 78 88 34 -34 76 -42 112 -26 -38 -8 -76 4 -104 34 44 8 78 34 96 72 -34 -28 -74 -38 -116 -30 -18 44 -24 110 -28 174z"/></svg>
</div>
<div class="ibody">
<h1 class="logo"><span class="v">VICE</span><span class="c">CITY</span></h1>
<p class="tag">OCEAN DRIVE &nbsp;//&nbsp; 1986 &nbsp;//&nbsp; A THREE.JS SANDBOX</p>
<div id="loadwrap">
<div id="loadtext">Loading…</div>
<div id="loadtrack"><div id="loadbar"></div></div>
</div>
<div id="playwrap" style="display:none">
<button id="playBtn">CLICK TO PLAY</button>
<div class="keys">
<div><b>Mouse</b> look &nbsp; <b>LMB</b> fire &nbsp; <b>RMB</b> aim</div>
<div><b>W A S D</b> move / strafe — in a car this steers &amp; accelerates</div>
<div><b>Space</b> jump / handbrake &nbsp; <b>Shift</b> sprint &nbsp; <b>F</b> steal &amp; exit cars</div>
<div><b>1–4</b> weapons &nbsp; <b>R</b> reload &nbsp; <b>M</b> radio station &nbsp; <b>H</b> horn &nbsp; <b>P</b> pause</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
<script>window.THREE||document.write('<script src="https://unpkg.com/three@0.147.0/build/three.min.js"><\/script>')</script>
<script>window.THREE||document.write('<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r147/three.min.js"><\/script>')</script>
<script>
if (!window.THREE) {
var t = document.getElementById('loadtext');
var track = document.getElementById('loadtrack');
if (track) track.style.display = 'none';
if (t) {
t.style.cssText = 'color:#fff;background:rgba(24,7,32,.78);' +
'border:1px solid rgba(255,120,170,.6);border-radius:12px;padding:14px 20px;' +
'letter-spacing:.03em;font-style:normal;font-size:15px;line-height:1.5;' +
'max-width:38ch;margin:0 auto;text-shadow:none;box-shadow:0 8px 30px rgba(0,0,0,.35)';
t.innerHTML = '<b>Could not load three.js from the CDN.</b><br>' +
'<span style="opacity:.86">This build streams three@0.147.0 from jsDelivr, unpkg and ' +
'cdnjs, so it needs one of them reachable on first load. The multi-file build ' +
'(index.html) ships its own copy of three.js and works fully offline.</span>';
}
window.__VC_noThree = true;
}
</script>
<script>
;(function () {
if (!window.THREE) return; /* the guard script above already told the user */
/*! ---- js/core.js ---- */
/* ============================================================================
* VICE CITY CLONE - core.js
* Small shared toolbox: math, seeded RNG, geometry merging, procedural canvas
* textures and a 2D spatial hash used for collision.
* No build step, no modules: everything hangs off the global VC namespace.
* ==========================================================================*/
window.VC = window.VC || {};
/* ---------------------------------------------------------------- math ---- */
const U = VC.util = {
clamp: (v, a, b) => (v < a ? a : v > b ? b : v),
clamp01: (v) => (v < 0 ? 0 : v > 1 ? 1 : v),
lerp: (a, b, t) => a + (b - a) * t,
mix: (a, b, t) => a + (b - a) * t,
smoothstep: (e0, e1, x) => {
const t = U.clamp01((x - e0) / (e1 - e0));
return t * t * (3 - 2 * t);
},
/* frame-rate independent exponential smoothing */
damp: (a, b, lambda, dt) => U.lerp(a, b, 1 - Math.exp(-lambda * dt)),
/* shortest-arc angle helpers */
wrapAngle: (a) => {
a = (a + Math.PI) % (Math.PI * 2);
if (a < 0) a += Math.PI * 2;
return a - Math.PI;
},
angleLerp: (a, b, t) => a + U.wrapAngle(b - a) * t,
angleDamp: (a, b, lambda, dt) =>
a + U.wrapAngle(b - a) * (1 - Math.exp(-lambda * dt)),
sign: (v) => (v < 0 ? -1 : v > 0 ? 1 : 0),
/* cheap 1D value noise */
hash1(n) {
const s = Math.sin(n * 127.1) * 43758.5453;
return s - Math.floor(s);
},
smoothNoise(x) {
const i = Math.floor(x), f = x - i;
const u = f * f * (3 - 2 * f);
return U.lerp(U.hash1(i), U.hash1(i + 1), u);
},
};
/* ------------------------------------------------------------- rng -------- */
/* deterministic so the city layout is identical every run (easier to debug) */
VC.rng = (function () {
let s = 0x9e3779b9;
function next() {
s |= 0; s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
return {
seed(v) { s = v | 0; },
f: next, /* 0..1 */
r: (a, b) => a + (b - a) * next(), /* float in [a,b) */
i: (a, b) => Math.floor(a + (b - a + 1) * next()), /* int inclusive */
pick: (arr) => arr[Math.floor(next() * arr.length)],
chance: (p) => next() < p,
/* approx normal distribution, good for natural looking spread */
gauss: (mean, sd) => {
const u = Math.max(1e-6, next()), v = next();
return mean + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(6.283185 * v);
},
};
})();
/* ------------------------------------------------- merge geometries ------- */
/* Minimal stand-in for BufferGeometryUtils.mergeBufferGeometries (which lives
* in three's examples and therefore is not in the UMD build). All input
* geometries are converted to non-indexed and must share the attribute set
* {position, normal, uv, color}. */
VC.mergeGeoms = function (geoms) {
const out = new THREE.BufferGeometry();
const names = ['position', 'normal', 'uv', 'color'];
const store = {};
let total = 0;
const prepped = geoms.map((g) => (g.index ? g.toNonIndexed() : g));
for (const name of names) {
let size = 0;
for (const g of prepped) if (g.attributes[name]) size = g.attributes[name].itemSize;
if (!size) continue;
let count = 0;
for (const g of prepped) count += g.attributes[name] ? g.attributes[name].count : 0;
const arr = new Float32Array(count * size);
let o = 0;
for (const g of prepped) {
const a = g.attributes[name];
if (a) { arr.set(a.array.subarray(0, a.count * size), o); o += a.count * size; continue; }
/* missing attribute -> fill with zeros so counts line up */
const ref = g.attributes.position;
const n = ref ? ref.count : 0;
for (let i = 0; i < n * size; i++) arr[o++] = 0;
}
store[name] = new THREE.BufferAttribute(arr, size);
total = count;
}
for (const name of names) if (store[name]) out.setAttribute(name, store[name]);
out.computeBoundingSphere();
out.computeBoundingBox();
/* free temp geometries */
for (let i = 0; i < prepped.length; i++) if (prepped[i] !== geoms[i]) prepped[i].dispose();
return out;
};
/* ---------------------------------------------------------------------------
* Geometry builders
* Everything below produces BufferGeometry with position/normal/uv/color so it
* can be merged. Colours are multiplied into the material's diffuse map.
* ==========================================================================*/
const G = VC.geo = {};
function mkGeom(pos, nor, uv, col) {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3));
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(nor), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uv), 2));
g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(col), 3));
return g;
}
/* one quad from 4 corners (CCW), uv scaled by (su,sv) = world size / module.
* Indexed 0-1-2 / 0-2-3: a bare 4-vertex non-indexed quad would render as a
* single triangle and leak its 4th corner into the next quad of a merge. */
G.quad = function (a, b, c, d, nrm, uvRect, col) {
const [u0, v0, u1, v1] = uvRect;
const c3 = col || [1, 1, 1];
const g = mkGeom(
[...a, ...b, ...c, ...d],
[nrm[0], nrm[1], nrm[2], nrm[0], nrm[1], nrm[2], nrm[0], nrm[1], nrm[2], nrm[0], nrm[1], nrm[2]],
[u0, v0, u1, v0, u1, v1, u0, v1],
[...c3, ...c3, ...c3, ...c3]
);
g.setIndex([0, 1, 2, 0, 2, 3]);
return g;
};
/* Horizontal quad (floor / road / sidewalk) on plane y. uv scaled to meters. */
G.floor = function (x0, z0, x1, z1, y, uvScale, col, flip) {
const s = uvScale || 1;
const c = col || [1, 1, 1];
if (flip) return G.quad([x0, y, z1], [x1, y, z1], [x1, y, z0], [x0, y, z0], [0, 1, 0], [x0 / s, z0 / s, x1 / s, z1 / s], c);
return G.quad([x0, y, z0], [x0, y, z1], [x1, y, z1], [x1, y, z0], [0, 1, 0], [x0 / s, z0 / s, x1 / s, z1 / s], c);
};
/* Box shell (no bottom face). UV: u wraps perimeter-ish per face, v spans height.
* `faceUV` maps 1 world unit -> uv units so facade textures tile per floor. */
G.boxSides = function (cx, cz, w, d, y0, y1, uvPerMeter, col) {
const hw = w / 2, hd = d / 2, s = uvPerMeter || 0.06;
const c = col || [1, 1, 1];
const out = [];
/* four sides: -z, +z, -x, +x */
out.push(G.quad([cx - hw, y0, cz - hd], [cx + hw, y0, cz - hd], [cx + hw, y1, cz - hd], [cx - hw, y1, cz - hd], [0, 0, -1], [(cx - hw) * s, y0 * s, (cx + hw) * s, y1 * s], c));
out.push(G.quad([cx + hw, y0, cz + hd], [cx - hw, y0, cz + hd], [cx - hw, y1, cz + hd], [cx + hw, y1, cz + hd], [0, 0, 1], [(cx - hw) * s, y0 * s, (cx + hw) * s, y1 * s], c));
out.push(G.quad([cx - hw, y0, cz + hd], [cx - hw, y0, cz - hd], [cx - hw, y1, cz - hd], [cx - hw, y1, cz + hd], [-1, 0, 0], [(cz - hd) * s, y0 * s, (cz + hd) * s, y1 * s], c));
out.push(G.quad([cx + hw, y0, cz - hd], [cx + hw, y0, cz + hd], [cx + hw, y1, cz + hd], [cx + hw, y1, cz - hd], [1, 0, 0], [(cz - hd) * s, y0 * s, (cz + hd) * s, y1 * s], c));
return out;
};
/* Roof top face, tinted darker (sun never hits it directly at low sun angle) */
G.roofTop = function (cx, cz, w, d, y, uvScale, shade) {
const s = uvScale || 0.25;
const k = shade === undefined ? 0.55 : shade;
return G.floor(cx - w / 2, cz - d / 2, cx + w / 2, cz + d / 2, y, s, [k, k, k * 1.02], true);
};
/* Simple box (all faces, uv in meters) for props/cars */
G.box = function (cx, cy, cz, w, h, d, col, uvScale) {
const hw = w / 2, hh = h / 2, hd = d / 2, s = uvScale || 1;
const c = col || [1, 1, 1];
const p = [], n = [], u = [], k = [];
const push = (ax, ay, az, bx, by, bz, cx2, cy2, cz2, dx2, dy2, dz2, nx, ny, nz, ux, uy) => {
p.push(ax, ay, az, bx, by, bz, cx2, cy2, cz2, ax, ay, az, cx2, cy2, cz2, dx2, dy2, dz2);
for (let i = 0; i < 6; i++) { n.push(nx, ny, nz); k.push(c[0], c[1], c[2]); }
u.push(0, 0, ux, 0, ux, uy, 0, 0, ux, uy, 0, uy);
for (let i = 0; i < 6; i++) { u[i * 2 + 0] *= s; u[i * 2 + 1] *= s; }
};
/* local corners (relative to center), then translated */
const F = (nx, ny, nz, v) => { /* helper to emit a face from local verts */ return [nx, ny, nz, v]; };
const faces = [
/* front +z */ [[-hw, -hh, hd], [hw, -hh, hd], [hw, hh, hd], [-hw, hh, hd], [0, 0, 1], w, h],
/* back -z */ [[hw, -hh, -hd], [-hw, -hh, -hd], [-hw, hh, -hd], [hw, hh, -hd], [0, 0, -1], w, h],
/* left -x */ [[-hw, -hh, -hd], [-hw, -hh, hd], [-hw, hh, hd], [-hw, hh, -hd], [-1, 0, 0], d, h],
/* right +x */ [[hw, -hh, hd], [hw, -hh, -hd], [hw, hh, -hd], [hw, hh, hd], [1, 0, 0], d, h],
/* top +y */ [[-hw, hh, hd], [hw, hh, hd], [hw, hh, -hd], [-hw, hh, -hd], [0, 1, 0], w, d],
/* bot -y */ [[-hw, -hh, -hd], [hw, -hh, -hd], [hw, -hh, hd], [-hw, -hh, hd], [0, -1, 0], w, d],
];
const out = [];
for (const f of faces) {
const [a, b, cc, dd, nn, su, sv] = f;
out.push(G.quad(
[cx + a[0], cy + a[1], cz + a[2]], [cx + b[0], cy + b[1], cz + b[2]],
[cx + cc[0], cy + cc[1], cz + cc[2]], [cx + dd[0], cy + dd[1], cz + dd[2]],
nn, [0, 0, su * s, sv * s], c));
}
return out;
};
/* Vertical quad standing at (x,z) rotated to face +x/-x/+z/-z, arbitrary size */
G.billboard = function (x, y, z, w, h, rotY, col) {
const hw = w / 2, hh = h / 2;
const c = Math.cos(rotY), s = Math.sin(rotY);
const rx = (lx, lz) => [x + lx * c + lz * s, 0, z - lx * s + lz * c];
const a = rx(-hw, -hh * 0 + 0), b = rx(hw, 0), cc = rx(hw, 0), dd = rx(-hw, 0);
const p = [
a[0], y - hh, a[2],
b[0], y - hh, b[2],
cc[0], y + hh, cc[2],
dd[0], y + hh, dd[2],
];
const nrm = [s, 0, c];
return G.quad(
[p[0], p[1], p[2]], [p[3], p[4], p[5]], [p[6], p[7], p[8]], [p[9], p[10], p[11]],
nrm, [0, 0, 1, 1], col || [1, 1, 1]);
};
/* Cylindrical tower approximation (n-gon prism + roof) used for water tanks */
G.prism = function (cx, cz, y0, y1, r, n, col, uvScale) {
const c = col || [1, 1, 1], s = uvScale || 1;
const out = [];
const ring = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
ring.push([cx + Math.cos(a) * r, cz + Math.sin(a) * r]);
}
for (let i = 0; i < n; i++) {
const a0 = ring[i], a1 = ring[(i + 1) % n];
const dx = a1[0] - a0[0], dz = a1[1] - a0[1];
const len = Math.hypot(dx, dz);
const nx = dz / len, nz = -dx / len;
out.push(G.quad([a0[0], y0, a0[1]], [a1[0], y0, a1[1]], [a1[0], y1, a1[1]], [a0[0], y1, a0[1]],
[nx, 0, nz], [0, y0 * s, len * s, y1 * s], c));
}
/* cap as triangle fan */
const p = [], nn = [], uv = [], kk = [];
for (let i = 0; i < n; i++) {
const a0 = ring[i], a1 = ring[(i + 1) % n];
p.push(cx, y1, cz, a0[0], y1, a0[1], a1[0], y1, a1[1]);
for (let j = 0; j < 3; j++) { nn.push(0, 1, 0); kk.push(c[0], c[1], c[2]); }
uv.push(0.5, 0.5, a0[0] * s, a0[1] * s, a1[0] * s, a1[1] * s);
}
out.push(mkGeom(p, nn, uv, kk));
return out;
};
/* ---------------------------------------------------------------------------
* Procedural textures (2D canvas -> CanvasTexture)
* ==========================================================================*/
const T = VC.tex = {};
T._cache = {};
function canvas(size, hsize) {
const c = document.createElement('canvas');
c.width = size; c.height = hsize || size;
return c;
}
function toTex(c, { srgb = true, repeat = 1, clamp = false, aniso = 8 } = {}) {
const t = new THREE.CanvasTexture(c);
if (srgb) t.encoding = THREE.sRGBEncoding;
t.wrapS = t.wrapT = clamp ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
if (repeat !== 1) t.repeat.set(repeat, repeat);
t.anisotropy = aniso;
t.needsUpdate = true;
return t;
}
T.cached = function (key, build) {
if (!T._cache[key]) T._cache[key] = build();
return T._cache[key];
};
/* grain / stucco noise drawn straight into a context */
function grain(ctx, w, h, amount, alpha) {
const img = ctx.getImageData(0, 0, w, h);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (Math.random() - 0.5) * amount;
d[i] = Math.max(0, Math.min(255, d[i] + n));
d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n));
d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n));
if (alpha) d[i + 3] = Math.max(0, Math.min(255, d[i + 3] + (Math.random() - 0.5) * alpha));
}
ctx.putImageData(img, 0, 0);
}
/* --------------------------------------------------------------------------
* Art-deco facade. Tileable: cols x rows identical cells, one window each.
* Returns { map, emissive } where emissive has only the lit windows so towers
* glow at dusk. `style` varies the deco ornament.
* ==========================================================================*/
T.facade = function (index) {
return T.cached('fac' + index, () => {
const S = 512;
const c = canvas(S), e = canvas(S);
const g = c.getContext('2d'), eg = e.getContext('2d');
const cols = 4, rows = 4;
const cw = S / cols, ch = S / rows;
const base = [
['#f6c9b8', '#e8a58f'], /* peach */
['#cfe9e4', '#a7cfc9'], /* mint */
['#f7e3a1', '#e6c675'], /* cream/sand */
['#e3c6e8', '#c39fca'], /* lilac */
['#cfe0f2', '#a4c0dd'], /* powder blue */
][index % 5];
const g0 = g.createLinearGradient(0, 0, 0, S);
g0.addColorStop(0, base[0]); g0.addColorStop(1, base[1]);
g.fillStyle = g0; g.fillRect(0, 0, S, S);
eg.fillStyle = '#000'; eg.fillRect(0, 0, S, S);
/* horizontal string courses between floors (deco banding) */
for (let r = 0; r <= rows; r++) {
const y = r * ch;
g.fillStyle = 'rgba(255,255,255,0.55)';
g.fillRect(0, y - 6, S, 6);
g.fillStyle = 'rgba(0,0,0,0.18)';
g.fillRect(0, y, S, 3);
}
/* vertical pilasters between window columns */
for (let col = 0; col <= cols; col++) {
const x = col * cw;
g.fillStyle = 'rgba(255,255,255,0.35)';
g.fillRect(x - 9, 0, 12, S);
g.fillStyle = 'rgba(0,0,0,0.10)';
g.fillRect(x + 3, 0, 6, S);
}
const litPalette = ['#ffd9a0', '#ffbe73', '#fff2cf', '#a8f0ff', '#ffd0f0', '#9fd8ff'];
for (let r = 0; r < rows; r++) {
for (let col = 0; col < cols; col++) {
const x = col * cw, y = r * ch;
/* window recess */
const ww = cw * 0.52, wh = ch * 0.52;
const wx = x + (cw - ww) / 2, wy = y + ch * 0.22;
g.fillStyle = 'rgba(0,0,0,0.35)';
g.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
const lit = VC.rng.chance(0.46);
const glass = lit ? VC.rng.pick(litPalette) : VC.rng.pick(['#2b3550', '#37456b', '#22304d', '#4b3f66']);
const gg = g.createLinearGradient(wx, wy, wx + ww, wy + wh);
gg.addColorStop(0, glass);
gg.addColorStop(0.55, lit ? glass : '#1b2338');
gg.addColorStop(1, lit ? glass : '#2a3450');
g.fillStyle = gg;
g.fillRect(wx, wy, ww, wh);
/* mullions */
g.strokeStyle = 'rgba(255,255,255,0.5)';
g.lineWidth = 2;
g.beginPath();
g.moveTo(wx + ww / 2, wy); g.lineTo(wx + ww / 2, wy + wh);
g.moveTo(wx, wy + wh * 0.45); g.lineTo(wx + ww, wy + wh * 0.45);
g.stroke();
/* deco sill / lintel */
if (index % 3 === 0) {
g.fillStyle = 'rgba(255,255,255,0.6)';
g.fillRect(wx - 6, wy + wh + 3, ww + 12, 5);
} else if (index % 3 === 1) {
g.fillStyle = 'rgba(0,0,0,0.12)';
g.fillRect(wx - 4, wy - 10, ww + 8, 7);
}
if (lit) {
/* interior glow for the emissive map, with a soft halo */
const hx = wx + ww / 2, hy = wy + wh / 2;
const rad = eg.createRadialGradient(hx, hy, 2, hx, hy, ww * 1.1);
rad.addColorStop(0, glass);
rad.addColorStop(1, 'rgba(0,0,0,0)');
eg.fillStyle = rad;
eg.fillRect(hx - ww * 1.2, hy - ww * 1.2, ww * 2.4, ww * 2.4);
eg.fillStyle = glass;
eg.fillRect(wx, wy, ww, wh);
}
/* occasional balcony */
if (lit && VC.rng.chance(0.28)) {
g.fillStyle = 'rgba(20,20,30,0.30)';
g.fillRect(wx - 5, wy + wh * 0.55, ww + 10, wh * 0.5);
g.fillStyle = 'rgba(255,255,255,0.5)';
g.fillRect(wx - 6, wy + wh * 0.55, ww + 12, 3);
}
}
}
grain(g, S, S, 26);
return { map: toTex(c, { aniso: 8 }), emissive: toTex(e, { aniso: 4 }) };
});
};
/* --------------------------------------------------------------------------
* Ground floor storefront: big glass, door, striped awning.
* Tileable horizontally. Emissive map = neon interior + sign band.
* ==========================================================================*/
T.storefront = function () {
return T.cached('store', () => {
const S = 512;
const c = canvas(S), e = canvas(S);
const g = c.getContext('2d'), eg = e.getContext('2d');
g.fillStyle = '#dcd6cf'; g.fillRect(0, 0, S, S);
eg.fillStyle = '#000'; eg.fillRect(0, 0, S, S);
/* polished stone plinth */
const gnd = g.createLinearGradient(0, S * 0.78, 0, S);
gnd.addColorStop(0, '#9aa0a6'); gnd.addColorStop(1, '#6f757b');
g.fillStyle = gnd; g.fillRect(0, S * 0.88, S, S * 0.12);
/* two glass bays + door */
const bays = [[S * 0.05, S * 0.40], [S * 0.58, S * 0.37]];
for (const [bx, bw] of bays) {
const by = S * 0.22, bh = S * 0.6;
g.fillStyle = '#1d2334'; g.fillRect(bx - 4, by - 4, bw + 8, bh + 8);
const gg = g.createLinearGradient(bx, by, bx + bw, by + bh);
gg.addColorStop(0, '#22334f'); gg.addColorStop(0.5, '#111a2c'); gg.addColorStop(1, '#2e4568');
g.fillStyle = gg; g.fillRect(bx, by, bw, bh);
/* warm interior light bleeding through the glass */
const warm = ['#ffcf94', '#ffb3d1', '#b9f4ff'][VC.rng.i(0, 2)];
g.fillStyle = warm; g.globalAlpha = 0.42;
g.fillRect(bx + 4, by + bh * 0.45, bw - 8, bh * 0.5);
g.globalAlpha = 1;
eg.fillStyle = warm; eg.globalAlpha = 0.78;
eg.fillRect(bx + 4, by + bh * 0.4, bw - 8, bh * 0.55);
eg.globalAlpha = 1;
/* mullions */
g.strokeStyle = 'rgba(255,255,255,0.45)'; g.lineWidth = 3;
for (let i = 1; i < 4; i++) {
const x = bx + (bw / 4) * i;
g.beginPath(); g.moveTo(x, by); g.lineTo(x, by + bh); g.stroke();
}
}
/* sign band with faux lettering */
const bandColors = ['#ff4fa3', '#41e7ff', '#ffd84f', '#8cff6b', '#ff8a3d'];
const sc = VC.rng.pick(bandColors);
g.fillStyle = '#20222b'; g.fillRect(0, S * 0.06, S, S * 0.13);
g.fillStyle = sc; g.fillRect(0, S * 0.06, S, 4);
g.fillRect(0, S * 0.19 - 4, S, 4);
g.font = 'bold 42px "Arial Black", Impact, sans-serif';
g.textBaseline = 'middle';
g.fillStyle = sc;
const word = VC.rng.pick(['SUNSET', 'OCEAN DR', 'CLUB', 'MOTEL', 'PARKING', ' DINER', 'BEACH', 'CASINO']);
g.fillText(word, 24, S * 0.125);
eg.fillStyle = '#000'; eg.fillRect(0, 0, S, S);
eg.fillStyle = sc; eg.fillRect(0, S * 0.06, S, 4); eg.fillRect(0, S * 0.19 - 4, S, 4);
eg.font = 'bold 42px "Arial Black", Impact, sans-serif';
eg.textBaseline = 'middle';
eg.fillText(word, 24, S * 0.125);
grain(g, S, S, 18);
return { map: toTex(c), emissive: toTex(e) };
});
};
/* roof surface: gravel, tar patches, painted vents */
T.roof = function () {
return T.cached('roof', () => {
const S = 256, c = canvas(S), g = c.getContext('2d');
g.fillStyle = '#6d6a68'; g.fillRect(0, 0, S, S);
for (let i = 0; i < 220; i++) {
g.fillStyle = `rgba(${120 + VC.rng.i(-30, 40)},${118 + VC.rng.i(-30, 40)},${116 + VC.rng.i(-30, 40)},0.5)`;
const s = VC.rng.r(1, 4);
g.fillRect(VC.rng.r(0, S), VC.rng.r(0, S), s, s);
}
for (let i = 0; i < 10; i++) {
g.fillStyle = 'rgba(40,40,45,0.35)';
g.fillRect(VC.rng.r(0, S), VC.rng.r(0, S), VC.rng.r(30, 90), VC.rng.r(20, 70));
}
grain(g, S, S, 34);
return toTex(c);
});
};
/* asphalt without markings (markings are separate geometry for crispness) */
T.asphalt = function () {
return T.cached('asphalt', () => {
const S = 256, c = canvas(S), g = c.getContext('2d');
g.fillStyle = '#5c5c66'; g.fillRect(0, 0, S, S);
for (let i = 0; i < 2600; i++) {
const v = 96 + VC.rng.i(-18, 26);
g.fillStyle = `rgba(${v},${v},${v + 6},0.55)`;
g.fillRect(VC.rng.r(0, S), VC.rng.r(0, S), VC.rng.r(1, 3), VC.rng.r(1, 3));
}
/* faint tar seams */
for (let i = 0; i < 5; i++) {
g.strokeStyle = 'rgba(34,34,42,0.5)'; g.lineWidth = VC.rng.r(1, 3);
g.beginPath();
g.moveTo(0, VC.rng.r(0, S));
g.bezierCurveTo(S / 3, VC.rng.r(0, S), (2 * S) / 3, VC.rng.r(0, S), S, VC.rng.r(0, S));
g.stroke();
}
return toTex(c);
});
};
/* sidewalk concrete with expansion joints (tileable) */
T.concrete = function () {
return T.cached('concrete', () => {
const S = 256, c = canvas(S), g = c.getContext('2d');
g.fillStyle = '#b3aeaa'; g.fillRect(0, 0, S, S);
for (let i = 0; i < 3000; i++) {
const v = 178 + VC.rng.i(-22, 18);
g.fillStyle = `rgba(${v},${v - 3},${v - 8},0.5)`;
g.fillRect(VC.rng.r(0, S), VC.rng.r(0, S), VC.rng.r(1, 2.5), VC.rng.r(1, 2.5));
}
g.strokeStyle = 'rgba(70,68,66,0.5)'; g.lineWidth = 2;
g.beginPath();
g.moveTo(0, 0); g.lineTo(S, 0); g.moveTo(0, 0); g.lineTo(0, S);
g.moveTo(S / 2, 0); g.lineTo(S / 2, S); g.moveTo(0, S / 2); g.lineTo(S, S / 2);
g.stroke();
return toTex(c);
});
};
/* wet sand near the shore / dry sand further up */
T.sand = function () {
return T.cached('sand', () => {
const S = 256, c = canvas(S), g = c.getContext('2d');
g.fillStyle = '#e7d6a8'; g.fillRect(0, 0, S, S);
for (let i = 0; i < 5000; i++) {
const v = VC.rng.i(-18, 22);
g.fillStyle = `rgba(${228 + v},${208 + v},${160 + v},0.55)`;
g.fillRect(VC.rng.r(0, S), VC.rng.r(0, S), VC.rng.r(1, 2), VC.rng.r(1, 2));
}
/* soft ripples */
for (let i = 0; i < 26; i++) {
g.strokeStyle = 'rgba(190,168,120,0.35)'; g.lineWidth = VC.rng.r(1, 3);
g.beginPath();
const y = VC.rng.r(0, S);
g.moveTo(0, y);
for (let x = 0; x <= S; x += 32) g.lineTo(x, y + Math.sin(x * 0.05 + i) * 5);
g.stroke();
}
return toTex(c);
});
};
/* palm frond: alpha-mapped leaf feather */
T.frond = function () {
return T.cached('frond', () => {
const W = 256, H = 128, c = canvas(W, H), g = c.getContext('2d');
g.clearRect(0, 0, W, H);
const mid = H / 2;
g.strokeStyle = '#2f6b2a'; g.lineWidth = 5;
g.beginPath(); g.moveTo(4, mid); g.lineTo(W - 6, mid); g.stroke();
for (let i = 0; i < 42; i++) {
const t = i / 41;
const x = 8 + t * (W - 18);
const len = (1 - Math.pow(t, 2.2)) * (H * 0.46) + 4;
for (const s of [-1, 1]) {
g.strokeStyle = `rgba(${34 + VC.rng.i(0, 34)},${110 + VC.rng.i(0, 60)},${40 + VC.rng.i(0, 34)},1)`;
g.lineWidth = 3.2;
g.beginPath();
g.moveTo(x, mid);
g.quadraticCurveTo(x + 10, mid + s * len * 0.6, x + 22, mid + s * len);
g.stroke();
}
}
return toTex(c, { clamp: true });
});
};
/* palm trunk bark strip (tileable vertically) */
T.bark = function () {
return T.cached('bark', () => {
const S = 128, c = canvas(S), g = c.getContext('2d');
g.fillStyle = '#8a7150'; g.fillRect(0, 0, S, S);
for (let y = 0; y < S; y += 9) {
g.fillStyle = 'rgba(60,45,28,0.45)';
g.fillRect(0, y, S, 3);
g.fillStyle = 'rgba(190,164,120,0.35)';
g.fillRect(0, y + 3, S, 2);
}
grain(g, S, S, 22);
return toTex(c);
});
};
/* Radial glow sprite used for muzzle flashes, lamps, sun sparkle */
T.glow = function (color) {
return T.cached('glow' + (color || ''), () => {
const S = 128, c = canvas(S), g = c.getContext('2d');
const col = color || '#ffffff';
const rg = g.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
rg.addColorStop(0, col);
rg.addColorStop(0.25, col + 'cc');
rg.addColorStop(0.6, col + '33');
rg.addColorStop(1, 'rgba(0,0,0,0)');
g.fillStyle = rg; g.fillRect(0, 0, S, S);
return toTex(c, { clamp: true });
});
};
/* --------------------------------------------------------------------------
* Neon sign text on transparent background. Drawn twice (tight + wide blur)
* so the bloom pass turns it into real neon.
* ==========================================================================*/
T.neon = function (text, color, sub) {
const key = 'neon' + text + color + (sub || '');
return T.cached(key, () => {
const W = 512, H = 256, c = canvas(W, H), g = c.getContext('2d');
g.clearRect(0, 0, W, H);
g.textAlign = 'center'; g.textBaseline = 'middle';
const draw = (blur, alpha, size, y) => {
g.save();
g.shadowColor = color;
g.shadowBlur = blur;
g.globalAlpha = alpha;
g.fillStyle = '#fff8ff';
g.font = `bold ${size}px "Arial Black", Impact, sans-serif`;
g.fillText(text, W / 2, y);
g.restore();
};
const big = text.length > 8 ? 62 : 92;
draw(38, 0.55, big, sub ? H * 0.36 : H * 0.5);
draw(12, 1.0, big, sub ? H * 0.36 : H * 0.5);
if (sub) {
draw(18, 0.9, 40, H * 0.72);
}
return toTex(c, { clamp: true });
});
};
/* simple icons (palm / flamingo / surfboard silhouettes) for signage */
T.icon = function (kind, color) {
return T.cached('icon' + kind + color, () => {
const S = 256, c = canvas(S), g = c.getContext('2d');
g.clearRect(0, 0, S, S);
g.shadowColor = color; g.shadowBlur = 22; g.fillStyle = '#ffffff';
g.strokeStyle = '#ffffff'; g.lineWidth = 10; g.lineCap = 'round';
if (kind === 'palm') {
g.beginPath(); g.moveTo(118, 232); g.quadraticCurveTo(128, 150, 148, 96); g.stroke();
for (let i = 0; i < 7; i++) {
const a = -Math.PI * 0.95 + i * (Math.PI * 0.9 / 6);
g.beginPath(); g.moveTo(148, 96);
g.quadraticCurveTo(148 + Math.cos(a) * 46, 96 + Math.sin(a) * 22, 148 + Math.cos(a) * 84, 96 + Math.sin(a) * 52 + 16);
g.stroke();
}
} else if (kind === 'flamingo') {
g.beginPath();
g.moveTo(96, 190); g.quadraticCurveTo(70, 150, 108, 122);
g.quadraticCurveTo(150, 96, 150, 62); g.quadraticCurveTo(150, 34, 176, 40);
g.stroke();
g.beginPath(); g.moveTo(96, 190); g.lineTo(96, 232); g.moveTo(116, 186); g.lineTo(122, 232); g.stroke();
g.beginPath(); g.arc(176, 42, 7, 0, 7); g.fill();
} else { /* surfboard */
g.beginPath();
g.ellipse(128, 128, 34, 100, 0, 0, 7);
g.fill();
}
return toTex(c, { clamp: true });
});
};
/* --------------------------------------------------------------------------
* Collision world: axis-aligned boxes on a uniform grid.
* ==========================================================================*/
VC.World = class World {
constructor(cell = 24) {
this.cell = cell;
this.boxes = [];
this.grid = new Map();
}
key(cx, cz) { return cx * 4096 + cz; }
add(x0, z0, x1, z1, h, kind) {
const b = { x0, z0, x1, z1, h: h === undefined ? 1e4 : h, kind: kind || 'solid', i: this.boxes.length };
this.boxes.push(b);
const c = this.cell;
const cx0 = Math.floor(x0 / c), cx1 = Math.floor(x1 / c);
const cz0 = Math.floor(z0 / c), cz1 = Math.floor(z1 / c);
for (let x = cx0; x <= cx1; x++)
for (let z = cz0; z <= cz1; z++) {
const k = this.key(x, z);
let a = this.grid.get(k);
if (!a) this.grid.set(k, (a = []));
a.push(b);
}
return b;
}
query(x, z, r, out) {
out = out || [];
const c = this.cell;
const cx0 = Math.floor((x - r) / c), cx1 = Math.floor((x + r) / c);
const cz0 = Math.floor((z - r) / c), cz1 = Math.floor((z + r) / c);
for (let i = cx0; i <= cx1; i++)
for (let j = cz0; j <= cz1; j++) {
const a = this.grid.get(this.key(i, j));
if (!a) continue;
for (const b of a) if (out.indexOf(b) < 0) out.push(b);
}
return out;
}
/* Push a circle of radius r out of every overlapping box. Returns true when
* something was hit. `y` lets low-clearance props block peds but not cars. */
resolveCircle(x, z, r, y) {
const list = this.query(x, z, r + 1);
let hit = false, hitNormal = null, hitBox = null;
for (const b of list) {
if (y !== undefined && b.h < y) continue;
const cx = U.clamp(x, b.x0, b.x1), cz = U.clamp(z, b.z0, b.z1);
let dx = x - cx, dz = z - cz;
let d2 = dx * dx + dz * dz;
if (d2 > r * r) continue;
hit = true; hitBox = b;
let d = Math.sqrt(d2);
if (d > 1e-4) {
const nx = dx / d, nz = dz / d;
x = cx + nx * r; z = cz + nz * r;
if (!hitNormal) hitNormal = { x: nx, z: nz };
} else {
/* centre inside box: eject through the closest face */
const left = x - b.x0, right = b.x1 - x, up = z - b.z0, down = b.z1 - z;
const m = Math.min(left, right, up, down);
if (m === left) { x = b.x0 - r; hitNormal = { x: -1, z: 0 }; }
else if (m === right) { x = b.x1 + r; hitNormal = { x: 1, z: 0 }; }
else if (m === up) { z = b.z0 - r; hitNormal = { x: 0, z: -1 }; }
else { z = b.z1 + r; hitNormal = { x: 0, z: 1 }; }
}
}
return { x, z, hit, normal: hitNormal, box: hitBox };
}
/* Step along a segment looking for the first box face (camera occlusion). */
rayXZ(x0, z0, x1, z1, maxT) {
const steps = 28;
for (let i = 1; i <= steps; i++) {
const t = (i / steps) * (maxT === undefined ? 1 : maxT);
const x = U.lerp(x0, x1, t), z = U.lerp(z0, z1, t);
const list = this.query(x, z, 0.05);
for (const b of list) if (x > b.x0 && x < b.x1 && z > b.z0 && z < b.z1) return t;
}
return 1;
}
};
/*! ---- js/sky.js ---- */
/* ============================================================================
* sky.js - procedural dusk sky (GLSL), sun light, and the environment probe
* used for glossy reflections on cars / glass / wet asphalt.
* ==========================================================================*/
(function () {
const S = (VC.sky = {});
/* --------------------------------------------------------------------------
* GLSL shared by the sky dome AND the water (so the ocean mirrors exactly
* what is above it). Kept as a string chunk injected into both shaders.
* ------------------------------------------------------------------------*/
S.chunk = /* glsl */ `
uniform vec3 uSunDir;
uniform vec3 uZenith;
uniform vec3 uHorizon;
uniform vec3 uHaze;
uniform vec3 uSunCol;
uniform float uTime;
uniform float uCloud;
uniform float uSunSize;
uniform float uSkyGain;
float vhash(vec2 p){
p = fract(p * vec2(233.34, 851.73));
p += dot(p, p + 23.45);
return fract(p.x * p.y);
}
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = vhash(i), b = vhash(i + vec2(1.0, 0.0));
float c = vhash(i + vec2(0.0, 1.0)), d = vhash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p){
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++){ v += a * vnoise(p); p *= 2.03; a *= 0.5; }
return v;
}
vec3 skyColor(vec3 dir){
dir = normalize(dir);
float up = dir.y;
float d = dot(dir, uSunDir);
/* base vertical gradient: warm horizon -> indigo zenith */
float t = pow(clamp(up * 0.5 + 0.5, 0.0, 1.0), 0.9);
vec3 col = mix(uHorizon, uZenith, smoothstep(0.24, 0.82, t));
/* warm haze band hugging the horizon, weighted toward the sun's azimuth
* so the sky stays violet instead of turning into flat pink soup */
float band = exp(-max(up, -0.05) * 7.0) * mix(0.25, 1.0, pow(max(d, 0.0), 0.55));
col = mix(col, uHaze, clamp(band * 0.9, 0.0, 1.0));
/* Only the *gradient* is gain-limited. The sun disc, its glow, the
* glitter it feeds into the sea and the stars are added afterwards at
* full strength, so dusk reads as a deep violet bowl with a burning
* sun instead of one flat sheet of pink. */
col *= uSkyGain;
/* retro sun: disc sliced by horizontal gaps that widen downwards */
float disc = smoothstep(uSunSize + 0.0008, uSunSize - 0.0008, acos(clamp(d, -1.0, 1.0)));
float slice = smoothstep(0.0, 1.0, sin((dir.y * 46.0) - uTime * 0.08) * 0.5 + 0.5);
float cut = mix(1.0, slice, clamp((uSunSize - dir.y) / (uSunSize * 1.6), 0.0, 1.0));
col += uSunCol * disc * cut * 2.1;
/* layered glow so the whole western sky burns */
col += uSunCol * pow(max(d, 0.0), 8.0) * 0.16;
col += uSunCol * pow(max(d, 0.0), 60.0) * 0.5;
col += uSunCol * pow(max(d, 0.0), 350.0) * 1.3;
/* drifting clouds, flattened onto a virtual dome */
if (up > 0.005) {
vec2 pp = dir.xz / (up + 0.11) * 0.55 + vec2(uTime * 0.006, uTime * 0.0025);
float f = fbm(pp);
float f2 = fbm(pp * 2.7 + 13.0);
float cl = smoothstep(0.52, 0.95, f * 1.05 + f2 * 0.25) * smoothstep(0.0, 0.22, up);
vec3 cloudLit = mix(vec3(0.5, 0.2, 0.26), vec3(0.52, 0.44, 0.38),
pow(max(d, 0.0), 1.6));
vec3 cloudDark = vec3(0.13, 0.09, 0.19);
vec3 cloud = mix(cloudDark, cloudLit, 0.55 + 0.45 * f);
col = mix(col, cloud, cl * uCloud);
}
/* first stars in the darkening east */
float dark = smoothstep(0.15, 0.55, up) * (1.0 - smoothstep(0.0, 0.5, pow(max(d, 0.0), 3.0)));
float st = step(0.9975, vhash(floor(dir.xz * 320.0 / max(up, 0.12))));
col += vec3(0.9, 0.95, 1.0) * st * dark * 1.6;
/* ground side: fade into the sea/haze fog colour */
col = mix(col, uHaze * 0.42 * uSkyGain, smoothstep(0.0, -0.09, up));
return col;
}
`;
S.sunDir = new THREE.Vector3(-0.66, 0.205, -0.72).normalize();
S.palette = {
zenith: new THREE.Color(0x120a2e),
horizon: new THREE.Color(0xff6f38),
haze: new THREE.Color(0xf23f72),
sun: new THREE.Color(0xffdca6).lerp(new THREE.Color(0xff7a4d), 0.3),
fog: new THREE.Color(0x3d2142),
};
/* --------------------------------------------------------------------------
* build(scene) - sky dome + sun disc + the three lights that define the mood
* ==========================================================================*/
S.build = function (scene) {
const uniforms = {
uSunDir: { value: S.sunDir },
uZenith: { value: S.palette.zenith },
uHorizon: { value: S.palette.horizon },
uHaze: { value: S.palette.haze },
uSunCol: { value: S.palette.sun },
uTime: { value: 0 },
uCloud: { value: 0.9 },
uSunSize: { value: 0.052 },
uSkyGain: { value: 0.3 },
};
S.uniforms = uniforms;
const mat = new THREE.ShaderMaterial({
uniforms,
side: THREE.BackSide,
depthWrite: false,
fog: false,
vertexShader: /* glsl */`
varying vec3 vDir;
void main(){
vDir = position;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: S.chunk + /* glsl */`
varying vec3 vDir;
void main(){
vec3 c = skyColor(vDir);
gl_FragColor = vec4(c, 1.0);
}`,
});
const dome = new THREE.Mesh(new THREE.SphereGeometry(1400, 48, 32), mat);
dome.frustumCulled = false;
dome.renderOrder = -1000;
dome.name = 'sky';
scene.add(dome);
S.mesh = dome;
/* ---------------------------------------------------------- lighting --- */
/* Sun: low + warm + long shadows. */
const sun = new THREE.DirectionalLight(0xffc48a, 1.55);
sun.position.copy(S.sunDir).multiplyScalar(220);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
const sc = sun.shadow.camera;
sc.left = -95; sc.right = 95; sc.top = 95; sc.bottom = -95;
sc.near = 1; sc.far = 620;
sun.shadow.bias = -0.0012;
sun.shadow.normalBias = 0.035;
sun.shadow.radius = 2.2;
scene.add(sun);
scene.add(sun.target);
S.sun = sun;
/* Sky bounce (pink from above, violet from the street) */
/* Cool blue skylight from above + warm bounce from the street: this
* orange-key / blue-fill split is what gives the dusk its film look. */
const hemi = new THREE.HemisphereLight(0x8fbcff, 0x533158, 0.86);
scene.add(hemi);
S.hemi = hemi;
/* Cool fill from the opposite side to keep silhouettes readable */
const fill = new THREE.DirectionalLight(0xa8c8ff, 0.5);
fill.position.set(140, 90, 190);
scene.add(fill);
S.fill = fill;
return S;
};
/* Keep the shadow frustum locked around the player - gives crisp contact
* shadows everywhere without a huge shadow map. */
S.follow = function (x, y, z) {
if (S.mesh) S.mesh.position.set(x, y, z);
const s = S.sun;
if (!s) return;
const step = 0.5;
const q = (v) => Math.round(v / step) * step;
s.position.set(q(x) + S.sunDir.x * 220, S.sunDir.y * 220, q(z) + S.sunDir.z * 220);
s.target.position.set(q(x), 0, q(z));
s.target.updateMatrixWorld();
};
S.tick = function (t) { S.uniforms.uTime.value = t; };
/* --------------------------------------------------------------------------
* envMap - PMREM probe generated from the sky dome only. Gives every
* standard material a cheap "reflection" of the sunset.
* ==========================================================================*/
S.makeEnv = function (renderer) {
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileCubemapShader();
const probeScene = new THREE.Scene();
const probeMat = S.mesh.material.clone();
const probe = new THREE.Mesh(new THREE.SphereGeometry(100, 32, 24), probeMat);
probe.frustumCulled = false;
probeScene.add(probe);
/* a dim ground disc so downward reflections are not pure sky */
const ground = new THREE.Mesh(
new THREE.SphereGeometry(80, 16, 8, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x2a2438, side: THREE.BackSide })
);
probeScene.add(ground);
const rt = pmrem.fromScene(probeScene, 0, 1, 400);
probe.geometry.dispose(); probeMat.dispose(); ground.geometry.dispose(); ground.material.dispose();
pmrem.dispose();
S.envMap = rt.texture;
return S.envMap;
};
})();
/*! ---- js/postfx.js ---- */
/* ============================================================================
* postfx.js - tiny custom post pipeline (no examples/js dependencies):
*
* scene -> HDR target (MSAA)
* -> bright pass (1/2 res) -> separable blur ----\
* -> downsample (1/4 res) -> separable blur -----+-> composite
*
* The composite does ACES tone mapping, sRGB encode, chromatic aberration,
* vignette, film grain, damage flash and fades - which is what sells the
* "sunset postcard" look.
* ==========================================================================*/
VC.PostFX = class PostFX {
constructor(renderer, opts = {}) {
this.renderer = renderer;
this.quality = opts.quality === undefined ? 1 : opts.quality; /* 0..2 */
this.bloom = opts.bloom === undefined ? 0.85 : opts.bloom;
this.exposure = opts.exposure === undefined ? 1.18 : opts.exposure;
this.damage = 0; this.flash = 0; this.fade = 0; this.speedBlur = 0;
const caps = renderer.capabilities;
this.hdrType = caps.isWebGL2 ? THREE.HalfFloatType : THREE.UnsignedByteType;
/* ---- fullscreen quad rig ---- */
this.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), null);
this.quad.frustumCulled = false;
this.scene = new THREE.Scene();
this.scene.add(this.quad);
const FS_VERT = /* glsl */`
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
this.brightMat = new THREE.ShaderMaterial({
uniforms: { tSrc: { value: null }, uThreshold: { value: 1.55 }, uKnee: { value: 0.7 } },
vertexShader: FS_VERT,
depthTest: false, depthWrite: false,
fragmentShader: /* glsl */`
uniform sampler2D tSrc; uniform float uThreshold, uKnee;
varying vec2 vUv;
void main(){
vec3 c = texture2D(tSrc, vUv).rgb;
float l = max(c.r, max(c.g, c.b));
float soft = clamp(l - uThreshold + uKnee, 0.0, 2.0 * uKnee);
soft = soft * soft / (4.0 * uKnee + 1e-4);
gl_FragColor = vec4(c * max(soft, l - uThreshold) / max(l, 1e-4), 1.0);
}`,
});
this.blurMat = new THREE.ShaderMaterial({
uniforms: { tSrc: { value: null }, uStep: { value: new THREE.Vector2(0, 0) } },
vertexShader: FS_VERT,
depthTest: false, depthWrite: false,
fragmentShader: /* glsl */`
uniform sampler2D tSrc; uniform vec2 uStep;
varying vec2 vUv;
void main(){
/* 9 tap gaussian; uStep is already a UV-space offset */
vec2 o = uStep;
vec3 c = texture2D(tSrc, vUv).rgb * 0.227027;
c += (texture2D(tSrc, vUv + o * 1.3846).rgb + texture2D(tSrc, vUv - o * 1.3846).rgb) * 0.3162162;
c += (texture2D(tSrc, vUv + o * 3.2308).rgb + texture2D(tSrc, vUv - o * 3.2308).rgb) * 0.0702702;
gl_FragColor = vec4(c, 1.0);
}`,
});
this.copyMat = new THREE.ShaderMaterial({
uniforms: { tSrc: { value: null } },
vertexShader: FS_VERT, depthTest: false, depthWrite: false,
fragmentShader: `uniform sampler2D tSrc; varying vec2 vUv;
void main(){ gl_FragColor = texture2D(tSrc, vUv); }`,
});
this.compMat = new THREE.ShaderMaterial({
uniforms: {
tScene: { value: null },
tBloom0: { value: null },
tBloom1: { value: null },
uBloom: { value: this.bloom },
uExposure: { value: this.exposure },
uTime: { value: 0 },
uDamage: { value: 0 },
uFlash: { value: 0 },
uFade: { value: 1 },
uAber: { value: 0.0018 },
uVig: { value: 0.42 },
uGrain: { value: 0.035 },
uSpeed: { value: 0 },
uRes: { value: new THREE.Vector2(1, 1) },
},
vertexShader: FS_VERT,
depthTest: false, depthWrite: false,
fragmentShader: /* glsl */`
uniform sampler2D tScene, tBloom0, tBloom1;
uniform float uBloom, uExposure, uTime, uDamage, uFlash, uFade, uAber, uVig, uGrain, uSpeed;
uniform vec2 uRes;
varying vec2 vUv;
vec3 aces(vec3 x){
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
vec3 srgb(vec3 c){
return mix(c * 12.92, 1.055 * pow(max(c, vec3(0.0)), vec3(0.41666)) - 0.055,
step(vec3(0.0031308), c));
}
float rnd(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main(){
vec2 uv = vUv;
vec2 cc = uv - 0.5;
float r2 = dot(cc, cc);
/* radial (speed) blur: sample inward toward the centre */
vec3 base;
if (uSpeed > 0.001) {
vec3 acc = vec3(0.0);
for (int i = 0; i < 6; i++) {
float t = float(i) / 5.0;
vec2 suv = 0.5 + cc * (1.0 - t * uSpeed * 0.05);
acc += texture2D(tScene, suv).rgb;
}
base = acc / 6.0;
} else {
base = texture2D(tScene, uv).rgb;
}
/* chromatic aberration grows toward the edges */
vec2 off = cc * uAber * (0.22 + r2 * 2.1);
base.r = texture2D(tScene, uv + off).r;
base.b = texture2D(tScene, uv - off).b;
vec3 bloom = texture2D(tBloom0, uv).rgb * 0.62 + texture2D(tBloom1, uv).rgb * 0.85;
vec3 col = base + bloom * uBloom;
col *= uExposure;
col = aces(col);
/* ACES flattens chroma; push saturation back for the posterised
* neon-noir look (and to undo the sky's milky highlights). */
float sat = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = clamp(mix(vec3(sat), col, 1.34), 0.0, 1.0);
/* subtle teal-in-shadows / warm-highlights grade */
col = mix(col, col * vec3(0.92, 0.985, 1.16), 0.4);
float lum = dot(col, vec3(0.299, 0.587, 0.114));
col += vec3(0.06, 0.012, 0.03) * smoothstep(0.35, 1.0, lum);
/* vignette */
col *= 1.0 - uVig * smoothstep(0.18, 0.78, r2);
/* damage: pulsing red rim */
float dmg = uDamage * smoothstep(0.06, 0.62, r2);
col = mix(col, vec3(0.72, 0.05, 0.12) * (0.5 + lum), clamp(dmg, 0.0, 0.92));
/* muzzle flash / pickup flash */
col += vec3(1.0, 0.82, 0.55) * uFlash * (0.35 + 0.65 * (1.0 - r2));
/* film grain + faint scanlines */
float g = rnd(uv * uRes * 0.5 + fract(uTime) * 91.0) - 0.5;
col += g * uGrain * (0.6 + 0.4 * lum);
col *= 1.0 - 0.02 * step(0.5, fract(gl_FragCoord.y * 0.5));
col = srgb(col);
col *= uFade;
gl_FragColor = vec4(col, 1.0);
}`,
});
this.setSize(1, 1);
}
_mkTarget(w, h, hdr, samples) {
const t = new THREE.WebGLRenderTarget(Math.max(2, w | 0), Math.max(2, h | 0), {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
type: hdr ? this.hdrType : THREE.UnsignedByteType,
depthBuffer: true,
stencilBuffer: false,
});
if (samples && this.renderer.capabilities.isWebGL2) t.samples = samples;
t.texture.generateMipmaps = false;
return t;
}
setSize(W, H) {
W = Math.max(2, Math.floor(W)); H = Math.max(2, Math.floor(H));
this.w = W; this.h = H;
const q = this.quality;
if (this.sceneRT) { this.sceneRT.dispose(); this.b0.dispose(); this.b1.dispose(); this.t0.dispose(); this.t1.dispose(); }
const samples = q >= 1 ? 4 : 0;
this.sceneRT = this._mkTarget(W, H, true, samples);
this.b0 = this._mkTarget(W / 2, H / 2, false, 0);
this.t0 = this._mkTarget(W / 2, H / 2, false, 0);
this.b1 = this._mkTarget(W / 4, H / 4, false, 0);
this.t1 = this._mkTarget(W / 4, H / 4, false, 0);
this.compMat.uniforms.uRes.value.set(W, H);
}
_blit(mat, target) {
this.quad.material = mat;
this.renderer.setRenderTarget(target);
this.renderer.clear(true, true, false);
this.renderer.render(this.scene, this.cam);
}
render(scene, camera, dt, time) {
const r = this.renderer;
/* 1. scene into HDR */
r.setRenderTarget(this.sceneRT);
r.clear();
r.render(scene, camera);
this.lastSceneInfo = { calls: r.info.render.calls, tris: r.info.render.triangles };
/* 2. bloom */
this.brightMat.uniforms.tSrc.value = this.sceneRT.texture;
this._blit(this.brightMat, this.b0);
const iters = this.quality >= 1 ? 2 : 1;
for (let i = 0; i < iters; i++) {
const rad = (1.4 + i * 1.6) / this.b0.width;
this.blurMat.uniforms.tSrc.value = this.b0.texture;
this.blurMat.uniforms.uStep.value.set(rad, 0);
this._blit(this.blurMat, this.t0);
this.blurMat.uniforms.tSrc.value = this.t0.texture;
this.blurMat.uniforms.uStep.value.set(0, rad);
this._blit(this.blurMat, this.b0);
}
/* second, wider octave at quarter res */
this.copyMat.uniforms.tSrc.value = this.b0.texture;
this._blit(this.copyMat, this.b1);
for (let i = 0; i < iters; i++) {
const rad = (2.2 + i * 2.4) / this.b1.width;
this.blurMat.uniforms.tSrc.value = this.b1.texture;
this.blurMat.uniforms.uStep.value.set(rad, 0);
this._blit(this.blurMat, this.t1);
this.blurMat.uniforms.tSrc.value = this.t1.texture;
this.blurMat.uniforms.uStep.value.set(0, rad);
this._blit(this.blurMat, this.b1);
}
/* 3. composite to screen */
const u = this.compMat.uniforms;
u.tScene.value = this.sceneRT.texture;
u.tBloom0.value = this.b0.texture;
u.tBloom1.value = this.b1.texture;
u.uBloom.value = this.bloom;
u.uExposure.value = this.exposure;
u.uTime.value = time;
u.uDamage.value = this.damage;
u.uFlash.value = this.flash;
u.uFade.value = this.fade;
u.uSpeed.value = this.speedBlur;
r.setRenderTarget(null);
this._blit(this.compMat, null);
}
};
/*! ---- js/water.js ---- */
/* ============================================================================
* water.js - the sea. Coarse vertex swell + per-pixel procedural normals,
* sky-reflection through the shared sky function, sun glitter, depth-tinted
* colour and an animated surge of foam where it meets the beach.
* ==========================================================================*/
VC.Water = class Water {
constructor(sky, city) {
const U = VC.util;
this.sky = sky;
this.size = 2600;
this.seg = 120;
this.follow = new THREE.Vector3();
const geo = new THREE.PlaneGeometry(this.size, this.size, this.seg, this.seg);
geo.rotateX(-Math.PI / 2);
this.uniforms = {
uTime: { value: 0 },
uCam: { value: new THREE.Vector3() },
uIsland: { value: city.waterRect },
uDeep: { value: new THREE.Color(0x0f3a63).multiplyScalar(1.0) },
uShallow: { value: new THREE.Color(0x21c2cf).multiplyScalar(0.8) },
uSurge: { value: new THREE.Color(0xffe6d2) },
...sky.uniforms,
};
const mat = new THREE.ShaderMaterial({
uniforms: this.uniforms,
transparent: false,
fog: false,
vertexShader: /* glsl */`
uniform float uTime;
uniform vec4 uIsland;
varying vec3 vWorld;
varying vec3 vSwell; /* dz/dx, dz/dz, height */
varying float vShore;
float shoreDist(vec2 p){
vec2 d = max(max(uIsland.xy - p, p - uIsland.zw), 0.0);
return length(d);
}
/* four travelling swells */
float h(vec2 p, float t){
float d = shoreDist(p);
float damp = smoothstep(2.0, 60.0, d); /* waves flatten in the shallows */
float z = 0.0;
z += sin(dot(p, vec2( 0.0520, 0.0310)) + t * 0.85) * 0.62;
z += sin(dot(p, vec2(-0.0310, 0.0720)) + t * 1.15) * 0.42;
z += sin(dot(p, vec2( 0.1100, -0.0610)) + t * 1.70) * 0.20;
z += sin(dot(p, vec2(-0.1900, -0.1400)) + t * 2.30) * 0.09;
return z * damp;
}
void main(){
vec3 p = position;
float t = uTime;
float e = 1.6;
float z = h(p.xz, t);
float zx = h(p.xz + vec2(e, 0.0), t);
float zz = h(p.xz + vec2(0.0, e), t);
vSwell = vec3((zx - z) / e, (zz - z) / e, z);
p.y += z;
vec4 wp = modelMatrix * vec4(p, 1.0);
vWorld = wp.xyz;
vShore = shoreDist(wp.xz);
gl_Position = projectionMatrix * viewMatrix * wp;
}`,
fragmentShader: sky.chunk + /* glsl */`
uniform vec3 uCam, uDeep, uShallow, uSurge;
varying vec3 vWorld;
varying vec3 vSwell;
varying float vShore;
void main(){
float t = uTime;
vec3 p = vWorld;
/* ---- per-pixel detail normal (two octaves of domain-warped noise) */
float s = 1.0;
vec2 q1 = p.xz * 0.42 + vec2(t * 0.55, t * 0.22);
vec2 q2 = p.xz * 1.35 - vec2(t * 0.85, t * 0.42);
float n1 = fbm(q1), n2 = fbm(q2);
float dh = 0.35;
float n1x = fbm(q1 + vec2(dh, 0.0)) - n1;
float n1z = fbm(q1 + vec2(0.0, dh)) - n1;
float n2x = fbm(q2 + vec2(dh, 0.0)) - n2;
float n2z = fbm(q2 + vec2(0.0, dh)) - n2;
float far = clamp(length(p.xz - uCam.xz) / 260.0, 0.0, 1.0);
float detail = mix(1.0, 0.15, far); /* avoid shimmer in the distance */
vec3 nrm = normalize(vec3(
-(vSwell.x + (n1x * 0.55 + n2x * 0.30) * detail),
1.0,
-(vSwell.y + (n1z * 0.55 + n2z * 0.30) * detail)));
vec3 V = normalize(uCam - p);
float fres = pow(1.0 - clamp(dot(V, nrm), 0.0, 1.0), 4.5);
/* cap the mirror so the sea keeps some body colour at grazing
* angles instead of turning into a sheet of pink sky */
fres = mix(0.03, 0.62, fres);
/* ---- reflection of the same sky the dome draws */
vec3 R = reflect(-V, nrm);
R.y = abs(R.y) * 0.85 + 0.012;
vec3 refl = skyColor(R);
/* ---- depth tint: turquoise shelf near the beach, indigo offshore */
float depthT = smoothstep(4.0, 90.0, vShore);
vec3 body = mix(uShallow, uDeep, depthT);
/* light shafts in the shallow water */
float caust = fbm(p.xz * 0.9 + vec2(t * 0.35, -t * 0.2));
body += uShallow * smoothstep(0.62, 0.95, caust) * (1.0 - depthT) * 0.5;
vec3 col = mix(body, refl, fres);
/* ---- sun glitter: sharp lobe + wide scattering, scintillating */
float sunAmt = pow(max(dot(R, uSunDir), 0.0), 380.0) * 6.0
+ pow(max(dot(R, uSunDir), 0.0), 22.0) * 0.22;
float twinkle = 0.6 + 0.4 * sin(n1 * 40.0 + t * 7.0) * sin(n2 * 31.0 - t * 5.0);
col += uSunCol * sunAmt * twinkle;
/* ---- shoreline surf: a surge band that breathes in and out */
float surge = sin(vShore * 0.55 - t * 1.25 + n1 * 3.0);
float line = smoothstep(0.35, 0.0, abs(vShore - (2.4 + surge * 1.9)));
float lip = smoothstep(7.0, 0.0, vShore);
float foam = clamp(line * 0.9 + smoothstep(3.4, 0.0, vShore) * 0.85, 0.0, 1.0) * lip;
foam += smoothstep(0.72, 1.0, fbm(p.xz * 0.55 + t * 0.25)) * (1.0 - depthT) * 0.35;
col = mix(col, uSurge, clamp(foam, 0.0, 1.0) * 0.85);
/* ---- distance haze so the sea melts into the horizon band */
float dist = length(p.xz - uCam.xz);
float haze = smoothstep(320.0, 1500.0, dist);
col = mix(col, uHaze * 0.5 + uDeep * 0.45, haze * 0.78);
gl_FragColor = vec4(col, 1.0);
}`,
});
this.mesh = new THREE.Mesh(geo, mat);
this.mesh.frustumCulled = false;
this.mesh.renderOrder = -10;
this.mesh.name = 'ocean';
}
update(camPos, t) {
const cell = 8;
const x = Math.round(camPos.x / cell) * cell;
const z = Math.round(camPos.z / cell) * cell;
this.mesh.position.set(x, 0, z);
this.uniforms.uTime.value = t;
this.uniforms.uCam.value.set(camPos.x - x, camPos.y, camPos.z - z);
}
};
/*! ---- js/fx.js ---- */
/* ============================================================================
* fx.js - pooled GPU particles (points), bullet tracers, decals and skid marks
* ==========================================================================*/
(function () {
const U = VC.util;
function spriteTex(kind) {
const S = 64, c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.clearRect(0, 0, S, S);
if (kind === 'soft') {
const rg = g.createRadialGradient(32, 32, 0, 32, 32, 32);
rg.addColorStop(0, 'rgba(255,255,255,1)');
rg.addColorStop(0.35, 'rgba(255,255,255,0.55)');
rg.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = rg; g.fillRect(0, 0, S, S);
} else if (kind === 'smoke') {
for (let i = 0; i < 26; i++) {
const x = 32 + (Math.random() - 0.5) * 26, y = 32 + (Math.random() - 0.5) * 26;
const r = 8 + Math.random() * 14;
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, 'rgba(255,255,255,0.32)');
rg.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = rg; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();
}
} else { /* streak */
const lg = g.createLinearGradient(0, 32, S, 32);
lg.addColorStop(0, 'rgba(255,255,255,0)');
lg.addColorStop(0.5, 'rgba(255,255,255,1)');
lg.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = lg; g.fillRect(0, 28, S, 8);
}
const t = new THREE.CanvasTexture(c);
t.needsUpdate = true;
return t;
}
const VERT = /* glsl */`
attribute float aSize;
attribute float aAlpha;
attribute vec3 aColor;
varying vec3 vCol;
varying float vA;
void main(){
vCol = aColor; vA = aAlpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (620.0 / max(1.0, -mv.z));
gl_Position = projectionMatrix * mv;
}`;
const FRAG = /* glsl */`
uniform sampler2D uMap;
varying vec3 vCol; varying float vA;
void main(){
vec4 t = texture2D(uMap, gl_PointCoord);
gl_FragColor = vec4(vCol, t.a * vA);
if (gl_FragColor.a < 0.01) discard;
}`;
class Pool {
constructor(scene, tex, max, additive, depthWrite) {
this.max = max; this.n = 0;
this.pos = new Float32Array(max * 3);
this.vel = new Float32Array(max * 3);
this.col = new Float32Array(max * 3);
this.size = new Float32Array(max);
this.alpha = new Float32Array(max);
this.life = new Float32Array(max);
this.maxLife = new Float32Array(max);
this.grav = new Float32Array(max);
this.drag = new Float32Array(max);
this.grow = new Float32Array(max);
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(this.pos, 3));
g.setAttribute('aColor', new THREE.BufferAttribute(this.col, 3));
g.setAttribute('aSize', new THREE.BufferAttribute(this.size, 1));
g.setAttribute('aAlpha', new THREE.BufferAttribute(this.alpha, 1));
g.setDrawRange(0, 0);
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
const m = new THREE.ShaderMaterial({
uniforms: { uMap: { value: tex } },
vertexShader: VERT, fragmentShader: FRAG,
transparent: true, depthWrite: !!depthWrite,
blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending,
});
this.geo = g;
this.points = new THREE.Points(g, m);
this.points.frustumCulled = false;
this.points.renderOrder = 10;
scene.add(this.points);
this._n = 0;
}
emit(x, y, z, vx, vy, vz, color, size, life, grav, drag, grow) {
let i;
if (this.n < this.max) { i = this.n++; }
else { i = this._n = (this._n + 1) % this.max; }
const i3 = i * 3;
this.pos[i3] = x; this.pos[i3 + 1] = y; this.pos[i3 + 2] = z;
this.vel[i3] = vx; this.vel[i3 + 1] = vy; this.vel[i3 + 2] = vz;
this.col[i3] = color[0]; this.col[i3 + 1] = color[1]; this.col[i3 + 2] = color[2];
this.size[i] = size; this.alpha[i] = 1;
this.life[i] = life; this.maxLife[i] = life;
this.grav[i] = grav || 0; this.drag[i] = drag === undefined ? 1.2 : drag;
this.grow[i] = grow || 0;
this.geo.setDrawRange(0, this.n);
}
update(dt) {
let live = 0;
for (let i = 0; i < this.n; i++) {
if (this.life[i] <= 0) { this.alpha[i] = 0; continue; }
this.life[i] -= dt;
const i3 = i * 3;
const d = Math.exp(-this.drag[i] * dt);
this.vel[i3] *= d; this.vel[i3 + 2] *= d;
this.vel[i3 + 1] = this.vel[i3 + 1] * d - this.grav[i] * dt;
this.pos[i3] += this.vel[i3] * dt;
this.pos[i3 + 1] += this.vel[i3 + 1] * dt;
this.pos[i3 + 2] += this.vel[i3 + 2] * dt;
this.size[i] += this.grow[i] * dt;
const t = U.clamp01(this.life[i] / this.maxLife[i]);
this.alpha[i] = t * t;
if (this.life[i] > 0) live = i + 1;
}
this.geo.setDrawRange(0, Math.max(live, 0));
this.geo.attributes.position.needsUpdate = true;
this.geo.attributes.aAlpha.needsUpdate = true;
this.geo.attributes.aSize.needsUpdate = true;
this.geo.attributes.aColor.needsUpdate = true;
}
}
/* ------------------------------------------------------------ skid marks */
class Skids {
constructor(scene, max) {
this.max = max; this.i = 0;
const g = new THREE.BufferGeometry();
const pos = new Float32Array(max * 4 * 3);
const col = new Float32Array(max * 4 * 4);
const idx = new Uint16Array(max * 6);
for (let i = 0; i < max; i++) {
const a = i * 4;
idx[i * 6] = a; idx[i * 6 + 1] = a + 1; idx[i * 6 + 2] = a + 2;
idx[i * 6 + 3] = a; idx[i * 6 + 4] = a + 2; idx[i * 6 + 5] = a + 3;
}
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 4));
g.setIndex(new THREE.BufferAttribute(idx, 1));
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
this.pos = pos; this.col = col;
this.life = new Float32Array(max);
const m = new THREE.MeshBasicMaterial({
transparent: true, vertexColors: true, depthWrite: false,
opacity: 0.85, side: THREE.DoubleSide, color: 0x12100f,
});
this.mesh = new THREE.Mesh(g, m);
this.mesh.frustumCulled = false;
this.mesh.renderOrder = 2;
scene.add(this.mesh);
}
add(x, z, dx, dz, w, strength) {
const i = this.i; this.i = (this.i + 1) % this.max;
const px = -dz, pz = dx;
const p = this.pos, c = this.col;
const o = i * 4 * 3;
const setv = (k, x2, y2, z2) => { p[o + k * 3] = x2; p[o + k * 3 + 1] = y2; p[o + k * 3 + 2] = z2; };
setv(0, x - dx * 0.5 + px * w, y0(x, z) + 0.02, z - dz * 0.5 + pz * w);
setv(1, x + dx * 0.5 + px * w, y0(x, z) + 0.02, z + dz * 0.5 + pz * w);
setv(2, x + dx * 0.5 - px * w, y0(x, z) + 0.02, z + dz * 0.5 - pz * w);
setv(3, x - dx * 0.5 - px * w, y0(x, z) + 0.02, z - dz * 0.5 - pz * w);
this.life[i] = 9;
this._fade();
}
_fade() {
/* refresh alphas from remaining life */
for (let i = 0; i < this.max; i++) {
const a = U.clamp01(this.life[i] / 9) * 0.75;
for (let k = 0; k < 4; k++) this.col[i * 16 + k * 4 + 3] = a;
}
this.mesh.geometry.attributes.color.needsUpdate = true;
}
tick(dt) {
for (let i = 0; i < this.max; i++) if (this.life[i] > 0) this.life[i] -= dt;
this._fade();
}
}
function y0(x, z) { return VC.game ? VC.game.city.groundH(x, z) : 1.44; }
/* ------------------------------------------------------------------------- */
VC.FX = class FX {
constructor(scene, camera) {
this.scene = scene; this.camera = camera;
this.soft = new Pool(scene, spriteTex('soft'), 1400, true);
this.smoke = new Pool(scene, spriteTex('smoke'), 700, false);
this.debris = new Pool(scene, spriteTex('soft'), 700, false);
this.skids = new Skids(scene, 900);
this.tracers = [];
this.tracerGeo = new THREE.BufferGeometry();
const tp = new Float32Array(3 * 2 * 64), tc = new Float32Array(3 * 2 * 64);
this.tracerPos = new THREE.BufferAttribute(tp, 3);
this.tracerCol = new THREE.BufferAttribute(tc, 3);
this.tracerGeo.setAttribute('position', this.tracerPos);
this.tracerGeo.setAttribute('color', this.tracerCol);
this.tracerGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
this.tracerGeo.setDrawRange(0, 0);
this.tracerMesh = new THREE.LineSegments(this.tracerGeo,
new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
this.tracerMesh.frustumCulled = false;
scene.add(this.tracerMesh);
this.flashLights = [];
this.shake = 0;
}
/* ---------- emitters ---------- */
muzzle(x, y, z, dx, dy, dz, power) {
const P = this.soft;
const n = 8 + (power | 0);
for (let i = 0; i < n; i++) {
const s = 8 + Math.random() * 16;
P.emit(x, y, z, dx * s + (Math.random() - 0.5) * 7, dy * s + (Math.random() - 0.5) * 7 + 2, dz * s + (Math.random() - 0.5) * 7,
[1.0, 0.78 + Math.random() * 0.2, 0.35], 0.5 + Math.random() * 0.7, 0.07 + Math.random() * 0.06, 0, 6, 2);
}
P.emit(x, y, z, 0, 0, 0, [1.6, 1.1, 0.5], 1.9, 0.055, 0, 0, 0);
this.shake = Math.min(1, this.shake + 0.25);
}
impact(x, y, z, nx, ny, nz, kind) {
if (kind === 'flesh') {
const n = 14;
for (let i = 0; i < n; i++) {
const s = 3 + Math.random() * 8;
this.debris.emit(x, y, z, nx * s + (Math.random() - 0.5) * 5, Math.random() * 4, nz * s + (Math.random() - 0.5) * 5,
[0.55, 0.05, 0.06], 0.16 + Math.random() * 0.2, 0.5 + Math.random() * 0.4, 14, 0.6, 0);
}
this.soft.emit(x, y, z, 0, 0, 0, [0.8, 0.1, 0.1], 0.9, 0.09, 0, 0, 0);
} else if (kind === 'glass') {
for (let i = 0; i < 16; i++) {
const s = 4 + Math.random() * 9;
this.debris.emit(x, y, z, (Math.random() - 0.5) * s, Math.random() * 5, (Math.random() - 0.5) * s,
[0.7, 0.9, 1.0], 0.12, 0.7, 12, 0.4, 0);
}
} else { /* concrete / metal */
for (let i = 0; i < 12; i++) {
const s = 5 + Math.random() * 12;
this.soft.emit(x, y, z, nx * s + (Math.random() - 0.5) * 8, Math.random() * 5, nz * s + (Math.random() - 0.5) * 8,
[1.0, 0.75, 0.35], 0.14 + Math.random() * 0.14, 0.18 + Math.random() * 0.2, 11, 1.0, 0);
}
this.smoke.emit(x, y, z, nx * 1.5, 1.2, nz * 1.5, [0.5, 0.48, 0.46], 0.5, 0.9, -0.4, 1.1, 1.6);
this.soft.emit(x, y, z, 0, 0, 0, [1.4, 1.0, 0.5], 1.1, 0.07, 0, 0, 0);
}
}
tracer(from, to, bright) {
this.tracers.push({ from: from.clone(), to: to.clone(), t: 0.055, b: bright === undefined ? 1 : bright });
}
tireSmoke(x, y, z, amount) {
for (let i = 0; i < amount; i++) {
this.smoke.emit(x + (Math.random() - 0.5) * 0.5, y + 0.15, z + (Math.random() - 0.5) * 0.5,
(Math.random() - 0.5) * 2.4, 0.6 + Math.random(), (Math.random() - 0.5) * 2.4,
[0.92, 0.88, 0.86], 0.7 + Math.random() * 0.5, 0.65 + Math.random() * 0.5, -0.25, 1.4, 2.4);
}
}
explosion(x, y, z, radius, damageFn) {
for (let i = 0; i < 70; i++) {
const a = Math.random() * 6.283, e = Math.random() * 1.4;
const s = 6 + Math.random() * 26;
this.soft.emit(x, y + 0.4, z, Math.cos(a) * s * Math.cos(e), Math.abs(Math.sin(e)) * s * 1.2, Math.sin(a) * s * Math.cos(e),
[1.6, 0.6 + Math.random() * 0.4, 0.16], 1.2 + Math.random() * 2.2, 0.4 + Math.random() * 0.5, 6, 1.6, 3);
}
for (let i = 0; i < 40; i++) {
const a = Math.random() * 6.283;
const s = 4 + Math.random() * 14;
this.smoke.emit(x, y + 1, z, Math.cos(a) * s * 0.4, 2 + Math.random() * 5, Math.sin(a) * s * 0.4,
[0.28, 0.26, 0.25], 2 + Math.random() * 3, 1.8 + Math.random() * 1.8, -1.1, 1.0, 3.4);
}
this.shake = 1.6;
if (VC.game && VC.game.audio) VC.game.audio.explode();
}
splash(x, y, z, n) {
for (let i = 0; i < (n || 18); i++) {
const a = Math.random() * 6.283, s = 2 + Math.random() * 7;
this.soft.emit(x, y + 0.1, z, Math.cos(a) * s, 3 + Math.random() * 5, Math.sin(a) * s,
[0.75, 0.92, 1.0], 0.28 + Math.random() * 0.3, 0.5, 12, 0.7, 0);
}
this.smoke.emit(x, y + 0.2, z, 0, 1.2, 0, [0.85, 0.95, 1.0], 1.2, 0.7, -0.4, 1.2, 2.6);
}
skid(x, z, dx, dz, w, strength) { this.skids.add(x, z, dx, dz, w || 0.16, strength); }
update(dt) {
this.soft.update(dt); this.smoke.update(dt); this.debris.update(dt);
this.skids.tick(dt);
/* tracers fade out and shrink toward their end point */
let n = 0;
const tp = this.tracerPos.array, tc = this.tracerCol.array;
for (let i = this.tracers.length - 1; i >= 0; i--) {
const t = this.tracers[i];
t.t -= dt;
if (t.t <= 0) { this.tracers.splice(i, 1); continue; }
if (n >= 64) break;
const k = t.t / 0.055;
const a = t.from.clone().lerp(t.to, 1 - k);
const b = t.to;
tp[n * 6] = a.x; tp[n * 6 + 1] = a.y; tp[n * 6 + 2] = a.z;
tp[n * 6 + 3] = b.x; tp[n * 6 + 4] = b.y; tp[n * 6 + 5] = b.z;
const c = t.b;
tc[n * 6] = 1.6 * c; tc[n * 6 + 1] = 1.2 * c; tc[n * 6 + 2] = 0.5 * c;
tc[n * 6 + 3] = 1.2 * c; tc[n * 6 + 4] = 0.9 * c; tc[n * 6 + 5] = 0.4 * c;
n++;
}
this.tracerGeo.setDrawRange(0, n * 2);
this.tracerPos.needsUpdate = true;
this.tracerCol.needsUpdate = true;
this.shake = Math.max(0, this.shake - dt * 3.2);
}
};
})();
/*! ---- js/audio.js ---- */
/* ============================================================================
* audio.js - 100% procedural WebAudio: three synthwave "radio stations" plus
* every sound effect (guns, engine, skids, sirens, explosions, UI).
* Nothing is downloaded; the music is a step sequencer driving synth voices.
* ==========================================================================*/
(function () {
const A = (VC.Audio = class Audio {
constructor() {
this.ready = false;
this.enabled = true;
this.volume = 0.75;
this.station = 0;
this.insideCar = false;
this.engineTargets = [];
}
/* must be called from a user gesture */
start() {
if (this.ready) return;
try {
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = this.ctx = new AC();
} catch (e) { this.enabled = false; return; }
const ctx = this.ctx;
this.master = ctx.createGain();
this.master.gain.value = 0.9;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14; comp.knee.value = 22; comp.ratio.value = 6;
comp.attack.value = 0.004; comp.release.value = 0.22;
this.master.connect(comp).connect(ctx.destination);
/* reverb (generated impulse response) */
this.rev = ctx.createConvolver();
this.rev.buffer = this._impulse(2.6, 2.4);
this.revGain = ctx.createGain(); this.revGain.gain.value = 0.55;
this.revGain.connect(this.rev).connect(this.master);
/* echo for leads */
this.echo = ctx.createDelay(1.2); this.echo.delayTime.value = 0.34;
const fb = ctx.createGain(); fb.gain.value = 0.36;
const echoLp = ctx.createBiquadFilter(); echoLp.type = 'lowpass'; echoLp.frequency.value = 2600;
this.echoIn = ctx.createGain();
this.echoIn.connect(this.echo); this.echo.connect(echoLp).connect(fb).connect(this.echo);
this.echoOut = ctx.createGain(); this.echoOut.gain.value = 0.5;
echoLp.connect(this.echoOut).connect(this.master);
/* buses */
this.sfx = ctx.createGain(); this.sfx.gain.value = 0.85; this.sfx.connect(this.master);
this.musicFilter = ctx.createBiquadFilter();
this.musicFilter.type = 'lowpass'; this.musicFilter.frequency.value = 18000;
this.musicHP = ctx.createBiquadFilter(); this.musicHP.type = 'highpass'; this.musicHP.frequency.value = 24;
this.music = ctx.createGain(); this.music.gain.value = 0.0;
this.music.connect(this.musicHP).connect(this.musicFilter).connect(this.master);
this.musicSend = ctx.createGain(); this.musicSend.gain.value = 0.35;
this.musicFilter.connect(this.musicSend).connect(this.revGain);
/* engine rig */
this.engGain = ctx.createGain(); this.engGain.gain.value = 0;
this.engGain.connect(this.master);
this.engFilt = ctx.createBiquadFilter(); this.engFilt.type = 'lowpass'; this.engFilt.frequency.value = 900;
this.engFilt.connect(this.engGain);
this.engDist = ctx.createWaveShaper(); this.engDist.curve = this._curve(6);
this.engDist.connect(this.engFilt);
this.engA = ctx.createOscillator(); this.engA.type = 'sawtooth';
this.engB = ctx.createOscillator(); this.engB.type = 'square';
this.engSub = ctx.createOscillator(); this.engSub.type = 'triangle';
const gA = ctx.createGain(); gA.gain.value = 0.6;
const gB = ctx.createGain(); gB.gain.value = 0.22;
this.engA.connect(gA).connect(this.engDist);
this.engB.connect(gB).connect(this.engDist);
this.engSub.connect(this.engGain);
this.engA.start(); this.engB.start(); this.engSub.start();
this.engSub.frequency.value = 42;
/* skid noise rig */
this.skidGain = ctx.createGain(); this.skidGain.gain.value = 0;
const skBP = ctx.createBiquadFilter(); skBP.type = 'bandpass'; skBP.Q.value = 1.1; skBP.frequency.value = 1150;
this.skidSrc = ctx.createBufferSource(); this.skidSrc.buffer = this._noise(2.2);
this.skidSrc.loop = true;
this.skidSrc.connect(skBP).connect(this.skidGain).connect(this.master);
this.skidSrc.start();
/* traffic rumble (proximity based) */
this.trafficGain = ctx.createGain(); this.trafficGain.gain.value = 0;
this.trafficSrc = ctx.createBufferSource(); this.trafficSrc.buffer = this._noise(2.2);
this.trafficSrc.loop = true;
const tF = ctx.createBiquadFilter(); tF.type = 'bandpass'; tF.Q.value = 0.7; tF.frequency.value = 420;
this.trafficSrc.connect(tF).connect(this.trafficGain).connect(this.master);
this.trafficSrc.start();
/* sea/ambience */
this.windGain = ctx.createGain(); this.windGain.gain.value = 0.05;
this.windSrc = ctx.createBufferSource(); this.windSrc.buffer = this._noise(3.0);
this.windSrc.loop = true;
const wF = ctx.createBiquadFilter(); wF.type = 'lowpass'; wF.frequency.value = 620;
this.windSrc.connect(wF).connect(this.windGain).connect(this.master);
this.windSrc.start();
this.ready = true;
this._initSeq();
this.setStation(0, true);
this.music.gain.setTargetAtTime(0.32 * this.volume, ctx.currentTime, 0.8);
return true;
}
_noise(sec) {
const ctx = this.ctx, n = Math.floor(ctx.sampleRate * sec);
const b = ctx.createBuffer(1, n, ctx.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return b;
}
_impulse(sec, decay) {
const ctx = this.ctx, n = Math.floor(ctx.sampleRate * sec);
const b = ctx.createBuffer(2, n, ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const d = b.getChannelData(c);
for (let i = 0; i < n; i++) {
const t = i / n;
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, decay) * (i < ctx.sampleRate * 0.006 ? 0.3 : 1);
}
}
return b;
}
_curve(amount) {
const n = 1024, c = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i * 2) / n - 1;
c[i] = ((3 + amount) * x * 20 * (Math.PI / 180)) / (Math.PI + amount * Math.abs(x));
}
return c;
}
/* --------------------------------------------------------------- pans -- */
/* Route a node to a bus with distance attenuation + stereo panning. */
_route(node, pos, bus) {
if (!pos || !this.ctx.createStereoPanner || !VC.game || !VC.game.camera) {
node.connect(bus || this.sfx);
return;
}
const cam = VC.game.camera.position;
const dx = pos.x - cam.x, dz = pos.z - cam.z;
const d = Math.hypot(dx, dz);
const g = this.ctx.createGain();
g.gain.value = 1 / (1 + Math.pow(d / 30, 2));
const p = this.ctx.createStereoPanner();
const fwd = VC.game.fwd || { x: 0, z: -1 };
const rx = -fwd.z, rz = fwd.x;
p.pan.value = VC.util.clamp(((dx * rx + dz * rz) / (d || 1)) * 0.9, -1, 1);
node.connect(g).connect(p).connect(bus || this.sfx);
}
/* ------------------------------------------------------------- voices -- */
_env(g, t, a, d, peak) {
g.gain.cancelScheduledValues(t);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + a);
g.gain.exponentialRampToValueAtTime(0.0001, t + a + d);
}
noiseHit(o) {
if (!this.ready || !this.enabled) return;
const ctx = this.ctx, t = ctx.currentTime + (o.delay || 0);
const src = ctx.createBufferSource(); src.buffer = this._noiseBuf || (this._noiseBuf = this._noise(1.2));
src.playbackRate.value = o.rate || 1;
const f = ctx.createBiquadFilter();
f.type = o.filter || 'bandpass'; f.frequency.value = o.freq || 1200; f.Q.value = o.q || 1;
if (o.sweep) f.frequency.exponentialRampToValueAtTime(Math.max(60, o.sweep), t + (o.dur || 0.2));
const g = ctx.createGain();
this._env(g, t, o.attack || 0.002, o.dur || 0.2, o.gain || 0.5);
src.connect(f).connect(g);
this._route(g, o.pos);
if (o.rev) { const s = ctx.createGain(); s.gain.value = o.rev; g.connect(s).connect(this.revGain); }
src.start(t, Math.random() * 0.4); src.stop(t + (o.dur || 0.2) + 0.1);
}
tone(o) {
if (!this.ready || !this.enabled) return;
const ctx = this.ctx, t = ctx.currentTime + (o.delay || 0);
const osc = ctx.createOscillator();
osc.type = o.type || 'sine';
osc.frequency.setValueAtTime(o.f0, t);
if (o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + (o.dur || 0.2));
const g = ctx.createGain();
this._env(g, t, o.attack || 0.004, o.dur || 0.25, o.gain || 0.3);
osc.connect(g);
this._route(g, o.pos);
if (o.rev) { const s = ctx.createGain(); s.gain.value = o.rev; g.connect(s).connect(this.revGain); }
osc.start(t); osc.stop(t + (o.dur || 0.25) + 0.12);
}
/* --------------------------------------------------------------- sfx --- */
shot(kind, pos) {
if (!this.ready) return;
if (kind === 'pistol') {
this.noiseHit({ freq: 1700, sweep: 320, q: 0.7, dur: 0.16, gain: 0.5, pos, rev: 0.18 });
this.tone({ type: 'square', f0: 190, f1: 60, dur: 0.1, gain: 0.32, pos });
} else if (kind === 'smg') {
this.noiseHit({ freq: 2200, sweep: 500, q: 0.8, dur: 0.09, gain: 0.34, pos, rev: 0.14 });
this.tone({ type: 'sawtooth', f0: 150, f1: 55, dur: 0.07, gain: 0.22, pos });
} else if (kind === 'shotgun') {
this.noiseHit({ freq: 900, sweep: 120, q: 0.5, dur: 0.42, gain: 0.72, pos, rev: 0.3 });
this.tone({ type: 'sine', f0: 92, f1: 34, dur: 0.36, gain: 0.55, pos });
} else if (kind === 'uzi_off') {
this.noiseHit({ freq: 3200, q: 2, dur: 0.05, gain: 0.2, pos });
}
}
dryFire(pos) { this.noiseHit({ freq: 2600, q: 3, dur: 0.05, gain: 0.16, pos }); }
reload(pos) {
this.noiseHit({ freq: 1500, q: 2.5, dur: 0.05, gain: 0.2, pos, delay: 0 });
this.noiseHit({ freq: 900, q: 2.5, dur: 0.06, gain: 0.22, pos, delay: 0.18 });
this.tone({ type: 'square', f0: 420, dur: 0.04, gain: 0.09, pos, delay: 0.32 });
}
ricochet(pos) {
this.noiseHit({ freq: 2600, sweep: 700, q: 6, dur: 0.22, gain: 0.24, pos, rev: 0.25 });
this.tone({ type: 'sine', f0: 1700, f1: 500, dur: 0.18, gain: 0.07, pos });
}
flesh(pos) {
this.noiseHit({ filter: 'lowpass', freq: 620, sweep: 160, dur: 0.16, gain: 0.5, pos });
this.tone({ type: 'triangle', f0: 140, f1: 60, dur: 0.14, gain: 0.24, pos });
}
glass(pos) {
this.noiseHit({ filter: 'highpass', freq: 3200, dur: 0.22, gain: 0.34, pos, rev: 0.3 });
for (let i = 0; i < 4; i++) this.tone({ type: 'triangle', f0: 2400 + Math.random() * 2600, dur: 0.09, gain: 0.09, pos, delay: i * 0.04 });
}
metal(pos) {
this.tone({ type: 'square', f0: 640, f1: 210, dur: 0.16, gain: 0.2, pos });
this.noiseHit({ freq: 1800, q: 1.4, dur: 0.12, gain: 0.3, pos });
}
crash(force, pos) {
this.noiseHit({ filter: 'lowpass', freq: 1400 * force, sweep: 140, dur: 0.3 * force, gain: 0.5 * force, pos, rev: 0.2 });
this.tone({ type: 'sawtooth', f0: 120, f1: 42, dur: 0.25, gain: 0.3 * force, pos });
}
explode() {
if (!this.ready) return;
this.noiseHit({ filter: 'lowpass', freq: 2400, sweep: 90, dur: 1.5, gain: 0.9, rev: 0.5 });
this.tone({ type: 'sine', f0: 120, f1: 24, dur: 1.1, gain: 0.8, rev: 0.3 });
this.tone({ type: 'square', f0: 68, f1: 20, dur: 0.7, gain: 0.35 });
}
pickup() {
this.tone({ type: 'triangle', f0: 660, dur: 0.09, gain: 0.2 });
this.tone({ type: 'triangle', f0: 990, dur: 0.09, gain: 0.2, delay: 0.07 });
this.tone({ type: 'triangle', f0: 1320, dur: 0.16, gain: 0.2, delay: 0.14, rev: 0.4 });
}
cash() {
for (let i = 0; i < 5; i++) this.tone({ type: 'square', f0: 1200 + i * 260, dur: 0.05, gain: 0.09, delay: i * 0.035 });
}
horn(pos) {
this.tone({ type: 'square', f0: 392, dur: 0.42, gain: 0.16, pos, rev: 0.15 });
this.tone({ type: 'square', f0: 494, dur: 0.42, gain: 0.13, pos, rev: 0.15 });
}
wasted() {
const seq = [523, 466, 392, 311, 233];
seq.forEach((f, i) => this.tone({ type: 'sawtooth', f0: f, dur: 0.5, gain: 0.22, delay: i * 0.19, rev: 0.5 }));
}
ui(f) { this.tone({ type: 'square', f0: f || 880, dur: 0.05, gain: 0.09 }); }
splashSfx(pos) { this.noiseHit({ filter: 'lowpass', freq: 1600, sweep: 300, dur: 0.4, gain: 0.4, pos, rev: 0.25 }); }
footstep() { this.noiseHit({ filter: 'bandpass', freq: 420 + Math.random() * 200, q: 1.4, dur: 0.055, gain: 0.09 }); }
/* continuous controllers */
setEngine(rpm01, load, active) {
if (!this.ready) return;
const t = this.ctx.currentTime;
const f = 38 + rpm01 * 118;
this.engA.frequency.setTargetAtTime(f, t, 0.05);
this.engB.frequency.setTargetAtTime(f * 1.005, t, 0.05);
this.engSub.frequency.setTargetAtTime(f * 0.5, t, 0.06);
this.engFilt.frequency.setTargetAtTime(320 + rpm01 * 2200 + load * 900, t, 0.06);
this.engGain.gain.setTargetAtTime(active ? 0.045 + load * 0.11 + rpm01 * 0.05 : 0.0, t, 0.12);
}
setSkid(v) { if (this.ready) this.skidGain.gain.setTargetAtTime(VC.util.clamp(v, 0, 1) * 0.22, this.ctx.currentTime, 0.06); }
setTraffic(v) { if (this.ready) this.trafficGain.gain.setTargetAtTime(VC.util.clamp(v, 0, 1) * 0.09, this.ctx.currentTime, 0.2); }
setInsideCar(v) {
if (!this.ready) return;
this.insideCar = v;
const t = this.ctx.currentTime;
this.musicFilter.frequency.setTargetAtTime(v ? 2600 : 18000, t, 0.4);
this.musicHP.frequency.setTargetAtTime(v ? 220 : 24, t, 0.4);
this.sfx.gain.setTargetAtTime(v ? 0.52 : 0.85, t, 0.3);
}
setMusicVolume(v) {
this.volume = v;
if (this.ready) this.music.gain.setTargetAtTime(v * 0.34 * this.volume, this.ctx.currentTime, 0.3);
}
/* ------------------------------------------------------------ sequencer */
_initSeq() {
this.bpm = 108;
this.step = 0;
this.nextTime = this.ctx.currentTime + 0.1;
this.seqTimer = setInterval(() => this._pump(), 25);
}
setStation(i, silent) {
this.station = ((i % STATIONS.length) + STATIONS.length) % STATIONS.length;
this.song = STATIONS[this.station];
this.bpm = this.song.bpm;
this.step = 0;
if (!silent) {
this.noiseHit({ filter: 'bandpass', freq: 2400, q: 0.6, dur: 0.28, gain: 0.3 });
this.tone({ type: 'sawtooth', f0: 900, f1: 220, dur: 0.2, gain: 0.1 });
}
if (this.ready) this.music.gain.setTargetAtTime(0.32 * this.volume, this.ctx.currentTime, 0.25);
return this.song;
}
_mtof(m) { return 440 * Math.pow(2, (m - 69) / 12); }
_pump() {
if (!this.ready || !this.enabled || !this.song) return;
const ctx = this.ctx;
const spb = 60 / this.bpm / 4; /* seconds per 16th */
while (this.nextTime < ctx.currentTime + 0.14) {
this._tick(this.step, this.nextTime);
this.step = (this.step + 1) % (16 * 32);
this.nextTime += spb;
}
}
/* a single synthwave voice helpers */
_mVoice(t, freq, dur, o) {
const ctx = this.ctx;
const g = ctx.createGain();
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(o.cut0 || 700, t);
f.frequency.exponentialRampToValueAtTime(o.cut1 || 3200, t + (o.cutT || dur * 0.5));
f.Q.value = o.q || 1;
const oscs = [];
for (let i = 0; i < (o.voices || 1); i++) {
const osc = ctx.createOscillator();
osc.type = o.type || 'sawtooth';
osc.frequency.setValueAtTime(freq * Math.pow(2, (o.det || 0) * (i - ((o.voices || 1) - 1) / 2) / 1200), t);
osc.connect(f); osc.start(t); osc.stop(t + dur + 0.3);
oscs.push(osc);
}
const peak = (o.gain || 0.12) / Math.max(1, o.voices || 1);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + (o.atk || 0.01));
g.gain.setValueAtTime(peak, t + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur + (o.rel || 0.12));
f.connect(g); g.connect(this.music);
if (o.echo) { const s = ctx.createGain(); s.gain.value = o.echo; g.connect(s).connect(this.echoIn); }
if (o.rev) { const s2 = ctx.createGain(); s2.gain.value = o.rev; g.connect(s2).connect(this.revGain); }
}
_kick(t, g) {
const ctx = this.ctx;
const o = ctx.createOscillator(), gg = ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(148, t);
o.frequency.exponentialRampToValueAtTime(41, t + 0.1);
gg.gain.setValueAtTime(0.0001, t);
gg.gain.exponentialRampToValueAtTime(g || 0.9, t + 0.005);
gg.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
o.connect(gg).connect(this.music); o.start(t); o.stop(t + 0.34);
this._mNoise(t, 0.02, 900, 0.18, 0);
}
_snare(t, g) {
this._mNoise(t, 0.16, 1750, g || 0.4, 0.22);
const ctx = this.ctx, o = ctx.createOscillator(), gg = ctx.createGain();
o.type = 'triangle'; o.frequency.setValueAtTime(210, t);
o.frequency.exponentialRampToValueAtTime(120, t + 0.1);
gg.gain.setValueAtTime(0.0001, t);
gg.gain.exponentialRampToValueAtTime(0.22 * (g || 1), t + 0.005);
gg.gain.exponentialRampToValueAtTime(0.0001, t + 0.13);
o.connect(gg).connect(this.music); o.start(t); o.stop(t + 0.16);
}
_hat(t, open, g) {
this._mNoise(t, open ? 0.19 : 0.045, 8200, (g || 0.16) * (open ? 0.8 : 1), open ? 0.2 : 0.06);
}
_mNoise(t, dur, freq, g, rev) {
const ctx = this.ctx;
const s = ctx.createBufferSource();
s.buffer = this._noiseBuf || (this._noiseBuf = this._noise(1.2));
s.playbackRate.value = 1;
const f = ctx.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = freq;
const gg = ctx.createGain();
gg.gain.setValueAtTime(0.0001, t);
gg.gain.exponentialRampToValueAtTime(g, t + 0.004);
gg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f).connect(gg).connect(this.music);
if (rev) { const r = ctx.createGain(); r.gain.value = rev; gg.connect(r).connect(this.revGain); }
s.start(t, Math.random() * 0.5); s.stop(t + dur + 0.05);
}
_tick(s, t) {
const song = this.song;
const bar = Math.floor(s / 16) % 16;
const st = s % 16;
const chordIdx = song.progression[bar % song.progression.length];
const chord = CHORDS[chordIdx];
const beat = st % 4 === 0;
/* ---- drums */
const D = song.drums;
if (D.kick[st] && (bar % (song.kickEveryBar || 1) !== 3 || st % 8 !== 0)) this._kick(t, D.kickG || 0.85);
const sn = D.snare[st];
if (sn) this._snare(t, (D.snareG || 0.42) * (sn === 3 ? 1.3 : 1));
if (D.hat[st]) this._hat(t, !!(D.hatOpen && D.hatOpen[st]), D.hatG || 0.13);
if (song.fill && bar === 15 && st >= 12) this._snare(t, 0.2 + (st - 12) * 0.08);
/* ---- bass */
if (song.bassPat[st]) {
const oct = song.bassOct || 33;
const deg = song.bassPat[st] === 2 ? chord[1] : chord[0];
const note = oct + deg;
this._mVoice(t, this._mtof(note - 12), (60 / this.bpm / 4) * (song.bassLen || 1.7), {
type: song.bassType || 'sawtooth', voices: 2, det: 9, gain: song.bassGain || 0.34,
cut0: 220, cut1: 900 + (song.bassCut || 500) * (1 + Math.sin(bar * 0.7)), cutT: 0.09, q: 6, atk: 0.006, rel: 0.06,
});
}
/* ---- pad: hold the chord across the bar */
if (st === 0 && song.padGain) {
for (const deg of chord) {
this._mVoice(t, this._mtof(60 + deg), (60 / this.bpm) * 4 * 0.96, {
type: 'sawtooth', voices: 3, det: 14, gain: song.padGain,
cut0: 900, cut1: 2100, cutT: 1.6, q: 0.8, atk: 0.42, rel: 0.8, rev: 0.5,
});
}
}
/* ---- arpeggio / lead */
const mel = song.melody && song.melody[bar % song.melody.length];
if (mel) {
const cell = mel[st];
if (cell !== null && cell !== undefined) {
const root = 60 + chord[0];
this._mVoice(t, this._mtof(root + cell), (60 / this.bpm / 4) * (song.leadLen || 1.4), {
type: song.leadType || 'square', voices: song.leadVoices || 1, det: 8,
gain: song.leadGain || 0.11, cut0: 1400, cut1: 5200, cutT: 0.05, q: 1.2,
atk: 0.008, rel: 0.1, echo: song.leadEcho === undefined ? 0.45 : song.leadEcho, rev: 0.16,
});
}
}
/* ---- sparkle arp on the off-beats for the dreamy station */
if (song.arp && st % 2 === 0) {
const n = chord[(st / 2) % chord.length];
this._mVoice(t, this._mtof(72 + n), (60 / this.bpm / 4) * 0.9, {
type: 'triangle', voices: 1, gain: song.arpGain || 0.09, cut0: 2600, cut1: 6000,
cutT: 0.04, atk: 0.004, rel: 0.1, echo: 0.5, rev: 0.3,
});
}
}
});
/* ----------------------------------------------------------- song data --- */
const CHORDS = {
/* semitone offsets from the root, minor / major / 7ths */
m: [0, 3, 7], M: [0, 4, 7], maj7: [0, 4, 7, 11], min7: [0, 3, 7, 10],
sus: [0, 5, 7], m6: [0, 3, 7, 9], dim: [0, 3, 6], add9: [0, 4, 7, 14],
M5: [0, 4, 7, 12], m5: [0, 3, 7, 12], power: [0, 7, 12],
};
const X = null;
const P16 = (str) => {
/* "x---x---x---x--o" style pattern parser */
const out = [];
for (let i = 0; i < 16; i++) {
const c = str[i] || '-';
out.push(c === 'x' ? 1 : c === 'o' ? 2 : c === 's' ? 3 : 0);
}
return out;
};
/* melody cells are scale offsets from the chord root, 16 sixteenths per bar */
const STATIONS = [
{ /* FLASH FM - driving retrowave */
name: 'FLASH FM', genre: 'RETROWAVE', bpm: 108,
progression: ['m', 'M', 'maj7', 'm', 'm', 'M', 'sus', 'm5'],
bassOct: 33, bassPat: P16('x-x-x-x-x-x-x-x-'), bassLen: 1.6, bassGain: 0.3, bassCut: 700,
drums: { kick: P16('x---x-x-x---x-x-'), snare: P16('--x--s----x--s--'), hat: P16('x-x-x-x-x-x-x-x-'), hatOpen: P16('0-0-0-0-0-0-x---'), hatG: 0.11 },
padGain: 0.075,
melody: [
[12, X, X, 15, X, 14, X, 12, X, X, 10, X, 12, X, X, X],
[7, X, 12, X, X, 11, X, X, 12, X, X, 11, X, X, 7, X],
[11, X, X, 14, X, X, 16, X, 14, X, 11, X, X, X, X, X],
[12, X, X, X, 10, X, X, 7, X, X, 10, X, 12, X, X, X],
],
leadType: 'square', leadGain: 0.1, leadEcho: 0.5, leadLen: 1.6,
},
{ /* WAVE 103 - dreamychill */
name: 'WAVE 103', genre: 'CHILLWAVE', bpm: 94,
progression: ['maj7', 'min7', 'M', 'min7', 'maj7', 'sus', 'm6', 'M'],
bassOct: 31, bassPat: P16('x-------x-------'), bassLen: 6.5, bassGain: 0.26, bassType: 'triangle', bassCut: 300,
drums: { kick: P16('x-------x-------'), snare: P16('----x-------x---'), snareG: 0.24, hat: P16('--x---x---x---x-'), hatG: 0.06 },
padGain: 0.1, arp: true, arpGain: 0.075,
melody: [
[X, X, 16, X, X, X, 14, X, X, X, 11, X, X, X, X, X],
[12, X, X, X, X, X, 14, X, X, X, X, X, 11, X, X, X],
[X, X, X, X, 16, X, X, X, 17, X, X, X, 16, X, X, X],
],
leadType: 'triangle', leadGain: 0.11, leadEcho: 0.62, leadLen: 2.6, leadVoices: 2,
fill: false,
},
{ /* V-ROCK - guitar-ish energy */
name: 'V-ROCK', genre: 'METRO-ROCK', bpm: 128,
progression: ['power', 'power', 'M5', 'power', 'm5', 'power', 'M5', 'M'],
bassOct: 33, bassPat: P16('x-x-x-x-x-x-x-x-'), bassLen: 1.0, bassGain: 0.32, bassType: 'sawtooth', bassCut: 1100,
drums: { kick: P16('x---x---x-x-----'), snare: P16('----x-------x-x-'), hat: P16('x-x-x-x-x-x-x-x-'), hatG: 0.12 },
padGain: 0.055,
melody: [
[12, 12, X, 15, X, 12, X, X, 17, X, 15, X, 12, X, X, X],
[X, 19, X, 17, X, 15, X, 14, X, X, X, X, 12, X, X, X],
[15, X, 17, X, 19, X, X, 22, X, 19, X, 17, X, X, X, X],
],
leadType: 'sawtooth', leadGain: 0.115, leadEcho: 0.3, leadLen: 1.2, leadVoices: 2,
fill: true,
},
];
VC.STATIONS = STATIONS;
})();
/*! ---- js/city.js ---- */
/* ============================================================================
* city.js - the island: beach, road network, sidewalks, art-deco blocks with
* lit storefronts, neon signage, palms and street furniture. Everything is
* generated once at load time from a seeded RNG and merged into a handful of
* meshes. Also owns the collision world and the ground-height function.
* ==========================================================================*/
(function () {
const U = VC.util;
const CELL = 64; /* road pitch */
const ROAD = 15; /* carriageway width (2 lanes both ways) */
const BLOCK = CELL - ROAD; /* 49 */
const SIDE = 3.7; /* sidewalk width */
const IW = BLOCK - 2 * SIDE; /* 41.6 buildable interior */
const GROUND = 1.44; /* street level */
const WALK = 1.60; /* sidewalk top */
const BEACH = 46; /* sand width beyond the island edge */
const IX0 = -6 * CELL - ROAD / 2, IX1 = 6 * CELL + ROAD / 2;
const IZ0 = -5 * CELL - ROAD / 2, IZ1 = 5 * CELL + ROAD / 2;
VC.CITY = { CELL, ROAD, BLOCK, SIDE, IW, GROUND, WALK, IX0, IX1, IZ0, IZ1, BEACH };
/* distance outside the island rectangle (0 when inside) */
function rectOut(x, z) {
const dx = Math.max(IX0 - x, x - IX1, 0);
const dz = Math.max(IZ0 - z, z - IZ1, 0);
return Math.hypot(dx, dz);
}
VC.City = class City {
constructor(scene) {
VC.rng.seed(0x51ce7);
this.scene = scene;
this.world = new VC.World(26);
this.roadX = []; for (let i = -6; i <= 6; i++) this.roadX.push(i * CELL);
this.roadZ = []; for (let j = -5; j <= 5; j++) this.roadZ.push(j * CELL);
this.blocks = [];
this.mats = {};
this.signs = [];
this.lamps = [];
this.buildings = [];
this.palms = [];
this.benches = [];
this.crowns = [];
this.beacons = [];
this.dumpsters = [];
this.parkingLots = [];
this.signMeshes = [];
this.neonMeshes = [];
this.palmMeshes = [];
this.palmSwayUniforms = [];
this.merged = {};
this.groundMeshes = [];
this._buf = {};
this._B = (k) => (this._buf[k] = this._buf[k] || []);
this.buildMaterials();
this.buildTerrain();
this.buildRoads();
this.buildBlocks();
this.buildSigns();
this.merge();
this.buildPalms();
this.buildProps();
this.buildWaterEdge();
}
/* ---------------------------------------------------------------------- */
get waterRect() {
const s = 41; /* where the sea meets the sand */
return new THREE.Vector4(IX0 - s, IZ0 - s, IX1 + s, IZ1 + s);
}
/* street / sidewalk / sand height under a point */
groundH(x, z) {
const d = rectOut(x, z);
if (d > 0.0) {
const t = U.clamp01(d / BEACH);
const n = U.smoothNoise(x * 0.04 + 3.1) * 0.24 + U.smoothNoise(z * 0.031) * 0.2;
return U.lerp(GROUND + 0.02, -1.6, Math.pow(t, 0.85)) + n * U.smoothstep(1.5, 12, d) * 0.9 - 0.55 * t;
}
/* sidewalk slab: inside the block cell (aligned to the grid) */
const lx = x - Math.floor((x + ROAD / 2) / CELL) * CELL - ROAD / 2;
const lz = z - Math.floor((z + ROAD / 2) / CELL) * CELL - ROAD / 2;
const onBlock = lx > 0 && lx < BLOCK && lz > 0 && lz < BLOCK;
if (!onBlock) return GROUND;
/* smooth curb ramp */
const ex = Math.min(lx, BLOCK - lx), ez = Math.min(lz, BLOCK - lz);
const e = Math.min(ex, ez);
return U.lerp(GROUND, WALK, U.smoothstep(0.0, 1.1, e));
}
isDeepWater(x, z) {
return this.groundH(x, z) < -0.42;
}
/* --------------------------------------------------------------------- */
buildMaterials() {
const M = this.mats;
const env = VC.sky.envMap;
const facadeOpts = (i) => {
const f = VC.tex.facade(i);
return {
map: f.map, emissiveMap: f.emissive, emissive: new THREE.Color(0xffffff),
/* the facade maps are pale pastels; a sub-1 material colour keeps
* them this side of the tonemapper's shoulder so the hue survives */
color: new THREE.Color(0.72, 0.68, 0.71),
emissiveIntensity: 1.15, roughness: 0.88, metalness: 0.03,
vertexColors: true, envMap: env, envMapIntensity: 0.55,
};
};
M.facade = [0, 1, 2, 3, 4].map((i) => new THREE.MeshStandardMaterial(facadeOpts(i)));
const sf = VC.tex.storefront();
M.store = new THREE.MeshStandardMaterial({
map: sf.map, emissiveMap: sf.emissive, emissive: new THREE.Color(0xffffff),
color: new THREE.Color(0.78, 0.74, 0.76),
emissiveIntensity: 1.25, roughness: 0.42, metalness: 0.25, vertexColors: true,
envMap: env, envMapIntensity: 0.9,
});
M.roof = new THREE.MeshStandardMaterial({ map: VC.tex.roof(), roughness: 0.95, vertexColors: true, envMap: env, envMapIntensity: 0.3 });
M.concrete = new THREE.MeshStandardMaterial({ map: VC.tex.concrete(), color: 0x8e8a8c, roughness: 0.9, metalness: 0.02, vertexColors: true, envMap: env, envMapIntensity: 0.25 });
M.asphalt = new THREE.MeshStandardMaterial({ map: VC.tex.asphalt(), roughness: 0.86, metalness: 0.06, vertexColors: true, envMap: env, envMapIntensity: 0.5 });
M.mark = new THREE.MeshStandardMaterial({ color: 0x9c9892, roughness: 0.7, metalness: 0.04, vertexColors: true, envMap: env, envMapIntensity: 0.3 });
M.sand = new THREE.MeshStandardMaterial({ map: VC.tex.sand(), color: 0x8f8880, roughness: 1.0, metalness: 0, vertexColors: true, envMap: env, envMapIntensity: 0.2 });
M.prop = new THREE.MeshStandardMaterial({ roughness: 0.55, metalness: 0.45, vertexColors: true, envMap: env, envMapIntensity: 0.8 });
M.trim = new THREE.MeshStandardMaterial({ color: 0x8f888c, roughness: 0.5, metalness: 0.05, vertexColors: true, envMap: env, envMapIntensity: 0.6 });
M.grass = new THREE.MeshStandardMaterial({ color: 0x2f6b32, roughness: 1, vertexColors: true });
M.neonAdd = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide });
M.glow = new THREE.SpriteMaterial({ map: VC.tex.glow('#ffd9a0'), color: 0xffffff, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
}
/* ------------------------------------------------------------- terrain */
buildTerrain() {
const step = 13;
const x0 = IX0 - BEACH - 20, x1 = IX1 + BEACH + 20;
const z0 = IZ0 - BEACH - 20, z1 = IZ1 + BEACH + 20;
const wet = new THREE.Color(0xbfa973), dry = new THREE.Color(0xfff0cf);
for (let x = x0; x < x1; x += step) {
for (let z = z0; z < z1; z += step) {
/* skip quads fully inside the built island */
const inside = (px, pz) => rectOut(px, pz) <= 0;
if (inside(x, z) && inside(x + step, z) && inside(x, z + step) && inside(x + step, z + step)) continue;
const h = (px, pz) => {
const d = rectOut(px, pz);
const t = U.clamp01(d / BEACH);
const n = U.smoothNoise(px * 0.04 + 3.1) * 0.24 + U.smoothNoise(pz * 0.031) * 0.2;
return U.lerp(GROUND + 0.02, -1.6, Math.pow(t, 0.85)) + n * U.smoothstep(1.5, 12, d) * 0.9 - 0.55 * t;
};
const y = (h(x, z) + h(x + step, z) + h(x, z + step) + h(x + step, z + step)) / 4;
const k = U.clamp01(1 - (y - -0.2) / 1.4);
const c = dry.clone().lerp(wet, k * 0.8);
this._B('sand').push(VC.geo.floor(x, z, x + step, z + step, y, 5.5, [c.r, c.g, c.b], true));
}
}
/* seawall lip around the island */
const wall = 0.34, wcol = [0.72, 0.68, 0.66];
const seg = (x0_, z0_, x1_, z1_) => {
this._B('trim').push(...VC.geo.boxSides((x0_ + x1_) / 2, (z0_ + z1_) / 2, Math.abs(x1_ - x0_) || 1, Math.abs(z1_ - z0_) || 1, GROUND - wall, GROUND + 0.06, 0.1, wcol));
};
seg(IX0 - 0.4, IZ0 - 0.4, IX1 + 0.4, IZ0); seg(IX0 - 0.4, IZ1, IX1 + 0.4, IZ1 + 0.4);
seg(IX0 - 0.4, IZ0, IX0, IZ1); seg(IX1, IZ0, IX1 + 0.4, IZ1);
}
/* ---------------------------------------------------------------- roads */
buildRoads() {
const A = this._B('asphalt'), Mk = this._B('mark');
const asphaltCol = [0.95, 0.95, 1.03];
const half = ROAD / 2;
/* horizontal roads (along x) between intersections + intersection pads */
for (const z of this.roadZ) {
for (let i = -6; i < 6; i++) {
const x0 = i * CELL + half, x1 = (i + 1) * CELL - half;
A.push(VC.geo.floor(x0, z - half, x1, z + half, GROUND, 6, asphaltCol, true));
/* white edge lines */
Mk.push(VC.geo.floor(x0, z - half + 0.28, x1, z - half + 0.58, GROUND + 0.012, 1, [1, .97, .9], true));
Mk.push(VC.geo.floor(x0, z + half - 0.58, x1, z + half - 0.28, GROUND + 0.012, 1, [1, .97, .9], true));
/* dashed centre (double yellow) */
for (let x = x0; x < x1 - 3; x += 7.5) {
Mk.push(VC.geo.floor(x, z - 0.34, x + 3.4, z - 0.12, GROUND + 0.012, 1, [1, .82, .25], true));
Mk.push(VC.geo.floor(x, z + 0.12, x + 3.4, z + 0.34, GROUND + 0.012, 1, [1, .82, .25], true));
}
}
}
for (const x of this.roadX) {
for (let j = -5; j < 5; j++) {
const z0 = j * CELL + half, z1 = (j + 1) * CELL - half;
A.push(VC.geo.floor(x - half, z0, x + half, z1, GROUND, 6, asphaltCol, true));
Mk.push(VC.geo.floor(x - half + 0.28, z0, x - half + 0.58, z1, GROUND + 0.012, 1, [1, .97, .9], true));
Mk.push(VC.geo.floor(x + half - 0.58, z0, x + half - 0.28, z1, GROUND + 0.012, 1, [1, .97, .9], true));
for (let z = z0; z < z1 - 3; z += 7.5) {
Mk.push(VC.geo.floor(x - 0.34, z, x - 0.12, z + 3.4, GROUND + 0.012, 1, [1, .82, .25], true));
Mk.push(VC.geo.floor(x + 0.12, z, x + 0.34, z + 3.4, GROUND + 0.012, 1, [1, .82, .25], true));
}
}
}
/* intersection pads + crosswalks */
for (const x of this.roadX) {
for (const z of this.roadZ) {
A.push(VC.geo.floor(x - half, z - half, x + half, z + half, GROUND, 6, [0.8, 0.8, 0.87], true));
for (const arm of [[0, -1], [0, 1], [-1, 0], [1, 0]]) {
const nx = arm[0], nz = arm[1];
const bx = x + nx * (half - 1.2), bz = z + nz * (half - 1.2);
for (let k = -3; k <= 3; k++) {
const off = k * 1.9;
const cx = nx === 0 ? off : 0, cz = nz === 0 ? off : 0;
const w = nx === 0 ? 0.9 : 2.4, d = nx === 0 ? 2.4 : 0.9;
Mk.push(VC.geo.floor(bx + cx - w / 2 - (nx === 0 ? 0 : 0), bz + cz - d / 2,
bx + cx + w / 2, bz + cz + d / 2, GROUND + 0.014, 1, [1, .98, .93], true));
}
}
}
}
}
/* --------------------------------------------------------------- blocks */
buildBlocks() {
const neonWords = [
['VICE', '#ff2f8e'], ['CLUB', '#22e8ff'], ['HOTEL', '#ffd23f'], ['MOTEL', '#ff6a3d'],
['OCEAN', '#37fff0'], ['PIZZA', '#ff4444'], ['DINER', '#8dff5a'], ['CASINO', '#ff9de2'],
['BEACH', '#ffd23f'], ['APART', '#9d7dff'], ['SUNSET', '#ff5ea8'], ['NEON', '#22e8ff'],
['BAHAMA', '#2fe0a0'], ['PARADISE', '#ffca6b'], ['STAR', '#fff36b'], ['BLUE', '#4f9dff'],
];
this.neonWords = neonWords;
for (let i = -6; i <= 5; i++) {
for (let j = -5; j <= 4; j++) {
const bx = (i + 0.5) * CELL, bz = (j + 0.5) * CELL;
const x0 = i * CELL + ROAD / 2, z0 = j * CELL + ROAD / 2;
const b = { i, j, cx: bx, cz: bz, x0, z0, x1: x0 + BLOCK, z1: z0 + BLOCK, kind: 'built' };
const dc = Math.hypot(i * CELL + CELL / 2, j * CELL + CELL / 2) / CELL; /* downtown-ness */
const r = VC.rng.f();
if (r < 0.09 && dc > 1.6) b.kind = 'park';
else if (r < 0.17) b.kind = 'lot';
else if (r < 0.21) b.kind = 'vacant';
this.blocks.push(b);
this.buildBlock(b, dc);
}
}
}
buildBlock(b, downtown) {
const Sid = this._B('concrete');
/* sidewalk slab + curbs */
const tint = b.kind === 'park' ? [0.55, 0.75, 0.5] : [1, 1, 1];
Sid.push(VC.geo.floor(b.x0, b.z0, b.x1, b.z1, WALK, 4.2, tint, true));
Sid.push(...VC.geo.boxSides(b.cx, b.cz, BLOCK, BLOCK, GROUND, WALK, 0.24, [0.95, 0.92, 0.9]));
if (b.kind === 'park') { this.makePark(b); return; }
if (b.kind === 'lot') { this.makeLot(b); return; }
if (b.kind === 'vacant') { this.makeVacant(b); return; }
/* -------- built block: a row of towers on each side ---------------- */
const inset = SIDE;
const ax0 = b.x0 + inset, ax1 = b.x1 - inset;
const az0 = b.z0 + inset, az1 = b.z1 - inset;
const depth = VC.rng.r(12, 17); /* building depth from the street */
const sides = [
{ axis: 'z', at: az0, dir: -1 }, /* north edge, faces -z */
{ axis: 'z', at: az1, dir: 1 },
{ axis: 'x', at: ax0, dir: -1 },
{ axis: 'x', at: ax1, dir: 1 },
];
for (const s of sides) {
const span = (s.axis === 'z' ? ax1 - ax0 : az1 - az0) - 2;
let p = (s.axis === 'z' ? ax0 : az0) + 1;
const end = p + span;
while (p < end - 7) {
let w = VC.rng.r(9, Math.min(17, end - p));
if (end - p - w < 8) w = end - p;
if (w < 7) break;
const hBase = VC.rng.r(11, 22) + Math.max(0, 5.5 - downtown) * 5 + (VC.rng.chance(0.16) ? VC.rng.r(8, 26) : 0);
const h = U.clamp(hBase * (downtown < 2 ? VC.rng.r(1.0, 1.9) : VC.rng.r(0.85, 1.15)), 9, 118);
/* sit the box just inside its street frontage (inward from the
* block edge, never over the pavement) */
const off = depth / 2 + 0.15;
const BX = s.axis === 'z' ? p + w / 2 : (s.at - s.dir * off);
const BZ = s.axis === 'z' ? (s.at - s.dir * off) : p + w / 2;
this.makeBuilding(BX, BZ, s.axis === 'z' ? w : depth, s.axis === 'z' ? depth : w, h, downtown, b);
p += w + VC.rng.r(0.5, 2.4);
}
}
/* interior block filler: one deep low warehouse behind the fronts */
if (VC.rng.chance(0.4)) {
const w = (ax1 - ax0) * VC.rng.r(0.25, 0.5), d = (az1 - az0) * VC.rng.r(0.25, 0.5);
this.makeBuilding(VC.rng.r(ax0 + w / 2, ax1 - w / 2), VC.rng.r(az0 + d / 2, az1 - d / 2),
w, d, VC.rng.r(8, 20), downtown + 3, b, true);
}
}
/* one art-deco tower: storefront podium + stacked upper floors + roof junk */
makeBuilding(cx, cz, w, d, h, downtown, block, plain) {
const rng = VC.rng;
const world = this.world;
world.add(cx - w / 2, cz - d / 2, cx + w / 2, cz + d / 2, h, 'building');
const fi = rng.i(0, 4);
const facade = this._B('fac' + fi);
const store = this._B('store');
const trim = this._B('trim');
const roof = this._B('roof');
const tintHex = rng.pick([0xffffff, 0xffe7d8, 0xf7ffe9, 0xe9f4ff, 0xffeaf6, 0xfff6d8, 0xe4fff4]);
const t = new THREE.Color(tintHex);
const col = [t.r, t.g, t.b];
const dark = [t.r * 0.6, t.g * 0.6, t.b * 0.62];
/* podium */
const ph = rng.r(4.0, 5.6);
store.push(...VC.geo.boxSides(cx, cz, w + 0.3, d + 0.3, WALK, WALK + ph, 1 / 16, [1, 1, 1]));
trim.push(...VC.geo.boxSides(cx, cz, w + 0.75, d + 0.75, WALK + ph - 0.45, WALK + ph, 0.2, [1, .98, .95]));
roof.push(VC.geo.roofTop(cx, cz, w + 0.3, d + 0.3, WALK + ph, 0.22, 0.6));
/* upper storeys, with art-deco setbacks */
let y = WALK + ph;
let cw = w, cd = d, left = h - ph;
let steps = 0;
while (left > 2 && steps < 5) {
const seg = Math.min(left, rng.r(9, 22));
const sCol = steps === 0 ? col : [col[0] * 0.97, col[1] * 0.97, col[2] * 0.98];
facade.push(...VC.geo.boxSides(cx, cz, cw, cd, y, y + seg, 1 / 15.5, sCol));
/* cornice band on top of every setback */
trim.push(...VC.geo.boxSides(cx, cz, cw + 0.55, cd + 0.55, y + seg - rng.r(0.5, 1.2), y + seg, 0.2, [1, .99, .96]));
y += seg; left -= seg; steps++;
if (left > 2 && (rng.chance(0.6) || steps === 1)) {
const k = rng.r(0.68, 0.88);
cw *= k; cd *= k;
/* keep the tower on the street line when it is a corner block */
}
}
roof.push(VC.geo.roofTop(cx, cz, cw, cd, y, 0.22, 1));
/* Vertical deco fins. Only on single-shaft towers: on a setback tower the
* shaft narrows, and fins glued to the wide lower face would read as
* slabs floating in the sky above the roofline. */
if (h > 26 && steps === 1) {
const fy = WALK + ph, fh = Math.max(4, h - ph);
const fins = Math.max(2, Math.round(w / 4.5));
for (let k = 0; k < fins; k++) {
const fx = cx - w / 2 + (k + 0.5) * (w / fins);
trim.push(...VC.geo.box(fx, fy + fh / 2, cz - d / 2 - 0.25, 0.7, fh, 0.5, [1, .98, .95], 0.5));
trim.push(...VC.geo.box(fx, fy + fh / 2, cz + d / 2 + 0.25, 0.7, fh, 0.5, [1, .98, .95], 0.5));
}
}
/* parapet */
trim.push(...VC.geo.boxSides(cx, cz, cw, cd, y, y + rng.r(0.6, 1.3), 0.2, [1, .97, .94]));
/* rooftop clutter */
const rw = Math.min(cw, cd);
if (rng.chance(0.7) && rw > 7) {
const tw = rng.r(2.2, 3.4);
const tx = cx + rng.r(-cw / 2 + tw, cw / 2 - tw), tz = cz + rng.r(-cd / 2 + tw, cd / 2 - tw);
roof.push(...VC.geo.prism(tx, tz, y, y + rng.r(2.6, 4.2), tw * 0.55, 8, [0.62, 0.55, 0.5]));
trim.push(...VC.geo.box(tx, y + 0.35, tz, tw * 1.3, 0.7, tw * 1.3, [0.5, 0.47, 0.45], 0.4));
}
for (let k = 0; k < rng.i(1, 3); k++) {
if (rw < 6) break;
trim.push(...VC.geo.box(cx + rng.r(-cw / 3, cw / 3), y + 0.55, cz + rng.r(-cd / 3, cd / 3),
rng.r(1.4, 2.6), rng.r(0.7, 1.3), rng.r(1.4, 2.2), [0.72, 0.72, 0.74], 0.4));
}
/* antenna + aircraft beacon on the tall ones */
if (h > 45 && rng.chance(0.6)) {
const ah = rng.r(5, 13);
this._B('prop').push(...VC.geo.box(cx, y + ah / 2, cz, 0.3, ah, 0.3, [0.8, 0.8, 0.82], 0.3));
this.beacons.push([cx, y + ah + 0.4, cz]);
}
/* glowing roof crown on downtown towers */
if (h > 52 && rng.chance(0.75)) {
const ccol = rng.pick([0x28f0ff, 0xff3ba0, 0xffd84f, 0x8f6bff, 0x3dff9e]);
const th = rng.r(1.2, 3.0);
const g = VC.geo.boxSides(cx, cz, cw + 0.3, cd + 0.3, y + 0.15, y + th, 0.2, [1, 1, 1]);
this.crowns.push({ geos: g, color: ccol, y0: y + 0.15, y1: y + th });
}
/* storefront awnings over the pavement */
const awn = rng.pick([[1, 0.28, 0.42], [0.22, 0.85, 0.9], [1, 0.78, 0.25], [0.6, 1, 0.4], [1, 0.5, 0.2]]);
if (!plain && rng.chance(0.7)) {
for (const s of [[0, -1], [0, 1], [-1, 0], [1, 0]]) {
if (rng.chance(0.35)) continue;
const along = s[0] === 0 ? w : d;
const n = Math.max(1, Math.round(along / 7));
for (let k = 0; k < n; k++) {
const len = along / n - 0.8;
const px = s[0] === 0 ? cx + (-along / 2 + (k + 0.5) * (along / n)) : cx + s[0] * (w / 2 + 0.9);
const pz = s[0] === 0 ? cz + s[1] * (d / 2 + 0.9) : cz + (-along / 2 + (k + 0.5) * (along / n));
this._B('trim').push(...VC.geo.box(px, WALK + ph * 0.66, pz,
s[0] === 0 ? len : 0.25, 0.18, s[0] === 0 ? 0.25 : len, awn, 0.4));
}
}
}
/* register a neon sign slot facing the street */
this.buildings.push({ cx, cz, w, d, h, top: y });
}
makePark(b) {
const rng = VC.rng;
const g = this._B('grass');
g.push(VC.geo.floor(b.x0 + 0.6, b.z0 + 0.6, b.x1 - 0.6, b.z1 - 0.6, WALK + 0.04, 4, [0.8, 1, 0.7], true));
/* crossing paths */
this._B('concrete').push(VC.geo.floor(b.cx - 1.6, b.z0, b.cx + 1.6, b.z1, WALK + 0.06, 3, [1.02, 1, .96], true));
this._B('concrete').push(VC.geo.floor(b.x0, b.cz - 1.6, b.x1, b.cz + 1.6, WALK + 0.06, 3, [1.02, 1, .96], true));
/* fence */
const posts = [];
for (let t = 0; t <= 4; t++) {
posts.push([U.lerp(b.x0, b.x1, t / 4), b.z0], [U.lerp(b.x0, b.x1, t / 4), b.z1]);
posts.push([b.x0, U.lerp(b.z0, b.z1, t / 4)], [b.x1, U.lerp(b.z0, b.z1, t / 4)]);
}
for (const [px, pz] of posts) {
this._B('prop').push(...VC.geo.box(px, WALK + 0.6, pz, 0.14, 1.2, 0.14, [0.85, 0.86, 0.88], 0.3));
}
this.world.add(b.x0 - 0.2, b.z0 - 0.2, b.x1 + 0.2, b.z1 + 0.2, 0.001, 'visual');
for (let k = 0; k < rng.i(3, 7); k++) {
this.palms.push([rng.r(b.x0 + 4, b.x1 - 4), rng.r(b.z0 + 4, b.z1 - 4), rng.r(0.85, 1.25)]);
}
for (let k = 0; k < rng.i(2, 4); k++) this.benches.push([rng.r(b.x0 + 5, b.x1 - 5), rng.r(b.z0 + 5, b.z1 - 5), rng.r(0, 6.28)]);
/* the fence is decorative: peds and cars can cut through the park */
}
makeLot(b) {
const rng = VC.rng;
this._B('asphalt').push(VC.geo.floor(b.x0 + 0.5, b.z0 + 0.5, b.x1 - 0.5, b.z1 - 0.5, WALK + 0.03, 6, [0.78, 0.78, 0.85], true));
/* two rows of parking bays painted from the far edges inwards */
for (let s = 0; s < 2; s++) {
const zEdge = s === 0 ? b.z0 + 0.9 : b.z1 - 0.9;
const dirZ = s === 0 ? 1 : -1;
for (let x = b.x0 + 3; x < b.x1 - 3; x += 2.9) {
this._B('mark').push(VC.geo.floor(x, Math.min(zEdge, zEdge + dirZ * 5),
x + 0.12, Math.max(zEdge, zEdge + dirZ * 5), WALK + 0.045, 1, [1, .97, .9], true));
}
}
for (let k = 0; k < rng.i(2, 6); k++) {
this.palms.push([rng.r(b.x0 + 2, b.x1 - 2), rng.r(b.z0 + 2, b.z1 - 2), rng.r(0.8, 1.1)]);
}
/* a few parked cars live here (spawned by the traffic system) */
this.parkingLots.push([b.cx, b.cz, b.x0, b.z0, b.x1, b.z1]);
/* low wall on the street front for a bit of silhouette */
if (rng.chance(0.5)) {
this._B('trim').push(...VC.geo.boxSides(b.cx, b.z0 + 0.25, BLOCK - 6, 0.4, WALK, WALK + 1.1, 0.2, [0.94, 0.9, .86]));
this.world.add(b.x0 + 3, b.z0, b.x1 - 3, b.z0 + 0.6, WALK + 1.1, 'wall');
}
}
makeVacant(b) {
const rng = VC.rng;
this._B('asphalt').push(VC.geo.floor(b.x0 + 0.6, b.z0 + 0.6, b.x1 - 0.6, b.z1 - 0.6, WALK + 0.02, 6, [0.7, 0.7, 0.76], true));
/* chain-link-ish perimeter + rubble */
for (let k = 0; k < rng.i(6, 16); k++) {
const px = rng.r(b.x0 + 4, b.x1 - 4), pz = rng.r(b.z0 + 4, b.z1 - 4);
this._B('prop').push(...VC.geo.box(px, WALK + rng.r(0.2, 1.2), pz, rng.r(0.8, 2.4), rng.r(0.4, 2.2), rng.r(0.8, 2.2),
[rng.r(0.4, 0.7), rng.r(0.38, 0.6), rng.r(0.35, 0.55)], 0.4));
}
this._B('trim').push(...VC.geo.boxSides(b.cx, b.cz, BLOCK - 1.2, BLOCK - 1.2, WALK, WALK + 1.9, 0.2, [0.8, 0.82, 0.84]));
this.world.add(b.x0 + 0.6, b.z0 + 0.6, b.x1 - 0.6, b.z1 - 0.6, WALK + 1.9, 'fence');
}
/* ---------------------------------------------------------------- signs */
buildSigns() {
const rng = VC.rng;
const words = this.neonWords;
/* pick buildings with a street-facing wall and hang signage on them */
const pool = this.buildings;
for (let k = 0; k < pool.length; k++) {
const bd = pool[k];
if (bd.h < 12) continue;
if (!rng.chance(0.2)) continue;
const [word, color] = rng.pick(words);
const face = rng.i(0, 3);
const dirs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
const d = dirs[face];
const w = rng.r(3.2, 6.2), h = w * 0.5;
/* The tower steps inward above the first upper storey, so every sign
* must live inside the full-footprint band or it floats in mid air. */
const topSafe = WALK + Math.min(bd.h, 12.6) - 0.8;
const yLo = WALK + 5.4, yHi = Math.max(yLo + 0.4, topSafe - h / 2);
const y = rng.r(yLo, yHi);
const px = bd.cx + d[0] * (bd.w / 2 + 0.35);
const pz = bd.cz + d[1] * (bd.d / 2 + 0.35);
const rot = Math.atan2(d[0], d[1]);
this.addSign(px, y, pz, rot, w, h, word, color);
/* vertical blade sign on corner towers */
if (bd.h > 30 && rng.chance(0.35)) {
const bw = rng.r(1.8, 2.6);
const bh = Math.min(topSafe - (y + h / 2 + 1.2), rng.r(9, 20));
if (bh > 6)
this.addSign(px + d[0] * 0.05, y + h / 2 + 1.2 + bh / 2, pz + d[1] * 0.05, rot + Math.PI / 2, bw, bh,
rng.pick(['HOTEL', 'MOTEL', 'CLUB', 'VICE', 'CASINO', 'BAR']), rng.pick(['#ff2f8e', '#22e8ff', '#ffd23f', '#7cffc4']));
}
}
/* rooftop crowns as glowing meshes */
for (const c of this.crowns) {
const merged = VC.mergeGeoms(c.geos);
const m = new THREE.MeshBasicMaterial({ color: new THREE.Color(c.color).multiplyScalar(1.7), toneMapped: true });
const mesh = new THREE.Mesh(merged, m);
this.scene.add(mesh);
this.neonMeshes.push(mesh);
}
/* beacons */
if (this.beacons.length) {
const g = new THREE.SphereGeometry(0.55, 6, 5);
const m = new THREE.MeshBasicMaterial({ color: new THREE.Color(1.2, 0.05, 0.05) });
const inst = new THREE.InstancedMesh(g, m, this.beacons.length);
const mtx = new THREE.Matrix4();
this.beacons.forEach((p, i) => { mtx.makeTranslation(p[0], p[1], p[2]); inst.setMatrixAt(i, mtx); });
inst.instanceMatrix.needsUpdate = true;
this.scene.add(inst);
this.beaconMesh = inst;
}
}
addSign(x, y, z, rotY, w, h, text, color) {
const tex = VC.tex.neon(text, color);
/* normal (not additive) blending: hundreds of overlapping additive
* quads would fog the whole screen. Bright colour + bloom does the rest. */
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
toneMapped: false, color: new THREE.Color(1.25, 1.15, 1.28),
});
const geo = new THREE.PlaneGeometry(w, h);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
mesh.rotation.y = rotY;
this.scene.add(mesh);
this.signMeshes.push(mesh);
this.signs.push({ x, y, z, w, h, text, color, mesh });
}
/* --------------------------------------------------------------- merge */
merge() {
const map = {
roof: 'roof', concrete: 'concrete', asphalt: 'asphalt', mark: 'mark',
sand: 'sand', prop: 'prop', trim: 'trim', grass: 'grass', store: 'store',
};
for (let i = 0; i < 5; i++) map['fac' + i] = 'facade';
const castRecv = { roof: [false, true], concrete: [false, true], asphalt: [false, true], mark: [false, false], sand: [false, true], prop: [true, true], trim: [true, true], grass: [false, true], store: [true, true] };
for (const key in this._buf) {
const list = this._buf[key];
if (!list || !list.length) continue;
const flat = [];
for (const g of list) if (Array.isArray(g)) for (const gg of g) flat.push(gg); else flat.push(g);
const merged = VC.mergeGeoms(flat);
for (const g of flat) if (g !== merged) g.dispose();
const matKey = map[key] || key;
const mat = matKey === 'facade' ? this.mats.facade[i_of(key)] : this.mats[matKey];
const mesh = new THREE.Mesh(merged, mat);
mesh.castShadow = castRecv[key] ? castRecv[key][0] : true;
mesh.receiveShadow = castRecv[key] ? castRecv[key][1] : true;
mesh.name = key;
this.scene.add(mesh);
this.merged[key] = mesh;
if (key === 'sand' || key === 'asphalt' || key === 'concrete') this.groundMeshes.push(mesh);
}
function i_of(k) { return parseInt(k.slice(3), 10); }
}
/* ---------------------------------------------------------------- palms */
buildPalms() {
const rng = VC.rng;
/* street palms along every block edge + beach palms */
for (const b of this.blocks) {
if (b.kind === 'built' && rng.chance(0.55)) {
const n = rng.i(1, 3);
for (let k = 0; k < n; k++) {
const side = rng.i(0, 3);
const t = rng.r(0.15, 0.85);
let px, pz;
if (side === 0) { px = U.lerp(b.x0 + 3, b.x1 - 3, t); pz = b.z0 + 1.9; }
else if (side === 1) { px = U.lerp(b.x0 + 3, b.x1 - 3, t); pz = b.z1 - 1.9; }
else if (side === 2) { px = b.x0 + 1.9; pz = U.lerp(b.z0 + 3, b.z1 - 3, t); }
else { px = b.x1 - 1.9; pz = U.lerp(b.z0 + 3, b.z1 - 3, t); }
this.palms.push([px, pz, rng.r(0.8, 1.05)]);
}
}
}
/* beach + promenade palms */
for (let k = 0; k < 210; k++) {
const edge = rng.i(0, 3);
const t = rng.f();
let px, pz;
const band = rng.r(3, BEACH - 8);
if (edge === 0) { px = U.lerp(IX0 - 10, IX1 + 10, t); pz = IZ0 - band; }
else if (edge === 1) { px = U.lerp(IX0 - 10, IX1 + 10, t); pz = IZ1 + band; }
else if (edge === 2) { px = IX0 - band; pz = U.lerp(IZ0 - 10, IZ1 + 10, t); }
else { px = IX1 + band; pz = U.lerp(IZ0 - 10, IZ1 + 10, t); }
this.palms.push([px, pz, rng.r(0.85, 1.35)]);
}
/* three palm variants, instanced (trunk + fronds share transforms) */
const trunkMat = new THREE.MeshStandardMaterial({ map: VC.tex.bark(), roughness: 0.9, metalness: 0.0, envMap: VC.sky.envMap, envMapIntensity: 0.3 });
const frondMat = new THREE.MeshStandardMaterial({
map: VC.tex.frond(), alphaTest: 0.42, side: THREE.DoubleSide,
roughness: 0.75, metalness: 0.0, color: 0xffffff,
envMap: VC.sky.envMap, envMapIntensity: 0.45,
});
const variants = [];
for (let v = 0; v < 3; v++) variants.push(this.makePalmGeom());
const perVariant = [[], [], []];
this.palmXform = [];
for (const p of this.palms) {
const [px, pz, s] = p;
if (this.isDeepWater(px, pz)) continue;
const v = VC.rng.i(0, 2);
const rot = VC.rng.r(0, Math.PI * 2);
perVariant[v].push([px, pz, s, rot]);
}
this.palmMeshes = [];
for (let v = 0; v < 3; v++) {
const list = perVariant[v];
if (!list.length) continue;
const [trunkGeo, frondGeo, topH] = variants[v];
const it = new THREE.InstancedMesh(trunkGeo, trunkMat, list.length);
const ifs = new THREE.InstancedMesh(frondGeo, frondMat, list.length);
it.castShadow = ifs.castShadow = true;
it.receiveShadow = ifs.receiveShadow = true;
const mtx = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(), pos = new THREE.Vector3();
list.forEach((L, i) => {
const [px, pz, s, rot] = L;
pos.set(px, this.groundH(px, pz), pz);
q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), rot);
sc.set(s, s * VC.rng.r(0.9, 1.15), s);
mtx.compose(pos, q, sc);
it.setMatrixAt(i, mtx); ifs.setMatrixAt(i, mtx);
this.world.add(px - 0.55 * s, pz - 0.55 * s, px + 0.55 * s, pz + 0.55 * s, topH, 'prop');
});
it.instanceMatrix.needsUpdate = true; ifs.instanceMatrix.needsUpdate = true;
this.scene.add(it); this.scene.add(ifs);
this.palmMeshes.push(ifs);
this.palmMeshes.push(it);
}
/* wind sway via shader injection */
const sway = (mat, amount) => {
mat.onBeforeCompile = (sh) => {
sh.uniforms.uTime = { value: 0 };
this.palmSwayUniforms.push(sh.uniforms.uTime);
sh.vertexShader = 'uniform float uTime;\n' + sh.vertexShader;
sh.vertexShader = sh.vertexShader.replace('#include <begin_vertex>', /* glsl */`
#include <begin_vertex>
#ifdef USE_INSTANCING
vec2 iph = instanceMatrix[3].xz;
#else
vec2 iph = vec2(0.0);
#endif
float ph = iph.x * 0.35 + iph.y * 0.27;
float hh = max(transformed.y, 0.0);
float bend = sin(uTime * 1.15 + ph) * 0.5 + sin(uTime * 2.3 + ph * 1.7) * 0.22;
float amt = ${amount.toFixed(3)} * pow(hh * 0.08, 1.6);
transformed.x += bend * amt;
transformed.z += bend * amt * 0.55;
`);
};
};
sway(trunkMat, 0.45); sway(frondMat, 1.35);
}
makePalmGeom() {
const rng = VC.rng;
const H = rng.r(7.5, 12.5);
const R = 0.32;
const lean = rng.r(0.6, 1.8) * (rng.chance(0.5) ? 1 : -1);
const segs = 7;
const sides = 7;
/* tapered, leaning trunk */
const p = [], n = [], uv = [], c = [];
const ringAt = (k) => {
const t = k / segs;
const y = t * H;
const lx = lean * t * t;
const r = R * (1 - t * 0.45);
const arr = [];
for (let s = 0; s < sides; s++) {
const a = (s / sides) * Math.PI * 2;
arr.push([lx + Math.cos(a) * r, y, Math.sin(a) * r]);
}
return arr;
};
const rings = [];
for (let k = 0; k <= segs; k++) rings.push(ringAt(k));
for (let k = 0; k < segs; k++) {
for (let s = 0; s < sides; s++) {
const a = rings[k][s], b = rings[k][(s + 1) % sides], d = rings[k + 1][(s + 1) % sides], e = rings[k + 1][s];
const push = (v, u, vV, sh) => {
p.push(v[0], v[1], v[2]);
const nx = v[0], nz = v[2];
const l = Math.hypot(nx, nz) || 1;
n.push(nx / l, 0.15, nz / l);
uv.push(u, vV);
const g = 0.85 + sh * 0.25;
c.push(g, g * 0.96, g * 0.9);
};
push(a, s / sides, k / segs, 0); push(b, (s + 1) / sides, k / segs, 1); push(d, (s + 1) / sides, (k + 1) / segs, 0.5);
push(a, s / sides, k / segs, 0); push(d, (s + 1) / sides, (k + 1) / segs, 0.5); push(e, s / sides, (k + 1) / segs, 1);
}
}
const mk = (pp, nn, uu, cc) => {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pp), 3));
g.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(nn), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uu), 2));
g.setAttribute('color', new THREE.BufferAttribute(new Float32Array(cc), 3));
return g;
};
const trunk = mk(p, n, uv, c);
/* coconuts + fronds */
const top = rings[segs];
const tx = lean, ty = H, tz = 0;
const fp = [], fn = [], fu = [], fc = [];
const fronds = rng.i(9, 12);
const quad = (a, b, cc, d, col) => {
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
const vx = d[0] - a[0], vy = d[1] - a[1], vz = d[2] - a[2];
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
const l = Math.hypot(nx, ny, nz) || 1; nx /= l; ny /= l; nz /= l;
const P = [a, b, cc, d], UU = [[0, 0], [1, 0], [1, 1], [0, 1]];
const idx = [0, 1, 2, 0, 2, 3];
for (const i of idx) {
fp.push(...P[i]); fn.push(nx, ny, nz); fu.push(...UU[i]); fc.push(col[0], col[1], col[2]);
}
};
for (let f = 0; f < fronds; f++) {
const a = (f / fronds) * Math.PI * 2 + rng.r(-0.2, 0.2);
const len = rng.r(3.2, 4.6);
const droop = rng.r(0.9, 2.1);
const dx = Math.cos(a), dz = Math.sin(a);
const wdt = rng.r(0.62, 0.9);
const bx = tx, bz = tz, by = ty + 0.25;
const mx = bx + dx * len * 0.55, mz = bz + dz * len * 0.55, my = by + 0.9;
const ex = bx + dx * len, ez = bz + dz * len, ey = by - droop;
const pxv = -dz * wdt, pzv = dx * wdt;
const colA = [0.72, 1.0, 0.62], colB = [0.5, 0.95, 0.45];
quad([bx - pxv, by, bz - pzv], [bx + pxv, by, bz + pzv], [mx + pxv, my, mz + pzv], [mx - pxv, my, mz - pzv], colA);
quad([mx - pxv, my, mz - pzv], [mx + pxv, my, mz + pzv], [ex + pxv * 0.25, ey, ez + pzv * 0.25], [ex - pxv * 0.25, ey, ez - pzv * 0.25], colB);
}
for (let k = 0; k < rng.i(2, 4); k++) {
const a = rng.r(0, 6.28), r = 0.42;
const cx0 = tx + Math.cos(a) * r, cz0 = tz + Math.sin(a) * r, cy0 = ty - 0.1;
const rr = 0.26;
for (const sgn of [0]) {
/* cheap coconut: octahedron-ish */
const v = [
[cx0, cy0 - rr, cz0], [cx0 + rr, cy0, cz0], [cx0, cy0, cz0 + rr],
[cx0 - rr, cy0, cz0], [cx0, cy0, cz0 - rr], [cx0, cy0 + rr, cz0],
];
const tri = (A, B, C) => quad([A[0], A[1], A[2]], [B[0], B[1], B[2]], [C[0], C[1], C[2]], [C[0], C[1], C[2]], [0.42, 0.32, 0.2]);
tri(v[0], v[1], v[2]); tri(v[0], v[2], v[3]); tri(v[0], v[3], v[4]); tri(v[0], v[4], v[1]);
tri(v[5], v[2], v[1]); tri(v[5], v[3], v[2]); tri(v[5], v[4], v[3]); tri(v[5], v[1], v[4]);
}
}
return [trunk, mk(fp, fn, fu, fc), H];
}
/* ---------------------------------------------------------- street props */
buildProps() {
const rng = VC.rng;
const P = this._B('prop'), T2 = this._B('trim');
const glowSprites = [];
/* street lamps. The pools/glow are instanced below: additive ground
* planes look like sheets of fog when seen edge-on, so we only keep a
* small sprite on the lamp head and let the bloom do the rest. */
const lampGlowMat = new THREE.SpriteMaterial({
map: VC.tex.glow('#ffd9a0'), transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, toneMapped: false, opacity: 0.75, color: 0xffc890,
});
const lampGlowMeshes = [];
for (const b of this.blocks) {
const n = 2;
for (let k = 0; k < n; k++) {
const t = k / n + 0.5 / n;
const side = k;
let px, pz, rot;
if (side === 0) { px = U.lerp(b.x0 + 4, b.x1 - 4, t); pz = b.z0 + 0.9; rot = 0; }
else if (side === 1) { px = U.lerp(b.x0 + 4, b.x1 - 4, t); pz = b.z1 - 0.9; rot = Math.PI; }
else if (side === 2) { px = b.x0 + 0.9; pz = U.lerp(b.z0 + 4, b.z1 - 4, t); rot = Math.PI / 2; }
else { px = b.x1 - 0.9; pz = U.lerp(b.z0 + 4, b.z1 - 4, t); rot = -Math.PI / 2; }
const H = 7.2;
const y0 = WALK;
P.push(...VC.geo.box(px, y0 + H / 2, pz, 0.22, H, 0.22, [0.28, 0.29, 0.3], 0.3));
/* arm reaching over the road */
const ax = px + Math.sin(rot) * 0 , az = pz + (side === 0 ? -1.5 : side === 1 ? 1.5 : 0);
const azx = px + (side === 2 ? -1.5 : side === 3 ? 1.5 : 0);
P.push(...VC.geo.box(azx, y0 + H - 0.15, az, side < 2 ? 3.0 : 0.22, 0.2, side < 2 ? 0.22 : 3.0, [0.28, 0.29, 0.3], 0.3));
const hx = side < 2 ? px : azx, hz = side < 2 ? az : pz;
P.push(...VC.geo.box(hx, y0 + H - 0.42, hz, 1.1, 0.34, 0.6, [0.35, 0.36, 0.36], 0.3));
this.lamps.push([hx, y0 + H - 0.62, hz]);
this.world.add(px - 0.3, pz - 0.3, px + 0.3, pz + 0.3, H + 0.6, 'prop');
}
}
/* lamp glow sprites */
for (const L of this.lamps) {
const sp = new THREE.Sprite(lampGlowMat);
sp.position.set(L[0], L[1], L[2]);
sp.scale.set(2.2, 2.2, 1);
this.scene.add(sp);
lampGlowMeshes.push(sp);
}
this.lampGlows = lampGlowMeshes;
this.lampPools = [];
/* fire hydrants, bins, benches, hydrants, newspaper boxes, cones */
for (const b of this.blocks) {
const cnt = rng.i(1, 4);
for (let k = 0; k < cnt; k++) {
const side = rng.i(0, 3), t = rng.r(0.08, 0.92);
let px, pz;
if (side === 0) { px = U.lerp(b.x0 + 2, b.x1 - 2, t); pz = b.z0 + 1.4; }
else if (side === 1) { px = U.lerp(b.x0 + 2, b.x1 - 2, t); pz = b.z1 - 1.4; }
else if (side === 2) { px = b.x0 + 1.4; pz = U.lerp(b.z0 + 2, b.z1 - 2, t); }
else { px = b.x1 - 1.4; pz = U.lerp(b.z0 + 2, b.z1 - 2, t); }
const kind = rng.i(0, 4);
const col = rng.pick([[0.85, 0.15, 0.15], [0.15, 0.6, 0.85], [0.2, 0.75, 0.35], [0.9, 0.75, 0.2], [0.6, 0.4, 0.85]]);
if (kind === 0) {
P.push(...VC.geo.box(px, WALK + 0.5, pz, 0.42, 1.0, 0.42, col, 0.4));
P.push(...VC.geo.box(px, WALK + 1.06, pz, 0.55, 0.16, 0.55, col, 0.4));
this.world.add(px - 0.35, pz - 0.35, px + 0.35, pz + 0.35, 1.2, 'prop');
} else if (kind === 1) {
P.push(...VC.geo.box(px, WALK + 0.55, pz, 0.75, 1.1, 0.75, [0.2, 0.42, 0.3], 0.4));
this.world.add(px - 0.5, pz - 0.5, px + 0.5, pz + 0.5, 1.2, 'prop');
} else if (kind === 2) {
const rot = rng.r(0, 6.28);
this.benches.push([px, pz, rot]);
} else if (kind === 3) {
P.push(...VC.geo.box(px, WALK + 0.65, pz, 0.6, 1.3, 0.45, col, 0.4));
this.world.add(px - 0.4, pz - 0.35, px + 0.4, pz + 0.35, 1.3, 'prop');
} else {
/* cone cluster (road works) */
for (let c2 = 0; c2 < rng.i(1, 4); c2++) {
const cxp = px + rng.r(-3, 3), czp = pz + rng.r(-3, 3);
P.push(...VC.geo.box(cxp, WALK + 0.35, czp, 0.4, 0.7, 0.4, [1, 0.38, 0.08], 0.4));
}
}
}
/* dumpsters in the alleys */
if (rng.chance(0.5)) {
const side = rng.i(0, 3);
const t = rng.r(0.3, 0.7);
let px, pz;
if (side === 0) { px = U.lerp(b.x0 + 6, b.x1 - 6, t); pz = b.z0 + 2.2; }
else if (side === 1) { px = U.lerp(b.x0 + 6, b.x1 - 6, t); pz = b.z1 - 2.2; }
else if (side === 2) { px = b.x0 + 2.2; pz = U.lerp(b.z0 + 6, b.z1 - 6, t); }
else { px = b.x1 - 2.2; pz = U.lerp(b.z0 + 6, b.z1 - 6, t); }
const col = rng.pick([[0.24, 0.36, 0.28], [0.3, 0.3, 0.34], [0.5, 0.24, 0.2]]);
P.push(...VC.geo.box(px, WALK + 1.0, pz, 2.6, 1.9, 1.5, col, 0.5));
P.push(...VC.geo.box(px, WALK + 2.02, pz, 2.7, 0.16, 1.6, [col[0] * 0.7, col[1] * 0.7, col[2] * 0.7], 0.5));
this.world.add(px - 1.4, pz - 0.9, px + 1.4, pz + 0.9, 2.1, 'prop');
this.dumpsters.push([px, pz]);
}
}
for (const [px, pz, rot] of this.benches) {
const c = rng.pick([[0.85, 0.5, 0.25], [0.2, 0.6, 0.6], [0.75, 0.75, 0.78]]);
const s = Math.sin(rot), c2 = Math.cos(rot);
const off = (lx, lz) => [px + lx * c2 + lz * s, pz - lx * s + lz * c2];
const [bx1, bz1] = off(-1.0, 0), [bx2, bz2] = off(1.0, 0);
P.push(...VC.geo.box((bx1 + bx2) / 2, WALK + 0.48, (bz1 + bz2) / 2, 2.1, 0.14, 0.55, c, 0.4));
P.push(...VC.geo.box((bx1 + bx2) / 2, WALK + 0.85, (bz1 + bz2) / 2, 2.1, 0.5, 0.12, c, 0.4));
for (const sgn of [-1, 1]) {
const [lx, lz] = off(sgn * 0.85, 0);
P.push(...VC.geo.box(lx, WALK + 0.24, lz, 0.12, 0.5, 0.5, [0.3, 0.3, 0.32], 0.3));
}
}
/* beach umbrellas + towels */
const umb = [];
for (let k = 0; k < 90; k++) {
const edge = rng.i(0, 3), t = rng.f();
const band = rng.r(6, BEACH - 14);
let px, pz;
if (edge === 0) { px = U.lerp(IX0, IX1, t); pz = IZ0 - band; }
else if (edge === 1) { px = U.lerp(IX0, IX1, t); pz = IZ1 + band; }
else if (edge === 2) { px = IX0 - band; pz = U.lerp(IZ0, IZ1, t); }
else { px = IX1 + band; pz = U.lerp(IZ0, IZ1, t); }
if (this.groundH(px, pz) < 0.25) continue;
umb.push([px, pz, rng.r(0, 6.28)]);
}
const umbGeo = [];
const umbTop = [], umbPost = [];
for (const [px, pz, rot] of umb) {
const y = this.groundH(px, pz);
const c = VC.rng.pick([[1, .3, .5], [.2, .85, .95], [1, .8, .2], [.7, 1, .4], [1, .55, .2], [.85, .85, .9]]);
umbTop.push(VC.geo.prism(px, pz, y + 2.0, y + 2.5, 1.5, 8, c));
umbPost.push(VC.geo.box(px, y + 1.1, pz, 0.1, 2.2, 0.1, [0.85, 0.85, 0.85]));
/* towel */
const tx = px + VC.rng.r(-1.6, 1.6), tz = pz + VC.rng.r(1.2, 2.6);
this._B('sand').push(VC.geo.floor(tx - 0.8, tz - 1.4, tx + 0.8, tz + 1.4, this.groundH(tx, tz) + 0.05, 1,
VC.rng.pick([[0.95, 0.35, 0.5], [0.3, 0.8, 0.9], [0.98, 0.85, 0.3]]), true));
}
if (umbTop.length) {
const m1 = new THREE.Mesh(VC.mergeGeoms([].concat(...umbTop)), this.mats.trim);
const m2 = new THREE.Mesh(VC.mergeGeoms([].concat(...umbPost)), this.mats.prop);
m1.castShadow = m2.castShadow = true; m1.receiveShadow = true;
this.scene.add(m1, m2);
}
/* traffic lights at a subset of intersections */
this.tlights = [];
for (let a = -5; a <= 5; a += 2) {
for (let bI = -4; bI <= 4; bI += 2) {
const x = a * CELL + ROAD / 2 - 0.9, z = bI * CELL + ROAD / 2 - 0.9;
const y = WALK;
P.push(...VC.geo.box(x, y + 3.0, z, 0.26, 6.0, 0.26, [0.25, 0.26, 0.27]));
P.push(...VC.geo.box(x - 1.6, y + 5.6, z, 3.0, 0.22, 0.22, [0.25, 0.26, 0.27]));
P.push(...VC.geo.box(x - 3.0, y + 5.0, z, 0.7, 2.0, 0.5, [0.18, 0.18, 0.2]));
this.world.add(x - 0.4, z - 0.4, x + 0.4, z + 0.4, 6.2, 'prop');
this.tlights.push([x - 3.0, y + 5.6, z]);
}
}
/* flush the props buffer built above */
this._flushProps();
/* bus stops / shelters near some intersections */
for (let k = 0; k < 26; k++) {
const x = rng.pick(this.roadX) + (ROAD / 2 - 1.2) * (rng.chance(0.5) ? 1 : -1);
const z = rng.r(-4 * CELL, 4 * CELL);
const y = WALK;
T2.push(...VC.geo.box(x, y + 1.4, z, 3.6, 0.16, 1.8, [0.85, 0.9, 0.95]));
T2.push(...VC.geo.box(x, y + 0.75, z - 0.85, 3.6, 1.5, 0.1, [0.55, 0.8, 0.85]));
for (const s of [-1.7, 1.7]) P.push(...VC.geo.box(x + s, y + 1.2, z, 0.14, 2.4, 0.14, [0.3, 0.3, 0.32]));
this.world.add(x - 1.8, z - 0.95, x + 1.8, z + 0.95, 2.6, 'prop');
}
this._flushProps();
}
/* helper: props were appended after merge(), so merge them now */
_flushProps() {
for (const key of ['prop', 'trim', 'grass', 'sand']) {
const list = this._buf[key];
if (!list || !list.length) continue;
const flat = [];
for (const g of list) if (Array.isArray(g)) for (const gg of g) flat.push(gg); else flat.push(g);
const merged = VC.mergeGeoms(flat);
for (const g of flat) if (g !== merged) g.dispose();
if (this.merged[key]) {
/* extend the existing mesh */
const prev = this.merged[key].geometry;
const all = VC.mergeGeoms([prev, merged]);
prev.dispose(); merged.dispose();
this.merged[key].geometry = all;
} else {
const mesh = new THREE.Mesh(merged, this.mats[key === 'grass' ? 'grass' : key]);
mesh.castShadow = key !== 'sand'; mesh.receiveShadow = true;
mesh.name = key;
this.scene.add(mesh);
this.merged[key] = mesh;
}
this._buf[key] = list;
list.length = 0; /* clear in place so external references stay valid */
}
}
/* water edge: an invisible wall so vehicles/peds stop before swimming */
buildWaterEdge() {
const step = 24;
const w = this.waterRect;
for (let x = w.x; x < w.z; x += step) {
this.world.add(x, w.y - 3, x + step, w.y, 8, 'water');
this.world.add(x, w.w, x + step, w.w + 3, 8, 'water');
}
for (let z = w.y; z < w.w; z += step) {
this.world.add(w.x - 3, z, w.x, z + step, 8, 'water');
this.world.add(w.z, z, w.z + 3, z + step, 8, 'water');
}
}
};
/* instance scratch state lives on the instance (created in the constructor) */
})();
/*! ---- js/vehicles.js ---- */
/* ============================================================================
* vehicles.js - low-poly car bodies (extruded side profiles), arcade vehicle
* physics with drift/grip, traffic that follows the road grid, parked cars and
* police that hunt you down. Wheels are drawn from ONE instanced mesh.
* ==========================================================================*/
(function () {
const U = VC.util;
/* ---------------------------------------------------------------- specs -- */
const SPECS = {
sedan: { L: 4.5, W: 1.88, H: 1.42, wb: 2.75, wr: 0.36, power: 12.5, top: 44, brake: 26, grip: 7.2, handGrip: 1.5, steer: 2.05, mass: 1.15, seat: [0, 0.92, -0.15] },
wedge: { L: 4.5, W: 2.0, H: 1.12, wb: 2.7, wr: 0.33, power: 20.5, top: 58, brake: 30, grip: 8.6, handGrip: 1.7, steer: 2.15, mass: 0.95, seat: [0, 0.66, -0.25] },
taxi: { L: 4.6, W: 1.9, H: 1.5, wb: 2.8, wr: 0.36, power: 13.5, top: 45, brake: 26, grip: 7.0, handGrip: 1.5, steer: 2.05, mass: 1.2, seat: [0, 0.95, -0.15] },
van: { L: 4.9, W: 2.0, H: 2.1, wb: 3.0, wr: 0.4, power: 11.0, top: 38, brake: 24, grip: 6.2, handGrip: 1.4, steer: 1.8, mass: 1.6, seat: [0, 1.3, 0.35] },
pickup: { L: 5.1, W: 2.05, H: 1.72, wb: 3.1, wr: 0.42, power: 13.0, top: 41, brake: 24, grip: 6.4, handGrip: 1.4, steer: 1.85, mass: 1.5, seat: [0, 1.05, 0.1] },
police: { L: 4.9, W: 1.95, H: 1.5, wb: 2.9, wr: 0.36, power: 18.5, top: 54, brake: 30, grip: 8.2, handGrip: 1.6, steer: 2.2, mass: 1.25, seat: [0, 0.95, -0.15] },
bike: { L: 2.3, W: 0.7, H: 1.2, wb: 1.55, wr: 0.33, power: 15.0, top: 52, brake: 24, grip: 9.5, handGrip: 2.4, steer: 2.9, mass: 0.5, seat: [0, 0.85, -0.1] },
};
VC.CAR_SPECS = SPECS;
/* side profiles: [forward, height] pairs, front at +z */
const PROFILES = {
sedan: [[-2.25, .38], [-2.3, .70], [-2.2, .95], [-1.35, 1.02], [-0.95, 1.40], [0.55, 1.42], [1.15, 1.02], [2.1, .95], [2.25, .62], [2.2, .38]],
wedge: [[-2.25, .30], [-2.3, .58], [-2.0, .80], [-1.0, .86], [-0.45, 1.10], [0.5, 1.08], [1.2, .80], [2.15, .70], [2.28, .44], [2.2, .30]],
taxi: [[-2.3, .40], [-2.35, .74], [-2.25, 1.0], [-1.4, 1.06], [-1.0, 1.46], [0.6, 1.48], [1.2, 1.06], [2.15, 1.0], [2.3, .66], [2.25, .40]],
van: [[-2.45, .46], [-2.5, 1.35], [-2.35, 2.05], [-1.3, 2.1], [-0.85, 2.02], [1.4, 1.98], [2.2, 1.5], [2.45, .95], [2.4, .46]],
pickup: [[-2.55, .48], [-2.6, 1.0], [-2.45, 1.06], [-1.5, 1.08], [-1.15, 1.66], [0.15, 1.68], [0.35, 1.12], [2.35, 1.08], [2.5, .74], [2.45, .48]],
police: [[-2.45, .38], [-2.5, .74], [-2.4, 1.0], [-1.4, 1.06], [-1.0, 1.46], [0.6, 1.48], [1.2, 1.06], [2.25, 1.0], [2.45, .66], [2.4, .38]],
};
const PAINTS = [
0xf7f7f5, 0xf3e6c8, 0xcdeee4, 0xa8e6f2, 0xffb3c8, 0xd6c4f0, 0xffcf9e,
0x2b3350, 0x1a1a1e, 0xd03a3a, 0x2f8f6f, 0xe8b42a, 0x6c7ee1, 0x8a2be2,
0xff7f50, 0x38b0a6, 0xb0c4de, 0x556b2f,
];
VC.CAR_PAINTS = PAINTS;
/* ---------------------------------------------------------- geometry ----- */
function paint(geom, col, shade) {
const n = geom.attributes.position.count;
const c = new Float32Array(n * 3);
let r, g2, b;
if (Array.isArray(col)) { r = col[0]; g2 = col[1]; b = col[2]; }
else { const cc = new THREE.Color(col); r = cc.r; g2 = cc.g; b = cc.b; }
if (shade !== undefined) { r *= shade; g2 *= shade; b *= shade; }
for (let i = 0; i < n; i++) { c[i * 3] = r; c[i * 3 + 1] = g2; c[i * 3 + 2] = b; }
geom.setAttribute('color', new THREE.BufferAttribute(c, 3));
return geom;
}
function ensureUv(geom) {
if (!geom.attributes.uv) {
const n = geom.attributes.position.count;
geom.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(n * 2), 2));
}
return geom;
}
/* signed area helper so we can force CCW winding (ExtrudeGeometry assumes it) */
function signedArea(pts) {
let a = 0;
for (let i = 0; i < pts.length; i++) {
const p = pts[i], q = pts[(i + 1) % pts.length];
a += p[0] * q[1] - q[0] * p[1];
}
return a * 0.5;
}
function profile(ptsIn, width, col, shade) {
const pts = ptsIn.slice();
if (signedArea(pts) < 0) pts.reverse();
const s = new THREE.Shape();
pts.forEach((p, i) => (i ? s.lineTo(p[0], p[1]) : s.moveTo(p[0], p[1])));
s.closePath();
const g = new THREE.ExtrudeGeometry(s, { depth: width, bevelEnabled: false, steps: 1 });
g.rotateY(-Math.PI / 2);
g.translate(width / 2, 0, 0);
ensureUv(g);
return paint(g, col, shade);
}
function box(x, y, z, w, h, d, col, shade) {
const list = VC.geo.box(x, y, z, w, h, d, [1, 1, 1], 1);
const merged = VC.mergeGeoms(list);
for (const g of list) g.dispose();
return paint(ensureUv(merged), col, shade);
}
function cylX(x, y, z, r, len, col, seg) {
const g = new THREE.CylinderGeometry(r, r, len, seg || 10, 1);
g.rotateZ(Math.PI / 2);
g.translate(x, y, z);
ensureUv(g);
return paint(g, col);
}
function ball(x, y, z, r, col, seg) {
const g = new THREE.SphereGeometry(r, seg || 8, seg ? Math.max(4, seg / 2) : 6);
g.translate(x, y, z);
ensureUv(g);
return paint(g, col);
}
/* build the paint + glass + lamp geometry for one car */
function buildBody(model, colorHex) {
const s = SPECS[model];
const paintG = [], lampG = [], glassG = [];
const dark = 0x14161c, chrome = 0xbfc6cf, rubber = 0x16171a;
const glassCol = 0x1b2536;
const W = s.W;
if (model === 'bike') {
paintG.push(box(0, 0.55, 0, 0.34, 0.42, 1.5, colorHex));
paintG.push(box(0, 0.86, -0.42, 0.42, 0.24, 0.7, colorHex, 0.8));
paintG.push(box(0, 1.0, 0.62, 0.1, 0.5, 0.1, dark, 1));
paintG.push(box(0, 1.16, 0.62, 0.8, 0.09, 0.09, chrome));
paintG.push(box(0, 0.95, 0.72, 0.34, 0.3, 0.22, 0x222630)); /* headlamp cowl */
paintG.push(box(0, 0.35, 0.2, 0.5, 0.3, 0.8, dark, 1)); /* engine block */
lampG.push(box(0, 0.95, 0.85, 0.22, 0.16, 0.06, [1.9, 1.75, 1.4]));
lampG.push(box(0, 0.9, -0.85, 0.2, 0.12, 0.06, [1.8, 0.2, 0.2]));
return { paintG, lampG, glassG, seat: s.seat };
}
/* main body from the side profile */
paintG.push(profile(PROFILES[model], W, colorHex));
/* lower cladding / rocker panel */
paintG.push(box(0, 0.26, 0, W * 0.98, 0.16, s.L * 0.78, colorHex, 0.6));
/* bumpers */
paintG.push(box(0, 0.44, s.L / 2 - 0.06, W * 0.99, 0.26, 0.3, model === 'police' ? 0x1a1a1e : colorHex, 0.8));
paintG.push(box(0, 0.44, -s.L / 2 + 0.06, W * 0.99, 0.26, 0.3, model === 'police' ? 0x1a1a1e : colorHex, 0.8));
/* cabin glass: a slightly narrower extrusion of the greenhouse */
const gh = PROFILES[model].filter((p) => p[1] > 0.95).map((p) => [p[0], p[1]]);
if (gh.length >= 3) {
const y0 = Math.min(...gh.map((p) => p[1]));
const pts = [[gh[0][0], y0], ...gh, [gh[gh.length - 1][0], y0]];
glassG.push(profile(pts, W * 0.92, glassCol));
} else {
glassG.push(box(0, 1.15, -0.1, W * 0.9, 0.5, s.L * 0.42, glassCol));
}
/* roof (solid) for open-topped look on vans */
if (model === 'van' || model === 'pickup') paintG.push(box(0, 2.0, -0.6, W * 0.9, 0.1, 1.6, colorHex, 1.1));
/* hood trunk crease */
paintG.push(box(0, PROFILES[model].reduce((a, p) => Math.max(a, p[1]), 0) - 0.02, 0, W * 0.5, 0.05, s.L * 0.55, colorHex, 1.12));
/* mirrors */
for (const sx of [-1, 1]) paintG.push(box(sx * (W / 2 + 0.14), 1.05, 0.35, 0.22, 0.1, 0.12, dark, 1));
/* grille + lights */
lampG.push(box(0, 0.62, s.L / 2 - 0.02, W * 0.66, 0.16, 0.08, [0.9, 0.9, 1.0]));
for (const sx of [-1, 1]) {
lampG.push(box(sx * (W / 2 - 0.3), 0.66, s.L / 2 - 0.04, 0.42, 0.2, 0.09, [2.3, 2.1, 1.7]));
lampG.push(box(sx * (W / 2 - 0.28), 0.72, -s.L / 2 + 0.04, 0.38, 0.18, 0.08, [1.9, 0.12, 0.12]));
}
/* plates */
paintG.push(box(0, 0.5, s.L / 2 + 0.01, 0.44, 0.14, 0.03, 0xf0ead6));
paintG.push(box(0, 0.5, -s.L / 2 - 0.01, 0.44, 0.14, 0.03, 0xf0ead6));
if (model === 'taxi') {
paintG.push(box(0, 1.62, -0.1, 0.62, 0.22, 0.3, 0xf5c400));
lampG.push(box(0, 1.62, -0.1, 0.64, 0.16, 0.05, [2.0, 1.6, 0.3]));
paintG.push(box(0, 0.75, 0.9, W * 0.99, 0.5, 0.6, 0x14161c)); /* door panels */
paintG.push(box(0, 0.75, -1.0, W * 0.99, 0.5, 0.6, 0x14161c));
}
if (model === 'police') {
paintG.push(box(0, 0.78, 0.95, W * 0.995, 0.42, 0.75, 0xf4f4f2));
paintG.push(box(0, 0.78, -1.05, W * 0.995, 0.42, 0.75, 0xf4f4f2));
paintG.push(box(0, 1.52, -0.1, 0.62, 0.12, 0.34, 0x22242c));
}
return { paintG, lampG, glassG, seat: s.seat, model };
}
/* shared materials for every car */
const MAT = {};
function initMats(envMap) {
if (MAT.paint) return;
MAT.paint = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.34, metalness: 0.62, envMap, envMapIntensity: 1.35 });
MAT.glass = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.08, metalness: 0.55, envMap, envMapIntensity: 1.7, transparent: true, opacity: 0.88 });
MAT.lamp = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false });
MAT.wheel = new THREE.MeshStandardMaterial({ color: 0x17181c, roughness: 0.85, metalness: 0.2, envMap, envMapIntensity: 0.5 });
MAT.beam = new THREE.MeshBasicMaterial({
map: VC.tex.glow('#fff0cc'), transparent: true, blending: THREE.AdditiveBlending,
depthWrite: false, toneMapped: false, side: THREE.DoubleSide, opacity: 0.2,
color: new THREE.Color(0.55, 0.45, 0.3),
});
MAT.sirenOn = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false });
MAT.interior = new THREE.MeshStandardMaterial({ color: 0x23242c, roughness: 0.9 });
MAT.skin = new THREE.MeshStandardMaterial({ color: 0xc98c63, roughness: 0.8 });
MAT.shirt = new THREE.MeshStandardMaterial({ color: 0xe4e4e8, roughness: 0.9 });
}
/* ------------------------------------------------------------- Vehicle --- */
VC.Vehicle = class Vehicle {
constructor(mgr, model, colorHex) {
const s = (this.spec = SPECS[model]);
this.mgr = mgr;
this.model = model;
this.color = colorHex;
this.root = new THREE.Group();
this.root.rotation.order = 'YXZ';
this.body = new THREE.Group();
this.root.add(this.body);
const parts = buildBody(model, colorHex);
const paintMesh = new THREE.Mesh(VC.mergeGeoms(parts.paintG), MAT.paint);
for (const g of parts.paintG) g.dispose();
paintMesh.castShadow = true; paintMesh.receiveShadow = true;
this.body.add(paintMesh);
if (parts.glassG.length) {
const gm = new THREE.Mesh(VC.mergeGeoms(parts.glassG), MAT.glass);
for (const g of parts.glassG) g.dispose();
this.body.add(gm);
}
this.lampMesh = new THREE.Mesh(VC.mergeGeoms(parts.lampG), MAT.lamp);
for (const g of parts.lampG) g.dispose();
this.body.add(this.lampMesh);
/* head light pools on the ground (small + dim: they read as glow, not fog) */
if (model !== 'bike') {
this.beams = new THREE.Group();
for (const sx of [-1, 1]) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 5.0), MAT.beam);
m.rotation.x = -Math.PI / 2;
m.position.set(sx * (s.W / 2 - 0.32), -0.02, s.L / 2 + 2.1);
this.beams.add(m);
}
this.root.add(this.beams);
}
/* driver proxy (visible when somebody is driving) */
this.driver = new THREE.Group();
const seat = parts.seat;
const head = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), MAT.skin);
head.position.set(0, 0, 0); head.castShadow = true;
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.4, 0.26), MAT.shirt);
torso.position.set(0, -0.32, 0.02); torso.castShadow = true;
this.driver.add(head, torso);
this.driver.position.set(seat[0], seat[1] + 0.3, seat[2]);
this.driver.visible = false;
this.body.add(this.driver);
/* police light bar */
if (model === 'police') {
this.siren = new THREE.Group();
this.sirenL = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.2, 0.3), new THREE.MeshBasicMaterial({ color: new THREE.Color(3, 0.05, 0.05), toneMapped: false }));
this.sirenR = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.2, 0.3), new THREE.MeshBasicMaterial({ color: new THREE.Color(0.1, 0.2, 3.0), toneMapped: false }));
this.sirenL.position.set(-0.18, 1.62, -0.1); this.sirenR.position.set(0.18, 1.62, -0.1);
this.siren.add(this.sirenL, this.sirenR);
this.body.add(this.siren);
}
/* state */
this.pos = new THREE.Vector3();
this.heading = 0;
this.vel = new THREE.Vector3();
this.vf = 0; this.vr = 0;
this.steer = 0; this.steerTarget = 0;
this.kind = 'parked';
this.health = 100;
this.dead = false;
this.burnt = false;
this.occupied = false;
this.engineOn = false;
this.wheelRot = 0;
this.skid = 0;
this.age = 0;
this.honk = 0;
this.aiTimer = 0;
this.path = null;
this.wp = 0;
this.sirenPhase = Math.random() * 6.28;
this.groundY = 1.44;
this.pitch = 0; this.roll = 0;
this.engineSample = 0;
this.doorSide = 1;
this.lastHit = 0;
this.mgr.scene.add(this.root);
this.updateMesh(0);
}
place(x, z, heading) {
this.pos.set(x, this.mgr.city.groundH(x, z), z);
this.heading = heading;
this.vel.set(0, 0, 0);
this.vf = 0; this.vr = 0;
this.updateMesh(0);
}
forward() { return { x: Math.sin(this.heading), z: Math.cos(this.heading) }; }
right() { return { x: -Math.cos(this.heading), z: Math.sin(this.heading) }; }
speed() { return Math.hypot(this.vel.x, this.vel.z); }
/* drive: inp = {throttle:-1..1, brake:0..1, steer:-1..1, hand:0..1} */
physics(dt, inp) {
const s = this.spec;
const f = this.forward(), r = this.right();
let vf = this.vel.x * f.x + this.vel.z * f.z;
let vr = this.vel.x * r.x + this.vel.z * r.z;
const onWater = this.mgr.city.isDeepWater(this.pos.x, this.pos.z);
/* engine */
const taper = U.clamp(1 - Math.abs(vf) / s.top, 0, 1);
if (inp.throttle > 0) vf += inp.throttle * s.power * taper * dt * (this.engineOn ? 1 : 0);
else if (inp.throttle < 0) vf += inp.throttle * s.power * 0.55 * dt * (this.engineOn ? 1 : 0);
if (inp.brake > 0) {
const b = s.brake * inp.brake * dt;
if (vf > 0.6) vf = Math.max(0, vf - b);
else vf = Math.max(-s.top * 0.4, vf - b * 0.6);
}
/* drag + rolling resistance */
vf -= vf * (onWater ? 2.6 : 0.34) * dt;
if (Math.abs(vf) < 0.06 && !inp.throttle) vf = 0;
/* lateral grip / drift */
const grip = U.lerp(s.grip, s.handGrip, inp.hand);
vr *= Math.exp(-grip * dt);
/* slip generated by turning */
vr += -this.steer * Math.abs(vf) * (0.42 + inp.hand * 0.5) * dt;
/* steering -> yaw */
const spd = Math.abs(vf);
const auth = U.clamp(spd / 5, 0, 1) * (1 / (1 + spd * 0.02));
this.heading += this.steer * s.steer * auth * dt * U.sign(vf);
this.vf = vf; this.vr = vr;
this.vel.set(f.x * vf + r.x * vr, 0, f.z * vf + r.z * vr);
/* integrate + collide (front / centre / rear probes) */
const half = s.L * 0.36, rad = s.W * 0.42;
let hitSomething = null;
for (const off of [half, 0, -half]) {
const px = this.pos.x + f.x * off, pz = this.pos.z + f.z * off;
const res = this.mgr.city.world.resolveCircle(px, pz, rad, 1.2);
if (res.hit && res.normal) {
const dx = res.x - px, dz = res.z - pz;
this.pos.x += dx; this.pos.z += dz;
const n = res.normal;
const into = this.vel.x * n.x + this.vel.z * n.z;
if (into < 0) {
const impact = -into;
this.vel.x -= n.x * into * 1.35; this.vel.z -= n.z * into * 1.35;
this.vel.multiplyScalar(0.55);
/* off-centre hits spin the car */
const rx = f.x * off, rz = f.z * off;
const torque = (rx * n.z - rz * n.x) * 0.5;
this.heading += torque * U.clamp(impact * 0.05, 0, 0.5) * U.sign(-off || 1);
if (impact > 3 && this.mgr.game.time - this.lastHit > 0.25) {
this.lastHit = this.mgr.game.time;
this.mgr.game.fx.impact(px, this.pos.y + 0.6, pz, n.x, 0.3, n.z, 'metal');
this.mgr.game.audio.crash(U.clamp(impact / 18, 0.25, 1), { x: px, y: this.pos.y, z: pz });
this.damage(impact * 1.5);
if (this.kind === 'player') this.mgr.game.shakeScreen(U.clamp(impact / 22, 0.1, 0.9));
}
hitSomething = n;
}
}
}
this.pos.x += this.vel.x * dt;
this.pos.z += this.vel.z * dt;
/* keep the car on the ground */
const gy = this.mgr.city.groundH(this.pos.x, this.pos.z);
this.groundY = U.damp(this.groundY, gy, 12, dt);
/* splash when entering water */
if (gy < -0.15 && this.groundY > 0.2) {
this.mgr.game.fx.splash(this.pos.x, 0.1, this.pos.z, 24);
this.mgr.game.audio.splashSfx({ x: this.pos.x, y: 0, z: this.pos.z });
}
this.pos.y = Math.max(this.groundY, -0.2);
/* body attitude */
const latAcc = this.vr * this.vf * 0.02;
this.roll = U.damp(this.roll, U.clamp(-latAcc, -0.12, 0.12) - (hitSomething ? 0.05 : 0), 8, dt);
const longAcc = (inp.throttle ? -0.02 : 0) + (inp.brake ? 0.025 : 0);
this.pitch = U.damp(this.pitch, longAcc, 7, dt);
/* wheels + skids */
this.wheelRot += vf * dt / s.wr;
const slip = Math.min(1, Math.abs(vr) / 5.5);
this.skid = U.damp(this.skid, slip * (inp.hand ? 1 : 0.8) + (inp.brake > 0.5 && spd > 8 ? 0.55 : 0), 8, dt);
if (this.skid > 0.22 && spd > 3) {
for (const sx of [-1, 1]) {
const wx = this.pos.x + r.x * sx * (s.W / 2 - 0.1) - f.x * (s.wb / 2);
const wz = this.pos.z + r.z * sx * (s.W / 2 - 0.1) - f.z * (s.wb / 2);
this.mgr.game.fx.skid(wx, wz, f.x, f.z, 0.17, this.skid);
}
if (Math.random() < this.skid * 0.8) this.mgr.game.fx.tireSmoke(this.pos.x - f.x * s.wb * 0.5, this.pos.y + 0.2, this.pos.z - f.z * s.wb * 0.5, 1);
}
if (this.kind === 'traffic' || this.kind === 'police') {
/* traffic cars scrape walls silently */
}
return { hit: hitSomething };
}
damage(amount) {
if (this.dead) return;
this.health -= amount;
if (this.health <= 0) this.kill(false);
}
kill(burntNow) {
if (this.dead) return;
this.dead = true;
this.burnt = true;
this.engineOn = false;
this.kind = 'wreck';
this.occupied = false;
this.driver.visible = false;
if (this.beams) this.beams.visible = false;
if (this.lampMesh) this.lampMesh.visible = false;
if (this.siren) this.siren.visible = false;
this.body.traverse((o) => {
if (o.isMesh && o.material === MAT.paint) {
o.material = MAT.wreck || (MAT.wreck = new THREE.MeshStandardMaterial({ color: 0x14120f, roughness: 1, metalness: 0.2 }));
} else if (o.isMesh && o.material === MAT.glass) o.visible = false;
});
const p = this.pos;
this.mgr.game.fx.explosion(p.x, p.y + 0.7, p.z, 8);
this.mgr.game.fx.smoke.emit(p.x, p.y + 1, p.z, 0, 1.6, 0, [0.2, 0.2, 0.2], 3, 4, -0.6, 0.8, 2);
this.explodeTimer = 3.2;
this.smokeTimer = 0;
if (this.mgr.game.player.vehicle === this) this.mgr.game.ejectFromCar();
}
updateMesh(dt) {
this.root.position.copy(this.pos);
this.root.rotation.y = this.heading;
this.body.rotation.x = this.pitch;
this.body.rotation.z = this.roll;
this.root.position.y = this.pos.y;
}
/* run one frame: AI (if any) then physics then visuals */
step(dt, playerPos) {
this.age += dt;
/* Headlight pools read as distant glow; seen up close an additive quad
* just fogs the frame, so only show them once the camera pulls away. */
if (this.beams) {
const d2 = VC.game.camera.position.distanceToSquared(this.root.position);
this.beams.visible = !this.dead && this.engineOn && d2 > 196;
}
/* dying cars smoke and burn before they go up */
if (!this.dead && this.health < 38) {
this.smokeTimer = (this.smokeTimer || 0) - dt;
if (this.smokeTimer <= 0) {
this.smokeTimer = this.health < 15 ? 0.07 : 0.18;
const fx = this.mgr.game.fx;
fx.smoke.emit(this.pos.x, this.pos.y + 1.1, this.pos.z, 0, 1.3, 0,
this.health < 15 ? [0.9, 0.45, 0.15] : [0.22, 0.21, 0.2], 0.9, 1.8, -0.5, 0.9, 1.6);
if (this.health < 15) this.damage(2.2);
}
}
if (this.dead) {
if (this.smokeTimer !== undefined) {
this.smokeTimer -= dt;
if (this.smokeTimer <= 0) {
this.smokeTimer = 0.16;
this.mgr.game.fx.smoke.emit(this.pos.x + (Math.random() - 0.5) * 0.8, this.pos.y + 0.9, this.pos.z + (Math.random() - 0.5) * 0.8,
(Math.random() - 0.5) * 0.7, 1.1 + Math.random(), (Math.random() - 0.5) * 0.7, [0.18, 0.17, 0.16], 1.1, 2.6, -0.5, 0.9, 1.5);
}
}
this.vel.multiplyScalar(Math.exp(-2 * dt));
this.pos.x += this.vel.x * dt; this.pos.z += this.vel.z * dt;
this.updateMesh(dt);
return;
}
let inp = this.input;
if (!this.occupied) inp = this.ai(dt, playerPos);
else if (this.kind !== 'player' && this.kind !== 'police')
/* a seated driver with no AI orders means the car is parked;
* cruisers carry a driver mesh *and* AI orders, so they must pass */
inp = { throttle: 0, brake: 1, steer: 0, hand: 0 };
this.steer = U.damp(this.steer, inp.steer, 11, dt);
this.physics(dt, inp);
if (this.siren) {
this.sirenPhase += dt * 9;
const s = Math.sin(this.sirenPhase);
this.sirenL.material.color.setRGB(s > 0 ? 4 : 0.08, 0.05, 0.05);
this.sirenR.material.color.setRGB(0.06, 0.25, s <= 0 ? 4 : 0.1);
}
this.updateMesh(dt);
}
/* placeholder AI - overridden by the manager for traffic / police */
ai() { return { throttle: 0, brake: 1, steer: 0, hand: 1 }; }
};
/* -------------------------------------------------------- wheel drawing -- */
class WheelBatch {
constructor(scene, cap, envMap) {
const g = new THREE.CylinderGeometry(1, 1, 1, 12, 1);
g.rotateZ(Math.PI / 2);
this.mesh = new THREE.InstancedMesh(g, MAT.wheel, cap);
this.mesh.frustumCulled = false;
this.mesh.castShadow = true;
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(this.mesh);
this.n = 0; this.cap = cap;
this.m = new THREE.Matrix4();
this.q = new THREE.Quaternion();
this.qy = new THREE.Quaternion();
this.qYaw = new THREE.Quaternion();
this.qRoll = new THREE.Quaternion();
this.X = new THREE.Vector3(1, 0, 0);
this.Y = new THREE.Vector3(0, 1, 0);
this.e = new THREE.Euler();
this.v = new THREE.Vector3();
this.s = new THREE.Vector3();
}
begin() { this.n = 0; }
add(x, y, z, r, width, roll, steerAngle) {
if (this.n >= this.cap) return;
this.qYaw.setFromAxisAngle(this.Y, steerAngle || 0);
this.qRoll.setFromAxisAngle(this.X, roll || 0);
this.q.copy(this.qYaw).multiply(this.qRoll);
this.v.set(x, y, z);
this.s.set(width, r, r);
this.m.compose(this.v, this.q, this.s);
this.mesh.setMatrixAt(this.n++, this.m);
}
end() {
this.mesh.count = this.n;
this.mesh.instanceMatrix.needsUpdate = true;
}
}
/* ---------------------------------------------------------- the manager -- */
VC.Vehicles = class Vehicles {
constructor(scene, city, game) {
this.scene = scene; this.city = city; this.game = game;
initMats(VC.sky.envMap);
this.cars = [];
this.wheels = new WheelBatch(scene, 420, VC.sky.envMap);
this.policeWanted = 0;
this.spawnTimer = 0;
this.trafficTarget = 24;
this.parkedTarget = 74;
this.copTimer = 2;
this.models = ['sedan', 'wedge', 'taxi', 'van', 'pickup', 'sedan', 'wedge', 'bike'];
}
make(model, color) {
const v = new VC.Vehicle(this, model, color === undefined ? VC.rng.pick(PAINTS) : color);
this.cars.push(v);
return v;
}
/* ---- road helpers ---- */
randRoadPoint(nearPos, minD, maxD) {
const C = VC.CITY;
for (let tries = 0; tries < 40; tries++) {
const horiz = VC.rng.chance(0.5);
const line = horiz ? VC.rng.pick(this.city.roadZ) : VC.rng.pick(this.city.roadX);
const along = horiz ? VC.rng.r(C.IX0, C.IX1) : VC.rng.r(C.IZ0, C.IZ1);
const off = VC.rng.r(-5, 5);
const x = horiz ? along : line + off;
const z = horiz ? line + off : along;
if (!nearPos) return { x, z };
const d = Math.hypot(x - nearPos.x, z - nearPos.z);
if (d > minD && d < maxD) return { x, z };
}
return { x: VC.rng.r(C.IX0, C.IX1), z: VC.rng.r(C.IZ0, C.IZ1) };
}
/* build a waypoint path along the road grid for traffic */
makePath(x, z, len) {
const C = VC.CITY;
let ni = U.clamp(Math.round(x / C.CELL), -6, 6);
let nk = U.clamp(Math.round(z / C.CELL), -5, 5);
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
let dir = VC.rng.pick(dirs);
const pts = [];
for (let k = 0; k < (len || 8); k++) {
const opts = dirs.filter((d) => {
if (d[0] === -dir[0] && d[1] === -dir[1]) return false;
const a = ni + d[0], b = nk + d[1];
return a >= -6 && a <= 6 && b >= -5 && b <= 5;
});
if (!opts.length) break;
const straight = opts.filter((d) => d[0] === dir[0] && d[1] === dir[1]);
dir = (straight.length && VC.rng.chance(0.74)) ? straight[0] : VC.rng.pick(opts);
ni += dir[0]; nk += dir[1];
/* keep to the right: offset = right(dir) * 3.3 */
const ox = -dir[1] * 3.3, oz = dir[0] * 3.3;
const wx = ni * C.CELL, wz = nk * C.CELL;
pts.push({ x: wx - dir[0] * C.CELL * 0.5 + ox, z: wz - dir[1] * C.CELL * 0.5 + oz, node: [ni, nk], dir: dir });
pts.push({ x: wx, z: wz, node: [ni, nk], dir: dir });
}
return pts;
}
laneOffset(d) { return { x: -d[1] * 3.3, z: d[0] * 3.3 }; }
spawnTraffic(nearPos) {
const p = this.randRoadPoint(nearPos, 90, 320);
const C = VC.CITY;
/* snap the spawn to the nearest junction so the path starts ahead of us */
const sx = U.clamp(Math.round(p.x / C.CELL), -6, 6) * C.CELL;
const sz = U.clamp(Math.round(p.z / C.CELL), -5, 5) * C.CELL;
const car = this.make(VC.rng.pick(this.models.filter((m) => m !== 'taxi' || VC.rng.chance(0.4))));
car.kind = 'traffic';
car.engineOn = true;
car.occupied = true; /* there is a driver inside */
car.driver.visible = true;
car.aiMode = 'traffic';
car.path = this.makePath(sx, sz, 9);
car.wp = 0;
const w = car.path[0];
car.speedTarget = VC.rng.r(11, 19);
car.place(sx, sz, Math.atan2(w.x - sx, w.z - sz));
return car;
}
spawnParked(nearPos) {
const C = VC.CITY;
for (let tries = 0; tries < 24; tries++) {
const horiz = VC.rng.chance(0.5);
const line = horiz ? VC.rng.pick(this.city.roadZ) : VC.rng.pick(this.city.roadX);
const along = VC.rng.r(C.IX0 + 8, C.IX1 - 8);
const side = VC.rng.chance(0.5) ? 1 : -1;
const off = 5.9 * side;
const x = horiz ? along : line + off;
const z = horiz ? line + off : along;
if (this.city.isDeepWater(x, z)) continue;
if (nearPos && Math.hypot(x - nearPos.x, z - nearPos.z) < 12) continue;
const model = VC.rng.pick(['sedan', 'wedge', 'van', 'pickup', 'taxi', 'sedan']);
const car = this.make(model);
car.kind = 'parked';
car.place(x, z, horiz ? (side > 0 ? 0 : Math.PI) : (side > 0 ? Math.PI / 2 : -Math.PI / 2));
car.steerLock = true;
return car;
}
return null;
}
/* Road-grid route from a cruiser to a target. Greedy steps that always
* reduce the remaining distance, in the right-hand lane, so a chase
* actually drives down the streets instead of nose-first into a facade.
* Same waypoint shape as makePath(), so trafficAI-style following works. */
chasePath(fx, fz, tx, tz) {
const C = VC.CITY;
let ni = U.clamp(Math.round(fx / C.CELL), -6, 6);
let nk = U.clamp(Math.round(fz / C.CELL), -5, 5);
const gi = U.clamp(Math.round(tx / C.CELL), -6, 6);
const gk = U.clamp(Math.round(tz / C.CELL), -5, 5);
const pts = [];
let dir = null, guard = 48;
while ((ni !== gi || nk !== gk) && guard-- > 0) {
const di = gi - ni, dk = gk - nk;
if (!dir) dir = di === 0 ? [0, Math.sign(dk)] : (dk === 0 || Math.abs(di) + VC.rng.r(-1.3, 1.3) >= Math.abs(dk)) ? [Math.sign(di), 0] : [0, Math.sign(dk)];
else if (VC.rng.chance(0.35)) {
/* occasionally take the parallel street so cruisers arrive spread
* over the block instead of single-file behind each other */
const alt = di !== 0 && dk !== 0 ? (VC.rng.chance(0.5) ? [Math.sign(di), 0] : [0, Math.sign(dk)]) : dir;
dir = alt;
}
ni += dir[0]; nk += dir[1];
const ox = -dir[1] * 3.3, oz = dir[0] * 3.3;
const wx = ni * C.CELL, wz = nk * C.CELL;
pts.push({ x: wx - dir[0] * C.CELL * 0.5 + ox, z: wz - dir[1] * C.CELL * 0.5 + oz, node: [ni, nk], dir: dir });
pts.push({ x: wx, z: wz, node: [ni, nk], dir: dir });
}
pts.push({ x: tx, z: tz, node: [gi, gk], dir: dir || [0, 1] });
/* never steer a pursuing car back through a point it just passed */
while (pts.length > 2 && Math.hypot(pts[0].x - fx, pts[0].z - fz) < 7) pts.shift();
return pts;
}
/* Junction with a real route to the player. Half the time close enough to
* hear the sirens arrive, the rest spread across the district. */
policeSpawnPoint(nearPos) {
const RX = this.city.roadX, RZ = this.city.roadZ;
const near = [], far = [];
for (let i = 0; i < RX.length; i++) {
for (let j = 0; j < RZ.length; j++) {
const d = Math.hypot(RX[i] - nearPos.x, RZ[j] - nearPos.z);
if (d > 34 && d < 95) near.push([RX[i], RZ[j]]);
else if (d >= 95 && d < 190) far.push([RX[i], RZ[j]]);
}
}
const pool = (near.length && VC.rng.chance(0.55)) ? near : (far.length ? far : near);
if (!pool || !pool.length) { const p = this.randRoadPoint(nearPos, 40, 200); return { x: p.x, z: p.z }; }
const c = VC.rng.pick(pool);
return { x: c[0], z: c[1] };
}
spawnPolice(nearPos, wanted) {
const p = this.policeSpawnPoint(nearPos);
const car = this.make('police', 0x1b2b52);
car.kind = 'police';
car.aiMode = 'police';
car.engineOn = true;
car.occupied = true;
car.driver.visible = true;
/* route down the streets toward the player and face the first leg, so
* the cruiser pulls away from the kerb instead of doing a U-turn */
car.cpath = this.chasePath(p.x, p.z, nearPos.x, nearPos.z);
car.cwp = 0;
car.crep = 0;
const first = car.cpath[Math.min(2, car.cpath.length - 1)] || p;
car.place(p.x, p.z, Math.atan2(first.x - p.x, first.z - p.z));
car.speedTarget = 21 + wanted * 3.2;
return car;
}
nearest(pos, maxD, filter) {
let best = null, bd = maxD;
for (const c of this.cars) {
if (c.dead) continue;
if (filter && !filter(c)) continue;
const d = Math.hypot(c.pos.x - pos.x, c.pos.z - pos.z);
if (d < bd) { bd = d; best = c; }
}
return best;
}
/* -------------------------------------------------------------- AI ----- */
trafficAI(car, dt, playerPos) {
const f = car.forward();
/* follow waypoint path */
if (!car.path || !car.path.length) { car.path = this.makePath(car.pos.x, car.pos.z, 8); car.wp = 0; }
let w = car.path[car.wp];
if (!w) { car.path = this.makePath(car.pos.x, car.pos.z, 8); car.wp = 0; w = car.path[0]; }
const dx = w.x - car.pos.x, dz = w.z - car.pos.z;
const d = Math.hypot(dx, dz);
if (d < 9) {
car.wp++;
if (car.wp >= car.path.length) {
car.path = this.makePath(car.path[car.path.length - 1].node[0] * VC.CITY.CELL, car.path[car.path.length - 1].node[1] * VC.CITY.CELL, 7);
car.wp = 0;
}
w = car.path[car.wp] || w;
}
/* steer toward the waypoint */
const want = Math.atan2(w.x - car.pos.x, w.z - car.pos.z);
const err = U.wrapAngle(want - car.heading);
let steer = U.clamp(err * 1.5, -1, 1);
/* avoid the car in front */
let brake = 0;
const list = this.cars;
for (let i = 0; i < list.length; i++) {
const o = list[i];
if (o === car || o.dead) continue;
const ox = o.pos.x - car.pos.x, oz = o.pos.z - car.pos.z;
const od = Math.hypot(ox, oz);
if (od > 16) continue;
const ahead = (ox * f.x + oz * f.z) / (od || 1);
if (ahead > 0.86) {
brake = Math.max(brake, U.clamp(1 - (od - 5) / 9, 0, 1));
if (od < 7) steer += (ox * f.z - oz * f.x) > 0 ? -0.4 : 0.4;
}
}
/* avoid the player on foot */
const pd = Math.hypot(playerPos.x - car.pos.x, playerPos.z - car.pos.z);
if (pd < 12) {
const ahead = ((playerPos.x - car.pos.x) * f.x + (playerPos.z - car.pos.z) * f.z) / (pd || 1);
if (ahead > 0.8) {
brake = Math.max(brake, U.clamp(1 - pd / 10, 0, 1));
if (car.honk <= 0 && pd < 6) { car.honk = 3; this.game.audio.horn(car.pos); }
}
}
car.honk = Math.max(0, car.honk - dt);
const spd = car.speed();
const target = (car.speedTarget || 15) * (1 - U.clamp(Math.abs(err) * 0.9, 0, 0.65));
const throttle = brake > 0.05 ? 0 : U.clamp((target - spd) * 0.22, -0.5, 1);
return { throttle, brake, steer: U.clamp(steer, -1, 1), hand: 0 };
}
policeAI(car, dt, player) {
const p = player.pos;
const dx = p.x - car.pos.x, dz = p.z - car.pos.z;
const d = Math.hypot(dx, dz);
const f = car.forward();
let brake = 0;
/* Far out, follow the street route: pointing the wheel straight at the
* player works perfectly in open water and not at all in a grid of
* city blocks, where the cruiser would simply park against a wall. */
let aim = { x: p.x, z: p.z };
if (d > 62) {
car.crep = (car.crep || 0) - dt;
if (!car.cpath || car.cwp >= car.cpath.length || car.crep <= 0) {
car.cpath = this.chasePath(car.pos.x, car.pos.z, p.x, p.z);
car.cwp = 0;
car.crep = 2.4;
}
let w = car.cpath[car.cwp];
if (w && Math.hypot(w.x - car.pos.x, w.z - car.pos.z) < 11) w = car.cpath[++car.cwp] || w;
if (w) aim = w;
} else if (car.cpath) {
/* last block: aim at the player and drive around whoever is there */
car.cpath = null;
}
const want = Math.atan2(aim.x - car.pos.x, aim.z - car.pos.z);
const err = U.wrapAngle(want - car.heading);
/* avoid the car directly ahead - but a cruiser still pushes through,
* otherwise a line of traffic parks the whole pursuit */
for (const o of this.cars) {
if (o === car || o.dead) continue;
const ox = o.pos.x - car.pos.x, oz = o.pos.z - car.pos.z;
const od = Math.hypot(ox, oz);
if (od > 9) continue;
if ((ox * f.x + oz * f.z) / (od || 1) > 0.94) brake = Math.max(brake, U.clamp(1 - (od - 4) / 5, 0, 0.62));
}
/* pull up alongside a fleeing pedestrian instead of running them over */
const stopDist = player.inCar ? 0 : 6.5;
if (d < stopDist) brake = 1;
const spd = car.speed();
const target = (car.speedTarget || 24) * (d < 22 ? 0.7 : d < 70 ? 0.9 : 1);
return {
throttle: brake > 0.5 ? 0 : U.clamp((target - spd) * 0.25, 0, 1),
brake,
steer: U.clamp(err * 1.7, -1, 1),
hand: 0,
};
}
/* ------------------------------------------------------------- loop ---- */
update(dt) {
const game = this.game;
const pp = game.player.pos;
const wanted = game.wanted;
/* spawn / recycle so the streets always feel busy near the player */
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnTimer = 0.45;
let traffic = 0, parked = 0;
for (const c of this.cars) {
const d = Math.hypot(c.pos.x - pp.x, c.pos.z - pp.z);
if (c.kind === 'traffic' && d < 320) traffic++;
if (c.kind === 'parked' && d < 260) parked++;
}
if (traffic < this.trafficTarget && this.cars.length < 220) this.spawnTraffic(pp);
if (parked < this.parkedTarget && this.cars.length < 220) this.spawnParked(pp);
}
/* police presence follows the wanted level */
this.copTimer -= dt;
let cops = 0;
for (const c of this.cars) if (c.kind === 'police' && !c.dead) cops++;
const want = wanted <= 0 ? 0 : Math.min(6, wanted + 1);
if (this.copTimer <= 0) {
this.copTimer = wanted >= 5 ? 1.1 : wanted >= 4 ? 1.5 : wanted >= 3 ? 2.2 : 3.4;
if (cops < want) {
this.spawnPolice(pp, wanted);
/* a proper chase arrives in force, not one cruiser at a time */
if (wanted >= 4 && cops + 1 < want) this.spawnPolice(pp, wanted);
}
}
for (let i = this.cars.length - 1; i >= 0; i--) {
const car = this.cars[i];
const d = Math.hypot(car.pos.x - pp.x, car.pos.z - pp.z);
car.visibleDist = d;
/* cull / recycle far away traffic */
if (!car.occupied && car.kind !== 'player') {
if (d > (car.kind === 'parked' ? 300 : 420) || (car.dead && d > 260 && car.age > 20)) {
this.recycle(car, i);
continue;
}
} else if (d > 700) {
this.recycle(car, i);
continue;
}
/* AI */
if (!car.occupied || car.kind === 'traffic' || car.kind === 'police') {
if (car.kind === 'traffic') car.input = this.trafficAI(car, dt, pp);
else if (car.kind === 'police') {
car.input = this.policeAI(car, dt, game.player);
/* officers shoot at high wanted levels */
if (wanted >= 2 && d < 26 && !game.player.dead) {
car.shootTimer = (car.shootTimer || VC.rng.r(0.4, 1.6)) - dt;
if (car.shootTimer <= 0) {
car.shootTimer = VC.rng.r(0.5, 1.4);
game.policeShoot(car);
}
}
} else if (car.kind === 'wreck') car.input = { throttle: 0, brake: 1, steer: 0, hand: 1 };
else car.input = { throttle: 0, brake: 1, steer: 0, hand: 1 };
}
if (car.kind === 'police' && d < 220) car.sirenOn = true;
car.step(dt, pp);
}
/* wheels: one instanced draw call for the whole city */
this.wheels.begin();
for (const car of this.cars) {
if (car.visibleDist > 190) continue;
const s = car.spec, r = car.right(), f = car.forward();
const bike = car.model === 'bike';
const hw = bike ? 0 : s.W / 2 - 0.12;
const ww = bike ? 0.16 : 0.26;
const steerA = car.steer * 0.42;
for (const side of (bike ? [0] : [-1, 1])) {
for (const fb of [1, -1]) {
const sx = side * hw, sz = fb * (s.wb / 2);
const x = car.pos.x + r.x * sx + f.x * sz;
const z = car.pos.z + r.z * sx + f.z * sz;
this.wheels.add(x, car.pos.y + s.wr, z, s.wr, ww, car.wheelRot, fb > 0 ? steerA : 0);
}
}
}
this.wheels.end();
/* engine audio for the car we are driving + traffic hum */
let hum = 0;
for (const c of this.cars) {
const d = Math.hypot(c.pos.x - pp.x, c.pos.z - pp.z);
if (d < 40 && c.speed() > 3 && !c.dead) hum += (1 - d / 40) * 0.5;
if (c.kind === 'police' && !c.dead && d < 120) hum += 0.35;
}
game.audio.setTraffic(Math.min(1, hum) * (game.player.inCar ? 0.5 : 1));
}
recycle(car, index) {
this.scene.remove(car.root);
car.root.traverse((o) => { if (o.isMesh && o.geometry && o.geometry.dispose && !o.userData.shared) o.geometry.dispose(); });
this.cars.splice(index === undefined ? this.cars.indexOf(car) : index, 1);
if (this.game.player.vehicle === car) { this.game.player.vehicle = null; this.game.player.inCar = false; }
}
};
})();
/*! ---- js/peds.js ---- */
/* ============================================================================
* peds.js - pedestrians. Each one is a handful of merged boxes with colours
* baked into vertex colours (one shared material), animated procedurally.
* States: walk / idle / flee / dead / cop.
* ==========================================================================*/
(function () {
const U = VC.util;
const MAT = {};
const SKIN = [0x9c6239, 0xc68642, 0xe0ac69, 0xf1c27d, 0xffdbac, 0x8d5524, 0xd9a066];
const SHIRTS = [0xff5f8a, 0x3fd0d4, 0xffd166, 0x9b5de5, 0x00f5d4, 0xf15bb5, 0xffffff,
0x00bbf9, 0xee6b22, 0x7ae582, 0xff99c8, 0x2b2d42];
const PANTS = [0x2b3a55, 0x44444a, 0xd9cbb0, 0x6b21a8, 0x1f1f24, 0x8d5b21, 0xb23a48, 0xe8e8e8];
const HAIR = [0x1a1a1a, 0x3b2416, 0x6b4423, 0xc9a227, 0xf2f2f2, 0xff5ea8, 0x3aa0ff];
function paint(geom, col, shade) {
const n = geom.attributes.position.count;
const c = new Float32Array(n * 3);
const cc = new THREE.Color(col);
if (shade !== undefined) cc.multiplyScalar(shade);
for (let i = 0; i < n; i++) { c[i * 3] = cc.r; c[i * 3 + 1] = cc.g; c[i * 3 + 2] = cc.b; }
geom.setAttribute('color', new THREE.BufferAttribute(c, 3));
return geom;
}
function bx(x, y, z, w, h, d, col, shade) {
const list = VC.geo.box(x, y, z, w, h, d, [1, 1, 1], 1);
const m = VC.mergeGeoms(list);
for (const g of list) g.dispose();
if (!m.attributes.uv) {
m.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(m.attributes.position.count * 2), 2));
}
return paint(m, col, shade);
}
function ball(x, y, z, r, col) {
const g = new THREE.SphereGeometry(r, 8, 6);
g.translate(x, y, z);
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(g.attributes.position.count * 2), 2));
return paint(g, col);
}
VC.Ped = class Ped {
constructor(mgr, opts) {
this.mgr = mgr;
const rng = VC.rng;
const cop = !!(opts && opts.cop);
const beach = !!(opts && opts.beach);
this.cop = cop;
const skin = rng.pick(SKIN);
const shirt = cop ? 0x1d2b52 : beach ? rng.pick([0xff5f8a, 0x3fd0d4, 0xffd166, 0xff99c8]) : rng.pick(SHIRTS);
const pants = cop ? 0x171f38 : beach ? rng.pick([0x2b6cff, 0xff4d6d, 0x22c1a8, 0xffd166]) : rng.pick(PANTS);
const hair = rng.pick(HAIR);
const scale = rng.r(0.93, 1.08);
const female = !cop && rng.chance(0.45);
this.root = new THREE.Group();
this.root.scale.setScalar(scale);
this.body = new THREE.Group();
this.root.add(this.body);
/* torso + head + accessories merged into one mesh */
const parts = [];
const torsoH = female ? 0.52 : 0.56;
parts.push(bx(0, 1.06, 0, 0.36, torsoH, 0.22, shirt));
if (!beach) parts.push(bx(0, 1.30, 0, 0.375, 0.10, 0.235, shirt, 1.18)); /* collar band */
parts.push(bx(0, 0.79, 0, 0.34, 0.12, 0.21, pants)); /* belt / hips */
parts.push(ball(0, 1.53, 0, 0.135, skin)); /* head */
parts.push(bx(0, 1.60, -0.005, 0.24, 0.16, 0.245, hair)); /* hair */
parts.push(bx(0, 1.545, 0.115, 0.20, 0.035, 0.03, 0x14161c)); /* shades */
if (cop) {
parts.push(bx(0, 1.68, 0, 0.24, 0.07, 0.26, 0x16204a)); /* cap */
parts.push(bx(0, 1.68, 0.15, 0.24, 0.035, 0.09, 0x16204a)); /* cap peak */
parts.push(bx(0.19, 1.12, 0.03, 0.08, 0.2, 0.12, 0x2a2a30)); /* radio */
}
if (!beach && rng.chance(0.25)) parts.push(bx(-0.24, 0.95, 0.03, 0.14, 0.22, 0.1, rng.pick([0xd94f70, 0x3fa7d6, 0xe8c39e]))); /* bag */
const bodyMesh = new THREE.Mesh(VC.mergeGeoms(parts), MAT.body);
for (const g of parts) g.dispose();
bodyMesh.castShadow = true;
this.body.add(bodyMesh);
/* limbs: separate pivots so we can animate them */
this.limbs = [];
for (const side of [-1, 1]) {
const hip = new THREE.Group();
hip.position.set(side * 0.095, 0.79, 0);
const leg = new THREE.Mesh(bx(0, -0.36, 0, 0.145, 0.72, 0.16, pants), MAT.body);
leg.castShadow = true;
hip.add(leg);
const shoe = new THREE.Mesh(bx(0, -0.72, 0.03, 0.15, 0.08, 0.24, 0x1b1b1f), MAT.body);
hip.add(shoe);
this.body.add(hip);
const sh = new THREE.Group();
sh.position.set(side * 0.235, 1.31, 0);
const arm = new THREE.Mesh(bx(0, -0.26, 0, 0.11, 0.52, 0.12, beach ? skin : shirt), MAT.body);
arm.castShadow = true;
sh.add(arm);
sh.add(new THREE.Mesh(ball(0, -0.55, 0, 0.062, skin), MAT.body));
this.body.add(sh);
this.limbs.push({ hip, sh, side });
}
/* gun in hand for cops */
if (cop) {
this.gun = new THREE.Mesh(bx(0, -0.6, 0.1, 0.06, 0.1, 0.26, 0x1a1a1e), MAT.body);
this.limbs[1].sh.add(this.gun);
}
this.pos = new THREE.Vector3();
this.yaw = rng.r(0, 6.28);
this.vel = new THREE.Vector3();
this.state = 'walk';
this.speed = 0;
this.walkPhase = rng.r(0, 6.28);
this.target = null;
this.timer = rng.r(1, 5);
this.alive = true;
this.deadT = 0;
this.fleeT = 0;
this.hp = cop ? 70 : 34;
this.shootTimer = rng.r(0.5, 2);
this.bob = 0;
this.mgr.scene.add(this.root);
}
place(x, z) {
this.pos.set(x, this.mgr.city.groundH(x, z), z);
this.root.position.copy(this.pos);
}
hurt(dmg, fromPos, mgr) {
if (!this.alive) return false;
this.hp -= dmg;
if (this.hp <= 0) { this.die(fromPos); return true; }
this.panic(5);
return false;
}
panic(t) {
if (!this.alive || this.cop) return;
this.state = 'flee';
this.fleeT = Math.max(this.fleeT, t);
}
die(fromPos) {
if (!this.alive) return;
this.alive = false;
this.state = 'dead';
this.deadT = 0;
const dir = fromPos ? Math.atan2(this.pos.x - fromPos.x, this.pos.z - fromPos.z) : this.yaw;
this.fallDir = dir;
this.vel.set(Math.sin(dir) * 2.2, 0, Math.cos(dir) * 2.2);
}
step(dt, game) {
const C = this.mgr.city;
if (!this.alive) {
this.deadT += dt;
/* ragdoll-ish topple then sink */
this.vel.multiplyScalar(Math.exp(-3 * dt));
this.pos.x += this.vel.x * dt; this.pos.z += this.vel.z * dt;
this.pos.y = C.groundH(this.pos.x, this.pos.z);
const fall = U.clamp01(this.deadT * 3.2);
this.body.rotation.x = -fall * Math.PI * 0.5 * Math.cos(this.fallDir - this.yaw);
this.body.rotation.z = fall * Math.PI * 0.5 * Math.sin(this.fallDir - this.yaw);
this.pos.y -= Math.max(0, (this.deadT - 9) * 0.5);
this.root.position.copy(this.pos);
this.root.position.y = this.pos.y;
return;
}
const pp = game.player.pos;
let move = 0, wantYaw = this.yaw;
if (this.state === 'flee') {
this.fleeT -= dt;
const dx = this.pos.x - pp.x, dz = this.pos.z - pp.z;
const d = Math.hypot(dx, dz) || 1;
wantYaw = Math.atan2(dx / d, dz / d);
move = d < 60 ? 4.1 : 2.2;
if (this.fleeT <= 0) { this.state = 'walk'; this.target = null; }
} else if (this.state === 'cop') {
const dx = pp.x - this.pos.x, dz = pp.z - this.pos.z;
const d = Math.hypot(dx, dz);
wantYaw = Math.atan2(dx, dz);
move = d > 9 ? 3.4 : 0;
this.shootTimer -= dt;
if (d < 26 && this.shootTimer <= 0) {
this.shootTimer = U.clamp(0.45 + Math.random() * 1.1, 0.3, 1.6);
game.pedShoot(this);
}
} else {
/* walk to a target, occasionally idle */
this.timer -= dt;
if (!this.target || this.timer <= 0 ||
Math.hypot(this.target.x - this.pos.x, this.target.z - this.pos.z) < 2.2) {
this.newTarget(game);
}
if (this.target) {
wantYaw = Math.atan2(this.target.x - this.pos.x, this.target.z - this.pos.z);
move = 1.35;
}
if (this.state === 'idle') move = 0;
}
/* smooth turning */
this.yaw = U.angleDamp(this.yaw, wantYaw, 8, dt);
const spd = move;
const fx = Math.sin(this.yaw), fz = Math.cos(this.yaw);
let nx = this.pos.x + fx * spd * dt, nz = this.pos.z + fz * spd * dt;
const res = C.world.resolveCircle(nx, nz, 0.3, 1.6);
nx = res.x; nz = res.z;
if (res.hit && this.state === 'walk') { this.target = null; this.timer = 0.2; }
if (C.isDeepWater(nx, nz)) { this.target = null; wantYaw += 2.2; }
this.pos.x = nx; this.pos.z = nz;
const gy = C.groundH(nx, nz);
this.pos.y = U.damp(this.pos.y, gy, 14, dt);
this.speed = spd;
/* procedural walk cycle */
const cadence = 2.05 + spd * 0.95;
this.walkPhase += dt * cadence;
const sw = Math.sin(this.walkPhase) * (0.18 + spd * 0.14);
const sw2 = Math.cos(this.walkPhase);
for (const L of this.limbs) {
const s = L.side;
L.hip.rotation.x = sw * s;
L.sh.rotation.x = -sw * s * 0.85;
L.sh.rotation.z = s * (0.07 + Math.abs(sw) * 0.05);
if (this.state === 'flee') { /* arms up, screaming */
L.sh.rotation.x = -2.2;
L.sh.rotation.z = s * 0.5;
}
}
this.bob = Math.abs(Math.sin(this.walkPhase)) * spd * 0.03;
this.body.position.y = this.bob;
this.body.rotation.y = Math.sin(this.walkPhase) * 0.05 * spd;
this.body.rotation.z = sw2 * 0.02 * spd;
this.root.position.copy(this.pos);
this.root.position.y = this.pos.y;
this.root.rotation.y = this.yaw;
}
newTarget(game) {
const rng = VC.rng;
const C = this.mgr.city;
this.timer = rng.r(2, 8);
if (this.state === 'idle' && rng.chance(0.6)) { this.state = 'walk'; this.target = null; return; }
if (rng.chance(0.14)) { this.state = 'idle'; this.target = null; return; }
this.state = 'walk';
/* pick a point on a nearby sidewalk ring */
for (let t = 0; t < 12; t++) {
const horiz = rng.chance(0.5);
const line = horiz ? rng.pick(C.roadZ) : rng.pick(C.roadX);
const along = this.pos.x + rng.r(-26, 26);
const side = rng.chance(0.5) ? 1 : -1;
const off = (VC.CITY.ROAD / 2 + 1.9) * side;
const x = horiz ? U.clamp(along, VC.CITY.IX0, VC.CITY.IX1) : line + off;
const z = horiz ? line + off : U.clamp(this.pos.z + rng.r(-26, 26), VC.CITY.IZ0, VC.CITY.IZ1);
if (C.isDeepWater(x, z)) continue;
this.target = { x, z };
return;
}
this.target = { x: this.pos.x + rng.r(-6, 6), z: this.pos.z + rng.r(-6, 6) };
}
};
VC.Peds = class Peds {
constructor(scene, city, game) {
this.scene = scene; this.city = city; this.game = game;
if (!MAT.body) {
MAT.body = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.88, metalness: 0.02, envMap: VC.sky.envMap, envMapIntensity: 0.35 });
}
this.peds = [];
this.target = 30;
this.spawnTimer = 0;
}
spawn(opts) {
const p = new VC.Ped(this, opts);
const rng = VC.rng;
const C = VC.CITY;
const pp = this.game.player.pos;
for (let t = 0; t < 30; t++) {
let x, z;
if (opts && opts.beach) {
const e = rng.i(0, 3);
const band = rng.r(4, 26);
if (e === 0) { x = rng.r(C.IX0, C.IX1); z = C.IZ0 - band; }
else if (e === 1) { x = rng.r(C.IX0, C.IX1); z = C.IZ1 + band; }
else if (e === 2) { x = C.IX0 - band; z = rng.r(C.IZ0, C.IZ1); }
else { x = C.IX1 + band; z = rng.r(C.IZ0, C.IZ1); }
} else {
const horiz = rng.chance(0.5);
const line = horiz ? rng.pick(this.city.roadZ) : rng.pick(this.city.roadX);
const off = (C.ROAD / 2 + 1.9) * (rng.chance(0.5) ? 1 : -1);
x = horiz ? rng.r(C.IX0 + 10, C.IX1 - 10) : line + off;
z = horiz ? line + off : rng.r(C.IZ0 + 10, C.IZ1 - 10);
}
const d = Math.hypot(x - pp.x, z - pp.z);
if (d < 14 || d > 190) continue;
if (this.city.isDeepWater(x, z)) continue;
p.place(x, z);
this.peds.push(p);
return p;
}
return null;
}
spawnAt(x, z, opts) {
const p = new VC.Ped(this, opts);
const r = this.city.world.resolveCircle(x, z, 0.4, 1.7);
p.place(r.x, r.z);
this.peds.push(p);
return p;
}
spawnCopNear(pos) {
const p = new VC.Ped(this, { cop: true });
for (let t = 0; t < 24; t++) {
const a = VC.rng.r(0, 6.28), d = VC.rng.r(40, 90);
const x = pos.x + Math.cos(a) * d, z = pos.z + Math.sin(a) * d;
if (Math.abs(x) > VC.CITY.IX1 || Math.abs(z) > VC.CITY.IZ1) continue;
p.place(x, z);
p.state = 'cop';
this.peds.push(p);
return p;
}
return null;
}
update(dt) {
const pp = this.game.player.pos;
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawnTimer = 0.5;
let near = 0;
for (const p of this.peds) if (Math.hypot(p.pos.x - pp.x, p.pos.z - pp.z) < 170) near++;
if (near < this.target) {
this.spawn({ beach: VC.rng.chance(0.22) });
/* fill the pavement quickly when the player arrives in a new area */
if (near < this.target - 8) this.spawn({ beach: VC.rng.chance(0.22) });
}
}
for (let i = this.peds.length - 1; i >= 0; i--) {
const p = this.peds[i];
const d = Math.hypot(p.pos.x - pp.x, p.pos.z - pp.z);
if (d > 230 || (!p.alive && p.deadT > 12 && d > 40)) {
this.despawn(i);
continue;
}
/* peds panic when shots are fired nearby */
if (this.game.lastShotLoud && p.alive && !p.cop) {
const dd = Math.hypot(p.pos.x - this.game.lastShotPos.x, p.pos.z - this.game.lastShotPos.z);
if (dd < 45) p.panic(U.clamp(6 - dd * 0.06, 1.5, 6));
}
/* run over by vehicles */
if (p.alive) {
for (const c of this.game.vehicles.cars) {
if (c.dead || c.speed() < 4.5) continue;
const dd = Math.hypot(c.pos.x - p.pos.x, c.pos.z - p.pos.z);
if (dd < (c.spec.L * 0.45)) {
const f = c.forward();
p.hurt(200, c.pos, this);
p.vel.set(f.x * 7, 0, f.z * 7);
this.game.fx.impact(p.pos.x, p.pos.y + 1.0, p.pos.z, -f.x, 0.4, -f.z, 'flesh');
this.game.audio.flesh(p.pos);
if (c.kind === 'player') {
this.game.crime(1, 'Mass Murder');
this.game.addCash(60);
}
break;
}
}
}
p.step(dt, this.game);
}
}
despawn(i) {
const p = this.peds[i];
this.scene.remove(p.root);
p.root.traverse((o) => { if (o.isMesh && o.geometry) o.geometry.dispose(); });
this.peds.splice(i, 1);
}
/* peds within a radius, for shooting */
query(x, z, r, out) {
out.length = 0;
for (const p of this.peds) {
if (!p.alive) continue;
if (Math.hypot(p.pos.x - x, p.pos.z - z) < r) out.push(p);
}
return out;
}
};
})();
/*! ---- js/player.js ---- */
/* ============================================================================
* player.js - Tommy-ish protagonist: third-person controller, weapon slots,
* hitscan shooting with tracers/spread/recoil, car jacking and driving.
* ==========================================================================*/
(function () {
const U = VC.util;
const WEAPONS = {
fist: { name: 'FISTS', dmg: 12, rate: 0.5, ammo: Infinity, mag: Infinity, spread: 0.02, auto: false, kind: 'melee', range: 2.4 },
pistol: {
name: 'M4 PISTOL', dmg: 34, rate: 0.17, mag: 12, reserve: 120, spread: 0.011, ads: 0.0025,
auto: false, kind: 'bullet', range: 160, sound: 'pistol', kick: 0.9, reload: 1.5,
},
smg: {
name: 'UZI', dmg: 21, rate: 0.075, mag: 30, reserve: 300, spread: 0.03, ads: 0.011,
auto: true, kind: 'bullet', range: 130, sound: 'smg', kick: 0.62, reload: 2.0,
},
shotgun: {
name: 'S12 SHOTGUN', dmg: 17, pellets: 8, rate: 0.85, mag: 7, reserve: 60, spread: 0.075, ads: 0.045,
auto: false, kind: 'bullet', range: 55, sound: 'shotgun', kick: 2.4, reload: 2.2,
},
};
VC.WEAPONS = WEAPONS;
const MAT = {};
function paint(geom, col, shade) {
const n = geom.attributes.position.count;
const c = new Float32Array(n * 3);
const cc = new THREE.Color(col);
if (shade !== undefined) cc.multiplyScalar(shade);
for (let i = 0; i < n; i++) { c[i * 3] = cc.r; c[i * 3 + 1] = cc.g; c[i * 3 + 2] = cc.b; }
geom.setAttribute('color', new THREE.BufferAttribute(c, 3));
return geom;
}
function ensure(geom) {
if (!geom.attributes.uv) geom.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(geom.attributes.position.count * 2), 2));
return geom;
}
function bx(x, y, z, w, h, d, col, shade) {
const list = VC.geo.box(x, y, z, w, h, d, [1, 1, 1], 1);
const m = VC.mergeGeoms(list);
for (const g of list) g.dispose();
return paint(ensure(m), col, shade);
}
function ball(x, y, z, r, col) {
const g = new THREE.SphereGeometry(r, 10, 8);
g.translate(x, y, z);
return paint(ensure(g), col);
}
VC.Player = class Player {
constructor(game) {
this.game = game;
const scene = game.scene;
if (!MAT.body) {
MAT.body = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.82, metalness: 0.03, envMap: VC.sky.envMap, envMapIntensity: 0.45 });
MAT.gun = new THREE.MeshStandardMaterial({ color: 0x1b1c20, roughness: 0.42, metalness: 0.85, envMap: VC.sky.envMap, envMapIntensity: 1.1 });
MAT.muzzle = new THREE.MeshBasicMaterial({ color: new THREE.Color(3.2, 2.2, 0.9), toneMapped: false, transparent: true, opacity: 0.95 });
}
this.pos = new THREE.Vector3(0, VC.CITY.WALK, 0);
this.vel = new THREE.Vector3();
this.yaw = 0;
this.bodyYaw = 0;
this.onGround = true;
this.health = 100; this.maxHealth = 100;
this.armor = 0;
this.cash = 0;
this.dead = false;
this.deadT = 0;
this.inCar = false;
this.vehicle = null;
this.speed = 0;
this.stepPhase = 0;
this.recoil = 0;
this.aimWeight = 0;
this.fireTimer = 0;
this.reloadTimer = 0;
this.invuln = 0;
/* weapon inventory */
this.slot = 1;
this.slots = ['fist', 'pistol', null, null];
this.ammo = { pistol: { mag: 12, reserve: 120 }, smg: { mag: 0, reserve: 0 }, shotgun: { mag: 0, reserve: 0 } };
/* ---------------------------------------------------------- visuals */
this.root = new THREE.Group();
this.body = new THREE.Group();
this.root.add(this.body);
const skin = 0xd8a06a, shirt = 0x7fd4d0, pants = 0x2a3348, hair = 0x3a2a1c;
const parts = [];
parts.push(bx(0, 1.08, 0, 0.37, 0.56, 0.23, shirt));
parts.push(bx(0, 1.31, 0, 0.385, 0.09, 0.245, 0xffffff));
parts.push(bx(0, 1.14, 0.115, 0.16, 0.3, 0.03, 0xf0f0f0)); /* shirt print */
parts.push(bx(0, 0.80, 0, 0.35, 0.13, 0.22, pants));
parts.push(ball(0, 1.56, 0, 0.135, skin));
parts.push(bx(0, 1.645, -0.01, 0.245, 0.15, 0.25, hair));
parts.push(bx(0, 1.575, 0.118, 0.205, 0.038, 0.03, 0x101216)); /* sunglasses */
parts.push(bx(0, 1.47, 0.11, 0.1, 0.05, 0.04, skin, 0.86)); /* nose */
const mesh = new THREE.Mesh(VC.mergeGeoms(parts), MAT.body);
for (const g of parts) g.dispose();
mesh.castShadow = true;
this.body.add(mesh);
this.legs = [];
this.arms = [];
for (const side of [-1, 1]) {
const hip = new THREE.Group(); hip.position.set(side * 0.098, 0.80, 0);
const leg = new THREE.Mesh(bx(0, -0.37, 0, 0.15, 0.74, 0.17, pants), MAT.body);
leg.castShadow = true;
hip.add(leg);
hip.add(new THREE.Mesh(bx(0, -0.745, 0.035, 0.155, 0.08, 0.25, 0x191a1e), MAT.body));
this.body.add(hip); this.legs.push({ g: hip, side });
const sh = new THREE.Group(); sh.position.set(side * 0.24, 1.33, 0);
const arm = new THREE.Mesh(bx(0, -0.26, 0, 0.115, 0.52, 0.13, shirt), MAT.body);
arm.castShadow = true;
sh.add(arm);
sh.add(new THREE.Mesh(ball(0, -0.55, 0, 0.065, skin), MAT.body));
this.body.add(sh); this.arms.push({ g: sh, side });
}
/* gun in the right hand */
this.gun = new THREE.Group();
this.gun.position.set(0, -0.56, 0.06);
this.gunMesh = new THREE.Mesh(this.gunGeom('pistol'), MAT.gun);
this.gun.add(this.gunMesh);
this.muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.09, 6, 5), MAT.muzzle);
this.muzzle.position.set(0, 0.03, 0.3);
this.muzzle.visible = false;
this.gun.add(this.muzzle);
this.arms[1].g.add(this.gun); /* right arm */
scene.add(this.root);
this.setWeapon(this.slot);
}
gunGeom(kind) {
const g = [];
if (kind === 'pistol') {
g.push(bx(0, 0, 0.06, 0.055, 0.09, 0.24, 0x1b1c20));
g.push(bx(0, -0.1, -0.04, 0.05, 0.14, 0.07, 0x24262c));
} else if (kind === 'smg') {
g.push(bx(0, 0, 0.09, 0.07, 0.11, 0.38, 0x1b1c20));
g.push(bx(0, -0.13, -0.02, 0.055, 0.2, 0.08, 0x24262c));
g.push(bx(0, -0.06, 0.3, 0.04, 0.05, 0.16, 0x2c2e34));
} else if (kind === 'shotgun') {
g.push(bx(0, 0.01, 0.16, 0.07, 0.09, 0.55, 0x1b1c20));
g.push(bx(0, -0.09, -0.12, 0.06, 0.12, 0.22, 0x5a3a22));
} else {
g.push(bx(0, 0, 0.05, 0.05, 0.08, 0.18, 0x1b1c20));
}
return VC.mergeGeoms(g);
}
give(kind, amount) {
if (!WEAPONS[kind]) return;
let idx = this.slots.indexOf(kind);
if (idx < 0) {
idx = [1, 2, 3].find((i) => !this.slots[i]);
if (idx === undefined) idx = this.slot === 0 ? 1 : this.slot;
this.slots[idx] = kind;
}
const a = this.ammo[kind];
const gift = amount === undefined ? (kind === 'pistol' ? 120 : kind === 'smg' ? 240 : 40) : amount;
a.reserve += gift;
if (a.mag <= 0) a.mag = WEAPONS[kind].mag;
this.setSlot(idx);
}
setSlot(i) {
if (!this.slots[i]) return false;
this.slot = i; this.setWeapon(i); return true;
}
setWeapon(i) {
const w = WEAPONS[this.slots[i]];
this.reloadTimer = 0;
if (!this.gun) return;
if (!w || w.kind === 'melee') { this.gun.visible = false; return; }
this.gun.visible = true;
this.body.remove(this.gunMesh);
this.gunMesh.geometry.dispose();
this.gunMesh = new THREE.Mesh(this.gunGeom(this.slots[i]), MAT.gun);
this.gun.add(this.gunMesh);
this.game.hud && this.game.hud.flashWeapon && this.game.hud.flashWeapon(w.name);
}
weapon() { return WEAPONS[this.slots[this.slot]]; }
ammoOf() { const w = this.weapon(); return this.ammo[this.slots[this.slot]] || { mag: Infinity, reserve: 0 }; }
/* ------------------------------------------------------------- damage */
hurt(amount, fromPos, silent) {
if (this.dead || this.invuln > 0) return;
if (this.armor > 0) {
const absorbed = Math.min(this.armor, amount * 0.65);
this.armor -= absorbed; amount -= absorbed;
}
this.health -= amount;
this.game.postfx.damage = Math.min(1, this.game.postfx.damage + amount / 55);
this.game.shakeScreen(U.clamp(amount / 26, 0.1, 0.7));
if (!silent) this.game.audio.crash(U.clamp(amount / 18, 0.2, 0.7), fromPos);
if (this.health <= 0) this.die();
}
die() {
if (this.dead) return;
this.dead = true; this.deadT = 0;
this.health = 0;
if (this.inCar) this.game.ejectFromCar();
this.game.onPlayerDied();
this.game.audio.wasted();
}
/* ---------------------------------------------------------------- loop */
update(dt, input) {
this.game.postfx.damage = Math.max(0, this.game.postfx.damage - dt * 0.9);
this.invuln = Math.max(0, this.invuln - dt);
if (this.dead) {
this.deadT += dt;
this.body.rotation.x = -U.clamp01(this.deadT * 2.2) * Math.PI * 0.5;
this.body.position.y = -U.clamp01(this.deadT * 2.2) * 0.25;
this.root.visible = !this.inCar;
return;
}
if (this.inCar) {
this.root.visible = false;
const v = this.vehicle;
if (v) {
this.pos.set(v.pos.x, v.pos.y, v.pos.z);
if (v.dead) this.game.ejectFromCar();
}
return;
}
this.root.visible = true;
const w = this.weapon();
const ads = input.ads;
this.aimWeight = U.damp(this.aimWeight, ads ? 1 : (input.firing ? 0.85 : 0), 12, dt);
/* ---------------- movement ---------------- */
const cam = this.game.camYaw;
let mx = 0, mz = 0;
if (input.f) mz += 1;
if (input.b) mz -= 1;
if (input.l) mx -= 1;
if (input.r) mx += 1;
const mag = Math.hypot(mx, mz);
if (mag > 0) {
mx /= mag; mz /= mag;
/* camera relative */
const fx = Math.sin(cam), fz = Math.cos(cam);
const rx = -fz, rz = fx;
const dx = fx * mz + rx * mx, dz = fz * mz + rz * mx;
const sprint = input.sprint && !ads && mz > 0.3;
const speed = (sprint ? 7.2 : ads ? 2.5 : 4.35) * (this.armor >= 100 ? 1 : 1);
this.vel.x = U.damp(this.vel.x, dx * speed, sprint ? 11 : 14, dt);
this.vel.z = U.damp(this.vel.z, dz * speed, sprint ? 11 : 14, dt);
this.bodyYaw = Math.atan2(dx, dz);
} else {
this.vel.x = U.damp(this.vel.x, 0, 14, dt);
this.vel.z = U.damp(this.vel.z, 0, 14, dt);
}
/* jump */
if (input.jump && this.onGround) {
this.vel.y = 5.2; this.onGround = false;
this.game.audio.ui(240);
}
this.vel.y -= 17 * dt;
let nx = this.pos.x + this.vel.x * dt;
let nz = this.pos.z + this.vel.z * dt;
const res = this.game.city.world.resolveCircle(nx, nz, 0.34, 1.7);
nx = res.x; nz = res.z;
this.pos.x = nx; this.pos.z = nz;
const gy = this.game.city.groundH(nx, nz);
this.pos.y += this.vel.y * dt;
if (this.pos.y <= gy) {
if (!this.onGround && this.vel.y < -6) this.game.audio.footstep();
this.pos.y = gy; this.vel.y = 0; this.onGround = true;
} else this.onGround = false;
this.speed = Math.hypot(this.vel.x, this.vel.z);
/* footsteps */
if (this.onGround && this.speed > 0.6) {
this.stepPhase += this.speed * dt * 1.5;
if (this.stepPhase > 1) { this.stepPhase = 0; this.game.audio.footstep(); }
}
/* ---------------- shooting ---------------- */
this.fireTimer -= dt;
if (this.reloadTimer > 0) {
this.reloadTimer -= dt;
if (this.reloadTimer <= 0) this.finishReload();
}
if (input.reload && this.reloadTimer <= 0) this.startReload();
if (input.firing && this.fireTimer <= 0 && this.reloadTimer <= 0) this.fire(ads);
this.recoil = U.damp(this.recoil, 0, 9, dt);
this.muzzleT = Math.max(0, (this.muzzleT || 0) - dt);
this.muzzle.visible = this.muzzleT > 0;
/* ---------------- animation ---------------- */
/* face the aim direction while aiming, otherwise travel direction */
const aimYaw = this.game.camYaw;
let want = this.bodyYaw;
if (this.aimWeight > 0.05) want = U.angleLerp(this.bodyYaw, aimYaw, this.aimWeight);
if (this.aimWeight > 0.5) want = aimYaw;
this.yaw = U.angleDamp(this.yaw, want, 15, dt);
this.root.position.copy(this.pos);
this.root.rotation.y = this.yaw;
const walk = this.speed;
this.walkPhase = (this.walkPhase || 0) + dt * (2.4 + walk * 0.9);
const sw = Math.sin(this.walkPhase) * (0.1 + walk * 0.13);
for (const L of this.legs) L.g.rotation.x = sw * L.side * (this.onGround ? 1 : 0.25);
const aim = this.aimWeight;
for (const A of this.arms) {
const s = A.side;
if (s > 0) { /* right (gun) arm */
A.g.rotation.x = U.lerp(-sw * 0.75 - 0.28, -1.52, aim) - this.recoil * 0.45;
A.g.rotation.z = U.lerp(s * 0.09, -0.16, aim);
} else {
A.g.rotation.x = U.lerp(sw * 0.75 - 0.12, -1.32, aim);
A.g.rotation.z = U.lerp(s * 0.09, 0.42, aim);
}
}
this.body.rotation.y = U.lerp(0, U.wrapAngle(aimYaw - this.yaw), aim * 0.55);
this.body.position.y = Math.abs(Math.sin(this.walkPhase)) * walk * 0.022;
this.gun.rotation.x = -this.recoil * 0.5;
this.gun.position.z = 0.06 - this.recoil * 0.05;
}
/* ------------------------------------------------------------- guns -- */
startReload() {
const k = this.slots[this.slot], w = WEAPONS[k], a = this.ammo[k];
if (!w || w.kind !== 'bullet' || !a) return;
if (a.mag >= w.mag || a.reserve <= 0) return;
this.reloadTimer = w.reload;
this.game.audio.reload(this.pos);
this.game.hud.showReload(w.reload);
}
finishReload() {
const k = this.slots[this.slot], w = WEAPONS[k], a = this.ammo[k];
const need = w.mag - a.mag;
const take = Math.min(need, a.reserve);
a.mag += take; a.reserve -= take;
this.game.audio.reload(this.pos);
this.game.hud.hideReload();
}
/* where the gun barrel actually is */
muzzleWorld(out) {
this.muzzle.getWorldPosition(out || new THREE.Vector3());
return out;
}
fire(ads) {
const w = this.weapon();
if (!w) return;
const k = this.slots[this.slot];
if (w.kind === 'melee') { this.melee(w); this.fireTimer = w.rate; return; }
const a = this.ammo[k];
if (a.mag <= 0) {
this.fireTimer = 0.4;
this.game.audio.dryFire(this.pos);
if (a.reserve > 0) this.startReload();
return;
}
a.mag--;
this.fireTimer = w.rate;
this.recoil = Math.min(1.6, this.recoil + w.kick * 0.5);
this.muzzleT = 0.045;
const cam = this.game.camera;
const from = this.muzzleWorld(new THREE.Vector3());
/* aim direction: from the eye through the crosshair, corrected to the gun */
const dir = this.game.aimDir(new THREE.Vector3());
const spread = (ads ? (w.ads || w.spread * 0.3) : w.spread) * (this.onGround ? 1 : 1.7) * (this.speed > 1 ? 1.35 : 1);
const pellets = w.pellets || 1;
const target = new THREE.Vector3();
const eye = cam.position.clone();
for (let p = 0; p < pellets; p++) {
const d = dir.clone();
if (pellets > 1 || spread > 0) {
d.x += (Math.random() - 0.5) * spread * 2;
d.y += (Math.random() - 0.5) * spread * 2;
d.z += (Math.random() - 0.5) * spread * 2;
d.normalize();
}
const hit = this.game.castBullet(eye, d, w.range);
target.copy(eye).addScaledVector(d, hit.dist);
/* tracer from the muzzle */
this.game.fx.tracer(from, target, 1);
if (hit.type === 'ped') {
this.game.fx.impact(target.x, target.y, target.z, -d.x, 0.3, -d.z, 'flesh');
this.game.audio.flesh(target);
const killed = hit.ped.hurt(w.dmg, this.pos);
if (killed) {
this.game.crime(1, null);
this.game.addCash(25);
this.game.hud.popup('+$25', '#ffd166');
} else {
this.game.crime(1, null);
}
} else if (hit.type === 'car') {
this.game.fx.impact(target.x, target.y, target.z, -d.x, 0.4, -d.z, hit.glass ? 'glass' : 'metal');
if (hit.glass) this.game.audio.glass(target); else this.game.audio.metal(target);
hit.car.damage(w.dmg * (hit.glass ? 0.7 : 1));
if (hit.car.kind === 'police') this.game.crime(1, null);
else this.game.crime(0.34, null);
} else if (hit.type === 'water') {
this.game.fx.splash(target.x, 0.05, target.z, 6);
} else if (hit.type !== 'air') {
this.game.fx.impact(target.x, target.y, target.z, hit.n.x, hit.n.y, hit.n.z, 'hard');
}
}
this.game.audio.shot(w.sound, { x: from.x, y: from.y, z: from.z });
this.game.fx.muzzle(from.x, from.y, from.z, dir.x, dir.y, dir.z, w.pellets ? 12 : 6);
this.game.lastShotLoud = this.game.time;
this.game.lastShotPos = { x: this.pos.x, y: this.pos.y, z: this.pos.z };
this.game.crime(0.16, null);
this.game.shakeScreen(ads ? 0.12 : 0.22);
if (a.mag === 0) this.startReload();
}
melee(w) {
this.game.audio.noiseHit({ freq: 700, q: 1.4, dur: 0.1, gain: 0.3, pos: this.pos });
const dir = new THREE.Vector3(Math.sin(this.game.camYaw), 0, Math.cos(this.game.camYaw));
const list = [];
this.game.peds.query(this.pos.x, this.pos.z, 3, list);
for (const p of list) {
const dx = p.pos.x - this.pos.x, dz = p.pos.z - this.pos.z;
if ((dx * dir.x + dz * dir.z) / (Math.hypot(dx, dz) || 1) > 0.6) {
if (p.hurt(w.dmg, this.pos)) { this.game.crime(1, null); this.game.addCash(20); }
this.game.fx.impact(p.pos.x, p.pos.y + 1.1, p.pos.z, -dir.x, 0.3, -dir.z, 'flesh');
this.game.audio.flesh(p.pos);
}
}
this.game.crime(0.2, null);
}
};
})();
/*! ---- js/game.js ---- */
/* ============================================================================
* game.js - renderer, camera rig, input, wanted system, pickups, HUD/radar
* and the frame loop. This ties every other module together.
* ==========================================================================*/
(function () {
const U = VC.util;
/* Our pipeline renders to a linear HDR target and does ACES + sRGB encode in
* the composite pass, so every authored colour must enter as *linear*. */
THREE.ColorManagement.legacyMode = false;
/* pointer-lock friendly key names */
const KEY = {
fwd: ['KeyW', 'ArrowUp'], back: ['KeyS', 'ArrowDown'], left: ['KeyA', 'ArrowLeft'],
right: ['KeyD', 'ArrowRight'], sprint: ['ShiftLeft', 'ShiftRight'], jump: ['Space'],
};
VC.Game = class Game {
constructor(canvas) {
this.canvas = canvas;
VC.game = this;
this.time = 0;
this.frames = 0;
this.fps = 60;
this.quality = 1;
this.paused = false;
this.started = false;
this.keys = {};
this.mouse = { dx: 0, dy: 0, lmb: false, rmb: false };
this.wanted = 0;
this.heat = 0;
this.heatCool = 0;
this.lastShotLoud = -99;
this.lastShotPos = { x: 0, y: 0, z: 0 };
this.pickups = [];
this.objective = null;
this.shakeAmt = 0;
this.camYaw = 0; this.camPitch = -0.12;
this.camDist = 5.0; this.camDistNow = 5.0;
this.fwd = { x: 0, z: -1 };
this.tmp = new THREE.Vector3();
this.copPedTimer = 3;
this.starsEl = null;
this.running = false;
}
/* ------------------------------------------------------------ boot ---- */
async init() {
const renderer = this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas, antialias: false, stencil: false, powerPreference: 'high-performance',
alpha: false,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.6));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.NoToneMapping; /* done in the composite pass */
renderer.outputEncoding = THREE.LinearEncoding; /* done in the composite pass */
renderer.setClearColor(0x0a0a12, 1);
this.postfx = new VC.PostFX(renderer, { quality: 1, bloom: 0.5, exposure: 1.34 });
this.postfx.setSize(renderer.domElement.width, renderer.domElement.height);
const scene = this.scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(VC.sky.palette.fog.getHex(), 0.0011);
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.35, 2400);
await this.stage('Seeding the island…', () => {
VC.sky.build(scene);
});
await this.stage('Baking sunset reflections…', () => {
this.env = VC.sky.makeEnv(this.renderer);
scene.environment = this.env;
});
await this.stage('Raising 500 art-deco towers…', () => {
this.city = new VC.City(scene);
scene.fog.color.copy(VC.sky.palette.fog);
});
await this.stage('Flooding the ocean…', () => {
this.water = new VC.Water(VC.sky, this.city);
this.scene.add(this.water.mesh);
});
this.fx = new VC.FX(this.scene, this.camera);
await this.stage('Wiring the traffic lights…', () => {
this.audio = new VC.Audio();
this.vehicles = new VC.Vehicles(this.scene, this.city, this);
this.peds = new VC.Peds(this.scene, this.city, this);
this.player = new VC.Player(this);
});
await this.stage('Rolling out the sunsets…', () => {
this.buildHUD();
this.spawnInitial();
this.buildPickups();
this.newObjective();
this.buildRadar();
this.bindInput();
/* warm up the shader cache so the first frames are not a slideshow */
this.updateCamera(0.016);
this.postfx.render(this.scene, this.camera, 0.016, 0);
});
this.running = true;
this.postfx.fade = 0;
this.last = performance.now();
requestAnimationFrame((t) => this.frame(t));
return this;
}
async stage(label, fn) {
const el = document.getElementById('loadtext');
const bar = document.getElementById('loadbar');
if (el) el.textContent = label;
await new Promise((r) => setTimeout(r, 16));
fn();
if (bar) bar.style.width = Math.min(100, (this._stageIdx = (this._stageIdx || 0) + 1) * (100 / 6)) + '%';
await new Promise((r) => requestAnimationFrame(r));
}
spawnInitial() {
const pp = this.player.pos;
/* start the player on a street corner near the middle of the island */
const sx = VC.CITY.CELL * 0, sz = VC.CITY.CELL * 1;
const px = sx + VC.CITY.ROAD / 2 + 2.2, pz = sz - VC.CITY.ROAD / 2 - 2.0;
this.player.pos.set(px, this.city.groundH(px, pz), pz);
this.camYaw = Math.PI * 0.15;
for (let i = 0; i < 20; i++) this.vehicles.spawnTraffic(this.player.pos);
for (let i = 0; i < 46; i++) this.vehicles.spawnParked(this.player.pos);
for (let i = 0; i < 16; i++) this.peds.spawn({ beach: VC.rng.chance(0.3) });
/* a car right next to the player so driving is discoverable */
const c = this.vehicles.make('wedge', 0xff9ecb);
c.kind = 'parked';
c.place(px + 3.2, pz - 8.5, Math.PI);
this.hud && this.hud.setRadio && this.hud.setRadio();
}
/* -------------------------------------------------------- pickups ---- */
buildPickups() {
const kinds = [
{ t: 'health', col: 0x66ff9e, glow: '#8effc0' },
{ t: 'armor', col: 0x6fc4ff, glow: '#a8dcff' },
{ t: 'smg', col: 0xffd166, glow: '#ffe6a0' },
{ t: 'shotgun', col: 0xff7f9e, glow: '#ffb3c8' },
{ t: 'ammo', col: 0xfff0a0, glow: '#fff6c8' },
{ t: 'cash', col: 0x9dff6b, glow: '#d4ffb0' },
];
const glowCache = {};
for (let i = 0; i < 46; i++) {
const k = VC.rng.pick(kinds);
const p = this.vehicles.randRoadPoint(null, 0, 9999);
const x = U.clamp(p.x, VC.CITY.IX0 + 3, VC.CITY.IX1 - 3);
const z = U.clamp(p.z, VC.CITY.IZ0 + 3, VC.CITY.IZ1 - 3);
const y = this.city.groundH(x, z);
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.55, 0.55, 0.55),
new THREE.MeshStandardMaterial({ color: k.col, emissive: new THREE.Color(k.col).multiplyScalar(1.8), roughness: 0.35, metalness: 0.4 }));
box.position.y = 1.0;
g.add(box);
if (!glowCache[k.glow]) glowCache[k.glow] = new THREE.SpriteMaterial({
map: VC.tex.glow(k.glow), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false,
});
const sp = new THREE.Sprite(glowCache[k.glow]);
sp.position.y = 1.0; sp.scale.set(2.6, 2.6, 1);
g.add(sp);
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(0.42, 0.42, 24, 8, 1, true),
new THREE.MeshBasicMaterial({ color: new THREE.Color(k.col).multiplyScalar(1.1), transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
beam.position.y = 12;
g.add(beam);
g.position.set(x, y, z);
this.scene.add(g);
this.pickups.push({ kind: k.t, pos: new THREE.Vector3(x, y, z), mesh: g, box, spin: VC.rng.r(0, 6.28), alive: true, respawn: 0 });
}
}
updatePickups(dt) {
const pp = this.player.pos;
for (const it of this.pickups) {
if (!it.alive) {
it.respawn -= dt;
if (it.respawn <= 0) { it.alive = true; it.mesh.visible = true; }
continue;
}
it.spin += dt * 1.8;
it.box.rotation.y = it.spin;
it.box.position.y = 1.0 + Math.sin(it.spin * 1.4) * 0.12;
const d = Math.hypot(it.pos.x - pp.x, it.pos.z - pp.z);
if (d < 1.9 && Math.abs(it.pos.y - pp.y) < 3) {
this.collect(it);
it.alive = false; it.mesh.visible = false; it.respawn = 45;
}
}
}
collect(it) {
const p = this.player, a = this.audio;
if (it.kind === 'health') { p.health = Math.min(p.maxHealth, p.health + 30); this.hud.popup('+30 HEALTH', '#66ff9e'); }
else if (it.kind === 'armor') { p.armor = Math.min(100, p.armor + 50); this.hud.popup('ARMOUR +50', '#6fc4ff'); }
else if (it.kind === 'cash') { this.addCash(250); }
else if (it.kind === 'ammo') {
p.ammo.pistol.reserve += 60; p.ammo.smg.reserve += 120; p.ammo.shotgun.reserve += 16;
this.hud.popup('AMMO', '#ffd166');
} else {
p.give(it.kind, it.kind === 'smg' ? 120 : 24);
this.hud.popup(VC.WEAPONS[it.kind].name, '#ffd166');
}
a.pickup();
this.fx.soft.emit(it.pos.x, it.pos.y + 1, it.pos.z, 0, 2, 0, [1.4, 1.4, 1.0], 2.2, 0.35, 0, 3, 4);
}
addCash(n) {
this.player.cash += n;
this.audio.cash();
}
/* ------------------------------------------------------- objective --- */
newObjective() {
if (this.objective) this.scene.remove(this.objective.mesh);
const p = this.vehicles.randRoadPoint(this.player.pos, 120, 400);
const x = U.clamp(p.x, VC.CITY.IX0 + 8, VC.CITY.IX1 - 8);
const z = U.clamp(p.z, VC.CITY.IZ0 + 8, VC.CITY.IZ1 - 8);
const y = this.city.groundH(x, z);
const g = new THREE.Group();
const ring = new THREE.Mesh(
new THREE.CylinderGeometry(2.6, 2.6, 16, 20, 1, true),
new THREE.MeshBasicMaterial({ color: new THREE.Color(2.4, 1.7, 0.2), transparent: true, opacity: 0.3, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
ring.position.y = 8;
const disc = new THREE.Mesh(
new THREE.RingGeometry(1.9, 2.5, 24),
new THREE.MeshBasicMaterial({ color: new THREE.Color(2.6, 1.9, 0.3), transparent: true, opacity: 0.85, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
disc.rotation.x = -Math.PI / 2; disc.position.y = 0.12;
g.add(ring, disc);
g.position.set(x, y, z);
this.scene.add(g);
this.objective = { x, z, y, mesh: g, disc, ring };
if (this.hud) this.hud.setObjective('REACH THE MARKER');
}
updateObjective(dt) {
const o = this.objective;
if (!o) return;
o.disc.rotation.z += dt * 1.1;
const d = Math.hypot(o.x - this.player.pos.x, o.z - this.player.pos.z);
if (!this._objShown && d < 90) { this._objShown = true; this.hud.setObjective('REACH THE MARKER', true); }
if (d < 4.5 && !this.player.dead) {
const reward = 500 + this.wanted * 250;
this.addCash(reward);
this.hud.popup('DELIVERY COMPLETE +$' + reward, '#ffe066');
this.hud.popupLarge('MISSION PASSED', '#ffe066');
this.player.health = Math.min(this.player.maxHealth, this.player.health + 20);
this.fx.soft.emit(o.x, o.y + 1.5, o.z, 0, 3, 0, [1.6, 1.4, 0.6], 3, 0.6, 0, 2, 6);
this._objShown = false;
this.newObjective();
}
}
/* ---------------------------------------------------------- wanted --- */
crime(amount, reason) {
this.heat += amount;
/* the first cruiser should show up while the shot still echoes */
if (this.vehicles) this.vehicles.copTimer = Math.min(this.vehicles.copTimer, 0.9);
if (this.peds) this.copPedTimer = Math.min(this.copPedTimer, 1.2);
if (reason && this.heat > 1) this.hud.wantedFlash(reason);
const stars = this.heat >= 26 ? 6 : this.heat >= 16 ? 5 : this.heat >= 10 ? 4 : this.heat >= 6 ? 3 : this.heat >= 2.6 ? 2 : this.heat > 0.35 ? 1 : 0;
if (stars > this.wanted) {
this.wanted = stars;
this.hud.setWanted(stars, true);
this.audio.ui(660);
}
}
coolWanted(dt) {
if (this.wanted <= 0) { this.heat = Math.max(0, this.heat - dt * 0.5); return; }
/* cool down when no cop has line-of-sight on us */
let near = 999;
for (const c of this.vehicles.cars) {
if (c.kind !== 'police' || c.dead) continue;
near = Math.min(near, Math.hypot(c.pos.x - this.player.pos.x, c.pos.z - this.player.pos.z));
}
for (const p of this.peds.peds) if (p.cop && p.alive) near = Math.min(near, Math.hypot(p.pos.x - this.player.pos.x, p.pos.z - this.player.pos.z));
if (near > 55) this.heat -= dt * 0.42;
else this.heat -= dt * 0.06;
this.heat = Math.max(0, this.heat);
const stars = this.heat >= 26 ? 6 : this.heat >= 16 ? 5 : this.heat >= 10 ? 4 : this.heat >= 6 ? 3 : this.heat >= 2.6 ? 2 : this.heat > 0.35 ? 1 : 0;
if (stars < this.wanted) { this.wanted = stars; this.hud.setWanted(stars, false); }
}
/* --------------------------------------------------------- shooting -- */
aimDir(out) {
this.camera.getWorldDirection(out);
return out;
}
castBullet(origin, dir, maxD, includePlayer) {
const city = this.city;
/* candidate entities near the ray */
const cars = [], peds = [];
for (const c of this.vehicles.cars) {
if (c === (this.player.vehicle)) continue;
const dx = c.pos.x - origin.x, dz = c.pos.z - origin.z;
if (dx * dir.x + dz * dir.z < -3) continue;
if (Math.hypot(dx, dz) > maxD + 6) continue;
cars.push(c);
}
for (const p of this.peds.peds) {
if (!p.alive) continue;
const dx = p.pos.x - origin.x, dz = p.pos.z - origin.z;
if (dx * dir.x + dz * dir.z < -2) continue;
if (Math.hypot(dx, dz) > maxD + 3) continue;
peds.push(p);
}
const step = 0.34;
const p = new THREE.Vector3();
let dist = 0;
const list = [];
const pl = this.player;
while (dist < maxD) {
dist += step;
p.set(origin.x + dir.x * dist, origin.y + dir.y * dist, origin.z + dir.z * dist);
/* the player is only tested for enemy fire */
if (includePlayer && !pl.dead && !pl.inCar) {
const pdx = p.x - pl.pos.x, pdz = p.z - pl.pos.z;
if (pdx * pdx + pdz * pdz < 0.16 && p.y > pl.pos.y - 0.1 && p.y < pl.pos.y + 1.8) {
return { type: 'player', dist, p: p.clone(), n: { x: -dir.x, y: 0.3, z: -dir.z } };
}
}
/* peds (cheap capsule) */
for (const q of peds) {
const dx = p.x - q.pos.x, dz = p.z - q.pos.z;
if (dx * dx + dz * dz < 0.14 && p.y > q.pos.y - 0.1 && p.y < q.pos.y + 1.8) {
return { type: 'ped', ped: q, dist: dist - step * 0.5, p: p.clone(), n: { x: -dir.x, y: 0.3, z: -dir.z } };
}
}
/* cars (OBB) */
for (const c of cars) {
const s = c.spec;
const dx = p.x - c.pos.x, dz = p.z - c.pos.z;
if (dx * dx + dz * dz > 16) continue;
const f = c.forward(), r = c.right();
const lx = dx * r.x + dz * r.z;
const lz = dx * f.x + dz * f.z;
if (Math.abs(lx) < s.W * 0.52 && Math.abs(lz) < s.L * 0.52 && p.y > c.pos.y - 0.15 && p.y < c.pos.y + s.H + 0.1) {
const glass = p.y > c.pos.y + s.H * 0.55;
return { type: 'car', car: c, glass, dist, p: p.clone(), n: { x: -dir.x, y: 0.4, z: -dir.z } };
}
}
/* world boxes */
city.world.query(p.x, p.z, 0.06, list);
for (const b of list) {
if (p.x > b.x0 && p.x < b.x1 && p.z > b.z0 && p.z < b.z1 && p.y < b.h && p.y > -3) {
return { type: 'solid', dist: dist - step * 0.5, p: p.clone(), n: { x: -dir.x, y: 0.5, z: -dir.z } };
}
}
/* ground / water */
if (dir.y < 0) {
const gy = city.groundH(p.x, p.z);
if (p.y < gy) return { type: 'ground', dist, p: p.clone(), n: { x: 0, y: 1, z: 0 } };
if (p.y < 0.02) return { type: 'water', dist, p: p.clone(), n: { x: 0, y: 1, z: 0 } };
}
}
return { type: 'air', dist: maxD, p: p.clone(), n: { x: 0, y: 1, z: 0 } };
}
/* enemy fire at the player */
enemyShoot(from, accuracy) {
const p = this.player;
const to = new THREE.Vector3(p.pos.x, p.pos.y + 1.0, p.pos.z);
const d = to.clone().sub(from).normalize();
d.x += (Math.random() - 0.5) * accuracy;
d.y += (Math.random() - 0.5) * accuracy * 0.5;
d.z += (Math.random() - 0.5) * accuracy;
d.normalize();
const hit = this.castBullet(from, d, 120, true);
const end = from.clone().addScaledVector(d, hit.dist);
this.fx.tracer(from, end, 0.8);
this.fx.muzzle(from.x, from.y, from.z, d.x, d.y, d.z, 4);
this.audio.shot('pistol', from);
if (hit.type === 'player') {
p.hurt(5 + Math.random() * 8, from);
} else if (hit.type === 'ped') {
hit.ped.hurt(20, from);
} else if (hit.type === 'solid' || hit.type === 'ground') {
this.fx.impact(end.x, end.y, end.z, -d.x, 0.3, -d.z, 'hard');
}
}
policeShoot(car) {
const from = new THREE.Vector3(car.pos.x, car.pos.y + 1.0, car.pos.z);
this.enemyShoot(from, 0.09);
}
pedShoot(ped) {
const from = new THREE.Vector3(ped.pos.x, ped.pos.y + 1.25, ped.pos.z);
this.enemyShoot(from, 0.12);
}
/* ---------------------------------------------------------- cars ----- */
tryEnterVehicle() {
const p = this.player;
if (p.inCar) { this.exitVehicle(); return; }
const car = this.vehicles.nearest(p.pos, 4.6, (c) => !c.dead && c.kind !== 'wreck');
if (!car) { this.hud.popup('NO CAR NEARBY', '#ff8fa3'); return; }
const driverWasAi = car.occupied && (car.kind === 'traffic' || car.kind === 'police');
if (driverWasAi) {
/* jack it: throw the driver out and he runs away crying */
car.occupied = false;
car.driver.visible = false;
const side = car.right();
const jx = car.pos.x + side.x * 2.6;
const jz = car.pos.z + side.z * 2.6;
const ped = this.peds.spawnAt(U.clamp(jx, VC.CITY.IX0, VC.CITY.IX1), U.clamp(jz, VC.CITY.IZ0, VC.CITY.IZ1),
{ cop: car.kind === 'police' });
if (ped) { ped.state = 'flee'; ped.fleeT = 14; }
this.crime(car.kind === 'police' ? 3.2 : 1.6, car.kind === 'police' ? 'Grand Theft Auto (Cop)' : 'Grand Theft Auto');
this.audio.horn(car.pos);
if (car.kind === 'police') this.hud.popup('GOOD NIGHT, OFFICER', '#7fd4ff');
else this.hud.popup('CAR JACKED', '#ffd166');
}
p.inCar = true;
p.vehicle = car;
car.kind = 'player';
car.occupied = true;
car.driver.visible = true;
car.engineOn = true;
this.audio.setInsideCar(true);
this.camYawTarget = null;
this.hud.setVehicle(car);
}
exitVehicle() {
const p = this.player, car = p.vehicle;
if (!car) return;
const side = car.right();
const f = car.forward();
let best = null;
for (const s of [1, -1]) {
for (const back of [0.2, -0.6, 0.8]) {
const x = car.pos.x + side.x * 2.5 * s + f.x * back;
const z = car.pos.z + side.z * 2.5 * s + f.z * back;
const r = this.city.world.resolveCircle(x, z, 0.45, 1.7);
if (!r.hit && !this.city.isDeepWater(x, z)) { best = { x: r.x, z: r.z }; break; }
}
if (best) break;
}
if (!best) best = { x: car.pos.x + side.x * 2.8, z: car.pos.z + side.z * 2.8 };
p.inCar = false;
p.vehicle = null;
p.pos.set(best.x, this.city.groundH(best.x, best.z), best.z);
p.vel.set(0, 0, 0);
car.kind = 'parked';
car.occupied = false;
car.driver.visible = false;
car.engineOn = false;
car.input = { throttle: 0, brake: 1, steer: 0, hand: 1 };
this.camYaw = car.heading;
this.audio.setInsideCar(false);
this.hud.setVehicle(null);
}
ejectFromCar() {
if (!this.player.inCar) return;
const car = this.player.vehicle;
this.exitVehicle();
this.player.hurt(18, car ? car.pos : null, true);
this.shakeScreen(0.7);
}
shakeScreen(v) { this.shakeAmt = Math.min(1.6, this.shakeAmt + v); }
onPlayerDied() {
this.hud.showWasted();
this.wanted = 0; this.heat = 0;
this.hud.setWanted(0, false);
}
respawn() {
const p = this.player;
p.dead = false; p.deadT = 0;
p.health = p.maxHealth; p.armor = 0;
p.cash = Math.max(0, Math.floor(p.cash * 0.85));
p.invuln = 3;
/* nearest "hospital": pick a road node close by */
const node = this.vehicles.randRoadPoint(p.pos, 8, 90);
p.pos.set(node.x, this.city.groundH(node.x, node.z), node.z);
p.vel.set(0, 0, 0);
this.hud.hideWasted();
this.hud.popup('PATCHED UP -$150', '#ff8fa3');
}
/* ----------------------------------------------------------- input --- */
bindInput() {
const kd = (e) => {
this.keys[e.code] = true;
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.code)) e.preventDefault();
if (!this.started) return;
if (e.code === 'KeyF') { this.tryEnterVehicle(); }
if (e.code === 'KeyR') this.keys.reload = 0.25;
if (e.code === 'KeyM') this.cycleStation();
if (e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5), 10);
if (n >= 1 && n <= 4 && this.player.setSlot(n - 1)) this.audio.ui(520);
}
if (e.code === 'KeyP' || e.code === 'Escape') this.togglePause();
if (e.code === 'KeyH' && this.player.vehicle) this.audio.horn(this.player.pos);
};
const ku = (e) => { this.keys[e.code] = false; };
window.addEventListener('keydown', kd);
window.addEventListener('keyup', ku);
window.addEventListener('blur', () => { this.keys = {}; });
this.canvas.addEventListener('mousedown', (e) => {
if (!this.started) { this.begin(); return; }
if (document.pointerLockElement !== this.canvas) {
this.canvas.requestPointerLock && this.canvas.requestPointerLock();
return;
}
if (e.button === 0) this.mouse.lmb = true;
if (e.button === 2) this.mouse.rmb = true;
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0) this.mouse.lmb = false;
if (e.button === 2) this.mouse.rmb = false;
});
window.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('mousemove', (e) => {
if (document.pointerLockElement === this.canvas) {
this.mouse.dx += e.movementX || 0;
this.mouse.dy += e.movementY || 0;
}
});
window.addEventListener('wheel', (e) => {
if (!this.started) return;
const dir = e.deltaY > 0 ? 1 : -1;
for (let i = 1; i <= 4; i++) {
const n = ((this.player.slot + dir * i - 1 + 8) % 4) + 1;
if (this.player.setSlot(n)) break;
}
}, { passive: true });
document.addEventListener('pointerlockchange', () => {
this.paused = document.pointerLockElement !== this.canvas && this.started;
document.getElementById('pause').style.display = this.paused ? 'flex' : 'none';
});
document.addEventListener('pointerlockerror', () => { this.paused = false; });
window.addEventListener('resize', () => this.resize());
}
cycleStation() {
if (!this.audio.ready) return;
const s = this.audio.setStation(this.audio.station + 1);
this.hud.setRadio(s);
}
togglePause() {
if (!this.started) return;
if (document.pointerLockElement === this.canvas) document.exitPointerLock();
else this.canvas.requestPointerLock();
}
begin() {
if (this.started) return;
this.started = true;
document.getElementById('intro').style.display = 'none';
this.audio.start();
this.hud.setRadio();
this.canvas.requestPointerLock && this.canvas.requestPointerLock();
}
resize() {
const w = window.innerWidth, h = window.innerHeight;
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
const pr = this.renderer.getPixelRatio();
this.postfx.setSize(Math.floor(w * pr), Math.floor(h * pr));
}
/* ---------------------------------------------------------- camera --- */
updateCamera(dt) {
const p = this.player;
/* mouse look */
const sens = (this.mouse.rmb ? 0.0012 : 0.0022);
this.camYaw -= this.mouse.dx * sens;
this.camPitch -= this.mouse.dy * sens;
this.mouse.dx = 0; this.mouse.dy = 0;
this.camPitch = U.clamp(this.camPitch, -1.15, 0.95);
let target, dist, fovT;
if (p.inCar && p.vehicle) {
const v = p.vehicle;
const spd = v.speed();
/* auto-follow behind the car unless the player has looked around lately */
if (this.mouseUsed > 0) this.mouseUsed -= dt;
else this.camYaw = U.angleDamp(this.camYaw, v.heading + (v.vf < -1 ? Math.PI : 0), 1.9, dt);
target = new THREE.Vector3(v.pos.x, v.pos.y + 1.75, v.pos.z);
dist = 7.6 + U.clamp(spd * 0.12, 0, 3.4);
fovT = 62 + U.clamp(spd * 1.15, 0, 26);
this.camPitch = U.clamp(this.camPitch, -0.75, 0.5);
} else {
const ads = this.mouse.rmb;
target = new THREE.Vector3(p.pos.x, p.pos.y + (ads ? 1.62 : 1.5), p.pos.z);
dist = ads ? 2.15 : 4.9;
fovT = ads ? 42 : 60;
}
this.camDist = dist;
this.camDistNow = U.damp(this.camDistNow, dist, 10, dt);
this.camera.fov = U.damp(this.camera.fov, fovT, 6, dt);
this.camera.updateProjectionMatrix();
/* spherical offset */
const cp = Math.cos(this.camPitch), sp = Math.sin(this.camPitch);
const off = new THREE.Vector3(-Math.sin(this.camYaw) * cp, -sp, -Math.cos(this.camYaw) * cp)
.multiplyScalar(this.camDistNow);
/* keep the camera out of walls */
const t = this.city.world.rayXZ(target.x, target.z, target.x + off.x, target.z + off.z, 1);
const allowed = Math.max(0.9, t * (this.camDistNow / Math.max(0.001, Math.hypot(off.x, off.z) || 1)));
const k = Math.min(1, allowed);
const pos = target.clone().add(off.multiplyScalar(k));
const gy = this.city.groundH(pos.x, pos.z) + 0.45;
pos.y = Math.max(pos.y, gy);
/* screen shake */
if (this.shakeAmt > 0.001) {
const s = this.shakeAmt;
pos.x += (Math.random() - 0.5) * s * 0.34;
pos.y += (Math.random() - 0.5) * s * 0.34;
pos.z += (Math.random() - 0.5) * s * 0.34;
this.shakeAmt = Math.max(0, this.shakeAmt - dt * 3.4);
}
this.camera.position.copy(pos);
this.camera.lookAt(target.x, target.y + 0.12, target.z);
/* extra roll while drifting */
if (p.inCar && p.vehicle) this.camera.rotateZ(U.clamp(-p.vehicle.vr * 0.012, -0.09, 0.09));
const dir = new THREE.Vector3();
this.camera.getWorldDirection(dir);
this.fwd.x = dir.x; this.fwd.z = dir.z;
this.camTarget = target;
this.postfx.speedBlur = p.inCar && p.vehicle ? U.clamp((p.vehicle.speed() - 9) / 24, 0, 1) : 0;
}
/* ------------------------------------------------------------- HUD --- */
buildHUD() {
this.hud = new VC.HUD(this);
}
buildRadar() {
/* pre-render the whole island into one canvas so the minimap is a blit */
const S = 1024;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
const minX = VC.CITY.IX0 - VC.CITY.BEACH - 20, maxX = VC.CITY.IX1 + VC.CITY.BEACH + 20;
const minZ = VC.CITY.IZ0 - VC.CITY.BEACH - 20, maxZ = VC.CITY.IZ1 + VC.CITY.BEACH + 20;
const sx = S / (maxX - minX), sz = S / (maxZ - minZ);
const w2 = (x) => (x - minX) * sx, h2 = (z) => (z - minZ) * sz;
g.fillStyle = '#0b2d55'; g.fillRect(0, 0, S, S);
/* sand */
g.fillStyle = '#d9c389';
g.fillRect(w2(VC.CITY.IX0 - VC.CITY.BEACH), h2(VC.CITY.IZ0 - VC.CITY.BEACH),
(VC.CITY.IX1 - VC.CITY.IX0 + VC.CITY.BEACH * 2) * sx, (VC.CITY.IZ1 - VC.CITY.IZ0 + VC.CITY.BEACH * 2) * sz);
/* asphalt island */
g.fillStyle = '#2c2c33';
g.fillRect(w2(VC.CITY.IX0), h2(VC.CITY.IZ0), (VC.CITY.IX1 - VC.CITY.IX0) * sx, (VC.CITY.IZ1 - VC.CITY.IZ0) * sz);
/* blocks */
for (const b of this.city.blocks) {
g.fillStyle = b.kind === 'park' ? '#2f6b3a' : b.kind === 'vacant' ? '#3b3b42' : '#585860';
g.fillRect(w2(b.x0), h2(b.z0), (b.x1 - b.x0) * sx, (b.z1 - b.z0) * sz);
/* downtown buildings footprint = darker mass */
if (b.kind === 'built') {
g.fillStyle = '#40404a';
g.fillRect(w2(b.x0 + VC.CITY.SIDE), h2(b.z0 + VC.CITY.SIDE),
(b.x1 - b.x0 - VC.CITY.SIDE * 2) * sx, (b.z1 - b.z0 - VC.CITY.SIDE * 2) * sz);
}
}
/* lane markings */
g.strokeStyle = 'rgba(190,190,200,0.25)'; g.lineWidth = 1;
for (const x of this.city.roadX) { g.beginPath(); g.moveTo(w2(x), h2(VC.CITY.IZ0)); g.lineTo(w2(x), h2(VC.CITY.IZ1)); g.stroke(); }
for (const z of this.city.roadZ) { g.beginPath(); g.moveTo(w2(VC.CITY.IX0), h2(z)); g.lineTo(w2(VC.CITY.IX1), h2(z)); g.stroke(); }
this.mapCanvas = c;
this.mapW2 = { sx, sz, w2, h2, minX, minZ };
}
/* ------------------------------------------------------------- loop -- */
/* one simulation tick: everything that integrates motion or game rules */
simulate(dt, input) {
VC.sky.tick(this.time);
this.player.update(dt, input);
if (this.player.inCar && this.player.vehicle) this.player.vehicle.input = input.car;
this.vehicles.update(dt);
this.peds.update(dt);
this.updatePickups(dt);
this.updateObjective(dt);
this.coolWanted(dt);
this.fx.update(dt);
/* cop officers join the party at higher wanted levels */
if (this.wanted >= 3) {
this.copPedTimer -= dt;
if (this.copPedTimer <= 0) {
this.copPedTimer = 4 - Math.min(3, this.wanted * 0.4);
if (this.peds.peds.filter((p) => p.cop).length < 3 + this.wanted) this.peds.spawnCopNear(this.player.pos);
}
}
}
/* per-frame presentation: camera, ocean follow, shimmer, HUD */
cosmetic(dt) {
this.updateCamera(dt);
this.water.update(this.camera.position, this.time);
VC.sky.follow(this.camera.position.x, this.camera.position.y, this.camera.position.z);
/* palm sway + beacon blink */
for (const u of this.city.palmSwayUniforms) u.value = this.time;
if (this.city.beaconMesh) {
const on = (this.time % 2) < 0.5;
this.city.beaconMesh.material.color.setRGB(on ? 3.4 : 0.2, 0.05, 0.05);
}
/* neon flicker */
for (let i = 0; i < this.city.signMeshes.length; i++) {
const m = this.city.signMeshes[i];
if ((i + Math.floor(this.time * 3)) % 17 === 0) m.material.opacity = 0.55 + Math.random() * 0.2;
else m.material.opacity = 1;
}
this.postfx.flash = Math.max(0, this.postfx.flash - dt * 4);
if (this.started) this.postfx.fade = Math.min(1, this.postfx.fade + dt * 0.75);
this.hud.update(dt);
}
frame(now) {
if (!this.running) return;
requestAnimationFrame((t) => this.frame(t));
let dt = (now - this.last) / 1000;
this.last = now;
if (dt > 0.25) dt = 0.25;
if (dt <= 0) dt = 0.0001;
this.frames++;
this.fpsAcc = (this.fpsAcc || 0) + dt; this.fpsN = (this.fpsN || 0) + 1;
if (this.fpsAcc > 0.5) { this.fps = this.fpsN / this.fpsAcc; this.fpsAcc = 0; this.fpsN = 0; this.autoQuality(); }
this.time += dt;
const input = this.readInput(dt);
if (!this.paused) {
/* A software rasteriser can easily drop to 5-10 fps. Clamping the
* frame delta to one physics step would make the world run in slow
* motion, so instead the simulation is sub-stepped: game time tracks
* wall-clock time and the vehicle integration stays stable. */
const n = Math.min(4, Math.max(1, Math.ceil(dt / 0.034)));
const h = dt / n;
for (let i = 0; i < n; i++) this.simulate(h, input);
this.cosmetic(dt);
}
/* audio controllers */
const v = this.player.vehicle;
if (this.player.inCar && v) {
const rpm = U.clamp01(Math.abs(v.vf) / v.spec.top * 0.8 + (input.car.throttle > 0 ? 0.25 : 0));
this.audio.setEngine(rpm, Math.abs(input.car.throttle), true);
this.audio.setSkid(v.skid * (v.speed() > 4 ? 1 : 0));
} else {
this.audio.setEngine(0, 0, false);
this.audio.setSkid(0);
}
this.postfx.render(this.scene, this.camera, dt, this.time);
}
autoQuality() {
const f = this.fps;
if (f < 26 && this.quality > 0) {
this.quality = 0;
this.renderer.setPixelRatio(1);
this.postfx.quality = 0;
this.renderer.shadowMap.enabled = true;
this.resize();
this.peds.target = 18;
} else if (f < 45 && this.quality === 1) {
this.renderer.setPixelRatio(Math.min(1.15, window.devicePixelRatio || 1));
this.resize();
}
}
readInput(dt) {
const k = this.keys, K = KEY;
const down = (arr) => arr.some((c) => k[c]);
if (k.reload) { k.reload -= dt; if (k.reload <= 0) k.reload = false; }
const firing = this.mouse.lmb;
if (this.mouseUsed === undefined) this.mouseUsed = 0;
if (this.mouse.dx || this.mouse.dy) this.mouseUsed = 1.1;
const car = {
throttle: (down(K.fwd) ? 1 : 0) - (down(K.back) ? 1 : 0),
brake: down(K.back) && this.player.vehicle && this.player.vehicle.vf > 1 ? 1 : 0,
steer: (down(K.right) ? 1 : 0) - (down(K.left) ? 1 : 0),
hand: down(K.jump) ? 1 : 0,
};
return {
f: down(K.fwd), b: down(K.back), l: down(K.left), r: down(K.right),
sprint: down(K.sprint), jump: down(K.jump), reload: !!k.reload,
ads: this.mouse.rmb, firing, car,
};
}
};
/* --------------------------------------------------------------- HUD --- */
VC.HUD = class HUD {
constructor(game) {
this.game = game;
this.el = document.getElementById('hud');
this.el.style.display = 'block';
this.radar = document.getElementById('radar');
this.rctx = this.radar.getContext('2d');
this.hpEl = document.getElementById('hpfill');
this.arEl = document.getElementById('arfill');
this.cashEl = document.getElementById('cash');
this.wepEl = document.getElementById('wep');
this.ammoEl = document.getElementById('ammo');
this.starsEl = document.getElementById('stars');
this.radioEl = document.getElementById('radio');
this.objEl = document.getElementById('objective');
this.popupEl = document.getElementById('popups');
this.bigEl = document.getElementById('bigmsg');
this.reloadEl = document.getElementById('reloadbar');
this.fpsEl = document.getElementById('fps');
this.crossEl = document.getElementById('cross');
this.popQueue = [];
this.bigT = 0;
this.drawStars(0);
}
update(dt) {
const g = this.game, p = g.player;
this.hpEl.style.width = U.clamp(p.health, 0, 100) + '%';
this.arEl.style.width = U.clamp(p.armor, 0, 100) + '%';
this.cashEl.textContent = '$' + p.cash.toLocaleString('en-US');
const w = p.weapon(), a = p.ammoOf();
if (w && w.kind === 'bullet') {
this.wepEl.textContent = w.name;
this.ammoEl.textContent = a.mag + ' / ' + a.reserve;
} else if (w) {
this.wepEl.textContent = w.name;
this.ammoEl.textContent = '∞';
} else { this.wepEl.textContent = ''; this.ammoEl.textContent = ''; }
this.drawStars(p.dead ? 0 : g.wanted);
this.drawRadar();
this.crossEl.style.opacity = p.inCar ? 0.35 : (this.game.mouse.rmb ? 0.95 : 0.7);
this.crossEl.classList.toggle('ads', !!this.game.mouse.rmb);
if (this.bigT > 0) { this.bigT -= dt; if (this.bigT <= 0) this.bigEl.style.opacity = 0; }
this.fpsEl.textContent = Math.round(g.fps) + ' FPS';
/* objective distance */
const o = g.objective;
if (o) {
const d = Math.hypot(o.x - p.pos.x, o.z - p.pos.z);
this.objEl.textContent = 'MARKER ' + Math.round(d) + ' M';
}
}
drawStars(n) {
let s = '';
for (let i = 0; i < 6; i++) s += '<i class="' + (i < n ? 'on' : 'off') + '">★</i>';
if (this._stars !== n) {
this._stars = n;
this.starsEl.innerHTML = s;
if (n > 0) this.starsEl.classList.add('live'); else this.starsEl.classList.remove('live');
}
}
setWanted(n, up) {
this.drawStars(n);
if (up) this.popup('WANTED LEVEL ' + n, '#ff5f8a');
}
wantedFlash(reason) { if (reason && !this._flashT) { this._flashT = 1; setTimeout(() => { this._flashT = 0; }, 900); this.popup(reason, '#ffb3c8'); } }
setVehicle(car) {
if (car) this.popup(VC.CAR_SPECS[car.model] ? carLabel(car) : '', '#c8d4ff');
}
setRadio(s) {
const st = s || VC.STATIONS[this.game.audio.station];
this.radioEl.innerHTML = '<b>' + st.name + '</b><span>' + st.genre + '</span>';
this.radioEl.classList.remove('flash');
void this.radioEl.offsetWidth;
this.radioEl.classList.add('flash');
}
setObjective(t, hot) {
this.objEl.textContent = t;
this.objEl.classList.toggle('hot', !!hot);
}
popup(text, color) {
const d = document.createElement('div');
d.className = 'pop';
d.style.color = color || '#fff';
d.textContent = text;
this.popupEl.appendChild(d);
setTimeout(() => { d.classList.add('out'); }, 1600);
setTimeout(() => { d.remove(); }, 2200);
}
popupLarge(text, color) {
this.bigEl.textContent = text;
this.bigEl.style.color = color || '#fff';
this.bigEl.style.opacity = 1;
this.bigT = 2.4;
}
flashWeapon(name) { /* hook for future weapon wheel animation */ }
showReload(dur) {
this.reloadEl.style.display = 'block';
this.reloadEl.style.setProperty('--dur', dur + 's');
this.reloadEl.classList.remove('run');
void this.reloadEl.offsetWidth;
this.reloadEl.classList.add('run');
}
hideReload() { this.reloadEl.style.display = 'none'; }
showWasted() { document.getElementById('wasted').style.display = 'flex'; }
hideWasted() { document.getElementById('wasted').style.display = 'none'; }
drawRadar() {
const g = this.game, c = this.rctx, S = this.radar.width, R = S / 2;
const p = g.player.pos;
c.save();
c.clearRect(0, 0, S, S);
c.beginPath(); c.arc(R, R, R - 1, 0, 7); c.closePath(); c.clip();
c.fillStyle = '#071a33'; c.fillRect(0, 0, S, S);
const scale = 0.62; /* world metres -> radar px */
c.save();
c.translate(R, R);
c.rotate(g.camYaw);
c.scale(scale, scale);
c.translate(-p.x, -p.z);
/* island map blit */
const mw = g.mapCanvas.width;
const wpp = (VC.CITY.IX1 - VC.CITY.IX0 + (VC.CITY.BEACH + 20) * 2);
const mScale = wpp / mw;
const minX = VC.CITY.IX0 - VC.CITY.BEACH - 20, minZ = VC.CITY.IZ0 - VC.CITY.BEACH - 20;
c.drawImage(g.mapCanvas, minX, minZ, mw * mScale, mw * mScale);
/* entities */
const dot = (x, z, r, col) => { c.fillStyle = col; c.beginPath(); c.arc(x, z, r / scale, 0, 7); c.fill(); };
for (const car of g.vehicles.cars) {
if (Math.abs(car.pos.x - p.x) > 150 || Math.abs(car.pos.z - p.z) > 150) continue;
if (car.dead) continue;
dot(car.pos.x, car.pos.z, car.kind === 'police' ? 3.4 : 2.4, car.kind === 'police' ? '#4fa8ff' : '#e8ecff');
}
for (const q of g.peds.peds) {
if (!q.alive) continue;
if (Math.abs(q.pos.x - p.x) > 110 || Math.abs(q.pos.z - p.z) > 110) continue;
dot(q.pos.x, q.pos.z, 1.8, q.cop ? '#7fd4ff' : '#c8c2b8');
}
for (const it of g.pickups) if (it.alive) dot(it.pos.x, it.pos.z, 3, '#ffe066');
if (g.objective) {
dot(g.objective.x, g.objective.z, 6, 'rgba(255,224,102,0.9)');
}
c.restore();
/* player arrow */
c.save();
c.translate(R, R);
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(0, -8); c.lineTo(6, 8); c.lineTo(0, 4); c.lineTo(-6, 8); c.closePath(); c.fill();
c.restore();
c.restore();
/* rim */
c.strokeStyle = 'rgba(255,255,255,0.25)'; c.lineWidth = 3;
c.beginPath(); c.arc(R, R, R - 2, 0, 7); c.stroke();
}
};
function carLabel(car) {
const names = { sedan: 'PONY', wedge: 'CHEETAH', taxi: 'CAB', van: 'MR WHOOPEE', pickup: 'BARRACKS', police: 'POLICE', bike: 'PCJ 600' };
return 'GETTING AROUND IN A ' + (names[car.model] || 'CAR');
}
})();
/* ---- bootstrap (from index.html) ---- */
(function () {
const g = new VC.Game(document.getElementById('gl'));
window.__VC = g;
g.init().then(() => {
document.getElementById('loadwrap').style.display = 'none';
document.getElementById('playwrap').style.display = 'block';
const intro = document.getElementById('intro');
const play = () => { g.begin(); intro.style.display = 'none'; };
document.getElementById('playBtn').addEventListener('click', play);
intro.addEventListener('mousedown', (e) => { if (e.target.id !== 'playBtn') play(); });
window.__VC_ready = true;
}).catch((err) => {
console.error('BOOT FAILURE', err);
const el = document.getElementById('loadtext');
el.textContent = 'BOOT FAILURE: ' + (err && err.message ? err.message : err);
el.style.color = '#ff6b8a';
if (window.__VC_fail === undefined) window.__VC_fail = err;
throw err;
});
/* respawn by clicking the WASTED screen */
document.getElementById('wasted').addEventListener('mousedown', () => {
if (window.__VC && window.__VC.player.dead) {
document.getElementById('wasted').style.display = 'none';
window.__VC.respawn();
const cv = document.getElementById('gl');
cv.requestPointerLock && cv.requestPointerLock();
}
});
document.getElementById('pause').addEventListener('mousedown', () => {
const g = window.__VC;
if (g && g.started) g.canvas.requestPointerLock && g.canvas.requestPointerLock();
});
})();
})();
</script>
</body>
</html>
Create a beautiful GTA vice city clone (3D) in three.js. Obviously I don't expect a perfectly working triple A game, but you should still try to impress me. Here are some of the things I want:
- Mouse for looking (click to shoot)
- adsw with a and d strafing left and right. When in a car, it should turn the wheel left/righ
- Beautiful graphics
- Music
- Gun/shooting mechanics
- People/cars on the street
- Being able to steal/drive
Chromium, xvfb-run, playwright should be available for you
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment