Last active
August 29, 2026 12:58
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 · 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 // 1986 // 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 <b>LMB</b> fire <b>RMB</b> aim</div> | |
| <div><b>W A S D</b> move / strafe — in a car this steers & accelerates</div> | |
| <div><b>Space</b> jump / handbrake <b>Shift</b> sprint <b>F</b> steal & exit cars</div> | |
| <div><b>1–4</b> weapons <b>R</b> reload <b>M</b> radio station <b>H</b> horn <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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