From fec1941e1275d2f63a285819240c8619925d1ec9 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 13 Oct 2026 06:43:09 +0200 Subject: [PATCH] emergency: add nearest-city emergency vehicle routes --- config.js | 23 +++++ vendor/clouds3d.js | 211 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/config.js b/config.js index 6639456..b421e75 100644 --- a/config.js +++ b/config.js @@ -118,6 +118,29 @@ window.CONFIG = { // weatherSimProfile: { rain: { code: 80, tempC: 9, precipMm: 2.0, windKmh: 28 } } }, + // ------------------------------------------------------------------ + // Emergency vehicles + // ------------------------------------------------------------------ + // Emergency vehicles start from the nearest city/town to CONFIG.location + // and drive a selected secondary/primary road chain out and back. The + // route selection is intentionally conservative: it uses already loaded + // road chains and does not run a full routing engine. + emergencyVehicles: { + enabled: true, + count: 3, + speedKmh: 85, + minKm: 40, + maxKm: 80, + rankMax: 1, // 0=city, 1=town + color: [0.22, 0.55, 1.0], + flashHz: 2.2, + nightOnly: true, + bright: 1.35 + // emergencyVehicles: { enabled: false } + // emergencyVehicles: { count: 6, speedKmh: 110, minKm: 25, maxKm: 120 } + // emergencyVehicles: { color: [1.0, 0.18, 0.12], flashHz: 3.0 } + }, + // ------------------------------------------------------------------ // Weather effects // ------------------------------------------------------------------ diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index 7272653..a351a7f 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -3463,6 +3463,20 @@ var carPtsVboB = null, carPtsCountB = 0; var carProg = null, carAttribs = null, carUniforms = null; var carPtsProg = null, carPtsAttribs = null, carPtsUniforms = null; +var EMERGENCY_CFG = (window.CONFIG && window.CONFIG.emergencyVehicles) || {}; +var EMERGENCY_ON = EMERGENCY_CFG.enabled !== false; +var EMERGENCY_COUNT = (typeof EMERGENCY_CFG.count === 'number') ? Math.max(1, Math.min(12, EMERGENCY_CFG.count)) : 3; +var EMERGENCY_SPEED_KMH = (typeof EMERGENCY_CFG.speedKmh === 'number') ? Math.max(10, Math.min(220, EMERGENCY_CFG.speedKmh)) : 85; +var EMERGENCY_MIN_KM = (typeof EMERGENCY_CFG.minKm === 'number') ? Math.max(5, EMERGENCY_CFG.minKm) : 40; +var EMERGENCY_MAX_KM = (typeof EMERGENCY_CFG.maxKm === 'number') ? Math.max(EMERGENCY_MIN_KM, EMERGENCY_CFG.maxKm) : 80; +var EMERGENCY_RANK_MAX = (typeof EMERGENCY_CFG.rankMax === 'number') ? Math.max(0, Math.min(4, EMERGENCY_CFG.rankMax)) : 1; +var EMERGENCY_COLOR = Array.isArray(EMERGENCY_CFG.color) ? EMERGENCY_CFG.color : [0.22, 0.55, 1.0]; +var EMERGENCY_FLASH_HZ = (typeof EMERGENCY_CFG.flashHz === 'number') ? Math.max(0.2, Math.min(8, EMERGENCY_CFG.flashHz)) : 2.2; +var EMERGENCY_NIGHT_ONLY = EMERGENCY_CFG.nightOnly !== false; +var EMERGENCY_BRIGHT = (typeof EMERGENCY_CFG.bright === 'number') ? Math.max(0.1, Math.min(4, EMERGENCY_CFG.bright)) : 1.35; +var emergencyRoutes = [], emergencyVehicles = [], emergencyVbo = null, emergencyPts = null, emergencyCount = 0; +var emergencyLastT = null, emergencyStartCity = null; + function carLonToX(lon) { var n = 1 << GROUND_TILE_Z; return GROUND_BASE_X - ((lon + 180) / 360) * n; @@ -3490,6 +3504,159 @@ function carYAt(x, z) { return m * ELEV_SCALE_UNITS + CAR_Y; } +function emergencyChainAt(c, idx) { + var k = 0, i, j; + for (i = 0; i < idx; i++) k += c.cnts[i] * 3; + var n = c.cnts[idx], out = []; + for (j = 0; j < n; j++) { + out.push({ x: c.pts[k], z: c.pts[k + 1], d: c.pts[k + 2] }); + k += 3; + } + return out; +} + +function nearestEmergencyCity() { + var cx = carLonToX(_baseLon), cz = carLatToZ(_baseLat); + var best = { x: cx, z: cz, rank: 0, pop: 0, dist: 0 }; + if (!cityGeo || cityGeo.length < 4) return best; + var found = false; + for (var i = 0; i + 3 < cityGeo.length; i += 4) { + var rank = cityGeo[i + 2] | 0; + if (rank > EMERGENCY_RANK_MAX) continue; + var dx = cityGeo[i] - cx, dz = cityGeo[i + 1] - cz; + var d = Math.hypot(dx, dz); + if (!found || d < best.dist) { + found = true; + best = { x: cityGeo[i], z: cityGeo[i + 1], rank: rank, pop: cityGeo[i + 3], dist: d }; + } + } + return best; +} + +function selectEmergencyRoutes() { + emergencyRoutes = []; + emergencyVehicles = []; + emergencyCount = 0; + emergencyLastT = null; + if (!EMERGENCY_ON || !carGeo || !carGeo.secondary || !carGeo.secondary.cnts || !carGeo.secondary.cnts.length) { + window.__emergencyDbg = { enabled: EMERGENCY_ON, startCity: null, routes: 0 }; + return; + } + emergencyStartCity = nearestEmergencyCity(); + var sec = carGeo.secondary; + var targetKm = (EMERGENCY_MIN_KM + EMERGENCY_MAX_KM) / 2; + var candidates = []; + var k = 0; + for (var ci = 0; ci < sec.cnts.length; ci++) { + var n = sec.cnts[ci]; + if (n < 2) { k += n * 3; continue; } + var firstX = sec.pts[k], firstZ = sec.pts[k + 1], firstD = sec.pts[k + 2]; + var last = k + (n - 1) * 3; + var lastX = sec.pts[last], lastZ = sec.pts[last + 1], lastD = sec.pts[last + 2]; + var len = Math.max(0, lastD - firstD); + var lenKm = len * KM_PER_TILE; + var dStart = Math.hypot(firstX - emergencyStartCity.x, firstZ - emergencyStartCity.z); + var dEnd = Math.hypot(lastX - emergencyStartCity.x, lastZ - emergencyStartCity.z); + var near = Math.min(dStart, dEnd); + if (near < 1.2 && lenKm >= EMERGENCY_MIN_KM * 0.55 && lenKm <= EMERGENCY_MAX_KM * 1.45) { + candidates.push({ + ci: ci, len: len, lenKm: lenKm, near: near, + score: Math.abs(lenKm - targetKm) + near * 18, + reverse: dEnd < dStart + }); + } + k += n * 3; + } + candidates.sort(function (a, b) { return a.score - b.score; }); + if (!candidates.length) { + var best = null, k2 = 0; + for (var ci2 = 0; ci2 < sec.cnts.length; ci2++) { + var n2 = sec.cnts[ci2]; + if (n2 < 2) { k2 += n2 * 3; continue; } + var f2 = k2, l2 = k2 + (n2 - 1) * 3; + var len2 = Math.max(0, sec.pts[l2 + 2] - sec.pts[f2 + 2]); + var near2 = Math.min( + Math.hypot(sec.pts[f2] - emergencyStartCity.x, sec.pts[f2 + 1] - emergencyStartCity.z), + Math.hypot(sec.pts[l2] - emergencyStartCity.x, sec.pts[l2 + 1] - emergencyStartCity.z)); + if (near2 < 2.5 && (!best || len2 > best.len)) best = { ci: ci2, len: len2, lenKm: len2 * KM_PER_TILE, near: near2, reverse: false }; + k2 += n2 * 3; + } + if (best) candidates.push(best); + } + for (var r = 0; r < Math.min(EMERGENCY_COUNT, candidates.length); r++) { + var route = emergencyChainAt(sec, candidates[r].ci); + if (candidates[r].reverse) route.reverse(); + var len = route.length ? route[route.length - 1].d - route[0].d : 0; + if (len <= 0) continue; + emergencyRoutes.push({ pts: route, len: len, lenKm: len * KM_PER_TILE, nearKm: candidates[r].near * KM_PER_TILE }); + } + for (var v = 0; v < emergencyRoutes.length; v++) { + emergencyVehicles.push({ + route: v, + d: emergencyRoutes[v].len * (0.08 + 0.72 * ((v * 0.371) % 1)), + dir: ((v % 2) === 0) ? 1 : -1, + seed: (v * 0.6180339887) % 1 + }); + } + emergencyCount = emergencyVehicles.length; + window.__emergencyDbg = { + enabled: EMERGENCY_ON, + startCity: emergencyStartCity ? { + x: +emergencyStartCity.x.toFixed(3), + z: +emergencyStartCity.z.toFixed(3), + rank: emergencyStartCity.rank, + pop: emergencyStartCity.pop, + km: +(emergencyStartCity.dist * KM_PER_TILE).toFixed(1) + } : null, + routes: emergencyRoutes.map(function (r) { return { km: +r.lenKm.toFixed(1), nearKm: +r.nearKm.toFixed(1) }; }) + }; +} + +function emergencyPointAt(route, dist) { + var pts = route.pts; + if (!pts.length) return null; + dist = Math.max(0, Math.min(route.len, dist)); + var lo = 0, hi = pts.length - 1; + while (lo + 1 < hi) { + var mid = (lo + hi) >> 1; + if (pts[mid].d < dist) lo = mid; else hi = mid; + } + var a = pts[lo], b = pts[hi]; + var seg = Math.max(0.0001, b.d - a.d); + var f = Math.max(0, Math.min(1, (dist - a.d) / seg)); + var x = a.x + (b.x - a.x) * f; + var z = a.z + (b.z - a.z) * f; + var dx = b.x - a.x, dz = b.z - a.z; + var L = Math.hypot(dx, dz) || 1; + return { x: x, y: carYAt(x, z), z: z, tx: dx / L, tz: dz / L }; +} + +function updateEmergencyVehicles(now) { + if (!EMERGENCY_ON || !emergencyVehicles.length) { emergencyCount = 0; return 0; } + if (emergencyLastT === null) emergencyLastT = now; + var dt = Math.max(0, Math.min(5, now - emergencyLastT)); + emergencyLastT = now; + var speed = EMERGENCY_SPEED_KMH / 3600.0 / KM_PER_TILE; + var max = emergencyVehicles.length * 10; + if (!emergencyPts || emergencyPts.length < max) emergencyPts = new Float32Array(max); + var pi = 0; + for (var i = 0; i < emergencyVehicles.length; i++) { + var v = emergencyVehicles[i], route = emergencyRoutes[v.route]; + if (!route) continue; + v.d += v.dir * speed * dt; + if (v.d >= route.len) { v.d = route.len; v.dir = -1; } + if (v.d <= 0) { v.d = 0; v.dir = 1; } + var p = emergencyPointAt(route, v.d); + if (!p) continue; + emergencyPts[pi++] = p.x; emergencyPts[pi++] = p.y; emergencyPts[pi++] = p.z; + emergencyPts[pi++] = p.tx; emergencyPts[pi++] = p.tz; + emergencyPts[pi++] = 1; emergencyPts[pi++] = v.seed; emergencyPts[pi++] = 1; + emergencyPts[pi++] = v.seed; emergencyPts[pi++] = route.len; + } + emergencyCount = pi / 10; + return emergencyCount; +} + function resamplePoly(pts) { var out = []; if (pts.length < 2) return out; @@ -3754,6 +3921,7 @@ function fetchSettlements() { function publish() { cityGeo = packPlaces(list); kickCityBuild(); + selectEmergencyRoutes(); // settlement weight feeds the car density field, so a fresh place // list must re-shape the traffic too if (carGeo) kickCarBuild(false); @@ -4005,6 +4173,10 @@ var CARP_FRAG_SRC = [ 'uniform float uFadeB;', // distance where it is fully faded', 'uniform float uWet;', // wet-road factor 0..1', 'uniform float uWetGain;', // extra halo on lights when wet', + 'uniform float uEmergency;', + 'uniform vec3 uEmergencyColor;', + 'uniform float uEmergencyFlashHz;', + 'uniform float uEmergencyNightOnly;', 'varying float vApproach;', 'varying float vDist;', 'varying float vSeed;', @@ -4037,6 +4209,10 @@ var CARP_FRAG_SRC = [ 'float age = mod(uTime + 3607.0 * hash1(vSeed * 0.913 + 7.7), per);', 'float on = smoothstep(0.0, 25.0, age) * (1.0 - smoothstep(life - 25.0, life, age));', 'float inten = on * vis;', + 'if (uEmergency > 0.5) {', + 'inten = 1.0;', + 'if (uEmergencyNightOnly > 0.5 && uNight < 0.12) discard;', + '}', 'if (inten <= 0.001) discard;', 'inten = pow(inten, 1.25) * uBright;', // tangent/view dot without a lane sign, sign applied here: toward', @@ -4044,6 +4220,11 @@ var CARP_FRAG_SRC = [ 'float approach = -vApproach * vDir;', 'vec3 col = mix(vec3(1.0, 0.12, 0.08), vec3(1.0, 0.96, 0.88),', 'smoothstep(-0.1, 0.1, approach));', + 'if (uEmergency > 0.5) {', + 'float flash = 0.35 + 0.65 * pow(max(0.0, sin(uTime * uEmergencyFlashHz * 6.2831853 + vSeed * 6.2831853)), 2.0);', + 'col = uEmergencyColor * (0.55 + 0.45 * flash);', + 'inten *= 0.75 + 0.55 * flash;', + '}', // Switching on/off individually: this vehicle's own threshold sits // somewhere in the twilight window, so the A-net lights up piece by // piece in the evening and goes dark piece by piece in the morning. @@ -4890,6 +5071,7 @@ function applyCarBuild(gl, res) { lightsMotorway: carPtsCountM, lightsSide: carPtsCountB, trafficSide: CAR_TRAFFIC > 0 ? 'right' : 'left', maxChainKm: Math.round(carMaxChainKm * 10) / 10 }; + selectEmergencyRoutes(); // After deleting a VBO, any still-enabled vertex attrib slot points at a // destroyed buffer -> the sky/cloud full-screen draws warn "no buffer is // bound to enabled attribute". Re-anchor every enabled slot to the @@ -6977,7 +7159,11 @@ window.clouds3dRender = function(target) { lightLo: gl.getUniformLocation(cpp, 'uLightLo'), lightSoft: gl.getUniformLocation(cpp, 'uLightSoft'), wet: gl.getUniformLocation(cpp, 'uWet'), - wetGain: gl.getUniformLocation(cpp, 'uWetGain') + wetGain: gl.getUniformLocation(cpp, 'uWetGain'), + emergency: gl.getUniformLocation(cpp, 'uEmergency'), + emergencyColor: gl.getUniformLocation(cpp, 'uEmergencyColor'), + emergencyFlashHz: gl.getUniformLocation(cpp, 'uEmergencyFlashHz'), + emergencyNightOnly: gl.getUniformLocation(cpp, 'uEmergencyNightOnly') }; } } @@ -7010,6 +7196,10 @@ window.clouds3dRender = function(target) { gl.uniform1f(carPtsUniforms.fadeB, CAR_FADE_B); gl.uniform1f(carPtsUniforms.wet, _roadWet); gl.uniform1f(carPtsUniforms.wetGain, WET_LIGHT_GAIN); + gl.uniform1f(carPtsUniforms.emergency, 0.0); + gl.uniform3f(carPtsUniforms.emergencyColor, EMERGENCY_COLOR[0], EMERGENCY_COLOR[1], EMERGENCY_COLOR[2]); + gl.uniform1f(carPtsUniforms.emergencyFlashHz, EMERGENCY_FLASH_HZ); + gl.uniform1f(carPtsUniforms.emergencyNightOnly, EMERGENCY_NIGHT_ONLY ? 1.0 : 0.0); var carPtsGroup = function (vbo, count, dash, speed, maxDens) { if (!vbo || !count) return; gl.bindBuffer(gl.ARRAY_BUFFER, vbo); @@ -7030,6 +7220,25 @@ window.clouds3dRender = function(target) { }; carPtsGroup(carPtsVboM, carPtsCountM, CAR_M_DASH, CAR_M_SPEED, CAR_M_DENS); carPtsGroup(carPtsVboB, carPtsCountB, CAR_B_DASH, CAR_B_SPEED, CAR_B_DENS); + var emergencyDrawCount = updateEmergencyVehicles(now); + if (emergencyDrawCount > 0) { + if (!emergencyVbo) emergencyVbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, emergencyVbo); + gl.bufferData(gl.ARRAY_BUFFER, emergencyPts.subarray(0, emergencyDrawCount * 10), gl.DYNAMIC_DRAW); + gl.vertexAttribPointer(carPtsAttribs.pos, 3, gl.FLOAT, false, 40, 0); + gl.vertexAttribPointer(carPtsAttribs.meta, 4, gl.FLOAT, false, 40, 12); + gl.vertexAttribPointer(carPtsAttribs.dens, 1, gl.FLOAT, false, 40, 28); + gl.vertexAttribPointer(carPtsAttribs.seed, 1, gl.FLOAT, false, 40, 32); + gl.vertexAttribPointer(carPtsAttribs.len, 1, gl.FLOAT, false, 40, 36); + gl.uniform1f(carPtsUniforms.dash, 0.0); + gl.uniform1f(carPtsUniforms.speed, 0.0); + gl.uniform1f(carPtsUniforms.dens, 1.0); + gl.uniform1f(carPtsUniforms.bright, EMERGENCY_BRIGHT); + gl.uniform1f(carPtsUniforms.emergency, 1.0); + gl.drawArrays(gl.POINTS, 0, emergencyDrawCount); + gl.uniform1f(carPtsUniforms.emergency, 0.0); + gl.uniform1f(carPtsUniforms.bright, CAR_BRIGHT); + } } } gl.disable(gl.BLEND);