import * as THREE from '/vendor/three/three.module.min.js'; import { cityAudio } from './audio.js'; const cfg = window.CITY_CONFIG || {}; const i18n = cfg.i18n || {}; const host = document.getElementById('scene'); const loadingEl = document.getElementById('loading'); const errorEl = document.getElementById('error'); const startGate = document.getElementById('start-gate'); const statsEl = document.getElementById('stats'); const selectedEl = document.getElementById('selected'); const reloadBtn = document.getElementById('reload'); const posPanel = document.getElementById('hud-pos'); const posXYZ = document.getElementById('pos-xyz'); const posGround = document.getElementById('pos-ground'); const posTris = document.getElementById('pos-tris'); const clockEl = document.getElementById('hud-clock'); const weatherEl = document.getElementById('hud-weather'); const dataUrl = cfg.dataUrl; const csrf = cfg.csrf; // ------------------------------------------------------------------ // Scene setup // ------------------------------------------------------------------ const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87ceeb); scene.fog = new THREE.Fog(0x87ceeb, 120, 600); const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000); camera.position.set(0, 1.7, 0); // preserveDrawingBuffer: lets automated tests/screenshots read real // frame pixels (the PIP visor proof); cost is negligible here. const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05; host.appendChild(renderer.domElement); const hemiLight = new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55); scene.add(hemiLight); const ambientLight = new THREE.AmbientLight(0xffffff, 0.35); scene.add(ambientLight); const sun = new THREE.DirectionalLight(0xfff2d9, 1.15); sun.position.set(80, 140, 60); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); // Tight shadow window that follows the player (see updateDayNight): the // city is far larger than the frustum now, and a follow box keeps the // per-frame caster list small while doubling effective shadow resolution. sun.shadow.camera.left = -120; sun.shadow.camera.right = 120; sun.shadow.camera.top = 120; sun.shadow.camera.bottom = -120; sun.shadow.camera.far = 500; sun.shadow.bias = -0.0004; scene.add(sun); scene.add(sun.target); // ------------------------------------------------------------------ // Texture helpers // ------------------------------------------------------------------ function canvasTexture(size, draw, repeatX = 1, repeatY = 1) { const c = document.createElement('canvas'); c.width = size; c.height = size; const ctx = c.getContext('2d'); draw(ctx, size); const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(repeatX, repeatY); tex.anisotropy = renderer.capabilities.getMaxAnisotropy(); return tex; } const asphaltTex = canvasTexture(512, (ctx, s) => { ctx.fillStyle = '#1a1a1e'; ctx.fillRect(0, 0, s, s); for (let i = 0; i < 9000; i++) { const v = 20 + Math.random() * 30; ctx.fillStyle = `rgb(${v},${v},${v + 4})`; ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1); } ctx.fillStyle = '#e8e8e8'; ctx.fillRect(s * 0.44, 0, 4, s); ctx.fillRect(s * 0.54, 0, 4, s); ctx.fillStyle = '#d0d0d0'; for (let y = 0; y < s; y += 48) { ctx.fillRect(s * 0.08, y, 4, 26); ctx.fillRect(s * 0.90, y, 4, 26); } }); const sidewalkTex = canvasTexture(256, (ctx, s) => { ctx.fillStyle = '#b8bcc4'; ctx.fillRect(0, 0, s, s); const tile = 64; ctx.strokeStyle = '#9aa0a8'; ctx.lineWidth = 2; for (let i = 0; i <= s; i += tile) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke(); } for (let i = 0; i < 40; i++) { ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`; ctx.beginPath(); ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2); ctx.fill(); } }, 4, 1); // Zebra crosswalk: white longitudinal bars parallel to vehicle travel // (MUTCD "longitudinal bar" design), transparent gaps so the asphalt // underneath shows through. Six bars (~0.6 unit each with ~0.6 gaps, // NZTA proportions) across the 8-unit road width. `barsAcrossU` draws // bars along V (for planes spanning roadW × band) — otherwise along U. function zebraTexture(barsAcrossU) { return canvasTexture(512, (ctx, s) => { ctx.clearRect(0, 0, s, s); ctx.fillStyle = '#f5f5f5'; const bars = 6, bar = 38, pitch = 76; const start = (s - (bars * bar + (bars - 1) * (pitch - bar))) / 2; for (let i = 0; i < bars; i++) { const p = start + i * pitch; if (barsAcrossU) { ctx.fillRect(p, 0, bar, s); } else { ctx.fillRect(0, p, s, bar); } } }, 1, 1); } const zebraTexOverZRoad = zebraTexture(true); const zebraTexOverXRoad = zebraTexture(false); function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4, size = 512) { const w = size, h = size; const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); const e = document.createElement('canvas'); e.width = w; e.height = h; const ectx = e.getContext('2d'); ctx.fillStyle = baseColor; ctx.fillRect(0, 0, w, h); ectx.fillStyle = '#000000'; ectx.fillRect(0, 0, w, h); const floorH = h / floorCount; for (let f = 0; f < floorCount; f++) { ctx.fillStyle = 'rgba(30,30,30,0.85)'; ctx.fillRect(0, f * floorH + floorH - 4, w, 4); for (let wi = 0; wi < windowsPerFloor; wi++) { const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25; const wy = f * floorH + floorH * 0.18; const ww = (w / windowsPerFloor) * 0.5; const wh = floorH * 0.5; ctx.fillStyle = '#1a1a1a'; ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6); ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0'; ctx.fillRect(wx, wy, ww, wh); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4); if (Math.random() > 0.45) { ectx.fillStyle = Math.random() > 0.5 ? '#ffd9a0' : '#ffcc80'; ectx.fillRect(wx, wy, ww, wh); } } } ctx.fillStyle = 'rgba(40,40,40,0.7)'; ctx.fillRect(0, 0, 6, h); ctx.fillRect(w - 6, 0, 6, h); const map = new THREE.CanvasTexture(c); const emissiveMap = new THREE.CanvasTexture(e); map.anisotropy = renderer.capabilities.getMaxAnisotropy(); emissiveMap.anisotropy = renderer.capabilities.getMaxAnisotropy(); return { map, emissiveMap }; } function facadeTexture(baseColor, floorCount, windowsPerFloor) { return facadePair(baseColor, floorCount, windowsPerFloor).map; } function doorTexture() { const c = document.createElement('canvas'); c.width = 128; c.height = 256; const ctx = c.getContext('2d'); ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, 128, 256); ctx.fillStyle = '#3a2f1a'; ctx.fillRect(8, 8, 112, 240); ctx.fillStyle = '#c9a84c'; ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill(); return new THREE.CanvasTexture(c); } // ------------------------------------------------------------------ // World state // ------------------------------------------------------------------ let cityData = null; let meCharacter = null; let cityBounds = null; let citizenMeshes = []; const colliders = []; const worldGroup = new THREE.Group(); scene.add(worldGroup); const storeMeshes = []; const agentMeshes = []; const crowdMeshes = []; const facadeMaterials = []; const lampHeads = []; // Luminous signs (letreiros): materials get a day/night glow ramp in // updateVehicleHeadlights; counts feed stats().signs. const signMaterials = []; let signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 }; let grid = null; let crosswalkCount = 0; let sidewalkSegmentCount = 0; // Per-frame cost split (update JS vs render submit) for perf monitoring. const perfFrame = { update: 0, render: 0 }; let trafficSystem = null; let vehicleSystem = null; let pedestrianSystem = null; let airplaneSystem = null; let trainSystem = null; let riverSystem = null; let shopSystem = null; let mayorSystem = null; let meMesh = null; let hospitalBusy = false; let spawnApplied = false; let plazas = []; let gymEquipment = []; let exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null, }; // Beach leisure (east shore): geometry from the payload + relax session. let relax = { active: false, timer: 0, animPhase: 0 }; let beach = null; let beachSystem = null; // Airports payload {visit, sites} — geometry, walk zone and flight paths. let airportsCfg = null; // Port payload {visit, water, quay, yard, terminal, cranes, berths, ships…}. let portCfg = null; // Port ops runtime (ships, boats, trucks, crane, AI clearances). let portSystem = null; let remoteCitizens = new Map(); // characterId -> mesh let presenceTimer = null; let vehicleHeadlights = []; let vehicleTailLights = []; let vehicleBeams = []; let mobileInput = { x: 0, y: 0, active: false }; let minimapCtx = null; let lastPresenceOk = 0; function clearGroup(g) { while (g.children.length) { const c = g.children.pop(); c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); } citizenMeshes = []; remoteCitizens = new Map(); vehicleHeadlights = []; vehicleTailLights = []; vehicleBeams = []; } function addCollider(cx, cy, cz, w, h, d) { colliders.push({ minX: cx - w / 2, maxX: cx + w / 2, minY: cy, maxY: cy + h, minZ: cz - d / 2, maxZ: cz + d / 2, }); } // ------------------------------------------------------------------ // Simulation clock (day/night) // ------------------------------------------------------------------ const DAY_LENGTH = 240; // real seconds per simulated day const sim = { hour: 9, nightFactor: 0, }; function updateClock(dt) { sim.hour = (sim.hour + dt * (24 / DAY_LENGTH)) % 24; } function skyColorForHour(hour) { const stops = [ [0, 0x0a1226], [4.5, 0x101a36], [6, 0x4a3a6a], [7, 0xff9a5c], [8.5, 0x87ceeb], [16.5, 0x87ceeb], [18.2, 0xff7a45], [19.5, 0x2a2550], [21, 0x0c1428], [24, 0x0a1226], ]; let a = stops[0], b = stops[stops.length - 1]; for (let i = 0; i < stops.length - 1; i++) { if (hour >= stops[i][0] && hour <= stops[i + 1][0]) { a = stops[i]; b = stops[i + 1]; break; } } const t = b[0] === a[0] ? 0 : (hour - a[0]) / (b[0] - a[0]); const ca = new THREE.Color(a[1]); const cb = new THREE.Color(b[1]); return ca.lerp(cb, t); } function updateDayNight() { const hour = sim.hour; const dayT = (hour - 6) / 12; const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI); const isDay = hour >= 6 && hour <= 18; // Smooth dusk (17–19) / dawn (5–7) so headlights ramp in gradually. let night = 1; if (hour >= 19 || hour < 5) night = 1; else if (hour >= 17) night = (hour - 17) / 2; else if (hour < 7) night = (7 - hour) / 2; else night = 0; sim.nightFactor = night; const weatherDim = weatherSunDim(); const sky = skyColorForHour(hour); const gray = new THREE.Color(0x4a5560); const dim = weatherState.state === 'clear' ? 0 : weatherState.state === 'cloudy' ? 0.35 : weatherState.state === 'rain' ? 0.55 : 0.7; sky.lerp(gray, dim * (isDay ? 1 : 0.4)); if (lightningFlash > 0) { sky.lerp(new THREE.Color(0xdfe8ff), Math.min(1, lightningFlash * 2)); } scene.background = sky; scene.fog.color.copy(sky); const ang = ((hour - 6) / 12) * Math.PI; // Anchor sun + shadow frustum on the player so the shadow pass only // sees casters around them, wherever they are in the expanded city. const focus = player.pos; sun.position.set( focus.x + Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), focus.z + 60 ); sun.target.position.set(focus.x, 0, focus.z); sun.target.updateMatrixWorld(); sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2; sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5); hemiLight.intensity = 0.12 + (isDay ? elevation * 0.5 : 0) * weatherDim; ambientLight.intensity = 0.14 + (isDay ? elevation * 0.28 : 0) * weatherDim + lightningFlash; const fogNear = weatherState.state === 'rain' ? 50 : weatherState.state === 'storm' ? 35 : (night ? 70 : 110); const fogFar = weatherState.state === 'rain' ? 340 : weatherState.state === 'storm' ? 220 : (night ? 380 : 600); scene.fog.near = fogNear; scene.fog.far = fogFar; const lampGlow = 0.05 + night * 1.5; for (const m of lampHeads) m.emissiveIntensity = lampGlow; for (const m of facadeMaterials) m.emissiveIntensity = night * 0.85; } // ------------------------------------------------------------------ // Weather system // ------------------------------------------------------------------ const WEATHER_TYPES = ['clear', 'cloudy', 'rain', 'storm']; const WEATHER_ICONS = { clear: '☀️', cloudy: '☁️', rain: '🌧️', storm: '⛈️' }; const weatherState = { state: 'clear', timer: 0, duration: 50, sequence: ['clear', 'cloudy', 'rain', 'storm', 'cloudy', 'clear'], index: 0, }; let lightningFlash = 0; let lightningTimer = 3; let rainPoints = null; let cloudSprites = []; function weatherSunDim() { switch (weatherState.state) { case 'cloudy': return 0.55; case 'rain': return 0.3; case 'storm': return 0.15; default: return 1; } } function makeCloudTexture() { const c = document.createElement('canvas'); c.width = 128; c.height = 128; const ctx = c.getContext('2d'); const g = ctx.createRadialGradient(64, 64, 8, 64, 64, 60); g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(0.6, 'rgba(230,235,240,0.55)'); g.addColorStop(1, 'rgba(220,225,230,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128); return new THREE.CanvasTexture(c); } function initWeather() { const rainGeo = new THREE.BufferGeometry(); const N = 2500; const pos = new Float32Array(N * 3); for (let i = 0; i < N; i++) { pos[i * 3] = (Math.random() - 0.5) * 70; pos[i * 3 + 1] = Math.random() * 40; pos[i * 3 + 2] = (Math.random() - 0.5) * 70; } rainGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const rainMat = new THREE.PointsMaterial({ color: 0x9ab8cc, size: 0.14, transparent: true, opacity: 0.65, depthWrite: false, }); rainPoints = new THREE.Points(rainGeo, rainMat); rainPoints.visible = false; scene.add(rainPoints); const cloudTex = makeCloudTexture(); cloudSprites = []; for (let i = 0; i < 14; i++) { const mat = new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.3, depthWrite: false }); const s = new THREE.Sprite(mat); s.position.set((Math.random() - 0.5) * 500, 55 + Math.random() * 35, (Math.random() - 0.5) * 500); const sc = 45 + Math.random() * 55; s.scale.set(sc, sc * 0.45, 1); scene.add(s); cloudSprites.push(s); } } function applyWeatherVisuals() { const w = weatherState.state; const raining = w === 'rain' || w === 'storm'; if (rainPoints) { rainPoints.visible = raining; rainPoints.material.opacity = w === 'storm' ? 0.85 : 0.6; } const cloudOpacity = { clear: 0.12, cloudy: 0.65, rain: 0.85, storm: 0.95 }[w] ?? 0.3; const cloudTint = w === 'storm' ? 0x6a7280 : w === 'rain' ? 0x9aa3ad : 0xffffff; for (const s of cloudSprites) { s.material.opacity = cloudOpacity; s.material.color.setHex(cloudTint); } cityAudio.setWeather(w); } function setWeather(type) { if (!WEATHER_TYPES.includes(type)) return; weatherState.state = type; weatherState.timer = 0; applyWeatherVisuals(); } function updateWeather(dt) { weatherState.timer += dt; if (weatherState.timer >= weatherState.duration) { weatherState.timer = 0; weatherState.index = (weatherState.index + 1) % weatherState.sequence.length; weatherState.state = weatherState.sequence[weatherState.index]; applyWeatherVisuals(); } for (const s of cloudSprites) { s.position.x += dt * (weatherState.state === 'storm' ? 3.5 : 1.2); if (s.position.x > 320) s.position.x = -320; } if (rainPoints && rainPoints.visible) { rainPoints.position.x = camera.position.x; rainPoints.position.z = camera.position.z; const arr = rainPoints.geometry.attributes.position.array; const fall = weatherState.state === 'storm' ? 38 : 26; for (let i = 1; i < arr.length; i += 3) { arr[i] -= fall * dt; if (arr[i] < 0) arr[i] = 40; } rainPoints.geometry.attributes.position.needsUpdate = true; } if (lightningFlash > 0) lightningFlash = Math.max(0, lightningFlash - dt * 4); if (weatherState.state === 'storm') { lightningTimer -= dt; if (lightningTimer <= 0) { lightningTimer = 2.5 + Math.random() * 5; lightningFlash = 1; cityAudio.play('thunder'); } } } // ------------------------------------------------------------------ // City grid helpers // ------------------------------------------------------------------ function buildGrid(layout) { const xs = [...new Set(layout.roads.filter(r => r.axis === 'z').map(r => r.from[0]))] .sort((a, b) => a - b); const zs = [...new Set(layout.roads.filter(r => r.axis === 'x').map(r => r.from[2]))] .sort((a, b) => a - b); const roadW = layout.roads[0]?.width ?? 8; const squares = []; for (let i = 0; i < xs.length - 1; i++) { for (let j = 0; j < zs.length - 1; j++) { squares.push({ x0: xs[i], x1: xs[i + 1], z0: zs[j], z1: zs[j + 1] }); } } return { xs, zs, roadW, squares, minX: xs[0], maxX: xs[xs.length - 1], minZ: zs[0], maxZ: zs[zs.length - 1] }; } function onRoad(x, z, g) { const lim = g.roadW / 2 - 0.4; for (const vx of g.xs) if (Math.abs(x - vx) < lim) return true; for (const vz of g.zs) if (Math.abs(z - vz) < lim) return true; return false; } /** * Split the interval [a, b] of a road's travel axis into segments that * exclude ±half bands around every perpendicular crossing line. Sidewalks * and curbs use this so they stop at each crosswalk instead of running * across the roadway like a plain walkway. * * @param {number[]} lines grid lines of the perpendicular roads * @return {Array<[number, number]>} */ function stripSegments(lines, a, b, half) { if (a > b) [a, b] = [b, a]; const gaps = []; for (const line of lines) { const g0 = line - half; const g1 = line + half; if (g1 > a && g0 < b) gaps.push([Math.max(g0, a), Math.min(g1, b)]); } gaps.sort((p, q) => p[0] - q[0]); const merged = []; for (const gap of gaps) { const last = merged[merged.length - 1]; if (last && gap[0] <= last[1] + 1e-6) { last[1] = Math.max(last[1], gap[1]); } else { merged.push([gap[0], gap[1]]); } } const segs = []; let cursor = a; for (const [g0, g1] of merged) { if (g0 - cursor > 0.05) segs.push([cursor, g0]); cursor = Math.max(cursor, g1); } if (b - cursor > 0.05) segs.push([cursor, b]); return segs; } /** * Batches static same-material geometries into single merged meshes so a * large city costs a handful of draw calls instead of thousands. Entries * are baked into world space at flush(); only truly static, non- * interactive meshes belong here (roadsides, poles, housings, roofs). * * @returns {{add: Function, flush: Function}} */ function makeStaticBatcher() { /** @type {Map, cast: boolean, receive: boolean}>} */ const buckets = new Map(); return { add(geometry, material, matrix, cast = false, receive = false) { let bucket = buckets.get(material); if (!bucket) { bucket = { entries: [], cast: false, receive: false }; buckets.set(material, bucket); } bucket.entries.push({ geometry, matrix }); bucket.cast = bucket.cast || cast; bucket.receive = bucket.receive || receive; }, flush(parent) { for (const [material, bucket] of buckets) { if (!bucket.entries.length) continue; const parts = bucket.entries.map(({ geometry, matrix }) => { const g = geometry.index ? geometry.toNonIndexed() : geometry.clone(); if (matrix) g.applyMatrix4(matrix); return g; }); let total = 0; for (const g of parts) total += g.getAttribute('position').count; const pos = new Float32Array(total * 3); const nor = new Float32Array(total * 3); const uv = new Float32Array(total * 2); let o3 = 0; let o2 = 0; for (const g of parts) { const count = g.getAttribute('position').count; pos.set(g.getAttribute('position').array, o3); const n = g.getAttribute('normal'); if (n) nor.set(n.array, o3); const u = g.getAttribute('uv'); if (u) uv.set(u.array, o2); o3 += count * 3; o2 += count * 2; g.dispose(); } const merged = new THREE.BufferGeometry(); merged.setAttribute('position', new THREE.BufferAttribute(pos, 3)); merged.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); merged.computeBoundingSphere(); const mesh = new THREE.Mesh(merged, material); mesh.castShadow = bucket.cast; mesh.receiveShadow = bucket.receive; parent.add(mesh); } buckets.clear(); }, }; } // ------------------------------------------------------------------ // Traffic light system // ------------------------------------------------------------------ const PHASES = [ { name: 'NS_GREEN', dur: 10, z: 'green', x: 'red' }, { name: 'NS_YELLOW', dur: 2, z: 'yellow', x: 'red' }, { name: 'ALL_RED1', dur: 1, z: 'red', x: 'red' }, { name: 'EW_GREEN', dur: 10, z: 'red', x: 'green' }, { name: 'EW_YELLOW', dur: 2, z: 'red', x: 'yellow' }, { name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' }, ]; /** * Pedestrian signal for a road axis. * * Pedestrians may cross a road when vehicles on that road are stopped (red). * Yellow is treated as "do not start crossing" for safety. * * @param {string} axis * @returns {'green'|'red'} */ function pedStateForAxis(axis) { if (!trafficSystem) return 'green'; const vehicle = trafficSystem.axisState(axis); return vehicle === 'red' ? 'green' : 'red'; } // Shared housings: hundreds of signal bodies merge into one draw call // while the lamp spheres stay individual (animated, distance-culled). const pedHousingMat = new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 }); const signalHousingMat = new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 }); function makePedSignal() { const g = new THREE.Group(); const box = new THREE.Mesh( new THREE.BoxGeometry(0.28, 0.7, 0.22), pedHousingMat ); box.userData.batchStatic = true; g.add(box); // Top = red "don't walk", bottom = green "walk". const red = new THREE.Mesh( new THREE.SphereGeometry(0.09, 10, 10), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 0.08 }) ); red.position.set(0, 0.16, 0.13); g.add(red); const green = new THREE.Mesh( new THREE.SphereGeometry(0.09, 10, 10), new THREE.MeshStandardMaterial({ color: 0x22dd66, emissive: 0x22dd66, emissiveIntensity: 0.08 }) ); green.position.set(0, -0.16, 0.13); g.add(green); g.userData.lamps = { red, green }; return g; } function makeSignalHead() { const g = new THREE.Group(); const box = new THREE.Mesh( new THREE.BoxGeometry(0.32, 1.0, 0.26), signalHousingMat ); box.userData.batchStatic = true; g.add(box); const colors = [0xff2222, 0xffcc00, 0x22cc44]; const lamps = []; colors.forEach((col, i) => { const m = new THREE.Mesh( new THREE.SphereGeometry(0.1, 10, 10), new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 0.08 }) ); m.position.set(0, 0.32 - i * 0.32, 0.15); g.add(m); lamps.push(m); }); g.userData.lamps = lamps; return g; } /** * Move a signal head's static housing into the batcher, leaving the * animated lamp spheres in the group (they are distance-culled instead). * * @param {THREE.Group} head * @param {ReturnType} batcher */ function batchHeadStatic(head, batcher) { head.updateMatrixWorld(true); for (const child of [...head.children]) { if (!child.userData.batchStatic) continue; batcher.add(child.geometry, child.material, child.matrixWorld); head.remove(child); } } function initTrafficLights(g, batcher) { const group = new THREE.Group(); const heads = []; const pedHeads = []; const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 }); const pedPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.65, metalness: 0.4 }); // Sidewalk band is [half, half+2] from the road centre (half=4 → 4..6). const half = g.roadW / 2; const sw = half + 1; // sidewalk centre for (const x of g.xs) { for (const z of g.zs) { // SE-corner pole, planted on the sidewalk (group/world are at // identity, so local matrices are world matrices here). const poleGeo = new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6); const pole = new THREE.Mesh(poleGeo, poleMat); pole.position.set(x + sw, 2.1, z + sw); pole.updateMatrix(); batcher.add(poleGeo, poleMat, pole.matrix, true); // Mast arm over the vertical road (northbound lane), facing // south so approaching traffic sees the signal. Northbound // drives on the east half (right-hand traffic); the SE-corner // pole sits on that same (right) side. const armNSGeo = new THREE.BoxGeometry(0.12, 0.1, 2.2); const armNS = new THREE.Mesh(armNSGeo, poleMat); armNS.position.set(x + sw, 4.0, z + sw - 1.1); armNS.updateMatrix(); batcher.add(armNSGeo, poleMat, armNS.matrix); const headNS = makeSignalHead(); headNS.position.set(x + sw, 3.7, z + sw - 2.2); headNS.rotation.y = 0; // face +Z (south — toward northbound traffic) group.add(headNS); batchHeadStatic(headNS, batcher); heads.push({ head: headNS, axis: 'z' }); // Mast arm over the horizontal (eastbound lane), facing west. const armEWGeo = new THREE.BoxGeometry(2.2, 0.1, 0.12); const armEW = new THREE.Mesh(armEWGeo, poleMat); armEW.position.set(x + sw - 1.1, 4.0, z + sw); armEW.updateMatrix(); batcher.add(armEWGeo, poleMat, armEW.matrix); const headEW = makeSignalHead(); headEW.position.set(x + sw - 2.2, 3.7, z + sw); headEW.rotation.y = -Math.PI / 2; // face -X (west) group.add(headEW); batchHeadStatic(headEW, batcher); heads.push({ head: headEW, axis: 'x' }); // Pedestrian signals: short posts on the same corner, well // inside the sidewalk, facing the crosswalks they control. // // The east crosswalk crosses the horizontal (x) road, so its // signal follows the x-axis vehicle state. It sits on the east // sidewalk facing north, for walkers arriving from the north. const pedEast = makePedSignal(); const pedEastPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6); const pedEastPole = new THREE.Mesh(pedEastPoleGeo, pedPoleMat); pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6); pedEastPole.updateMatrix(); batcher.add(pedEastPoleGeo, pedPoleMat, pedEastPole.matrix); pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6); pedEast.rotation.y = Math.PI; // faces north (approach from the north) group.add(pedEast); batchHeadStatic(pedEast, batcher); pedHeads.push({ head: pedEast, axis: 'x' }); // The south crosswalk crosses the vertical (z) road, so its // signal follows the z-axis vehicle state. It sits on the south // sidewalk facing west, for walkers arriving from the east. const pedSouth = makePedSignal(); const pedSouthPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6); const pedSouthPole = new THREE.Mesh(pedSouthPoleGeo, pedPoleMat); pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6); pedSouthPole.updateMatrix(); batcher.add(pedSouthPoleGeo, pedPoleMat, pedSouthPole.matrix); pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6); pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east) group.add(pedSouth); batchHeadStatic(pedSouth, batcher); pedHeads.push({ head: pedSouth, axis: 'z' }); } } worldGroup.add(group); trafficSystem = { phaseIndex: 0, phaseTimer: 0, heads, pedHeads, axisState(axis) { return PHASES[this.phaseIndex][axis]; }, pedState(axis) { return pedStateForAxis(axis); }, get phaseName() { return PHASES[this.phaseIndex].name; }, /** Jump to a named phase (used by tests / debug). */ setPhase(name) { const idx = PHASES.findIndex(p => p.name === name); if (idx >= 0) { this.phaseIndex = idx; this.phaseTimer = 0; } }, update(dt) { this.phaseTimer += dt; while (this.phaseTimer >= PHASES[this.phaseIndex].dur) { this.phaseTimer -= PHASES[this.phaseIndex].dur; this.phaseIndex = (this.phaseIndex + 1) % PHASES.length; } for (const { head, axis } of this.heads) { const state = PHASES[this.phaseIndex][axis]; const lamps = head.userData.lamps; lamps[0].material.emissiveIntensity = state === 'red' ? 2.2 : 0.06; lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06; lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06; } for (const { head, axis } of this.pedHeads) { const ped = pedStateForAxis(axis); const lamps = head.userData.lamps; lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06; lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06; } // Lamp dots are sub-pixel at distance — hide them far away. // Housings are batched separately and always stay visible. const camX = camera.position.x; const camZ = camera.position.z; const hideD2 = 60 * 60; for (const { head } of this.heads) { const dx = head.position.x - camX; const dz = head.position.z - camZ; head.visible = dx * dx + dz * dz < hideD2; } for (const { head } of this.pedHeads) { const dx = head.position.x - camX; const dz = head.position.z - camZ; head.visible = dx * dx + dz * dz < hideD2; } }, }; } // ------------------------------------------------------------------ // Vehicle system // ------------------------------------------------------------------ const CAR_COLORS = [0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400]; const MOTO_COLORS = [0x111827, 0xb91c1c, 0x1d4ed8, 0x166534, 0x7c2d12, 0x4b5563]; const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827]; function buildCarMesh(colorHex, isBus = false) { const g = new THREE.Group(); const bodyLen = isBus ? 7.5 : 4.2; const bodyW = isBus ? 2.2 : 1.85; const bodyH = isBus ? 1.6 : 0.55; const paint = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.32, metalness: 0.55 }); const dark = new THREE.MeshStandardMaterial({ color: 0x14161a, roughness: 0.5, metalness: 0.3 }); const glass = new THREE.MeshStandardMaterial({ color: 0x9fd4ff, roughness: 0.12, metalness: 0.2, transparent: true, opacity: 0.55, }); const chrome = new THREE.MeshStandardMaterial({ color: 0xd8dee6, roughness: 0.2, metalness: 0.9 }); const rubber = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, roughness: 0.92 }); const bodyY = isBus ? 1.15 : 0.52; const chassis = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), paint); chassis.position.y = bodyY; chassis.castShadow = true; g.add(chassis); // Rounded hood / trunk edges. const hood = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.38, 0.18, bodyW * 0.92), paint); hood.position.set(bodyLen * 0.28, bodyY + bodyH * 0.5 + 0.05, 0); g.add(hood); if (isBus) { const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.92, 1.35, bodyW * 0.96), paint); cabin.position.set(-0.1, bodyY + 1.15, 0); cabin.castShadow = true; g.add(cabin); for (const side of [-1, 1]) { const strip = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.8, 0.5, 0.04), glass); strip.position.set(-0.1, bodyY + 1.35, side * (bodyW / 2 + 0.01)); g.add(strip); } const front = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.55, bodyW * 0.7), glass); front.position.set(bodyLen / 2 - 0.02, bodyY + 1.3, 0); g.add(front); const roof = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.12, bodyW * 0.9), chrome); roof.position.set(-0.1, bodyY + 1.9, 0); g.add(roof); } else { const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.48, 0.52, bodyW * 0.86), paint); cabin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.28, 0); cabin.castShadow = true; g.add(cabin); const windshield = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.22, 0.38, bodyW * 0.78), glass); windshield.position.set(bodyLen * 0.08, bodyY + bodyH * 0.5 + 0.3, 0); g.add(windshield); for (const side of [-1, 1]) { const sideWin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.3, 0.32, 0.04), glass); sideWin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.3, side * (bodyW * 0.44)); g.add(sideWin); } // Bumpers + grille. const bumperF = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome); bumperF.position.set(bodyLen / 2 + 0.02, 0.38, 0); g.add(bumperF); const bumperR = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome); bumperR.position.set(-bodyLen / 2 - 0.02, 0.38, 0); g.add(bumperR); const grille = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, bodyW * 0.55), dark); grille.position.set(bodyLen / 2 + 0.08, 0.52, 0); g.add(grille); // License plates. const plateMat = new THREE.MeshStandardMaterial({ color: 0xf5f5f0, roughness: 0.8 }); const plateF = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat); plateF.position.set(bodyLen / 2 + 0.1, 0.42, 0); g.add(plateF); const plateR = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat); plateR.position.set(-bodyLen / 2 - 0.1, 0.42, 0); g.add(plateR); // Side mirrors. const mirrorMat = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.4, metalness: 0.5 }); for (const side of [-1, 1]) { const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.1), mirrorMat); mirror.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.48 + 0.08)); g.add(mirror); const armM = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.04, 0.12), mirrorMat); armM.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.44)); g.add(armM); } // Roof detail. const roofTrim = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.4, 0.06, bodyW * 0.7), chrome); roofTrim.position.set(-0.2, bodyY + bodyH * 0.5 + 0.56, 0); g.add(roofTrim); // Headlights / taillights (materials stored for night ramp). g.userData.headMats = []; g.userData.tailMats = []; for (const side of [-1, 1]) { const headMat = new THREE.MeshStandardMaterial({ color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2, }); const head = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.14, 0.28), headMat); head.position.set(bodyLen / 2 + 0.05, 0.58, side * (bodyW * 0.32)); g.add(head); g.userData.headMats.push(headMat); const tailMat = new THREE.MeshStandardMaterial({ color: 0xff3344, emissive: 0xcc1122, emissiveIntensity: 0.4, }); const tail = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.12, 0.3), tailMat); tail.position.set(-bodyLen / 2 - 0.04, 0.55, side * (bodyW * 0.32)); g.add(tail); g.userData.tailMats.push(tailMat); } // Visible beam cones (hidden by day). g.userData.beams = []; for (const side of [-1, 1]) { const beam = new THREE.Mesh( new THREE.ConeGeometry(0.55, 6, 10, 1, true), new THREE.MeshBasicMaterial({ color: 0xfff2b0, transparent: true, opacity: 0, side: THREE.DoubleSide, depthWrite: false, }) ); // Cone default points +Y; rotate to point forward (+X). beam.rotation.z = -Math.PI / 2; beam.position.set(bodyLen / 2 + 3.1, 0.5, side * (bodyW * 0.28)); g.add(beam); g.userData.beams.push(beam.material); } } const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.26, 14); wheelGeo.rotateX(Math.PI / 2); const rimGeo = new THREE.CylinderGeometry(0.16, 0.16, 0.28, 10); rimGeo.rotateX(Math.PI / 2); const wheels = []; const wx = bodyLen * 0.3; const wz = bodyW / 2 + 0.04; [[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => { const w = new THREE.Mesh(wheelGeo, rubber); w.position.set(px, 0.34, pz); w.castShadow = false; w.userData.lodDetail = true; g.add(w); const rim = new THREE.Mesh(rimGeo, chrome); rim.position.set(px, 0.34, pz); g.add(rim); wheels.push(w); }); g.userData.wheels = wheels; g.userData.kind = isBus ? 'bus' : 'car'; g.userData.halfL = bodyLen * 0.46; g.userData.halfW = bodyW * 0.52; return g; } function buildMotoMesh(colorHex) { const g = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.6 }); const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.22, metalness: 0.9 }); const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 }); const dark = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.7 }); const tank = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.38, 0.42), bodyMat); tank.position.set(0.1, 0.74, 0); tank.castShadow = true; g.add(tank); const tankStripe = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.08, 0.44), chrome); tankStripe.position.set(0.1, 0.88, 0); g.add(tankStripe); const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.14, 0.36), dark); seat.position.set(-0.38, 0.84, 0); g.add(seat); const engine = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.32, 0.38), chrome); engine.position.set(0.15, 0.42, 0); g.add(engine); const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.58, 8), chrome); fork.rotation.z = 0.35; fork.position.set(0.7, 0.55, 0); g.add(fork); const bar = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.09, 0.58), chrome); bar.position.set(0.62, 0.86, 0); g.add(bar); for (const side of [-1, 1]) { const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.14, 6), dark); grip.rotation.x = Math.PI / 2; grip.position.set(0.62, 0.86, side * 0.3); g.add(grip); } const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.14, 14); wheelGeo.rotateX(Math.PI / 2); const rimGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.16, 8); rimGeo.rotateX(Math.PI / 2); const wheels = []; [[0.74, 0], [-0.56, 0]].forEach(([px]) => { const w = new THREE.Mesh(wheelGeo, rubber); w.position.set(px, 0.34, 0); w.castShadow = false; w.userData.lodDetail = true; g.add(w); const rim = new THREE.Mesh(rimGeo, chrome); rim.position.set(px, 0.34, 0); g.add(rim); wheels.push(w); }); const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.95, 8), chrome); exhaust.rotation.z = Math.PI / 2; exhaust.position.set(-0.2, 0.36, 0.2); g.add(exhaust); // Headlight. const head = new THREE.Mesh( new THREE.SphereGeometry(0.14, 10, 10), new THREE.MeshStandardMaterial({ color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.3 }) ); head.position.set(0.9, 0.72, 0); g.add(head); g.userData.headMats = [head.material]; g.userData.tailMats = []; g.userData.beams = []; const rider = new THREE.Mesh( new THREE.CapsuleGeometry(0.19, 0.48, 4, 8), new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 }) ); rider.position.set(-0.14, 1.18, 0); rider.rotation.z = -0.28; rider.castShadow = true; g.add(rider); const helmet = new THREE.Mesh( new THREE.SphereGeometry(0.17, 12, 12), new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.25, metalness: 0.5 }) ); helmet.position.set(0.06, 1.54, 0); g.add(helmet); const visor = new THREE.Mesh( new THREE.BoxGeometry(0.12, 0.08, 0.22), new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.2, metalness: 0.4 }) ); visor.position.set(0.2, 1.52, 0); g.add(visor); g.userData.wheels = wheels; g.userData.kind = 'moto'; g.userData.halfL = 0.98; g.userData.halfW = 0.36; return g; } function buildBikeMesh(colorHex) { const g = new THREE.Group(); const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.55 }); const rubber = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 }); const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.25, metalness: 0.85 }); const dark = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.85 }); const tube = (len, x, y, rz = 0) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(0.038, 0.038, len, 8), frameMat); m.position.set(x, y, 0); m.rotation.z = rz; g.add(m); return m; }; tube(1.1, 0, 0.58, Math.PI / 2.4); tube(0.58, -0.15, 0.78, 0); tube(0.48, 0.35, 0.88, Math.PI / 2); // Chainstay. tube(0.7, -0.3, 0.38, Math.PI / 2.1); const chainring = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.02, 6, 16), chrome); chainring.position.set(-0.05, 0.38, 0.12); g.add(chainring); const seat = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.07, 0.18), dark); seat.position.set(-0.2, 1.02, 0); g.add(seat); const seatPost = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.22, 6), chrome); seatPost.position.set(-0.18, 0.9, 0); g.add(seatPost); const bar = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.52), chrome); bar.position.set(0.52, 1.02, 0); g.add(bar); for (const side of [-1, 1]) { const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.12, 6), dark); grip.rotation.x = Math.PI / 2; grip.position.set(0.52, 1.02, side * 0.24); g.add(grip); } const wheelGeo = new THREE.TorusGeometry(0.34, 0.05, 8, 20); const wheels = []; [[0.58], [-0.58]].forEach(([px]) => { const w = new THREE.Mesh(wheelGeo, rubber); w.position.set(px, 0.34, 0); w.castShadow = false; w.userData.lodDetail = true; g.add(w); // Spokes. for (let i = 0; i < 6; i++) { const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.64, 0.015, 0.015), chrome); spoke.position.set(px, 0.34, 0); spoke.rotation.z = (i / 6) * Math.PI; g.add(spoke); } const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.09, 8), chrome); hub.rotation.x = Math.PI / 2; hub.position.set(px, 0.34, 0); g.add(hub); wheels.push(w); }); // Rider pedalling. const torso = new THREE.Mesh( new THREE.CapsuleGeometry(0.17, 0.42, 4, 8), new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 }) ); torso.position.set(-0.05, 1.28, 0); torso.rotation.z = -0.38; torso.castShadow = true; g.add(torso); const head = new THREE.Mesh( new THREE.SphereGeometry(0.15, 12, 12), new THREE.MeshStandardMaterial({ color: 0xf1c27d, roughness: 0.7 }) ); head.position.set(0.14, 1.58, 0); g.add(head); // Helmet. const helmet = new THREE.Mesh( new THREE.SphereGeometry(0.17, 10, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.4 }) ); helmet.position.set(0.14, 1.6, 0); g.add(helmet); const legGeo = new THREE.CapsuleGeometry(0.065, 0.4, 4, 6); [-1, 1].forEach(side => { const leg = new THREE.Mesh(legGeo, new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85 })); leg.position.set(0.05, 0.78, side * 0.13); leg.rotation.z = side * 0.42; g.add(leg); // Legs animate with "wheel" spin for a pedalling look. wheels.push(leg); }); // Front reflector (night headlight). const reflMat = new THREE.MeshStandardMaterial({ color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2, }); const refl = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.08, 0.1), reflMat); refl.position.set(0.72, 0.9, 0); g.add(refl); g.userData.headMats = [reflMat]; g.userData.tailMats = []; g.userData.beams = []; g.userData.wheels = wheels; g.userData.kind = 'bike'; g.userData.halfL = 0.9; g.userData.halfW = 0.32; return g; } function squarePath(sq, inset) { const o = inset; return [ new THREE.Vector3(sq.x0 + o, 0, sq.z0 + o), new THREE.Vector3(sq.x1 - o, 0, sq.z0 + o), new THREE.Vector3(sq.x1 - o, 0, sq.z1 - o), new THREE.Vector3(sq.x0 + o, 0, sq.z1 - o), ]; } function squarePathOuter(sq, outset) { const o = outset; return [ new THREE.Vector3(sq.x0 - o, 0, sq.z0 - o), new THREE.Vector3(sq.x0 - o, 0, sq.z1 + o), new THREE.Vector3(sq.x1 + o, 0, sq.z1 + o), new THREE.Vector3(sq.x1 + o, 0, sq.z0 - o), ]; } function pathLength(pts) { let L = 0; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; L += a.distanceTo(b); } return L; } function posOnPath(pts, s, L, out) { const n = pts.length; let rem = ((s % L) + L) % L; for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % n]; const seg = a.distanceTo(b); if (rem <= seg || i === n - 1) { const t = seg === 0 ? 0 : rem / seg; out.lerpVectors(a, b, Math.min(1, t)); return i; } rem -= seg; } return 0; } function initVehicles(g) { const vehicles = []; const loops = []; // Prefetch the layout so stop-line math can see real intersection coords. initVehicles.g = g; for (const sq of g.squares) { const dx = sq.x1 - sq.x0; const dz = sq.z1 - sq.z0; if (dx < 30 || dz < 30) continue; // Right-hand traffic only: every block circulates clockwise (from // above). Adjacent blocks then occupy the two opposite lanes of a // shared road without ever driving contra-mão. Outer paths are // never used — they coincide with the neighbour's inner path. const cornerR = Math.min(dx, dz) * 0.22; const corners = squarePath(sq, g.roadW / 4); const pts = roundedRectPath(corners, cornerR); loops.push({ pts, L: pathLength(pts) }); } const spawnLoop = (loop, offsetS) => { const roll = Math.random(); let kind = 'car'; if (roll < 0.12) kind = 'moto'; else if (roll < 0.28) kind = 'bike'; else if (roll < 0.44) kind = 'bus'; const color = kind === 'moto' ? MOTO_COLORS[Math.floor(Math.random() * MOTO_COLORS.length)] : kind === 'bike' ? BIKE_COLORS[Math.floor(Math.random() * BIKE_COLORS.length)] : CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)]; let mesh; if (kind === 'moto') mesh = buildMotoMesh(color); else if (kind === 'bike') mesh = buildBikeMesh(color); else mesh = buildCarMesh(color, kind === 'bus'); worldGroup.add(mesh); const lodDetail = []; mesh.traverse(o => { if (o.userData.lodDetail) lodDetail.push(o); }); const v = { mesh, pts: loop.pts, L: loop.L, s: offsetS, speed: 0, kind, maxSpeed: kind === 'bus' ? 6.5 : kind === 'moto' ? 10 + Math.random() * 3 : kind === 'bike' ? 4.5 + Math.random() * 1.5 : 8 + Math.random() * 3, accel: kind === 'moto' ? 7 : kind === 'bike' ? 3.5 : 5, decel: 10, loopKey: loop, lodDetail, lodFar: false, }; vehicles.push(v); return v; }; for (const loop of loops) { if (Math.random() < 0.72) { const count = 1 + Math.floor(Math.random() * 2); for (let i = 0; i < count; i++) { spawnLoop(loop, (loop.L / count) * i + Math.random() * 8); } } } // Shuttle vehicles on synthetic roads (bridge + level crossing). const shuttles = []; const ext = layoutExtras; if (ext.bridgeRoad) { const r = ext.bridgeRoad; const a = new THREE.Vector3(r.from[0], 0, r.from[2]); const b = new THREE.Vector3(r.to[0], 0, r.to[2]); shuttles.push({ a, b, t: 0.3, dir: 1, mesh: buildCarMesh(0x2980b9, false), speed: 0, maxSpeed: 7, axis: 'x' }); } if (ext.crossRoad) { const r = ext.crossRoad; const a = new THREE.Vector3(r.from[0], 0, r.from[2]); const b = new THREE.Vector3(r.to[0], 0, r.to[2]); shuttles.push({ a, b, t: 0.2, dir: 1, mesh: buildCarMesh(0xc0392b, false), speed: 0, maxSpeed: 7, axis: 'z' }); } for (const sh of shuttles) { worldGroup.add(sh.mesh); sh.lodDetail = []; sh.mesh.traverse(o => { if (o.userData.lodDetail) sh.lodDetail.push(o); }); sh.lodFar = false; vehicles.push(sh); } vehicleSystem = { vehicles, positions() { return vehicles.map(v => { const p = new THREE.Vector3(); if (v.pts) { posOnPath(v.pts, v.s, v.L, p); } else { p.lerpVectors(v.a, v.b, v.t); } return { x: p.x, z: p.z }; }); }, count() { return vehicles.length; }, kinds() { const out = { car: 0, bus: 0, moto: 0, bike: 0 }; for (const v of vehicles) { const k = v.kind || v.mesh?.userData?.kind || 'car'; out[k] = (out[k] || 0) + 1; } return out; }, update(dt) { const camX = camera.position.x; const camZ = camera.position.z; const lodD2 = 45 * 45; for (const v of vehicles) { if (v.pts) { updateLoopVehicle(v, dt); } else { updateShuttleVehicle(v, dt); } // Wheels past the near band are a handful of pixels — skip // their draw calls (body/cabin stay; state flips only on change). if (v.lodDetail && v.lodDetail.length) { const dx = v.mesh.position.x - camX; const dz = v.mesh.position.z - camZ; const far = dx * dx + dz * dz > lodD2; if (far !== v.lodFar) { v.lodFar = far; for (const part of v.lodDetail) part.visible = !far; } } } }, }; } function vehicleAheadGap(v) { let minGap = Infinity; for (const other of vehicleSystem.vehicles) { if (other === v || !other.pts || other.loopKey !== v.loopKey) continue; const gap = ((other.s - v.s) % v.L + v.L) % v.L; if (gap > 0.01 && gap < minGap) minGap = gap; } return minGap; } /** * World-space pose of a vehicle: position, unit forward, body half-sizes. */ function getVehiclePose(v) { const pos = new THREE.Vector3(); const ahead = new THREE.Vector3(); if (v.pts) { posOnPath(v.pts, v.s, v.L, pos); posOnPath(v.pts, v.s + 0.6, v.L, ahead); } else { pos.lerpVectors(v.a, v.b, v.t); const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.06)); ahead.lerpVectors(v.a, v.b, lookT); if (ahead.distanceToSquared(pos) < 1e-6) { ahead.x = pos.x + (v.b.x - v.a.x) * v.dir; ahead.z = pos.z + (v.b.z - v.a.z) * v.dir; } } const fx = ahead.x - pos.x; const fz = ahead.z - pos.z; const len = Math.hypot(fx, fz) || 1; return { x: pos.x, z: pos.z, fx: fx / len, fz: fz / len, halfL: v.mesh?.userData?.halfL ?? 2.0, halfW: v.mesh?.userData?.halfW ?? 0.95, }; } /** * Do the two oriented boxes (sampled along each long axis) intersect? */ function bodiesOverlap(a, b) { const lat = (a.halfW + b.halfW) * 0.88; for (let ta = -1; ta <= 1.001; ta += 0.35) { const ax = a.x + a.fx * a.halfL * ta; const az = a.z + a.fz * a.halfL * ta; for (let tb = -1; tb <= 1.001; tb += 0.35) { const bx = b.x + b.fx * b.halfL * tb; const bz = b.z + b.fz * b.halfL * tb; const dx = ax - bx; const dz = az - bz; if (dx * dx + dz * dz < lat * lat) return true; } } return false; } /** * Hard body-overlap check against every other vehicle. * * Returns null when clear, otherwise the pose of the nearest overlapping * vehicle plus the along-axis offset (positive = other is ahead of us). * Deliberately does NOT block on "vehicle ahead in lane" — that is what * same-loop following and traffic lights are for. World-space blocking * caused intersection deadlocks (two cars freezing each other). */ function vehicleSeparation(v) { const self = getVehiclePose(v); const list = vehicleSystem.vehicles; for (const other of list) { if (other === v) continue; const o = getVehiclePose(other); const dx = o.x - self.x; const dz = o.z - self.z; const reach = self.halfL + o.halfL + 0.5; if (dx * dx + dz * dz > reach * reach) continue; if (bodiesOverlap(self, o)) { return { type: 'overlap', along: dx * self.fx + dz * self.fz, other, }; } } return null; } function probeAhead(pts, s, L) { let rem = ((s % L) + L) % L; for (let i = 0; i < pts.length; i++) { const a = pts[i]; const b = pts[(i + 1) % pts.length]; const seg = a.distanceTo(b); if (rem <= seg + 1e-6) { const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z'; return { distToCorner: seg - rem, axis, corner: b, segStart: a, segEnd: b, segIndex: i }; } rem -= seg; } return { distToCorner: 999, axis: 'x', corner: pts[0], segStart: pts[0], segEnd: pts[1] || pts[0], segIndex: 0 }; } /** * Distance along travel to the real intersection stop line. * * Uses the road grid, not path waypoints: finds the next grid line ahead * in the direction of travel and returns how far the vehicle still is * from the stop line (edge of the crossing box). * * Returns: * >= 0 — metres still to go before the stop line * -1 — stop line already passed (committed; must clear the box) */ function distanceToStopLine(probe, vehiclePos) { const g = initVehicles.g; if (!g) return probe.distToCorner; // Travel direction from the current path segment (dominant axis). const dx = probe.segEnd.x - probe.segStart.x; const dz = probe.segEnd.z - probe.segStart.z; const axis = Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z'; const sign = (axis === 'x' ? dx : dz) >= 0 ? 1 : -1; const coord = axis === 'x' ? vehiclePos.x : vehiclePos.z; const lines = axis === 'x' ? g.xs : g.zs; // Next grid line strictly ahead of the vehicle. let next = null; let bestAhead = Infinity; for (const line of lines) { const ahead = (line - coord) * sign; if (ahead > 0.6 && ahead < bestAhead) { bestAhead = ahead; next = line; } } if (next === null) return 999; // Stop line sits just before the crossing box (half road + nose + margin). const boxEdge = g.roadW * 0.5 + 2.0 + 0.8; const stopCoord = next - sign * boxEdge; const dist = (stopCoord - coord) * sign; if (dist <= 0) return -1; // already at/past the line — clear the box return dist; } /** * Yaw so a mesh whose nose is local +X faces the world direction (dx, dz). * * Three.js: local +X after rotation.y=θ becomes (cos θ, 0, -sin θ). */ function yawFromDelta(dx, dz) { return Math.atan2(-dz, dx); } /** * Rounded rectangle path for smoother 90° turns. * * Each corner is replaced by a quarter-arc of radius r so vehicles * steers through curves instead of snapping headings. */ function roundedRectPath(corners, radius) { const pts = []; const n = corners.length; for (let i = 0; i < n; i++) { const prev = corners[(i - 1 + n) % n]; const curr = corners[i]; const next = corners[(i + 1) % n]; const toPrev = new THREE.Vector3().subVectors(prev, curr); const toNext = new THREE.Vector3().subVectors(next, curr); const lenP = toPrev.length() || 1; const lenN = toNext.length() || 1; toPrev.divideScalar(lenP); toNext.divideScalar(lenN); const r = Math.min(radius, lenP * 0.45, lenN * 0.45); const enter = curr.clone().addScaledVector(toPrev, r); const exit = curr.clone().addScaledVector(toNext, r); // Circular arc tangent to both straights. Centre lies on the // angle bisector at distance r / sin(θ/2) from the corner. // half-angle θ/2 from cosθ = toPrev·toNext: // cos(θ/2) = √((1 + cosθ) / 2) pts.push(enter); const bis = toPrev.clone().add(toNext).normalize(); const cosTheta = Math.max(-1, Math.min(1, toPrev.dot(toNext))); const halfAngle = Math.acos(Math.sqrt(Math.max(0, (1 + cosTheta) / 2))); const sinH = Math.sin(halfAngle) || Math.SQRT1_2; const centre = curr.clone().addScaledVector(bis, r / sinH); const v0 = enter.clone().sub(centre); const v1 = exit.clone().sub(centre); const a0 = Math.atan2(v0.z, v0.x); let a1 = Math.atan2(v1.z, v1.x); // Sweep the short way (≤ 180°). while (a1 - a0 > Math.PI) a1 -= Math.PI * 2; while (a1 - a0 < -Math.PI) a1 += Math.PI * 2; const steps = 10; for (let k = 1; k < steps; k++) { const ang = a0 + (a1 - a0) * (k / steps); pts.push(new THREE.Vector3( centre.x + Math.cos(ang) * r, 0, centre.z + Math.sin(ang) * r )); } pts.push(exit); } return pts; } /** * Speed cap approaching a corner so the turn angle looks natural. */ function cornerSpeedCap(kind, distToCorner, maxSpeed) { if (distToCorner > 10) return maxSpeed; const kindScale = kind === 'bike' ? 0.55 : kind === 'moto' ? 0.75 : kind === 'bus' ? 0.55 : 0.7; const base = Math.max(1.6, maxSpeed * kindScale); // Ease in: full cap when very close, blend out by 10 units. const t = Math.min(1, Math.max(0, (10 - distToCorner) / 10)); return maxSpeed - (maxSpeed - base) * t; } function updateLoopVehicle(v, dt) { const probe = probeAhead(v.pts, v.s, v.L); const cur = new THREE.Vector3(); posOnPath(v.pts, v.s, v.L, cur); const distToStop = distanceToStopLine(probe, cur); const committed = distToStop < 0; // already past the stop line const distForLogic = committed ? 999 : distToStop; let wantStop = false; // Stop line: keep well before the intersection so the nose never // enters the crossing box while the light is red/yellow. const stopLine = 9.5; const stopAt = 4.5; // never brake once closer than this — commit to crossing const travelAxis = (() => { const dx = probe.segEnd.x - probe.segStart.x; const dz = probe.segEnd.z - probe.segStart.z; return Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z'; })(); if (trafficSystem && !committed && distForLogic < stopLine) { const state = trafficSystem.axisState(travelAxis); if (state === 'red') { // Always stop before the line — never enter the box on red, // regardless of how close we already are. wantStop = true; } else if (state === 'yellow') { // Brake for yellow unless already about to clear the box. wantStop = distForLogic > 6; } } const gap = vehicleAheadGap(v); // Same-loop following only (path distance) — keep a comfortable buffer. if (gap < 6.5 && !committed) wantStop = true; if (gap < 4.5 && committed) wantStop = v.speed < 2; // Hard overlap: brake into the vehicle ahead of us AND nudge the // pair apart so perpendicular clips dissolve in a frame or two. const sep = vehicleSeparation(v); if (sep && sep.type === 'overlap') { // Brake into the vehicle ahead; if the overlap is severe also // nudge ourselves back along the path so bodies separate. if (sep.along > 0.5) { v.speed = Math.max(0, v.speed - 28 * dt); if (v.speed < 0.3) v.speed = 0; } else if (sep.along < -0.5) { // Their nose is behind ours — keep going to pull away. v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt); } else { v.speed = Math.max(0, v.speed - 18 * dt); } const o = getVehiclePose(sep.other); const dx = o.x - v.mesh.position.x; const dz = o.z - v.mesh.position.z; if (dx * dx + dz * dz < 2.2) { v.s = (v.s - 0.5 + v.L) % v.L; } } if (wantStop) { v.speed = Math.max(0, v.speed - v.decel * dt); // Clamp exactly at the stop line — never roll into the junction. if (distForLogic <= 0.35 && v.speed < 0.8) v.speed = 0; if (distForLogic <= 0 && v.speed > 0) v.speed = 0; } else if (!committed) { const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed); v.speed = Math.min(cap, v.speed + v.accel * dt); } else { // Inside the box: keep momentum to clear it. v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt); } // Extra safety: if somehow still inside the stop zone on red, freeze. if (trafficSystem && !committed && distForLogic >= 0 && trafficSystem.axisState(travelAxis) === 'red' && distForLogic < 1.2) { v.speed = 0; } v.s = (v.s + v.speed * dt) % v.L; const pos = new THREE.Vector3(); const tmp = new THREE.Vector3(); posOnPath(v.pts, v.s, v.L, pos); // Constant look-ahead so the heading never oscillates with speed. const lookAhead = 1.1; posOnPath(v.pts, v.s + lookAhead, v.L, tmp); v.mesh.position.copy(pos); if (tmp.distanceToSquared(pos) > 0.0001) { const targetYaw = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z); const curYaw = v.mesh.rotation.y; let delta = targetYaw - curYaw; while (delta > Math.PI) delta -= Math.PI * 2; while (delta < -Math.PI) delta += Math.PI * 2; // Exponential damp toward the target; snap when close so the // body settles instead of hunting (which caused the zig-zag). const turnRate = v.kind === 'bike' ? 9 : v.kind === 'moto' ? 7 : 6; const t = Math.min(1, turnRate * dt); const yawStep = Math.abs(delta) < 0.01 ? delta : delta * t; v.mesh.rotation.y = curYaw + yawStep; if (v.kind === 'bike' || v.kind === 'moto') { v.mesh.rotation.z = -yawStep * 0.85; } const wheels = v.mesh.userData.wheels || []; // Front-axle steering follows the yaw rate, damped so it settles. const steer = Math.max(-0.45, Math.min(0.45, yawStep * 4)); if (wheels[0]) wheels[0].rotation.y += (steer - wheels[0].rotation.y) * Math.min(1, 10 * dt); if (wheels[1]) wheels[1].rotation.y += (steer - wheels[1].rotation.y) * Math.min(1, 10 * dt); } const wheelSpin = v.speed * dt * 3; for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin; } function updateShuttleVehicle(v, dt) { const trainNear = trainSystem && trainSystem.blocksCrossing(); const distToEnd = v.dir > 0 ? (1 - v.t) : v.t; let wantStop = false; if (v.axis === 'z' && trainNear) { const total = v.a.distanceTo(v.b); const stopT = (trainSystem.crossZ - v.a.z) / (v.b.z - v.a.z); const stopPos = v.a.z + (stopT - 0.12) * (v.b.z - v.a.z); const curZ = v.a.z + v.t * (v.b.z - v.a.z); if ((v.dir > 0 && curZ < stopPos && stopPos - curZ < 14) || (v.dir < 0 && curZ > stopPos && curZ - stopPos < 14)) { wantStop = true; } void total; } // Avoid driving through other vehicles on bridges / crossing. const sep = vehicleSeparation(v); if (sep && sep.type === 'overlap' && sep.along > 0.8) { v.speed = Math.max(0, v.speed - 22 * dt); } else if (wantStop) { v.speed = Math.max(0, v.speed - 10 * dt); } else { v.speed = Math.min(v.maxSpeed, v.speed + 5 * dt); } if (distToEnd < 0.03) { v.dir *= -1; v.speed = 0; } v.t += v.dir * (v.speed * dt) / v.a.distanceTo(v.b); v.t = Math.max(0, Math.min(1, v.t)); const pos = new THREE.Vector3().lerpVectors(v.a, v.b, v.t); v.mesh.position.copy(pos); const look = new THREE.Vector3().lerpVectors(v.a, v.b, Math.max(0, Math.min(1, v.t + v.dir * 0.05))); if (look.distanceToSquared(pos) > 0.0001) { v.mesh.rotation.y = yawFromDelta(look.x - pos.x, look.z - pos.z); } for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3; } // ------------------------------------------------------------------ // Humanoid builder // ------------------------------------------------------------------ function buildHumanoid(agent, showLabel = true) { const g = new THREE.Group(); const type = agent.type || 'pedestrian'; const configs = { pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 }, shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 }, mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 }, traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 }, crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 }, // Beach crowd: swimwear palette; lifeguards wear classic red/yellow. bather: { skin: 0xf1c27d, hair: [0x2a1a0a, 0x553311, 0x111111, 0x886644][Math.floor(Math.random() * 4)], shirt: [0xd9534f, 0x5bc0de, 0xf0ad4e, 0x8e44ad, 0x2ecc71][Math.floor(Math.random() * 5)], pants: [0x2c5f8a, 0xc0392b, 0x111111][Math.floor(Math.random() * 3)], scale: 1 }, lifeguard: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xd92b2b, pants: 0xf2e14c, scale: 1 }, surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 }, basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 }, atc: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0x223366, pants: 0x0f1420, scale: 1.02 }, // Humanoid worker robots: brushed metal body + safety-orange vest. worker_robot: { skin: 0xc9d1d9, hair: 0x9aa4b2, shirt: 0xd97706, pants: 0x47515e, scale: 1, metalness: 0.5 }, }; const cfgc = configs[type] || configs.pedestrian; const s = cfgc.scale; const metalness = cfgc.metalness ?? 0; const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 - metalness * 0.4, metalness }); const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6, metalness }); const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 - metalness * 0.3, metalness }); const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 - metalness * 0.3, metalness }); const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 }); const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat); head.position.y = 1.62 * s; head.castShadow = true; g.add(head); const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat); hair.position.y = 1.65 * s; hair.castShadow = true; g.add(hair); if (type === 'worker_robot') { // Glowing visor across the face. const visor = new THREE.Mesh( new THREE.BoxGeometry(0.34 * s, 0.1 * s, 0.06 * s), new THREE.MeshStandardMaterial({ color: 0x0e7490, emissive: 0x22d3ee, emissiveIntensity: 1.5, roughness: 0.3, }) ); visor.position.set(0, 1.63 * s, 0.255 * s); g.add(visor); // Antenna with a blinking beacon (ramps/pulses with the sign system). const antennaRod = new THREE.Mesh( new THREE.CylinderGeometry(0.014, 0.02, 0.24, 6), new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.5, metalness: 0.7 }) ); antennaRod.position.set(0, 1.97 * s, 0); g.add(antennaRod); const beacon = new THREE.Mesh( new THREE.SphereGeometry(0.045, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) ); beacon.position.set(0, 2.1 * s, 0); g.add(beacon); signMaterials.push({ mat: beacon.material, base: 1.2, nightBoost: 1.0, pulse: 0.9, phase: Math.random() * Math.PI * 2, }); // Chest status light (amber, softer pulse). const chest = new THREE.Mesh( new THREE.BoxGeometry(0.15, 0.11, 0.05), new THREE.MeshStandardMaterial({ color: 0x78350f, emissive: 0xf59e0b, emissiveIntensity: 1.1, roughness: 0.4, }) ); chest.position.set(0, 1.2 * s, 0.21 * s); g.add(chest); signMaterials.push({ mat: chest.material, base: 0.8, nightBoost: 0.7, pulse: 0.35, phase: Math.random() * Math.PI * 2, }); } const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat); torso.position.y = 1.12 * s; torso.castShadow = true; g.add(torso); const arms = []; [-1, 1].forEach(side => { const shoulder = new THREE.Group(); shoulder.position.set(side * 0.32 * s, 1.38 * s, 0); shoulder.rotation.z = side * 0.12; const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat); arm.position.y = -0.28 * s; arm.castShadow = true; shoulder.add(arm); const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat); hand.position.y = -0.56 * s; shoulder.add(hand); g.add(shoulder); arms.push(shoulder); }); const legs = []; [-1, 1].forEach(side => { const hip = new THREE.Group(); hip.position.set(side * 0.13 * s, 0.78 * s, 0); const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat); leg.position.y = -0.36 * s; leg.castShadow = true; hip.add(leg); const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat); shoe.position.set(0, -0.72 * s, 0.05 * s); shoe.castShadow = true; hip.add(shoe); g.add(hip); legs.push(hip); }); let label = null; if (showLabel) { label = makeLabel(agent.name, agent.status || 'active'); label.position.y = 2.15 * s; g.add(label); } g.userData = { agent, label, legs, arms, animPhase: Math.random() * Math.PI * 2, moving: false }; return g; } function animateHumanoid(mesh, dt, speed) { const u = mesh.userData; const moving = speed > 0.05; u.moving = moving; if (moving) { u.animPhase += dt * (4 + speed * 2.5); const swing = Math.sin(u.animPhase) * 0.55; if (u.legs) { u.legs[0].rotation.x = swing; u.legs[1].rotation.x = -swing; u.arms[0].rotation.x = -swing * 0.8; u.arms[1].rotation.x = swing * 0.8; } } else { if (u.legs) { u.legs[0].rotation.x *= 0.85; u.legs[1].rotation.x *= 0.85; u.arms[0].rotation.x *= 0.85; u.arms[1].rotation.x *= 0.85; } } } function makeLabel(name, status) { const c = document.createElement('canvas'); c.width = 256; c.height = 64; const ctx = c.getContext('2d'); ctx.fillStyle = 'rgba(10,15,28,0.85)'; ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64); ctx.fill(); ctx.fillStyle = '#e2e8f0'; ctx.font = 'bold 22px sans-serif'; ctx.textAlign = 'center'; const nm = name.length > 20 ? name.slice(0, 19) + '…' : name; ctx.fillText(nm, 128, 30); const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e'; ctx.fillStyle = dotColor; ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#94a3b8'; ctx.font = '14px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(status || 'active', 56, 53); const tex = new THREE.CanvasTexture(c); const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false })); sprite.scale.set(3.2, 0.8, 1); return sprite; } function refreshLabel(mesh, status) { const u = mesh.userData; if (!u || !u.agent) return; u.agent.status = status; if (u.label) { mesh.remove(u.label); u.label.material.map?.dispose(); u.label.material.dispose(); } const l = makeLabel(u.agent.name, status); l.position.y = 2.15; mesh.add(l); u.label = l; } // ------------------------------------------------------------------ // Pedestrian routes + system // ------------------------------------------------------------------ function segmentCrossAxis(a, b, g) { const dx = Math.abs(b.x - a.x); const dz = Math.abs(b.z - a.z); if (dx < 7 && dz < 7) return null; if (dx >= dz) { for (const vx of g.xs) { if (vx > Math.min(a.x, b.x) + 1 && vx < Math.max(a.x, b.x) - 1) return 'z'; } return null; } for (const vz of g.zs) { if (vz > Math.min(a.z, b.z) + 1 && vz < Math.max(a.z, b.z) - 1) return 'x'; } return null; } function makeRoute(pts, g) { const waypoints = pts.map(p => ({ x: p.x ?? p[0], z: p.z ?? p[1] })); const segments = []; for (let i = 0; i < waypoints.length; i++) { const a = waypoints[i]; const b = waypoints[(i + 1) % waypoints.length]; segments.push({ crossAxis: segmentCrossAxis( new THREE.Vector3(a.x, 0, a.z), new THREE.Vector3(b.x, 0, b.z), g ), midX: (a.x + b.x) / 2, midZ: (a.z + b.z) / 2, }); } return { waypoints, segments }; } function ringRoute(sq, g, inset) { const o = inset; return makeRoute([ { x: sq.x0 + o, z: sq.z0 + o }, { x: sq.x1 - o, z: sq.z0 + o }, { x: sq.x1 - o, z: sq.z1 - o }, { x: sq.x0 + o, z: sq.z1 - o }, ], g); } function crossingRoute(sq, g) { const o = g.roadW / 2 + 1.2; const zc = (sq.z0 + sq.z1) / 2; return makeRoute([ { x: sq.x0 + o, z: zc }, { x: sq.x0 - o, z: zc }, { x: sq.x0 - o, z: zc + 5 }, { x: sq.x0 + o, z: zc + 5 }, ], g); } function vehiclesNear(x, z, radius) { if (!vehicleSystem) return false; const r2 = radius * radius; for (const v of vehicleSystem.vehicles) { const p = new THREE.Vector3(); if (v.pts) posOnPath(v.pts, v.s, v.L, p); else p.lerpVectors(v.a, v.b, v.t); const dx = p.x - x, dz = p.z - z; if (dx * dx + dz * dz < r2) return true; } return false; } function initPedestrians(g, agents) { const walkers = []; const routes = []; g.squares.forEach((sq, i) => { routes.push({ route: ringRoute(sq, g, g.roadW / 2 + 1.2), kind: 'ring' }); if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' }); }); const pedAgents = agents.filter(a => a.type === 'pedestrian' || a.type === 'worker_robot'); pedAgents.forEach((a, i) => { const pick = routes[i % routes.length]; const mesh = buildHumanoid(a, true); worldGroup.add(mesh); agentMeshes.push(mesh); const w = { mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4, wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0, robot: a.type === 'worker_robot', }; const start = w.route.waypoints[0]; mesh.position.set(start.x, 0, start.z); walkers.push(w); }); const CROWD_COUNT = 14; for (let i = 0; i < CROWD_COUNT; i++) { const pick = routes[(i + pedAgents.length + 1) % routes.length]; const mesh = buildHumanoid({ name: '', type: 'crowd', status: 'active' }, false); worldGroup.add(mesh); crowdMeshes.push(mesh); const start = pick.route.waypoints[i % pick.route.waypoints.length]; mesh.position.set(start.x, 0, start.z); walkers.push({ mesh, route: pick.route, wp: i % pick.route.waypoints.length, speed: 1.0 + Math.random() * 0.5, wait: Math.random() * 3, pauseAt: 5 + Math.random() * 10, pauseTimer: 0, }); } pedestrianSystem = { walkers, count() { return walkers.length; }, positions() { return walkers.map(w => ({ x: w.mesh.position.x, z: w.mesh.position.z })); }, update(dt) { for (const w of walkers) { if (w.wait > 0) { w.wait -= dt; animateHumanoid(w.mesh, dt, 0); continue; } if (w.pauseTimer > 0) { w.pauseTimer -= dt; animateHumanoid(w.mesh, dt, 0); continue; } const wps = w.route.waypoints; const segIdx = w.wp % w.route.segments.length; const seg = w.route.segments[segIdx]; const target = wps[(w.wp + 1) % wps.length]; if (seg.crossAxis && trafficSystem) { const state = trafficSystem.axisState(seg.crossAxis); const meshPos = w.mesh.position; const distToMid = Math.hypot(meshPos.x - seg.midX, meshPos.z - seg.midZ); const approaching = distToMid > 1.5; if (approaching && distToMid < 14) { if (state !== 'red') { animateHumanoid(w.mesh, dt, 0); continue; } if (vehiclesNear(seg.midX, seg.midZ, 8)) { animateHumanoid(w.mesh, dt, 0); continue; } } } const dx = target.x - w.mesh.position.x; const dz = target.z - w.mesh.position.z; const dist = Math.hypot(dx, dz); if (onRoad(w.mesh.position.x, w.mesh.position.z, g) && vehiclesNear(w.mesh.position.x, w.mesh.position.z, 5)) { animateHumanoid(w.mesh, dt, 0); continue; } if (dist < 0.35) { w.wp = (w.wp + 1) % wps.length; if (Math.random() < 0.22) w.pauseTimer = w.pauseAt * (0.4 + Math.random() * 0.6); animateHumanoid(w.mesh, dt, 0); continue; } const step = Math.min(dist, w.speed * dt); w.mesh.position.x += (dx / dist) * step; w.mesh.position.z += (dz / dist) * step; w.mesh.rotation.y = Math.atan2(dx, dz); animateHumanoid(w.mesh, dt, w.speed); } }, }; } // ------------------------------------------------------------------ // Shop system (open/close by hour, signs, shopkeepers) // ------------------------------------------------------------------ function makeStoreSign(store, isOpen) { const c = document.createElement('canvas'); c.width = 512; c.height = 128; const ctx = c.getContext('2d'); ctx.fillStyle = '#0d1118'; ctx.fillRect(0, 0, 512, 128); ctx.strokeStyle = isOpen ? '#22c55e' : '#ef4444'; ctx.lineWidth = 6; ctx.strokeRect(4, 4, 504, 120); ctx.fillStyle = '#e2e8f0'; ctx.font = 'bold 34px sans-serif'; ctx.textAlign = 'center'; const nm = store.name.length > 24 ? store.name.slice(0, 23) + '…' : store.name; ctx.fillText(nm, 256, 55); ctx.font = 'bold 30px sans-serif'; ctx.fillStyle = isOpen ? '#4ade80' : '#f87171'; ctx.fillText(isOpen ? (i18n.open || 'OPEN') : (i18n.closed || 'CLOSED'), 256, 100); const tex = new THREE.CanvasTexture(c); const mat = new THREE.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.4, roughness: 0.6, }); const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 0.9), mat); return mesh; } // ------------------------------------------------------------------ // Luminous signs (letreiros): glowing boards for plazas, the beach, // airports, buildings and stores. Emissive glow ramps up at night via // updateVehicleHeadlights. // ------------------------------------------------------------------ /** * Shrink a font until the text fits the given canvas width. * * @returns {number} the final font size in px */ function fitSignText(ctx, text, maxWidth, startSize, weight = 'bold') { let size = startSize; ctx.font = `${weight} ${size}px sans-serif`; while (ctx.measureText(text).width > maxWidth && size > 12) { size -= 2; ctx.font = `${weight} ${size}px sans-serif`; } return size; } /** * Board rotation.y so a front facing local +Z points toward (dx, dz). */ function rotationToward(dx, dz) { if (Math.abs(dx) > Math.abs(dz)) { return dx > 0 ? Math.PI / 2 : -Math.PI / 2; } return dz > 0 ? 0 : Math.PI; } /** * Rotation that faces the nearest road grid line (board front +Z). */ function faceNearestRoad(x, z, g) { if (!g) return 0; let bestD = Infinity; let dir = [0, 1]; for (const vx of g.xs) { const d = Math.abs(x - vx); if (d < bestD) { bestD = d; dir = [vx - x, 0]; } } for (const vz of g.zs) { const d = Math.abs(z - vz); if (d < bestD) { bestD = d; dir = [0, vz - z]; } } return rotationToward(dir[0], dir[1]); } /** * Wall offset pushing a roof/facade sign out of the building volume * for a given facing rotation. * * @return {[number, number]} [ox, oz] */ function facingOffset(rot, halfW, halfD) { if (Math.abs(rot - 0) < 0.01 || Math.abs(Math.abs(rot) - Math.PI) < 0.01) { return [0, rot === 0 ? halfD : -halfD]; } return [rot > 0 ? halfW : -halfW, 0]; } /** * Build a luminous text sign (or a decorative neon strip when * opts.decor is set — no text, no i18n needed). * * opts: {w, h, vertical, accent, bg, posts: 'double'|'arch', postH, * decor, y} — returns the display node (plane or group); * callers set .position / .rotation. */ function buildLitSign(text, opts = {}) { const vertical = !!opts.vertical; const decor = !!opts.decor; const w = opts.w ?? (vertical ? 1.4 : 6); const h = opts.h ?? (vertical ? 4.6 : 1.6); const accent = opts.accent ?? '#38bdf8'; const bg = opts.bg ?? '#0c1220'; const c = document.createElement('canvas'); c.width = vertical ? 160 : 512; c.height = vertical ? 640 : 128; const ctx = c.getContext('2d'); // Backplate + accent border. const grad = ctx.createLinearGradient(0, 0, c.width, c.height); grad.addColorStop(0, bg); grad.addColorStop(1, '#151c2c'); ctx.fillStyle = grad; ctx.fillRect(0, 0, c.width, c.height); ctx.strokeStyle = accent; ctx.lineWidth = 8; ctx.strokeRect(6, 6, c.width - 12, c.height - 12); if (decor) { // Abstract glowing tubes — no letters, works in every locale. ctx.strokeStyle = accent; ctx.lineWidth = 14; ctx.lineCap = 'round'; ctx.shadowColor = accent; ctx.shadowBlur = 22; ctx.beginPath(); const steps = 5; for (let i = 0; i <= steps; i++) { const px = 40 + (i * (c.width - 80)) / steps; const py = c.height / 2 + ((i % 2 ? -1 : 1) * (c.height - 60)) / 3; if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); for (const px of [34, c.width - 34]) { ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(px, c.height / 2, 12, 0, Math.PI * 2); ctx.fill(); } ctx.shadowBlur = 0; } else { ctx.fillStyle = '#f8fafc'; ctx.textAlign = 'center'; ctx.shadowColor = accent; ctx.shadowBlur = 18; if (vertical) { // Stacked upright letters (classic blade sign). const chars = [...text]; const size = Math.min(84, Math.floor((c.height - 40) / Math.max(1, chars.length)) + 6); ctx.font = `bold ${size}px sans-serif`; ctx.textBaseline = 'middle'; const step = (c.height - 30) / Math.max(1, chars.length); chars.forEach((ch, i) => { ctx.fillText(ch, c.width / 2, 15 + step * (i + 0.5)); }); } else { fitSignText(ctx, text, c.width - 48, 56); ctx.textBaseline = 'middle'; ctx.fillText(text, c.width / 2, c.height / 2 + 2, c.width - 40); } ctx.shadowBlur = 0; } const tex = new THREE.CanvasTexture(c); const mat = new THREE.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.6, roughness: 0.55, side: THREE.DoubleSide, }); signMaterials.push({ mat, base: 0.5, nightBoost: decor ? 1.5 : 1.2, pulse: decor ? 0.16 : 0.08, phase: Math.random() * Math.PI * 2, }); const board = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); if (!opts.posts) { return board; } const group = new THREE.Group(); const postMat = new THREE.MeshStandardMaterial({ color: 0x3a4050, roughness: 0.6, metalness: 0.4 }); const postH = opts.postH ?? 2.4; const postXs = opts.posts === 'arch' ? [-w / 2, w / 2] : [-w * 0.32, w * 0.32]; for (const px of postXs) { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.13, postH, 8), postMat); post.position.set(px, postH / 2, 0); post.castShadow = true; group.add(post); } board.position.y = postH + h / 2 - 0.05; board.castShadow = true; group.add(board); return group; } function initShops(stores, agents) { const entries = []; for (const s of stores) { const mesh = storeMeshes.find(m => m.userData?.store?.id === s.id); if (!mesh) continue; const [x, y, z] = s.position; const [w, h, d] = s.size; const sign = makeStoreSign(s, shopOpenNow(s)); sign.position.set(x, y + 3.1, z + d / 2 + 0.12); worldGroup.add(sign); entries.push({ store: s, sign, lastOpen: shopOpenNow(s) }); const light = new THREE.PointLight(0xffe0a0, 0, 8, 2); light.position.set(x, y + 2.6, z + d / 2 + 1.2); worldGroup.add(light); entries[entries.length - 1].light = light; } const keepers = []; const shopAgents = agents.filter(a => a.type === 'shopkeeper'); for (const a of shopAgents) { const store = stores.find(s => s.name?.includes(a.name.split('—').pop()?.trim() || '§')) || stores.reduce((best, s) => { const d = Math.hypot(s.position[0] - a.position[0], s.position[2] - a.position[2]); return (!best || d < best.d) ? { s, d } : best; }, null)?.s || stores[0]; if (!store) continue; const mesh = buildHumanoid(a, true); const [sx, , sz] = store.position; const [, , sd] = store.size; mesh.position.set(sx + 1.5, 0, sz + sd / 2 + 1.6); mesh.rotation.y = Math.PI; worldGroup.add(mesh); agentMeshes.push(mesh); keepers.push({ mesh, home: mesh.position.clone(), t: Math.random() * 10 }); } shopSystem = { entries, keepers, openCount() { return entries.filter(e => shopOpenNow(e.store)).length; }, update(dt) { const hour = sim.hour; for (const e of entries) { const open = shopOpenNow(e.store); if (open !== e.lastOpen) { e.lastOpen = open; worldGroup.remove(e.sign); e.sign.geometry.dispose(); e.sign.material.map?.dispose(); e.sign.material.emissiveMap?.dispose(); e.sign.material.dispose(); e.sign = makeStoreSign(e.store, open); const [x, y, z] = e.store.position; const d = e.store.size[2]; e.sign.position.set(x, y + 3.1, z + d / 2 + 0.12); worldGroup.add(e.sign); } const night = sim.nightFactor; e.sign.material.emissiveIntensity = open ? 0.5 + night * 1.3 : 0.15 + night * 0.3; if (e.light) e.light.intensity = open ? night * 1.6 : night * 0.3; } for (const k of this.keepers) { k.t += dt; const store = entries.find(e => Math.abs(e.store.position[0] - k.home.x) < 6); const open = store ? shopOpenNow(store.store) : true; if (open) { const sway = Math.sin(k.t * 1.5) * 0.4; k.mesh.position.x = k.home.x + sway * 0.3; k.mesh.rotation.y = Math.PI + Math.sin(k.t * 0.7) * 0.3; animateHumanoid(k.mesh, dt, 0.15); } else { k.mesh.position.x = k.home.x; animateHumanoid(k.mesh, dt, 0); } } }, }; } function shopOpenNow(store) { if (!store.is_open) return false; return sim.hour >= 8 && sim.hour < 20; } // ------------------------------------------------------------------ // Mayor patrol system // ------------------------------------------------------------------ function initMayor(agents, stores, g) { const mayor = agents.find(a => a.type === 'mayor'); if (!mayor) { mayorSystem = { update() {}, mesh: null }; return; } const pts = []; for (const s of stores) { pts.push({ x: s.position[0] + s.size[0] / 2 + 2, z: s.position[2] + s.size[2] / 2 + 2.2 }); } pts.push({ x: g.xs[0] + g.roadW / 2 + 1.2, z: g.zs[0] + g.roadW / 2 + 1.2 }); pts.push({ x: g.xs[Math.floor(g.xs.length / 2)] + g.roadW / 2 + 1.2, z: g.zs[Math.floor(g.zs.length / 2)] + g.roadW / 2 + 1.2 }); const route = makeRoute(pts, g); const mesh = buildHumanoid(mayor, true); mesh.position.set(route.waypoints[0].x, 0, route.waypoints[0].z); worldGroup.add(mesh); agentMeshes.push(mesh); mayorSystem = { mesh, route, wp: 0, speed: 1.05, pauseTimer: 2, decideTimer: 8, status: 'active', update(dt) { this.decideTimer -= dt; if (this.decideTimer <= 0) { this.decideTimer = 8 + Math.random() * 7; this.status = this.status === 'active' ? 'thinking' : 'active'; refreshLabel(mesh, this.status); } if (this.pauseTimer > 0) { this.pauseTimer -= dt; animateHumanoid(mesh, dt, 0); return; } const seg = this.route.segments[this.wp % this.route.segments.length]; const target = this.route.waypoints[(this.wp + 1) % this.route.waypoints.length]; if (seg.crossAxis && trafficSystem) { const state = trafficSystem.axisState(seg.crossAxis); const dMid = Math.hypot(mesh.position.x - seg.midX, mesh.position.z - seg.midZ); if (dMid > 1.5 && dMid < 14) { if (state !== 'red' || vehiclesNear(seg.midX, seg.midZ, 8)) { animateHumanoid(mesh, dt, 0); return; } } } const dx = target.x - mesh.position.x; const dz = target.z - mesh.position.z; const dist = Math.hypot(dx, dz); if (dist < 0.4) { this.wp = (this.wp + 1) % this.route.waypoints.length; this.pauseTimer = 3 + Math.random() * 3; if (this.status !== 'thinking') { this.status = 'thinking'; refreshLabel(mesh, this.status); } animateHumanoid(mesh, dt, 0); return; } const step = Math.min(dist, this.speed * dt); mesh.position.x += (dx / dist) * step; mesh.position.z += (dz / dist) * step; mesh.rotation.y = Math.atan2(dx, dz); animateHumanoid(mesh, dt, this.speed); }, }; } // ------------------------------------------------------------------ // Airplane system // ------------------------------------------------------------------ // ------------------------------------------------------------------ // Airports: airline liveries, aircraft models, ground build, operations // ------------------------------------------------------------------ const AIRLINE_LIVERIES = [ { name: 'City10 Air', accent: 0x1f6fb2 }, { name: 'AzulCéu', accent: 0x2fb6c9 }, { name: 'Bandeirante', accent: 0x3f9d4f }, { name: 'Sol Nascente', accent: 0xe08a1e }, { name: 'Pampa Air', accent: 0xc0392b }, { name: 'NorteLine', accent: 0x2c3e6b }, { name: 'City10 Heli', accent: 0x7a5aa8 }, ]; /** Runway marking overlay: centerline, threshold keys, designators. */ function runwayMarkings(designators) { return canvasTexture(1024, (ctx, s) => { ctx.clearRect(0, 0, s, s); ctx.fillStyle = '#f2f2f2'; // Centerline dashes along the length (u axis). for (let x = 110; x < s - 110; x += 64) { ctx.fillRect(x, s / 2 - 5, 36, 10); } // Threshold "piano keys": bars parallel to travel at both ends. const slot = (s - 96) / 6; for (const end of [0, 1]) { const baseX = end ? s - 56 : 14; for (let i = 0; i < 6; i++) { ctx.fillRect(baseX, 48 + i * slot, 42, slot - 16); } // Designator near the threshold, reading along travel. ctx.save(); ctx.translate(end ? s - 130 : 130, s / 2); ctx.rotate(-Math.PI / 2); ctx.font = 'bold 72px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(end ? designators[1] : designators[0], 0, 0); ctx.restore(); } }, 1, 1); } /** * Fixed-wing aircraft: airliner, regional jet or turboprop with the * airline livery (accent tail/cone/engine inlets). Nose points +X. * * @param {'airliner'|'regional'|'prop'} kind * @param {{name: string, accent: number}} livery */ function buildAircraft(kind, livery) { const g = new THREE.Group(); const dims = { // clearance: fuselage centreline height — the wheels (bottom at // y≈0) carry the airframe above the pavement instead of dragging. airliner: { L: 16, r: 0.95, span: 15, chord: 2.6, tail: 3.6, fin: 2.8, engines: true, clearance: 1.75, wheel: 0.34 }, regional: { L: 12, r: 0.75, span: 11.6, chord: 2.1, tail: 2.8, fin: 2.2, engines: true, clearance: 1.4, wheel: 0.3 }, prop: { L: 9.5, r: 0.65, span: 12, chord: 1.9, tail: 2.4, fin: 1.9, engines: false, prop: true, blade: 2.6, clearance: 1.5, wheel: 0.28 }, }; const d = dims[kind] || dims.airliner; const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf3f5f8, roughness: 0.35, metalness: 0.45 }); const accentMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 }); const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.55 }); const fus = new THREE.Mesh(new THREE.CylinderGeometry(d.r, d.r, d.L, 14), bodyMat); fus.rotation.z = Math.PI / 2; g.add(fus); const noseLen = d.L * 0.14; const nose = new THREE.Mesh(new THREE.ConeGeometry(d.r, noseLen, 14), bodyMat); nose.rotation.z = -Math.PI / 2; nose.position.x = d.L / 2 + noseLen / 2 - 0.05; g.add(nose); const tailLen = d.L * 0.13; const tailCone = new THREE.Mesh(new THREE.ConeGeometry(d.r * 0.92, tailLen, 14), accentMat); tailCone.rotation.z = Math.PI / 2; tailCone.position.x = -d.L / 2 - tailLen / 2 + 0.05; g.add(tailCone); const wing = new THREE.Mesh(new THREE.BoxGeometry(d.chord, 0.18, d.span), bodyMat); wing.position.set(d.L * 0.04, -d.r * 0.3, 0); g.add(wing); const tailWing = new THREE.Mesh(new THREE.BoxGeometry(d.chord * 0.6, 0.14, d.span * 0.34), bodyMat); tailWing.position.set(-d.L / 2 + d.tail * 0.45, d.r * 0.3, 0); g.add(tailWing); const fin = new THREE.Mesh(new THREE.BoxGeometry(d.tail, d.fin, 0.16), accentMat); fin.position.set(-d.L / 2 + d.tail * 0.5, d.fin / 2 + d.r * 0.5, 0); g.add(fin); for (const s of [1, -1]) { const win = new THREE.Mesh(new THREE.BoxGeometry(d.L * 0.5, 0.16, 0.05), darkMat); win.position.set(d.L * 0.06, d.r * 0.35, s * (d.r - 0.02)); g.add(win); } if (d.engines) { for (const s of [1, -1]) { const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.55, 2.6, 12), darkMat); eng.rotation.z = Math.PI / 2; eng.position.set(d.L * 0.02, -d.r * 0.3 - 0.5, s * d.span * 0.3); g.add(eng); const inlet = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.25, 12), accentMat); inlet.rotation.z = Math.PI / 2; inlet.position.set(d.L * 0.02 + 1.35, -d.r * 0.3 - 0.5, s * d.span * 0.3); g.add(inlet); } } if (d.prop) { const prop = new THREE.Group(); const bladeLen = d.blade ?? 3.4; for (let i = 0; i < 3; i++) { const blade = new THREE.Mesh(new THREE.BoxGeometry(0.12, bladeLen, 0.16), darkMat); blade.rotation.x = (i * Math.PI * 2) / 3; prop.add(blade); } prop.position.x = d.L / 2 + noseLen; g.add(prop); g.userData.prop = prop; } // Cockpit glass at the nose (rider sits above the nose gear). const glassMat = new THREE.MeshStandardMaterial({ color: 0x0e1620, roughness: 0.2, metalness: 0.8 }); const cockpit = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.4, 0.62), glassMat); cockpit.position.set(d.L * 0.44, d.r * 0.5, 0); g.add(cockpit); // Winglets at the tips (airline livery accent). for (const side of [1, -1]) { const winglet = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.55, 0.12), accentMat); winglet.position.set(d.L * 0.04, 0.1, side * (d.span / 2)); g.add(winglet); } const beacon = new THREE.Mesh( new THREE.SphereGeometry(0.2, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) ); beacon.position.set(d.L * 0.08, d.r + 0.3, 0); g.add(beacon); g.userData.beacon = beacon; // Ride on the landing gear: lift the whole airframe so the wheels // (added next, ground-based) carry it above the pavement. for (const child of g.children) { child.position.y += d.clearance; } // ---- Landing gear: nose leg + two mains; wheel bottoms touch y=0 ---- const gearMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.45, metalness: 0.6 }); const addGear = (gx, gz, wr, strutR) => { const strutBottom = wr * 0.5; const top = d.clearance - d.r * 0.35; const strutH = top - strutBottom; const strut = new THREE.Mesh(new THREE.CylinderGeometry(strutR, strutR * 1.2, strutH, 8), gearMat); strut.position.set(gx, strutBottom + strutH / 2, gz); g.add(strut); const wheel = new THREE.Mesh(new THREE.CylinderGeometry(wr, wr, wr * 0.75, 12), gearMat); wheel.rotation.x = Math.PI / 2; // axle along Z — rolls along the runway // Centre at half the tyre thickness → the bottom sits exactly on y=0. wheel.position.set(gx, wr * 0.375, gz); g.add(wheel); }; addGear(d.L * 0.4, 0, d.wheel * 0.85, 0.06); // nose gear addGear(d.L * 0.06, d.span * 0.2, d.wheel, 0.085); // main right addGear(d.L * 0.06, -d.span * 0.2, d.wheel, 0.085); // main left g.userData.kind = kind; g.userData.gear = { kind: 'wheels', clearance: d.clearance }; g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } /** * Helicopter with spinning main/tail rotors and skids (livery accent). * * @param {{name: string, accent: number}} livery */ function buildHelicopter(livery) { const g = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 }); const trimMat = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.4, metalness: 0.3 }); const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.5 }); const cabin = new THREE.Mesh(new THREE.CapsuleGeometry(0.95, 2.4, 6, 12), bodyMat); cabin.rotation.z = Math.PI / 2; g.add(cabin); const bubble = new THREE.Mesh(new THREE.SphereGeometry(0.85, 14, 12), trimMat); bubble.position.set(1.9, 0.05, 0); g.add(bubble); const boom = new THREE.Mesh(new THREE.BoxGeometry(4.6, 0.34, 0.34), bodyMat); boom.position.set(-3.4, 0.35, 0); g.add(boom); const fin = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.14), bodyMat); fin.position.set(-5.5, 0.95, 0); g.add(fin); const tailRotor = new THREE.Group(); for (let i = 0; i < 2; i++) { const blade = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.14, 0.08), darkMat); blade.rotation.z = (i * Math.PI) / 2; tailRotor.add(blade); } tailRotor.position.set(-5.7, 0.95, 0.22); g.add(tailRotor); g.userData.tailRotor = tailRotor; for (const s of [1, -1]) { const skid = new THREE.Mesh(new THREE.BoxGeometry(3.8, 0.1, 0.12), darkMat); skid.position.set(0.2, -1.35, s * 0.85); g.add(skid); for (const bx of [-1, 1.2]) { const strut = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.75, 0.1), darkMat); strut.position.set(bx, -0.95, s * 0.85); g.add(strut); } } const mastCol = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.8, 8), darkMat); mastCol.position.y = 1.0; g.add(mastCol); const rotor = new THREE.Group(); for (let i = 0; i < 4; i++) { const blade = new THREE.Mesh(new THREE.BoxGeometry(9, 0.08, 0.5), darkMat); blade.rotation.y = (i * Math.PI) / 2; rotor.add(blade); } const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.4, 10), trimMat); rotor.add(hub); rotor.position.y = 1.35; g.add(rotor); g.userData.rotor = rotor; const beacon = new THREE.Mesh( new THREE.SphereGeometry(0.18, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) ); beacon.position.set(0, 2.0, 0); g.add(beacon); g.userData.beacon = beacon; // Ride on the skids: lift the fuselage so the skid tubes rest on y=0. for (const child of g.children) { child.position.y += 1.4; } g.userData.gear = { kind: 'skids', clearance: 1.4 }; g.userData.kind = 'helicopter'; g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } /** Helipad marking (white circle + H) on transparent ground. */ function helipadTexture() { return canvasTexture(256, (ctx, s) => { ctx.clearRect(0, 0, s, s); ctx.strokeStyle = '#f2f2f2'; ctx.lineWidth = 14; ctx.beginPath(); ctx.arc(s / 2, s / 2, s / 2 - 20, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = '#f2f2f2'; ctx.font = 'bold 110px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('H', s / 2, s / 2 + 6); }, 1, 1); } /** * Build both airports (pavement, markings, terminal, hangars, tower, * stands, helipads) from the payload geometry. Adds colliders for the * buildings so the player cannot walk through them. * * @param {{visit: object, sites: Array}} cfg */ function buildAirports(cfg) { if (!cfg?.sites?.length) return; const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x2b2d31, roughness: 0.92 }); const taxiMat = new THREE.MeshStandardMaterial({ color: 0x34373c, roughness: 0.92 }); const apronMat = new THREE.MeshStandardMaterial({ color: 0x50555c, roughness: 0.95 }); const centerLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 }); const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 }); const hangarBodyMat = new THREE.MeshStandardMaterial({ color: 0xd8dade, roughness: 0.7, metalness: 0.3 }); const hangarDoorMat = new THREE.MeshStandardMaterial({ color: 0x4a5058, roughness: 0.6, metalness: 0.4 }); const hangarRoofMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.6, metalness: 0.3 }); const towerBaseMat = new THREE.MeshStandardMaterial({ color: 0xcfd3d8, roughness: 0.7 }); const towerGlassMat = new THREE.MeshStandardMaterial({ color: 0x1d2a38, roughness: 0.25, metalness: 0.6 }); const bridgeMat = new THREE.MeshStandardMaterial({ color: 0xb9bec6, roughness: 0.7 }); const helipadMat = new THREE.MeshStandardMaterial({ map: helipadTexture(), transparent: true, alphaTest: 0.3, roughness: 0.9, }); const addFlat = (cx, cz, w, h, mat, y) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); m.rotation.x = -Math.PI / 2; m.position.set(cx, y, cz); m.receiveShadow = true; worldGroup.add(m); return m; }; for (const site of cfg.sites) { // Runway + markings. const rw = site.runway; const rwAlongX = rw.axis === 'x'; addFlat(rw.center[0], rw.center[1], rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length, asphaltMat, 0.03); const marks = new THREE.Mesh( new THREE.PlaneGeometry(rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length), new THREE.MeshStandardMaterial({ map: runwayMarkings(rw.designators), transparent: true, alphaTest: 0.3, roughness: 0.85, }) ); marks.rotation.x = -Math.PI / 2; marks.position.set(rw.center[0], 0.045, rw.center[1]); worldGroup.add(marks); // Taxiways + yellow centerlines. for (const tw of site.taxiways) { const alongX = tw.axis === 'x'; addFlat(tw.center[0], tw.center[1], alongX ? tw.length : tw.width, alongX ? tw.width : tw.length, taxiMat, 0.03); const line = new THREE.Mesh( alongX ? new THREE.BoxGeometry(tw.length, 0.02, 0.16) : new THREE.BoxGeometry(0.16, 0.02, tw.length), centerLineMat ); line.position.set(tw.center[0], 0.05, tw.center[1]); worldGroup.add(line); } // Apron + stand markings (aircraft parking positions). addFlat(site.apron.center[0], site.apron.center[1], site.apron.size[0], site.apron.size[1], apronMat, 0.03); for (const [sx, sz] of site.stands) { const lead = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.02, 7), whiteLineMat); lead.position.set(sx, 0.05, sz - 5); worldGroup.add(lead); const stopBar = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.02, 0.25), whiteLineMat); stopBar.position.set(sx, 0.05, sz - 8.2); worldGroup.add(stopBar); } // Helipad. const pad = site.helipad; const padMesh = new THREE.Mesh(new THREE.PlaneGeometry(pad.radius * 2, pad.radius * 2), helipadMat); padMesh.rotation.x = -Math.PI / 2; padMesh.position.set(pad.center[0], 0.05, pad.center[1]); worldGroup.add(padMesh); // Terminal building (multi-window facade + roof slab). const term = site.terminal; const termFloors = 3; const termPair = facadePair('#a4553f', termFloors, Math.max(3, Math.round(term.size[0] / 7))); const termMat = new THREE.MeshStandardMaterial({ map: termPair.map, emissiveMap: termPair.emissiveMap, emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, }); facadeMaterials.push(termMat); const termH = 9; const termMesh = new THREE.Mesh(new THREE.BoxGeometry(term.size[0], termH, term.size[1]), termMat); termMesh.position.set(term.center[0], termH / 2, term.center[1]); termMesh.castShadow = true; termMesh.receiveShadow = true; worldGroup.add(termMesh); addCollider(term.center[0], 0, term.center[1], term.size[0], termH, term.size[1]); const termRoof = new THREE.Mesh( new THREE.BoxGeometry(term.size[0] + 0.6, 0.5, term.size[1] + 0.6), new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 }) ); termRoof.position.set(term.center[0], termH + 0.15, term.center[1]); worldGroup.add(termRoof); // Jet bridges reaching from the terminal toward the apron. const bridgeZ = term.center[1] + term.size[1] / 2 + 3.2; for (let i = -1; i <= 1; i++) { const bridge = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.5, 6.4), bridgeMat); bridge.position.set(term.center[0] + i * 18, 3.0, bridgeZ); bridge.castShadow = true; worldGroup.add(bridge); } // Hangars: tall box, wide door toward the apron, roof slab. for (const hg of site.hangars) { const [sx, sz] = hg.size; const body = new THREE.Mesh(new THREE.BoxGeometry(sx, 7, sz), hangarBodyMat); body.position.set(hg.center[0], 3.5, hg.center[1]); body.castShadow = true; body.receiveShadow = true; worldGroup.add(body); const door = new THREE.Mesh(new THREE.BoxGeometry(sx * 0.7, 5.2, 0.4), hangarDoorMat); door.position.set(hg.center[0], 2.7, hg.center[1] + sz / 2 + 0.15); worldGroup.add(door); const roof = new THREE.Mesh(new THREE.BoxGeometry(sx + 0.6, 0.6, sz + 0.6), hangarRoofMat); roof.position.set(hg.center[0], 7.2, hg.center[1]); worldGroup.add(roof); addCollider(hg.center[0], 0, hg.center[1], sx, 7, sz); } // Control tower. const tw2 = site.tower; const base = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 2.1, 10, 12), towerBaseMat); base.position.set(tw2.center[0], 5, tw2.center[1]); base.castShadow = true; worldGroup.add(base); const cabin = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.6, 4.6), towerGlassMat); cabin.position.set(tw2.center[0], 11.3, tw2.center[1]); cabin.castShadow = true; worldGroup.add(cabin); const cap = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.35, 5.2), towerBaseMat); cap.position.set(tw2.center[0], 12.8, tw2.center[1]); worldGroup.add(cap); const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.6, 6), towerBaseMat); antenna.position.set(tw2.center[0], 14.2, tw2.center[1]); worldGroup.add(antenna); const towerBeacon = new THREE.Mesh( new THREE.SphereGeometry(0.22, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 }) ); towerBeacon.position.set(tw2.center[0], 15.6, tw2.center[1]); worldGroup.add(towerBeacon); addCollider(tw2.center[0], 0, tw2.center[1], 4.6, 12, 4.6); // Luminous letreiros: airport name on the terminal roofline // (facing the apron) + entrance pylon on the city side. const toApron = [ site.apron.center[0] - term.center[0], site.apron.center[1] - term.center[1], ]; const termFace = rotationToward(toApron[0], toApron[1]); const [tOffX, tOffZ] = facingOffset(termFace, term.size[0] / 2 + 0.3, term.size[1] / 2 + 0.3); const termSign = buildLitSign(site.name, { w: Math.min(15, term.size[0] * 0.8), h: 1.8, accent: '#7dd3fc', }); termSign.position.set(term.center[0] + tOffX, termH + 1.4, term.center[1] + tOffZ); termSign.rotation.y = termFace; worldGroup.add(termSign); signCounts.airports++; const cityDir = Math.sign(site.apron.center[1]) >= 0 ? -1 : 1; const apronEdgeZ = site.apron.center[1] + cityDir * site.apron.size[1] / 2; const entry = buildLitSign(site.name, { w: 8.5, h: 1.9, posts: 'double', postH: 3.0, accent: '#a78bfa', }); entry.position.set(tw2.center[0] + 6, 0, apronEdgeZ + cityDir * 8); entry.rotation.y = rotationToward(0, cityDir); worldGroup.add(entry); signCounts.airports++; } } /** Linear interpolation between 3D points. */ function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } /** 2D waypoint interpolation for ground/water paths [x, z]. */ function lerp2(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; } /** Distance between two 3D points. */ function dist3(a, b) { return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); } /** * Flight operations: taxi, takeoff roll, climb, cruise circuit, approach, * landing and taxi-in for both airports, plus vertical helipad cycles. * * Aircraft states: park → taxi_out → roll → climb → cruise → approach → * land → taxi_in → park (helicopters: park → takeoff → cruise → land). */ function initAirplanes(cfg) { const aircraft = []; const flights = { takeoffs: 0, landings: 0 }; const sites = cfg?.sites || []; const emptyFlights = () => ({ total: 0, parked: 0, taxiing: 0, airborne: 0, takeoffs: 0, landings: 0, }); if (!sites.length) { airplaneSystem = { planes: [], count: () => 0, flights: emptyFlights, update() {}, }; return; } // Flight paths bind to the runway orientation, not to the labels: // the east-west runway site uses the east-west ops, the other uses // the north-south ops (both derive every coordinate from the site). const ewSite = sites.find(s => s.runway?.axis === 'x') || sites[0]; const nsSite = sites.find(s => s.runway?.axis === 'z') || sites[1] || sites[0]; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const V = (x, y, z) => [x, y, z]; // ---- Per-airport flight paths (shared, stand-specific starts) ---- function ewOps(stand) { const rz = ewSite.runway.center[1]; const tz = ewSite.taxiways[0].center[1]; const j = clamp(stand[0] - 40, -80, 80); return { heli: false, taxiOut: [V(stand[0], 0, stand[1]), V(j, 0, 310), V(-80, 0, tz), V(-80, 0, rz)], roll: { from: V(-80, 0, rz), to: V(85, 0, rz) }, climb: { from: V(85, 0, rz), to: V(175, 40, rz) }, cruise: [ V(300, 85, 60), V(40, 85, -150), V(-280, 80, -90), V(-300, 80, 240), V(-140, 75, 400), V(60, 70, 400), V(240, 65, rz), ], approach: [V(140, 15, rz), V(85, 0, rz)], landRoll: { from: V(85, 0, rz), to: V(-80, 0, rz) }, taxiIn: [V(-80, 0, tz), V(j, 0, 310), V(stand[0], 0, stand[1])], }; } function nsOps(stand) { const rx = nsSite.runway.center[0]; const tx = nsSite.taxiways[0].center[0]; return { heli: false, taxiOut: [V(stand[0], 0, stand[1]), V(tx, 0, stand[1]), V(tx, 0, -240), V(rx, 0, -240)], roll: { from: V(rx, 0, -240), to: V(rx, 0, -370) }, climb: { from: V(rx, 0, -370), to: V(rx, 40, -460) }, cruise: [ V(-280, 85, -420), V(-340, 85, -40), V(-60, 85, 240), V(240, 80, 200), V(320, 80, -160), V(200, 75, -360), V(rx, 65, -60), ], approach: [V(rx, 20, -140), V(rx, 0, -220)], landRoll: { from: V(rx, 0, -220), to: V(rx, 0, -370) }, taxiIn: [V(-40, 0, -370), V(tx, 0, -370), V(tx, 0, clamp(stand[1] + 40, -365, -235)), V(stand[0], 0, stand[1])], }; } function heliOps(site) { const pad = site.helipad.center; const far = Math.sign(site.runway.center[1] || 1) >= 0 ? [40, 430] : [-320, -430]; return { heli: true, pad: [pad[0], 0, pad[1]], cruise: [ V(far[0], 60, far[1]), V(-far[0] * 0.8, 58, far[1] * 0.6), V(-far[0], 60, -far[1] * 0.4), V(pad[0], 60, pad[1] - 40), V(pad[0], 60, pad[1]), ], }; } // ---- State machine helpers ---- const poseForLegs = (a, legIdx, t) => { const start = legIdx === 0 ? a.stateFrom : a.legs[legIdx - 1]; return lerp3(start, a.legs[legIdx], t); }; function legsFor(a, state) { const ops = a.ops; if (a.ops.heli) { return state === 'cruise' ? ops.cruise : null; } return { taxi_out: ops.taxiOut, cruise: ops.cruise, approach: ops.approach, taxi_in: ops.taxiIn, }[state] || null; } function enterState(a, state) { a.state = state; a.stateFrom = [a.p[0], a.p[1], a.p[2]]; a.legs = legsFor(a, state); a.leg = 0; a.t = 0; a.done = false; a.speed = state === 'taxi_out' || state === 'taxi_in' ? 6 : state === 'cruise' ? 55 : state === 'approach' ? 24 : state === 'roll' ? 3 : a.speed; a.dist = 0; if (state === 'roll' || state === 'takeoff') { flights.takeoffs++; // Ask the AI control tower for a clearance (fire and forget). postJson('/city/airport/dispatch', {}).catch(() => { /* throttled */ }); } else if (state === 'land') { flights.landings++; } else if (state === 'park') { a.wait = 8 + Math.random() * 6; } // Ground taxi and fixed-heading states get an immediate yaw. if (state === 'takeoff') { a.yaw = a.yaw ?? 0; } else if (!a.heli && (state === 'roll' || state === 'land')) { const seg = state === 'roll' ? a.ops.roll : a.ops.landRoll; a.yaw = Math.atan2(-(seg.to[2] - seg.from[2]), seg.to[0] - seg.from[0]); } } /** Place an aircraft exactly at a mid-flight spawn state. */ function poseSpawn(a, state, leg, t) { const ops = a.ops; a.state = state; a.legs = legsFor(a, state); a.leg = leg || 0; a.t = t || 0; a.dist = 0; a.speed = 6; if (state === 'park') { a.p = ops.heli ? [...ops.pad] : [...a.stand3]; a.wait = a.spawnWait; a.yaw = 0; } else if (state === 'taxi_out') { a.stateFrom = [...a.stand3]; a.p = poseForLegs(a, a.leg, a.t); a.yaw = Math.atan2(-(a.legs[0][2] - a.stand3[2]), a.legs[0][0] - a.stand3[0]); } else if (state === 'roll') { a.stateFrom = [...ops.roll.from]; a.p = lerp3(ops.roll.from, ops.roll.to, a.t); a.yaw = Math.atan2(-(ops.roll.to[2] - ops.roll.from[2]), ops.roll.to[0] - ops.roll.from[0]); a.speed = 12; } else if (state === 'climb') { a.stateFrom = [...ops.climb.from]; a.p = lerp3(ops.climb.from, ops.climb.to, a.t); a.yaw = Math.atan2(-(ops.climb.to[2] - ops.climb.from[2]), ops.climb.to[0] - ops.climb.from[0]); } else if (state === 'cruise') { a.stateFrom = ops.heli ? [ops.pad[0], 45, ops.pad[2]] : [...ops.climb.to]; a.p = poseForLegs(a, a.leg, a.t); const from = a.leg === 0 ? a.stateFrom : a.legs[a.leg - 1]; a.yaw = Math.atan2(-(a.legs[a.leg][2] - from[2]), a.legs[a.leg][0] - from[0]); a.speed = ops.heli ? 32 : 55; } else if (state === 'approach') { a.stateFrom = [...ops.cruise[ops.cruise.length - 1]]; a.p = poseForLegs(a, a.leg, a.t); a.yaw = Math.atan2(-(ops.approach[0][2] - a.stateFrom[2]), ops.approach[0][0] - a.stateFrom[0]); a.speed = 24; } else if (state === 'land') { a.stateFrom = [...ops.approach[ops.approach.length - 1]]; a.p = lerp3(ops.landRoll.from, ops.landRoll.to, a.t); a.yaw = Math.atan2(-(ops.landRoll.to[2] - ops.landRoll.from[2]), ops.landRoll.to[0] - ops.landRoll.from[0]); a.speed = 14; } else if (state === 'taxi_in') { a.stateFrom = [...ops.landRoll.to]; a.p = poseForLegs(a, a.leg, a.t); a.yaw = Math.atan2(-(a.legs[0][2] - a.stateFrom[2]), a.legs[0][0] - a.stateFrom[0]); } } /** Advance along the current leg targets; returns true when finished. */ function stepLegs(a, speed, dt) { if (!a.legs || a.leg >= a.legs.length) return true; let guard = 0; while (guard++ < 4) { const target = a.legs[a.leg]; const dx = target[0] - a.p[0]; const dy = target[1] - a.p[1]; const dz = target[2] - a.p[2]; const len = Math.hypot(dx, dy, dz); if (len < 1e-3) { a.leg++; if (a.leg >= a.legs.length) return true; continue; } const step = speed * dt; if (step >= len) { a.p = [...target]; a.leg++; if (a.leg >= a.legs.length) return true; continue; } a.p = [a.p[0] + (dx / len) * step, a.p[1] + (dy / len) * step, a.p[2] + (dz / len) * step]; a.yaw = Math.atan2(-dz, dx); return false; } return true; } // ---- Fleet ---- function spawnFixed(site, opsFn, stand, kind, airlineIdx, state, leg, t, wait) { const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length]; const mesh = kind === 'helicopter' ? buildHelicopter(livery) : buildAircraft(kind, livery); worldGroup.add(mesh); const a = { mesh, kind, livery, callsign: 'C10-'.concat(101 + aircraft.length), ops: opsFn(stand), stand3: [stand[0], 0, stand[1]], p: [stand[0], 0, stand[1]], yaw: 0, state: 'park', legs: null, leg: 0, t: 0, speed: 6, dist: 0, wait: wait ?? 6, spawnWait: wait ?? 6, heli: false, }; poseSpawn(a, state, leg, t); aircraft.push(a); return a; } function spawnHeli(site, airlineIdx, state, wait) { const ops = heliOps(site); const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length]; const mesh = buildHelicopter(livery); worldGroup.add(mesh); const a = { mesh, kind: 'helicopter', livery, callsign: 'C10-'.concat(101 + aircraft.length), ops, stand3: [...ops.pad], p: [...ops.pad], yaw: 0, state: 'park', legs: null, leg: 0, t: 0, speed: 6, dist: 0, wait: wait ?? 10, spawnWait: wait ?? 10, heli: true, }; poseSpawn(a, state, 0, 0); aircraft.push(a); return a; } // Staggered initial states so operations are already in motion. spawnFixed(ewSite, ewOps, ewSite.stands[0], 'airliner', 0, 'park', 0, 0, 7); spawnFixed(ewSite, ewOps, ewSite.stands[1], 'airliner', 1, 'taxi_out', 1, 0.25, 0); spawnFixed(ewSite, ewOps, ewSite.stands[2], 'regional', 2, 'cruise', 2, 0.4, 0); spawnHeli(ewSite, 3, 'park', 14); spawnFixed(nsSite, nsOps, nsSite.stands[0], 'regional', 4, 'approach', 0, 0.5, 0); spawnFixed(nsSite, nsOps, nsSite.stands[1], 'prop', 5, 'park', 0, 0, 20); spawnHeli(nsSite, 6, 'park', 12); let atcPoll = 6; let clearanceShown = null; airplaneSystem = { planes: aircraft, count: () => aircraft.length, flights: () => { let parked = 0; let taxiing = 0; let airborne = 0; for (const a of aircraft) { if (a.state === 'park') parked++; else if (a.state === 'taxi_out' || a.state === 'taxi_in') taxiing++; else if (a.state === 'climb' || a.state === 'cruise' || a.state === 'approach') airborne++; } return { total: aircraft.length, parked, taxiing, airborne, takeoffs: flights.takeoffs, landings: flights.landings, states: aircraft.map(a => a.callsign + ':' + a.state), gear: aircraft.map(a => ({ kind: a.mesh.userData.kind, gear: a.mesh.userData.gear?.kind || 'none', state: a.state, x: +a.p[0].toFixed(1), y: +a.p[1].toFixed(2), z: +a.p[2].toFixed(1), })), }; }, update(dt) { const time = performance.now() / 1000; for (const a of aircraft) { switch (a.state) { case 'park': { a.wait -= dt; if (a.wait <= 0) enterState(a, a.heli ? 'takeoff' : 'taxi_out'); break; } case 'taxi_out': if (stepLegs(a, 6, dt)) enterState(a, 'roll'); break; case 'roll': { a.speed = Math.min(18, a.speed + 5.5 * dt); const len = dist3(a.ops.roll.from, a.ops.roll.to); a.dist += a.speed * dt; const s = Math.min(1, a.dist / len); a.p = lerp3(a.ops.roll.from, a.ops.roll.to, s); if (s >= 1) enterState(a, 'climb'); break; } case 'climb': { const len = dist3(a.ops.climb.from, a.ops.climb.to); a.t += (16 * dt) / len; if (a.t >= 1) { a.p = [...a.ops.climb.to]; enterState(a, 'cruise'); } else { a.p = lerp3(a.ops.climb.from, a.ops.climb.to, a.t); } break; } case 'cruise': if (stepLegs(a, a.ops.heli ? 32 : 55, dt)) { enterState(a, a.ops.heli ? 'land' : 'approach'); } break; case 'approach': if (stepLegs(a, 24, dt)) enterState(a, 'land'); break; case 'land': { // Helicopter landing (vertical descent) is handled below. if (a.heli) break; a.speed = Math.max(4, a.speed - 3.2 * dt); const len = dist3(a.ops.landRoll.from, a.ops.landRoll.to); a.dist += a.speed * dt; const s = Math.min(1, a.dist / len); a.p = lerp3(a.ops.landRoll.from, a.ops.landRoll.to, s); if (s >= 1) enterState(a, 'taxi_in'); break; } case 'taxi_in': if (stepLegs(a, 6, dt)) { enterState(a, 'park'); a.wait = 8 + Math.random() * 6; } break; case 'takeoff': { // Vertical departure from the helipad. a.t += dt / 5; if (a.t >= 1) { a.p = [a.ops.pad[0], 45, a.ops.pad[2]]; enterState(a, 'cruise'); } else { a.p = [a.ops.pad[0], 45 * a.t, a.ops.pad[2]]; } break; } default: break; } // Helicopter landing: descend back onto the pad. if (a.state === 'land' && a.heli) { a.p = [a.ops.pad[0], Math.max(0, a.p[1] - 9 * dt), a.ops.pad[2]]; if (a.p[1] <= 0.01) { a.p = [...a.ops.pad]; enterState(a, 'park'); a.wait = 8 + Math.random() * 6; } } // Pose the mesh. a.mesh.position.set(a.p[0], a.p[1], a.p[2]); a.mesh.rotation.y = a.yaw || 0; let pitch = 0; if (a.state === 'climb') pitch = 0.16; else if (a.state === 'approach') pitch = -0.1; else if (a.state === 'land' && !a.heli) pitch = 0.06; a.mesh.rotation.z = a.heli ? 0 : pitch; if (a.mesh.userData.beacon) { a.mesh.userData.beacon.material.emissiveIntensity = (Math.sin(time * 6) > 0) ? 2.5 : 0.2; } const spinning = a.state !== 'park'; if (a.mesh.userData.rotor) { a.mesh.userData.rotor.rotation.y += (spinning ? 26 : 5) * dt; } if (a.mesh.userData.tailRotor) { a.mesh.userData.tailRotor.rotation.z += (spinning ? 30 : 4) * dt; } if (a.mesh.userData.prop) { a.mesh.userData.prop.rotation.x += (spinning ? 28 : 0) * dt; } } // Poll the AI tower clearance and flash new authorizations. atcPoll -= dt; if (atcPoll <= 0) { atcPoll = 10; fetch('/city/airport', { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-store', }) .then(r => (r.ok ? r.json() : null)) .then((data) => { const c = data?.clearance; if (c?.at && c.at !== clearanceShown) { clearanceShown = c.at; flashCharMsg('✈ '.concat(c.text)); } }) .catch(() => { /* offline-safe */ }); } }, }; } // ------------------------------------------------------------------ // Port — far half of the north-east coast (z −430..−290; the near half // stays free for city expansion): quay, yard, terminal, gantry cranes, // container ships, liner, speedboats, trucks — with ship maneuvers // authorised by the Capitania AI agent (/city/port/dispatch). // ------------------------------------------------------------------ /** Ship heading: hull nose points local −Z. */ function shipYaw(dx, dz) { return Math.atan2(-dx, -dz); } /** Clamped keyframe interpolation: kf(t, [[time, value], …]). */ function kf(t, frames) { if (t <= frames[0][0]) return frames[0][1]; for (let i = 1; i < frames.length; i++) { if (t <= frames[i][0]) { const [t0, v0] = frames[i - 1]; const [t1, v1] = frames[i]; const k = (t - t0) / Math.max(1e-6, t1 - t0); return v0 + (v1 - v0) * k; } } return frames[frames.length - 1][1]; } /** * Big vessels: hull with stepped bow, decks, bridge/funnel and (for * container ships) a stacked container block on deck. Nose points −Z. * * @param {'container'|'liner'} kind * @param {{name: string, company: string, accent?: number}} spec */ function buildShip(kind, spec) { const g = new THREE.Group(); const white = new THREE.MeshStandardMaterial({ color: 0xe8ecf1, roughness: 0.55, metalness: 0.2 }); const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.4, metalness: 0.5 }); const accent = new THREE.MeshStandardMaterial({ color: spec.accent ?? 0x1f6fb2, roughness: 0.45, metalness: 0.3 }); const box = (w, h, d, mat, x, y, z) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); m.position.set(x, y, z); g.add(m); return m; }; if (kind === 'liner') { const L = 88, W = 20; box(W, 6.5, L, white, 0, 1.2, 0); // hull box(W + 0.4, 1.3, L + 0.4, accent, 0, 2.4, 0); // company band box(14, 5, 8, white, 0, 1.8, -L / 2 - 2); // stepped bow box(17, 4, 60, white, 0, 6.2, 4); // deck tier 1 box(17.3, 1, 60.3, dark, 0, 7.0, 4); // window band box(14, 3.4, 44, white, 0, 10.0, 6); // tier 2 box(14.3, 1, 44.3, dark, 0, 10.6, 6); // windows box(11, 2.6, 14, white, 0, 13.4, -14); // bridge box(11.3, 1, 14.3, dark, 0, 13.8, -14); // bridge glass for (const fz of [14, 22]) { const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 2.3, 6, 12), accent); funnel.position.set(0, 14.0, fz); g.add(funnel); } } else { const L = 60, W = 16; box(W, 5.5, L - 2, dark, 0, 1.0, 0); // hull box(W + 0.3, 1.1, L - 1.6, accent, 0, 3.2, 0); // livery band box(W - 0.6, 1.2, L - 3, white, 0, 4.0, 0); // deck rim box(11, 4.5, 6, dark, 0, 1.6, -L / 2 - 1); // stepped bow box(11, 3.6, 9, white, 0, 5.9, 21); // deckhouse t1 box(9, 2.4, 8, white, 0, 8.6, 21.5); // deckhouse t2 box(9.2, 0.9, 8.2, dark, 0, 8.9, 21.5); // bridge glass const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.8, 4.4, 10), accent); funnel.position.set(0, 11.4, 17); g.add(funnel); // Container stacks on deck (3 lanes × 6 rows, some double-stacked). const containerColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910]; let ci = 0; for (const lx of [-5, 0, 5]) { for (let r = 0; r < 6; r++) { const cz = -22 + r * 6.4; const color = containerColors[ci++ % containerColors.length]; box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color, roughness: 0.7 }), lx, 5.9, cz); if ((ci + r) % 3 === 0) { box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color: containerColors[ci % containerColors.length], roughness: 0.7 }), lx, 8.5, cz); } } } } g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); g.userData.shipName = spec.name; return g; } /** Small fast boat (speedboat) hull + cabin. */ function buildPortBoat(accentColor) { const g = new THREE.Group(); const white = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.5 }); const accent = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.45 }); const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.6 }); const hull = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.4, 9), white); hull.position.y = 0.55; g.add(hull); const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.3, 0.4, 8.6), accent); stripe.position.y = 0.95; g.add(stripe); const nose = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.0, 2.4), white); nose.position.set(0, 0.6, -5.2); g.add(nose); const cabin = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.4, 3), white); cabin.position.set(0, 1.9, 0.6); g.add(cabin); const screen = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.6, 0.5), dark); screen.position.set(0, 2.2, -0.9); g.add(screen); const motor = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.9, 0.8), dark); motor.position.set(0, 0.9, 4.8); g.add(motor); g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } /** Yard truck: cab + chassis, optional container on the back (nose +X). */ function buildPortTruck(accentColor, loaded) { const g = new THREE.Group(); const steel = new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.6, metalness: 0.4 }); const cabMat = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.5, metalness: 0.2 }); const chassis = new THREE.Mesh(new THREE.BoxGeometry(7, 0.5, 2.6), steel); chassis.position.y = 1.15; g.add(chassis); const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.3, 2.5), cabMat); cab.position.set(2.4, 2.3, 0); g.add(cab); if (loaded) { const container = new THREE.Mesh( new THREE.BoxGeometry(4.2, 2.3, 2.4), new THREE.MeshStandardMaterial({ color: 0x2980b9, roughness: 0.7 }) ); container.position.set(-1.2, 2.6, 0); g.add(container); } for (const [wx, wz] of [[2.4, 1.35], [2.4, -1.35], [-2.4, 1.35], [-2.4, -1.35]]) { const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.4, 10), steel); wheel.rotation.x = Math.PI / 2; wheel.position.set(wx, 0.5, wz); g.add(wheel); } g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } /** * Build the port and its moving fleet from the payload geometry. * * Fixed structures (quay, yard, terminal, bollards, container stacks, * crane frames) merge through a static batcher; ships, boats, trucks * and crane trolleys stay individual and animated. Container-ship * maneuvers wait for a Capitania clearance (AI text or deterministic * fallback) before departing or berthing. * * @param {object} cfg port payload {visit, water, quay, yard, terminal, cranes, berths, ships, boats} */ function initPort(cfg) { const quay = cfg.quay; const yard = cfg.yard; const terminal = cfg.terminal; const berths = cfg.berths; const cranes = cfg.cranes; const ships = cfg.ships; const batch = makeStaticBatcher(); const concreteMat = new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.95 }); const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x4a4f56, roughness: 0.95 }); const steelMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.5, metalness: 0.6 }); const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 }); const yellowLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 }); const addFlat = (cx, cz, w, d, mat, y) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat); m.rotation.x = -Math.PI / 2; m.position.set(cx, y, cz); m.receiveShadow = true; worldGroup.add(m); }; // ---- Quay + yard pavement ---- addFlat(quay.center[0], quay.center[1], quay.size[0], quay.size[1], concreteMat, 0.06); const edgeLine = new THREE.Mesh( new THREE.BoxGeometry(0.3, 0.02, quay.size[1] - 4), yellowLineMat ); edgeLine.position.set(quay.center[0] + quay.size[0] / 2 - 0.6, 0.08, quay.center[1]); worldGroup.add(edgeLine); addFlat(yard.center[0], yard.center[1], yard.size[0], yard.size[1], asphaltMat, 0.05); // Bollards along the quay edge (merged). const bollardGeo = new THREE.CylinderGeometry(0.17, 0.2, 0.7, 8); for (let z = quay.center[1] - quay.size[1] / 2 + 8; z <= quay.center[1] + quay.size[1] / 2 - 8; z += 13) { const b = new THREE.Mesh(bollardGeo, steelMat); b.position.set(quay.center[0] + quay.size[0] / 2 - 1.6, 0.4, z); b.updateMatrix(); batch.add(bollardGeo, steelMat, b.matrix, true); } // ---- Terminal (multi-window facade) ---- const termPair = facadePair('#6d7f93', 3, Math.max(4, Math.round(terminal.size[0] / 7))); const termMat = new THREE.MeshStandardMaterial({ map: termPair.map, emissiveMap: termPair.emissiveMap, emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, }); facadeMaterials.push(termMat); const termH = 9; const termMesh = new THREE.Mesh(new THREE.BoxGeometry(terminal.size[0], termH, terminal.size[1]), termMat); termMesh.position.set(terminal.center[0], termH / 2, terminal.center[1]); termMesh.castShadow = true; termMesh.receiveShadow = true; worldGroup.add(termMesh); addCollider(terminal.center[0], 0, terminal.center[1], terminal.size[0], termH, terminal.size[1]); const termRoof = new THREE.Mesh( new THREE.BoxGeometry(terminal.size[0] + 0.6, 0.5, terminal.size[1] + 0.6), new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 }) ); termRoof.position.set(terminal.center[0], termH + 0.15, terminal.center[1]); worldGroup.add(termRoof); // ---- Container stacks in the yard (merged per colour) ---- const stackColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910]; const stackMats = stackColors.map( color => new THREE.MeshStandardMaterial({ color, roughness: 0.7 }) ); let stackIdx = 0; for (const ox of [-16, -4, 8]) { for (let z = yard.center[1] - yard.size[1] / 2 + 8; z <= yard.center[1] + yard.size[1] / 2 - 8; z += 16) { const mat = stackMats[stackIdx % stackMats.length]; const sx = yard.center[0] + ox; const baseGeo = new THREE.BoxGeometry(5, 2.6, 2.8); const base = new THREE.Mesh(baseGeo, mat); base.position.set(sx, 1.35, z); base.updateMatrix(); batch.add(baseGeo, mat, base.matrix, true); if (stackIdx % 3 === 0) { const topGeo = new THREE.BoxGeometry(5, 2.6, 2.8); const top = new THREE.Mesh(topGeo, mat); top.position.set(sx, 3.95, z); top.updateMatrix(); batch.add(topGeo, mat, top.matrix, true); } stackIdx++; } } // ---- Gantry crane frames (merged) + animated trolleys ---- const craneRuntime = []; const deckYs = berths.map((b) => { const ship = ships.find(s => s.berth === b.id); return ship && ship.kind === 'liner' ? 6.2 : 4.6; }); cranes.forEach((crane, i) => { const [cx, cz] = crane.center; for (const lx of [cx - 2.5, cx + 2.5]) { for (const lz of [cz - 6.5, cz + 6.5]) { const legGeo = new THREE.BoxGeometry(1.1, 15, 1.1); const leg = new THREE.Mesh(legGeo, steelMat); leg.position.set(lx, 7.5, lz); leg.updateMatrix(); batch.add(legGeo, steelMat, leg.matrix, true); } const tieGeo = new THREE.BoxGeometry(1, 1, 14); const tie = new THREE.Mesh(tieGeo, steelMat); tie.position.set(lx, 15.5, cz); tie.updateMatrix(); batch.add(tieGeo, steelMat, tie.matrix); } const beamGeo = new THREE.BoxGeometry(6, 1, 1.2); for (const bz of [cz - 6.5, cz + 6.5]) { const beam = new THREE.Mesh(beamGeo, steelMat); beam.position.set(cx, 15.5, bz); beam.updateMatrix(); batch.add(beamGeo, steelMat, beam.matrix); } // Boom rails reaching out over the water. for (const rz of [cz - 0.9, cz + 0.9]) { const railGeo = new THREE.BoxGeometry(36, 0.6, 0.45); const rail = new THREE.Mesh(railGeo, steelMat); rail.position.set(cx + 18, 15.6, rz); rail.updateMatrix(); batch.add(railGeo, steelMat, rail.matrix); } const deckGeo = new THREE.BoxGeometry(36, 0.25, 2.6); const deck = new THREE.Mesh(deckGeo, steelMat); deck.position.set(cx + 18, 16.1, cz); deck.updateMatrix(); batch.add(deckGeo, steelMat, deck.matrix); // Animated trolley + cable + spreader + carried container. const trolley = new THREE.Group(); const carriage = new THREE.Mesh(new THREE.BoxGeometry(3, 1.5, 3.1), steelMat); carriage.position.y = 14.75; trolley.add(carriage); const cable = new THREE.Mesh( new THREE.BoxGeometry(0.14, 1, 0.14), new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.5 }) ); trolley.add(cable); const spreader = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.4, 2.5), yellowLineMat); trolley.add(spreader); const load = new THREE.Mesh( new THREE.BoxGeometry(4.6, 2.6, 2.9), new THREE.MeshStandardMaterial({ color: stackColors[i % stackColors.length], roughness: 0.7 }) ); trolley.add(load); worldGroup.add(trolley); craneRuntime.push({ cx, cz, trolley, cable, spreader, load, deckY: deckYs[i], t: i * 0.45, berthId: berths[i] ? berths[i].id : null, }); }); batch.flush(worldGroup); // ---- Fleet: container ships, liner, speedboats, trucks ---- const laneX = quay.center[0] + 52; // clear of the berthed hulls (W/2 sums ~18) const zoneStart = berths[0].center[1] + 20; const zoneEnd = berths[1].center[1] - 20; const oval = [ [laneX, zoneStart], [laneX + 8, zoneStart - 25], [laneX + 8, zoneEnd + 25], [laneX, zoneEnd], [laneX - 8, zoneEnd + 25], [laneX - 8, zoneStart - 25], ]; const departLegsFor = berthCenter => [ [berthCenter[0] + 16, berthCenter[1]], [laneX, zoneStart], ]; const returnLegsFor = berthCenter => [ [berthCenter[0] + 16, berthCenter[1]], [berthCenter[0], berthCenter[1]], ]; const fleet = []; ships.forEach((spec) => { const mesh = buildShip(spec.kind, spec); worldGroup.add(mesh); const berth = spec.berth ? berths.find(b => b.id === spec.berth) : null; const entity = { spec, mesh, berth: berth ? [...berth.center] : null, cycle: spec.kind === 'container', state: berth ? 'berthed' : 'sailing', legs: null, leg: 0, t: 0, p: [0, 0, 0], yaw: 0, timer: berth ? 11 + Math.random() * 6 : 22 + Math.random() * 14, purpose: berth ? 'depart' : 'berth', awaitedAt: 0, retry: 0, phase: Math.random() * Math.PI * 2, }; if (berth) { entity.p = [berth.center[0], 0, berth.center[1]]; } else { entity.legs = oval; entity.leg = 1; entity.t = 0.4; const [sx, sz] = lerp2(oval[0], oval[1], 0.4); entity.p = [sx, 0, sz]; entity.yaw = shipYaw(oval[1][0] - oval[0][0], oval[1][1] - oval[0][1]); entity.purpose = 'berth'; } fleet.push(entity); }); const boats = []; const boatOval = [ [laneX - 18, zoneStart], [laneX - 10, zoneEnd], [laneX + 18, zoneEnd], [laneX + 10, zoneStart], ]; [0x2fb6c9, 0xe08a1e, 0xd92b2b].forEach((color, i) => { const mesh = buildPortBoat(color); worldGroup.add(mesh); const start = i % boatOval.length; const t0 = (i * 0.37) % 1; boats.push({ mesh, legs: boatOval, leg: start, t: t0, p: (() => { const [bx, bz] = lerp2(boatOval[start], boatOval[(start + 1) % boatOval.length], t0); return [bx, 0, bz]; })(), yaw: 0, speed: 8 + i, phase: i * 2.1, }); }); const trucks = []; const yardLoop = [ [yard.center[0] - 16, yard.center[1] - 50], [yard.center[0] + 16, yard.center[1] - 50], [yard.center[0] + 16, yard.center[1] + 50], [yard.center[0] - 16, yard.center[1] + 50], ]; [true, false].forEach((loaded, i) => { const mesh = buildPortTruck(i === 0 ? 0xd97706 : 0x3f9d4f, loaded); worldGroup.add(mesh); const leg = (i * 2) % yardLoop.length; const t0 = i * 0.5; trucks.push({ mesh, legs: yardLoop, leg, t: t0, p: (() => { const [tx, tz] = lerp2(yardLoop[leg], yardLoop[(leg + 1) % yardLoop.length], t0); return [tx, 0, tz]; })(), yaw: 0, speed: loaded ? 4 : 4.6, truck: true, }); }); /** Advance one entity along its waypoint legs; true when finished. */ const stepAlong = (e, speed, dt) => { if (!e.legs || !e.legs.length) return true; let guard = 0; while (guard++ < 4) { // Perpetual loops wrap; one-shot paths reset via their caller. if (e.leg >= e.legs.length) e.leg = 0; const target = e.legs[e.leg]; // [x, z] waypoint pair const dx = target[0] - e.p[0]; const dz = target[1] - e.p[2]; const len = Math.hypot(dx, dz); if (len < 1e-3) { e.leg++; if (e.leg >= e.legs.length) return true; continue; } const step = speed * dt; if (step >= len) { e.p = [target[0], 0, target[1]]; e.leg++; if (e.leg >= e.legs.length) return true; continue; } e.p = [e.p[0] + (dx / len) * step, 0, e.p[2] + (dz / len) * step]; e.yaw = e.truck ? Math.atan2(-dz, dx) : shipYaw(dx, dz); return false; } return true; }; const postDispatch = () => { postJson('/city/port/dispatch', {}).catch(() => { /* throttled server-side */ }); }; const system = { clearance: null, shownAt: null, poll: 4, counts() { return { ships: fleet.length, boats: boats.length, trucks: trucks.length, cranes: craneRuntime.length, berthed: fleet.filter(f => f.state === 'berthed').length, underway: fleet.filter(f => ['departing', 'sailing', 'returning', 'turning'].includes(f.state)).length, states: fleet.map(f => `${f.spec.id}:${f.state}`), shipPositions: fleet.map(f => ({ id: f.spec.id, x: +f.p[0].toFixed(1), z: +f.p[2].toFixed(1), })), // Motion sample for tests: positions that must change // between two readings. movingSample: { boat: boats[0] ? [+boats[0].p[0].toFixed(1), +boats[0].p[2].toFixed(1)] : null, truck: trucks[0] ? [+trucks[0].p[0].toFixed(1), +trucks[0].p[2].toFixed(1)] : null, }, }; }, update(dt, time) { // ---- Capitania clearance poll (flash + maneuver gating) ---- this.poll -= dt; if (this.poll <= 0) { this.poll = 8; fetch('/city/port', { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-store', }) .then(r => (r.ok ? r.json() : null)) .then((data) => { const c = data && data.clearance; if (c && c.at && c.at !== this.shownAt) { this.shownAt = c.at; this.clearance = c; flashCharMsg('💬 '.concat(c.text)); } }) .catch(() => { /* offline-safe */ }); } // ---- Container ships: AI-gated maneuver cycle ---- for (const f of fleet) { if (!f.cycle) continue; if (f.state === 'berthed') { f.timer -= dt; if (f.timer <= 0) { f.state = 'awaiting'; f.purpose = 'depart'; f.awaitedAt = Date.now(); f.retry = 1; postDispatch(); } } else if (f.state === 'awaiting') { f.retry -= dt; if (f.retry <= 0) { f.retry = 7; postDispatch(); } const c = this.clearance; if (c && Date.parse(c.at) >= f.awaitedAt - 1500 && c.action === f.purpose) { if (f.purpose === 'depart') { if (!f.berth) continue; // nothing to leave f.legs = departLegsFor(f.berth); } else { // Claim the first FREE berth: occupied means // berthed, or still moored waiting to depart // (c1 sits on its berth until cleared to leave). const free = berths.find(b => !fleet.some(o => { if (o === f || !o.berth) return false; const sameSlot = o.berth[0] === b.center[0] && o.berth[1] === b.center[1]; const occupying = o.state === 'berthed' || o.state === 'returning' || o.state === 'turning' || (o.state === 'awaiting' && o.purpose === 'depart'); return sameSlot && occupying; })); if (!free) { f.retry = 6; // all busy: ask again shortly continue; } f.berth = [...free.center]; f.legs = returnLegsFor(f.berth); } f.leg = 0; f.state = f.purpose === 'depart' ? 'departing' : 'returning'; } } else if (f.state === 'departing') { if (stepAlong(f, 4.5, dt)) { f.legs = oval; f.leg = 0; f.timer = 20 + Math.random() * 14; f.state = 'sailing'; } } else if (f.state === 'sailing') { f.timer -= dt; stepAlong(f, 5.5, dt); if (f.timer <= 0) { f.state = 'awaiting'; f.purpose = 'berth'; f.awaitedAt = Date.now(); f.retry = 1; postDispatch(); } } else if (f.state === 'returning') { if (stepAlong(f, 4.5, dt)) { f.state = 'turning'; f.turnT = 0; f.fromYaw = f.yaw; } } else if (f.state === 'turning') { f.turnT += dt / 2.2; const k = Math.min(1, f.turnT); f.yaw = f.fromYaw + (0 - f.fromYaw) * k; if (k >= 1) { f.state = 'berthed'; f.timer = 10 + Math.random() * 6; f.purpose = 'depart'; } } // Pose: position + swell bob + gentle roll. f.mesh.position.set( f.p[0], 0.1 + beachWaveY(f.p[0], f.p[2], time) * 0.5, f.p[2] ); f.mesh.rotation.y = f.yaw; f.mesh.rotation.z = Math.sin(time * 0.7 + f.phase) * 0.03; } // ---- Speedboats: perpetual loops ---- for (const b of boats) { if (stepAlong(b, b.speed, dt)) b.leg = 0; b.mesh.position.set( b.p[0], 0.15 + beachWaveY(b.p[0], b.p[2], time) * 0.7, b.p[2] ); b.mesh.rotation.y = b.yaw; b.mesh.rotation.z = Math.sin(time * 1.1 + b.phase) * 0.07; } // ---- Yard trucks on their loop ---- for (const tr of trucks) { if (stepAlong(tr, tr.speed, dt)) tr.leg = 0; tr.mesh.position.set(tr.p[0], 0, tr.p[2]); tr.mesh.rotation.y = tr.yaw; } // ---- Gantry cranes: load cycle while their berth is occupied ---- for (const cr of craneRuntime) { const busy = fleet.some( f => f.spec.berth === cr.berthId && f.state === 'berthed' ); cr.busy = busy; if (busy) cr.t = (cr.t + dt / 13) % 1; const t = busy ? cr.t : 0; cr.trolley.position.x = kf(t, [ [0, cr.cx], [0.18, cr.cx + 32], [0.60, cr.cx + 32], [0.72, cr.cx], [1, cr.cx], ]); const hookY = kf(t, [ [0, 13], [0.18, 13], [0.30, cr.deckY], [0.40, 12.5], [0.60, 12.5], [0.72, 3.2], [0.82, 13], [1, 13], ]); cr.cable.scale.y = Math.max(0.1, 14.0 - hookY); cr.cable.position.y = (14.0 + hookY) / 2; cr.spreader.position.y = hookY; const carrying = busy && t >= 0.28 && t < 0.70; cr.load.visible = carrying; cr.load.position.y = hookY - 1.6; } }, }; return system; } // ------------------------------------------------------------------ // Personal drone (per logged-in session): first-person quad with a // command panel whose "video" hole shows the drone camera feed — the // renderer draws that exact scissored region of the main canvas, so no // second WebGL context is needed. // ------------------------------------------------------------------ let drone = { active: false, created: false, pos: new THREE.Vector3(0, 30, 0), yaw: 0, pitch: -0.55, vel: new THREE.Vector3(), mesh: null, cam: null, qPrev: false, frame: 0, teleTimer: 0, view: null, }; /** Quadcopter: nose toward −Z (matches drone.yaw like ships' convention). */ function buildDroneMesh() { const g = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: 0x232a35, roughness: 0.5, metalness: 0.5 }); const darkMat = new THREE.MeshStandardMaterial({ color: 0x141920, roughness: 0.6 }); const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.24, 1.1), bodyMat); g.add(body); const nose = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.18, 0.45), bodyMat); nose.position.set(0, 0.04, -0.72); g.add(nose); // X-arms. for (const rot of [Math.PI / 4, -Math.PI / 4]) { const arm = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.07, 0.14), darkMat); arm.rotation.y = rot; g.add(arm); } // Motors, spinning rotors and skids at the four tips. const rotors = []; for (const [ox, oz] of [[0.7, 0.7], [0.7, -0.7], [-0.7, 0.7], [-0.7, -0.7]]) { const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.12, 0.15, 8), darkMat); motor.position.set(ox, 0.1, oz); g.add(motor); const rotor = new THREE.Mesh( new THREE.CylinderGeometry(0.45, 0.45, 0.03, 12), new THREE.MeshStandardMaterial({ color: 0x9aa4b2, roughness: 0.3, metalness: 0.6, transparent: true, opacity: 0.7, }) ); rotor.position.set(ox, 0.21, oz); g.add(rotor); rotors.push(rotor); const skid = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.35, 0.07), darkMat); skid.position.set(ox, -0.25, oz); g.add(skid); } // Camera gimbal + lens pointing forward (−Z) and a status LED. const gimbal = new THREE.Mesh( new THREE.SphereGeometry(0.14, 10, 10), new THREE.MeshStandardMaterial({ color: 0x0e1620, roughness: 0.2, metalness: 0.8 }) ); gimbal.position.set(0, -0.14, -0.55); g.add(gimbal); const lens = new THREE.Mesh( new THREE.CylinderGeometry(0.06, 0.07, 0.1, 10), new THREE.MeshStandardMaterial({ color: 0x164e63, roughness: 0.2, metalness: 0.7 }) ); lens.rotation.x = -Math.PI / 2; lens.position.set(0, -0.14, -0.7); g.add(lens); const led = new THREE.Mesh( new THREE.SphereGeometry(0.05, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff3344, emissive: 0xff3344, emissiveIntensity: 2 }) ); led.position.set(0, 0.18, -0.45); g.add(led); g.userData = { rotors, led }; g.traverse(o => { if (o.isMesh) o.castShadow = true; }); return g; } /** Activate/deactivate the personal drone (spawn above the player). */ function toggleDrone() { if (!drone.mesh) { drone.pos.set( player.pos.x, Math.max(30, (player.pos.y || 0) + 28), player.pos.z ); drone.yaw = player.yaw; drone.pitch = -0.55; drone.vel.set(0, 0, 0); drone.mesh = buildDroneMesh(); drone.mesh.position.copy(drone.pos); worldGroup.add(drone.mesh); drone.cam = new THREE.PerspectiveCamera(68, 1.6, 0.1, 800); drone.created = true; } drone.active = !drone.active; const panel = document.getElementById('drone-panel'); if (panel) panel.style.display = drone.active ? 'block' : 'none'; if (drone.mesh?.userData.led) { const hex = drone.active ? 0x22ff66 : 0xff3344; drone.mesh.userData.led.material.color.setHex(hex); drone.mesh.userData.led.material.emissive.setHex(hex); } } /** Drone flight, rotor spin, telemetry and camera update (per frame). */ function updateDrone(dt) { if (!drone.mesh) return; // Q toggles (edge-detected through the shared key map). const q = !!keys['KeyQ']; if (q && !drone.qPrev) toggleDrone(); drone.qPrev = q; const time = performance.now() / 1000; const spin = drone.active ? 46 : 9; for (const r of drone.mesh.userData.rotors) r.rotation.y += spin * dt; let inFwd = 0; let inRight = 0; let inAsc = 0; if (drone.active) { if (keys['KeyW']) inFwd += 1; if (keys['KeyS']) inFwd -= 1; if (keys['KeyD']) inRight += 1; if (keys['KeyA']) inRight -= 1; if (keys['Space']) inAsc += 1; if (keys['KeyC']) inAsc -= 1; if (keys['ArrowLeft']) drone.yaw += 2.2 * dt; if (keys['ArrowRight']) drone.yaw -= 2.2 * dt; if (keys['ArrowUp']) drone.pitch = Math.min(1.2, drone.pitch + 1.6 * dt); if (keys['ArrowDown']) drone.pitch = Math.max(-1.4, drone.pitch - 1.6 * dt); const fwd = new THREE.Vector3(-Math.sin(drone.yaw), 0, -Math.cos(drone.yaw)); const right = new THREE.Vector3(Math.cos(drone.yaw), 0, -Math.sin(drone.yaw)); const wish = new THREE.Vector3() .addScaledVector(fwd, inFwd * 20) .addScaledVector(right, inRight * 20); wish.y = inAsc * 9; // Smooth acceleration (inertial feel). drone.vel.lerp(wish, 1 - Math.exp(-4 * dt)); drone.pos.addScaledVector(drone.vel, dt); // Flight envelope: generous box over city, beach and port. drone.pos.x = Math.max(-500, Math.min(500, drone.pos.x)); drone.pos.z = Math.max(-500, Math.min(500, drone.pos.z)); drone.pos.y = Math.max(4, Math.min(150, drone.pos.y)); } else { drone.vel.multiplyScalar(Math.exp(-3 * dt)); // rotors spool down } // Pose: heading, forward dip, strafe bank, idle hover bob. drone.mesh.position.copy(drone.pos); if (!drone.active) drone.mesh.position.y += Math.sin(time * 2) * 0.05; drone.mesh.rotation.y = drone.yaw; const targetPitch = drone.active ? -inFwd * 0.12 : 0; const targetBank = drone.active ? -inRight * 0.22 : 0; drone.mesh.rotation.x += (targetPitch - drone.mesh.rotation.x) * Math.min(1, dt * 6); drone.mesh.rotation.z += (targetBank - drone.mesh.rotation.z) * Math.min(1, dt * 6); // Camera: gimbal just below the nose, following yaw/pitch. drone.cam.position.set(drone.pos.x, drone.pos.y - 0.12, drone.pos.z); const cp = Math.cos(drone.pitch); drone.cam.lookAt( drone.pos.x - Math.sin(drone.yaw) * cp, drone.pos.y + Math.sin(drone.pitch), drone.pos.z - Math.cos(drone.yaw) * cp ); // Telemetry (5 Hz). drone.teleTimer -= dt; if (drone.active && drone.teleTimer <= 0) { drone.teleTimer = 0.2; const tele = document.getElementById('drone-tele'); if (tele) { const spd = Math.round(drone.vel.length()); const deg = Math.round(((drone.yaw * 180) / Math.PI % 360 + 360) % 360); tele.textContent = `↑${Math.round(drone.pos.y)} m · ${spd} m/s · ${deg}°`; } } } /** * Draw the drone camera feed into the panel's transparent region using a * scissored viewport (30 fps alternate frames to halve the extra cost). */ function renderDroneView() { const video = document.getElementById('drone-video'); if (!video || !drone.cam) return; const vr = video.getBoundingClientRect(); if (vr.width < 8 || vr.height < 8) return; const canvas = renderer.domElement; const cr = canvas.getBoundingClientRect(); const x = Math.round(vr.left - cr.left); const yTop = Math.round(vr.top - cr.top); const w = Math.round(vr.width); const h = Math.round(vr.height); // GL viewports are bottom-up. const y = Math.round(cr.height - yTop - h); drone.cam.aspect = w / h; drone.cam.updateProjectionMatrix(); // Neutralise the city fog for the drone feed so the aerial view is // never misted ("embassado"). Only the fog uniform values change for // this pass — three refreshes them per render, so no material is // recompiled and the ground view keeps its weather haze. const fog = scene.fog; const fogNear = fog ? fog.near : 0; const fogFar = fog ? fog.far : 0; if (fog) { fog.near = 1e7; fog.far = 2e7; // smoothstep → factor 0 at any real distance } renderer.setScissorTest(true); renderer.setScissor(x, y, w, h); renderer.setViewport(x, y, w, h); renderer.render(scene, drone.cam); // clears only the scissored hole renderer.setScissorTest(false); renderer.setViewport(0, 0, cr.width, cr.height); if (fog) { fog.near = fogNear; fog.far = fogFar; } drone.fogCleared = !!fog; drone.view = { x, y, w, h }; } // ------------------------------------------------------------------ // Train system + level crossing // ------------------------------------------------------------------ function initTrain(g) { const trackZ = g.maxZ + 14; const xMid = g.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.xs[0]); const trackFrom = g.minX - 70; const trackTo = g.maxX + 70; const railMat = new THREE.MeshStandardMaterial({ color: 0x6a6a72, roughness: 0.4, metalness: 0.8 }); const bedMat = new THREE.MeshStandardMaterial({ color: 0x5a5248, roughness: 1 }); const bed = new THREE.Mesh( new THREE.BoxGeometry(trackTo - trackFrom, 0.18, 3.6), bedMat ); bed.position.set((trackFrom + trackTo) / 2, 0.09, trackZ); worldGroup.add(bed); for (const off of [-0.75, 0.75]) { const rail = new THREE.Mesh(new THREE.BoxGeometry(trackTo - trackFrom, 0.14, 0.12), railMat); rail.position.set((trackFrom + trackTo) / 2, 0.24, trackZ + off); worldGroup.add(rail); } const sleeperGeo = new THREE.BoxGeometry(0.35, 0.1, 2.6); const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3d2f22, roughness: 0.9 }); const count = Math.floor((trackTo - trackFrom) / 2); const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count); const m4 = new THREE.Matrix4(); for (let i = 0; i < count; i++) { m4.setPosition(trackFrom + i * 2 + 1, 0.16, trackZ); sleepers.setMatrixAt(i, m4); } worldGroup.add(sleepers); const train = new THREE.Group(); const locoMat = new THREE.MeshStandardMaterial({ color: 0xb03030, roughness: 0.5, metalness: 0.3 }); const loco = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.4), locoMat); loco.position.y = 1.5; loco.castShadow = true; train.add(loco); const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 2.2), new THREE.MeshStandardMaterial({ color: 0x882222 })); cab.position.set(2.4, 3.2, 0); train.add(cab); const carColors = [0x2a5a8a, 0x2a5a8a, 0x3a7a4a, 0x2a5a8a]; carColors.forEach((col, i) => { const car = new THREE.Mesh( new THREE.BoxGeometry(6.5, 2.2, 2.3), new THREE.MeshStandardMaterial({ color: col, roughness: 0.6 }) ); car.position.set(-6.5 - i * 7.2, 1.4, 0); car.castShadow = true; train.add(car); }); worldGroup.add(train); // Level crossing barriers on the vertical road that crosses the track. const barriers = []; for (const side of [-1, 1]) { const pivot = new THREE.Group(); pivot.position.set(xMid + side * (g.roadW / 2 + 2.2), 1.1, trackZ + side * 2.6); const armGeo = new THREE.BoxGeometry(7, 0.18, 0.25); // Shift geometry so the pivot sits at the post end of the arm. armGeo.translate(side > 0 ? -3.5 : 3.5, 0, 0); const armMesh = new THREE.Mesh( armGeo, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 }) ); pivot.add(armMesh); const lamp = new THREE.Mesh( new THREE.SphereGeometry(0.16, 8, 8), new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff2222, emissiveIntensity: 0.3 }) ); lamp.position.y = 1.1; pivot.add(lamp); const post = new THREE.Mesh( new THREE.CylinderGeometry(0.1, 0.12, 2.2, 6), new THREE.MeshStandardMaterial({ color: 0x888888 }) ); pivot.add(post); // Start raised (open). pivot.rotation.z = -Math.PI / 2; worldGroup.add(pivot); barriers.push({ pivot, lamp }); } const state = { s: trackFrom + 30, dir: 1, speed: 14, pause: 0, blocks: false, crossZ: trackZ, xMid, trackFrom, trackTo, train, barriers, }; trainSystem = { ...state, blocksCrossing() { return this.blocks; }, update(dt) { if (this.pause > 0) { this.pause -= dt; } else { this.s += this.dir * this.speed * dt; if (this.s >= this.trackTo - 40) { this.s = this.trackTo - 40; this.dir = -1; this.pause = 3; this.train.rotation.y = Math.PI; } else if (this.s <= this.trackFrom + 40) { this.s = this.trackFrom + 40; this.dir = 1; this.pause = 3; this.train.rotation.y = 0; } } const noseX = this.s + this.dir * 4; this.train.position.set(this.s, 0, this.trackZ ?? this.crossZ); const distToCross = Math.abs(noseX - this.xMid); this.blocks = distToCross < 45 && this.pause <= 0; const blink = Math.sin(performance.now() * 0.012) > 0; for (const b of this.barriers) { const target = this.blocks ? 0 : -Math.PI / 2; b.pivot.rotation.z += (target - b.pivot.rotation.z) * Math.min(1, dt * 4); b.lamp.material.emissiveIntensity = this.blocks ? (blink ? 3 : 0.2) : 0.2; } }, }; trainSystem.crossZ = trackZ; trainSystem.trackZ = trackZ; } // ------------------------------------------------------------------ // River + bridge // ------------------------------------------------------------------ function initRiver(g) { const riverX = g.minX - 30; const riverW = 16; const zMid = g.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.zs[0]); const len = (g.maxZ - g.minZ) + 140; const waterTex = canvasTexture(256, (ctx, s) => { const grad = ctx.createLinearGradient(0, 0, s, s); grad.addColorStop(0, '#1a5f8a'); grad.addColorStop(0.5, '#2a7fb0'); grad.addColorStop(1, '#1a5f8a'); ctx.fillStyle = grad; ctx.fillRect(0, 0, s, s); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 3; for (let i = 0; i < 14; i++) { const y = Math.random() * s; ctx.beginPath(); ctx.moveTo(0, y); for (let x = 0; x <= s; x += 16) { ctx.lineTo(x, y + Math.sin(x * 0.08 + i) * 6); } ctx.stroke(); } }, 3, 10); const geo = new THREE.PlaneGeometry(riverW, len, 6, 40); const water = new THREE.Mesh( geo, new THREE.MeshStandardMaterial({ map: waterTex, color: 0x88bbdd, roughness: 0.25, metalness: 0.35, transparent: true, opacity: 0.92, }) ); water.rotation.x = -Math.PI / 2; water.position.set(riverX, 0.04, (g.minZ + g.maxZ) / 2); water.receiveShadow = true; worldGroup.add(water); const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7f66, roughness: 1 }); for (const side of [-1, 1]) { const bank = new THREE.Mesh(new THREE.BoxGeometry(4, 0.3, len), bankMat); bank.position.set(riverX + side * (riverW / 2 + 1.5), 0.1, (g.minZ + g.maxZ) / 2); worldGroup.add(bank); } const basePos = Float32Array.from(geo.attributes.position.array); // Bridge where the mid horizontal road extension crosses the river. const bridge = new THREE.Mesh( new THREE.BoxGeometry(riverW + 8, 0.25, 9), new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.9 }) ); bridge.position.set(riverX, 0.05, zMid); bridge.receiveShadow = true; worldGroup.add(bridge); const railMat = new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.6, metalness: 0.4 }); for (const side of [-1, 1]) { const rail = new THREE.Mesh(new THREE.BoxGeometry(riverW + 8, 0.7, 0.15), railMat); rail.position.set(riverX, 0.5, zMid + side * 4.4); worldGroup.add(rail); const pier = new THREE.Mesh( new THREE.BoxGeometry(1.2, 2.2, 1.2), new THREE.MeshStandardMaterial({ color: 0x555560, roughness: 0.9 }) ); pier.position.set(riverX, -0.9, zMid + side * 3); worldGroup.add(pier); } riverSystem = { riverX, riverW, zMid, water, basePos, update(dt, t) { waterTex.offset.y = (waterTex.offset.y + dt * 0.04) % 1; waterTex.offset.x = (waterTex.offset.x + dt * 0.012) % 1; const pos = geo.attributes.position; for (let i = 0; i < pos.count; i++) { const bx = basePos[i * 3]; const by = basePos[i * 3 + 1]; pos.array[i * 3 + 2] = Math.sin(bx * 0.6 + t * 2.2) * 0.09 + Math.sin(by * 0.35 + t * 1.6) * 0.07; } pos.needsUpdate = true; }, }; } // ------------------------------------------------------------------ // City builder // ------------------------------------------------------------------ const layoutExtras = { bridgeRoad: null, crossRoad: null }; // ------------------------------------------------------------------ // Public plazas + outdoor gym equipment // ------------------------------------------------------------------ function buildGymEquipment(kind, x, z) { const g = new THREE.Group(); const metal = new THREE.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.45, metalness: 0.6 }); const accent = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.5, metalness: 0.3 }); const pad = new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.9 }); if (kind === 'bench') { const seat = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.12, 0.5), pad); seat.position.y = 0.55; g.add(seat); [-0.6, 0.6].forEach(px => { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.4), metal); leg.position.set(px, 0.28, 0); g.add(leg); }); const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal); bar.rotation.z = Math.PI / 2; bar.position.set(0, 0.95, 0); g.add(bar); [-0.7, 0.7].forEach(px => { const plate = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.1, 10), accent); plate.rotation.z = Math.PI / 2; plate.position.set(px, 0.95, 0); g.add(plate); }); } else if (kind === 'bars') { [-0.8, 0.8].forEach(px => { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.2, 8), metal); post.position.set(px, 1.1, 0); g.add(post); }); const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal); top.rotation.z = Math.PI / 2; top.position.set(0, 2.15, 0); g.add(top); const mid = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.8, 8), accent); mid.rotation.z = Math.PI / 2; mid.position.set(0, 1.5, 0); g.add(mid); } else if (kind === 'bike') { const frame = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.12), metal); frame.position.set(0, 0.55, 0); g.add(frame); const seatB = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.1, 0.25), pad); seatB.position.set(-0.15, 1.1, 0); g.add(seatB); const handle = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.5), accent); handle.position.set(0.3, 1.15, 0); g.add(handle); const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 8, 16), metal); wheel.position.set(0.1, 0.4, 0); g.add(wheel); g.userData.spin = wheel; } else if (kind === 'treadmill') { const deck = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.9), pad); deck.position.set(0, 0.18, 0); g.add(deck); const railL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.08), metal); railL.position.set(0, 0.7, 0.4); g.add(railL); const railR = railL.clone(); railR.position.z = -0.4; g.add(railR); const consoleBox = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.55, 0.7), accent); consoleBox.position.set(1.1, 0.55, 0); g.add(consoleBox); } const ring = new THREE.Mesh( new THREE.RingGeometry(1.1, 1.45, 24), new THREE.MeshBasicMaterial({ color: 0x22c55e, side: THREE.DoubleSide, transparent: true, opacity: 0.45 }) ); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.08; g.add(ring); g.userData.glowRing = ring; g.position.set(x, 0, z); g.userData.kind = kind; g.userData.baseY = 0; worldGroup.add(g); // Soft collider so the player stands next to equipment without walking through the frame. addCollider(x, 0, z, 1.4, 1.2, 1.4); const item = { id: `${kind}-${x.toFixed(1)}-${z.toFixed(1)}`, kind, x, z, mesh: g }; gymEquipment.push(item); return item; } function initPlazas(list) { plazas = Array.isArray(list) ? list : []; gymEquipment = []; const grassMat = new THREE.MeshStandardMaterial({ color: 0x3f7d3a, roughness: 1 }); const pathMat = new THREE.MeshStandardMaterial({ color: 0xc4b8a0, roughness: 0.95 }); const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.9 }); const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f6b2a, roughness: 0.85 }); for (const p of plazas) { const [px, pz] = p.position; const [pw, pd] = p.size || [22, 22]; const lawn = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.12, pd), grassMat); lawn.position.set(px, 0.06, pz); lawn.receiveShadow = true; worldGroup.add(lawn); // Cross paths through the plaza. const pathA = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.04, 2.2), pathMat); pathA.position.set(px, 0.13, pz); worldGroup.add(pathA); const pathB = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.04, pd), pathMat); pathB.position.set(px, 0.13, pz); worldGroup.add(pathB); // Trees on plaza corners. const corners = [ [px - pw / 2 + 2.5, pz - pd / 2 + 2.5], [px + pw / 2 - 2.5, pz - pd / 2 + 2.5], [px - pw / 2 + 2.5, pz + pd / 2 - 2.5], [px + pw / 2 - 2.5, pz + pd / 2 - 2.5], ]; corners.forEach(([tx, tz], i) => { const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.28, 1.6, 6), trunkMat); trunk.position.set(tx, 0.9, tz); trunk.castShadow = true; worldGroup.add(trunk); const crown = new THREE.Mesh(new THREE.SphereGeometry(1.1 + (i % 2) * 0.2, 10, 10), leafMat); crown.position.set(tx, 2.4, tz); crown.castShadow = true; worldGroup.add(crown); }); // Central fountain basin. const basin = new THREE.Mesh( new THREE.CylinderGeometry(2.2, 2.4, 0.45, 16), new THREE.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.7 }) ); basin.position.set(px, 0.25, pz); basin.castShadow = true; worldGroup.add(basin); const water = new THREE.Mesh( new THREE.CylinderGeometry(1.9, 1.9, 0.1, 16), new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.2, metalness: 0.3, transparent: true, opacity: 0.85 }) ); water.position.set(px, 0.42, pz); worldGroup.add(water); // Luminous plaza letreiro on the perimeter, facing the nearest road. const plazaFace = faceNearestRoad(px, pz, grid); const [pOffX, pOffZ] = facingOffset(plazaFace, pw / 2 + 1.4, pd / 2 + 1.4); const plazaNum = (p.id || '').split('-')[1] || ''; const plazaLabel = plazaNum ? `${i18n.signPlaza || 'Praça'} ${plazaNum}` : (i18n.signPlaza || 'Praça'); const plazaSign = buildLitSign(plazaLabel, { w: 5.4, h: 1.5, posts: 'double', postH: 2.2, accent: '#facc15', }); plazaSign.position.set(px + pOffX, 0, pz + pOffZ); plazaSign.rotation.y = plazaFace; worldGroup.add(plazaSign); signCounts.plazas++; for (const eq of p.equipment || []) { buildGymEquipment(eq.kind, eq.position[0], eq.position[1]); } } } // ------------------------------------------------------------------ // Beach (east shore): sand, animated sea, food stalls, chairs, court, // lifeguards, bathers, surfers and basket players. // ------------------------------------------------------------------ /** Shared wave height — vertex animation and swimmers/surfers use it. */ function beachWaveY(x, z, t) { return Math.sin(x * 0.16 + t * 1.5) * 0.3 + Math.sin(z * 0.11 + t * 0.9) * 0.2; } /** * Build the whole east-beach scene from the payload geometry. * * @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max) * @returns {{update: Function, counts: Function}} */ function initBeach(cfg, portCfg = null) { const sandFrom = cfg.sand_from; const sandTo = cfg.sand_to; const seaTo = cfg.sea_to; const zMin = cfg.z_min; const zMax = cfg.z_max; const midZ = (zMin + zMax) / 2; const zDepth = zMax - zMin; // The sea extends north over the port basin (and the free half of // that coast, kept open for future city expansion). const seaZMin = Math.min(zMin, portCfg?.water?.z_min ?? zMin); const seaSpan = zMax - seaZMin; const seaCenterZ = (zMax + seaZMin) / 2; const group = new THREE.Group(); const counts = { bathers: 0, swimmers: 0, surfers: 0, basket: 0, lifeguards: 0, chairs: 0, umbrellas: 0, stalls: 0, towers: 0, }; // ---- Sand + wet shoreline strip ---- const sandMat = new THREE.MeshStandardMaterial({ color: 0xe7d6a3, roughness: 1 }); const sand = new THREE.Mesh(new THREE.PlaneGeometry(sandTo - sandFrom, zDepth), sandMat); sand.rotation.x = -Math.PI / 2; sand.position.set((sandFrom + sandTo) / 2, 0.05, midZ); sand.receiveShadow = true; group.add(sand); const wetMat = new THREE.MeshStandardMaterial({ color: 0xcbb982, roughness: 0.85, metalness: 0.08 }); const wet = new THREE.Mesh(new THREE.PlaneGeometry(6, zDepth), wetMat); wet.rotation.x = -Math.PI / 2; wet.position.set(sandTo - 3, 0.055, midZ); group.add(wet); // ---- Sea with animated waves (one mesh, vertex displacement) ---- const seaW = seaTo - sandTo; const seaGeo = new THREE.PlaneGeometry( seaW, seaSpan, 40, Math.max(16, Math.round((56 * seaSpan) / zDepth)) ); const seaMat = new THREE.MeshStandardMaterial({ color: 0x2f7fb5, roughness: 0.35, metalness: 0.15, transparent: true, opacity: 0.94, }); const seaMesh = new THREE.Mesh(seaGeo, seaMat); seaMesh.rotation.x = -Math.PI / 2; seaMesh.position.set(sandTo + seaW / 2, 0.09, seaCenterZ); seaMesh.frustumCulled = false; group.add(seaMesh); const seaBase = Float32Array.from(seaGeo.attributes.position.array); const seaWorldX = sandTo + seaW / 2; // Foam line where the waves break (opacity pulses with the swell). const foamMat = new THREE.MeshStandardMaterial({ color: 0xeef6fa, roughness: 0.9, transparent: true, opacity: 0.7, }); const foam = new THREE.Mesh(new THREE.PlaneGeometry(2.6, zDepth), foamMat); foam.rotation.x = -Math.PI / 2; foam.position.set(sandTo + 1.2, 0.1, midZ); group.add(foam); // Luminous letreiros: entrance arch over the road end (faces the // city/west) and a food board between the stalls. const entrance = buildLitSign(i18n.signBeach || 'Praia', { w: 9, h: 1.8, posts: 'arch', postH: 3.6, accent: '#22d3ee', }); entrance.position.set(sandFrom - 2, 0, 0); entrance.rotation.y = -Math.PI / 2; // face the city (west) group.add(entrance); const foodSign = buildLitSign(i18n.signFood || 'Comidas', { w: 4.4, h: 1.2, posts: 'double', postH: 2.0, accent: '#fb923c', }); foodSign.position.set(sandFrom + 4, 0, 59); foodSign.rotation.y = -Math.PI / 2; group.add(foodSign); signCounts.beach += 2; // ---- Basketball court: painted slab + two hoops ---- const courtTex = canvasTexture(512, (ctx, s) => { ctx.fillStyle = '#c9a06a'; ctx.fillRect(0, 0, s, s); ctx.strokeStyle = '#f4efe3'; ctx.lineWidth = 6; ctx.strokeRect(26, 26, s - 52, s - 52); ctx.beginPath(); ctx.moveTo(26, s / 2); ctx.lineTo(s - 26, s / 2); ctx.stroke(); ctx.beginPath(); ctx.arc(s / 2, s / 2, 58, 0, Math.PI * 2); ctx.stroke(); // Keys at both ends. const keyW = 110, keyH = 150; ctx.strokeRect(s / 2 - keyW / 2, 26, keyW, keyH); ctx.strokeRect(s / 2 - keyW / 2, s - 26 - keyH, keyW, keyH); ctx.beginPath(); ctx.arc(s / 2, 26 + keyH, 58, 0, Math.PI); ctx.stroke(); ctx.beginPath(); ctx.arc(s / 2, s - 26 - keyH, 58, Math.PI, Math.PI * 2); ctx.stroke(); }, 1, 1); const courtX = sandFrom + 24; const courtZ = -95; const court = new THREE.Mesh( new THREE.PlaneGeometry(16, 26), new THREE.MeshStandardMaterial({ map: courtTex, roughness: 0.95 }) ); court.rotation.x = -Math.PI / 2; court.position.set(courtX, 0.065, courtZ); court.receiveShadow = true; group.add(court); const poleMatBeach = new THREE.MeshStandardMaterial({ color: 0x5a5f66, roughness: 0.6, metalness: 0.4 }); const boardMat = new THREE.MeshStandardMaterial({ color: 0xf2e9d8, roughness: 0.7 }); const ringMat = new THREE.MeshStandardMaterial({ color: 0xe86a17, roughness: 0.5, metalness: 0.3 }); const addHoop = (zEnd, dir) => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 3.2, 8), poleMatBeach); pole.position.set(courtX, 1.6, zEnd); pole.castShadow = true; group.add(pole); const board = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.05, 0.1), boardMat); board.position.set(courtX, 3.05, zEnd - dir * 0.3); board.castShadow = true; group.add(board); const ring = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.035, 8, 20), ringMat); ring.rotation.x = Math.PI / 2; ring.position.set(courtX, 2.9, zEnd - dir * 0.75); group.add(ring); }; addHoop(courtZ - 13.5, 1); addHoop(courtZ + 13.5, -1); // ---- Food stalls (striped awnings) ---- const awningTex = canvasTexture(128, (ctx, s) => { for (let i = 0; i < 8; i++) { ctx.fillStyle = i % 2 ? '#d94b3a' : '#f7f2e6'; ctx.fillRect((i * s) / 8, 0, s / 8, s); } }, 2, 1); const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.9 }); const woodTopMat = new THREE.MeshStandardMaterial({ color: 0x6e4526, roughness: 0.85 }); const awningMat = new THREE.MeshStandardMaterial({ map: awningTex, roughness: 0.85, side: THREE.DoubleSide }); const postMat = new THREE.MeshStandardMaterial({ color: 0xd8c9a8, roughness: 0.9 }); const addStall = (z) => { const x = sandFrom + 5; const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 1.05, 2.2), woodMat); counter.position.set(x, 0.53, z); counter.castShadow = true; counter.receiveShadow = true; group.add(counter); const top = new THREE.Mesh(new THREE.BoxGeometry(3.9, 0.08, 2.5), woodTopMat); top.position.set(x, 1.1, z); group.add(top); for (const [ox, oz] of [[-1.6, -1], [1.6, -1], [-1.6, 1], [1.6, 1]]) { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.3, 6), postMat); post.position.set(x + ox, 1.2, z + oz); post.castShadow = true; group.add(post); } const awning = new THREE.Mesh(new THREE.BoxGeometry(4.3, 0.1, 3.1), awningMat); awning.position.set(x, 2.4, z); awning.rotation.z = 0.06; awning.castShadow = true; group.add(awning); // Crates of fruit on the counter. const crate1 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5), new THREE.MeshStandardMaterial({ color: 0xd93b3b, roughness: 0.8 })); crate1.position.set(x - 0.8, 1.28, z + 0.3); group.add(crate1); const crate2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5), new THREE.MeshStandardMaterial({ color: 0xe8b21e, roughness: 0.8 })); crate2.position.set(x + 0.7, 1.28, z - 0.2); group.add(crate2); counts.stalls++; }; addStall(40); addStall(78); // ---- Beach chairs + umbrellas facing the sea ---- const chairMat = new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 }); const chairBackMat = new THREE.MeshStandardMaterial({ color: 0x4a90c2, roughness: 0.9 }); const addChair = (x, z) => { const chair = new THREE.Group(); const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.08, 1.7), chairMat); seat.position.y = 0.32; chair.add(seat); const back = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.62, 0.07), chairBackMat); back.position.set(0, 0.58, -0.85); back.rotation.x = -0.35; chair.add(back); for (const [ox, oz] of [[-0.3, -0.7], [0.3, -0.7], [-0.3, 0.7], [0.3, 0.7]]) { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.3, 0.06), chairMat); leg.position.set(ox, 0.15, oz); chair.add(leg); } chair.position.set(x, 0.05, z); chair.rotation.y = Math.PI / 2; // face the sea (+X) chair.traverse(o => { if (o.isMesh) o.castShadow = true; }); group.add(chair); counts.chairs++; }; const umbrellaPoleMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8 }); const addUmbrella = (x, z) => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 2.5, 6), umbrellaPoleMat); pole.position.set(x, 1.25, z); pole.castShadow = true; group.add(pole); const canopy = new THREE.Mesh(new THREE.ConeGeometry(1.6, 0.6, 10), awningMat); canopy.position.set(x, 2.7, z); canopy.castShadow = true; group.add(canopy); counts.umbrellas++; }; const chairRowA = sandFrom + 34; const chairRowB = sandFrom + 41; [-30, -14, 2, 18].forEach(z => addChair(chairRowA, z)); [-22, 6, 34].forEach(z => addChair(chairRowB, z)); [-22, 10].forEach(z => addUmbrella(sandFrom + 37.5, z)); // ---- Lifeguard tower + guards ---- const towerX = sandTo - 7; const towerZ = 16; for (const [ox, oz] of [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.08, 2.5, 6), poleMatBeach); leg.position.set(towerX + ox, 1.25, towerZ + oz); leg.castShadow = true; group.add(leg); } const platform = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 1.7), woodMat); platform.position.set(towerX, 2.56, towerZ); platform.castShadow = true; group.add(platform); const railMat = new THREE.MeshStandardMaterial({ color: 0xd94f3a, roughness: 0.8 }); for (const [w, d, ox, oz] of [[1.7, 0.06, 0, -0.82], [0.06, 1.7, -0.82, 0], [0.06, 1.7, 0.82, 0]]) { const rail = new THREE.Mesh(new THREE.BoxGeometry(w, 0.5, d), railMat); rail.position.set(towerX + ox, 2.95, towerZ + oz); group.add(rail); } const roof = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2), awningMat); roof.position.set(towerX, 4.05, towerZ); roof.castShadow = true; group.add(roof); for (const [ox, oz] of [[-0.8, -0.8], [0.8, -0.8], [-0.8, 0.8], [0.8, 0.8]]) { const p = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.35, 6), postMat); p.position.set(towerX + ox, 3.4, towerZ + oz); group.add(p); } counts.towers = 1; // ---- Crowd: bathers, swimmers, surfers, basket players, lifeguards ---- const bathers = []; [-124, -70, -20, 34, 92, 132].forEach((z, i) => { const b = buildHumanoid({ type: 'bather', name: '' }, false); const x = sandTo - (i % 3) * 1.4; b.position.set(x, -0.28, z); b.rotation.y = i % 2 === 0 ? Math.PI / 2 : Math.PI; // face sea / along shore group.add(b); bathers.push({ mesh: b, baseX: x, baseZ: z, phase: i * 1.7 }); counts.bathers++; }); const swimmers = []; [-58, 52].forEach((z, i) => { const s = buildHumanoid({ type: 'bather', name: '' }, false); s.position.set(sandTo + 12 + i * 6, -0.55, z); s.rotation.y = Math.PI / 2; group.add(s); swimmers.push({ mesh: s, baseX: sandTo + 12 + i * 6, baseZ: z, phase: i * 2.4 }); counts.swimmers++; }); const surfers = []; [{ z: -44, phase: 0 }, { z: 74, phase: Math.PI }].forEach(sCfg => { const rig = new THREE.Group(); const board = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.1, 2.3), new THREE.MeshStandardMaterial({ color: 0xf5f2ea, roughness: 0.6 })); board.position.y = 0.06; rig.add(board); const rider = buildHumanoid({ type: 'surfer', name: '' }, false); rider.position.y = 0.1; rig.add(rider); rig.position.set(sandTo + 30, 0.2, sCfg.z); group.add(rig); surfers.push({ mesh: rig, baseX: sandTo + 30, baseZ: sCfg.z, phase: sCfg.phase, span: 26 }); counts.surfers++; }); const basket = []; const lanes = [courtX - 2.4, courtX, courtX + 2.4]; lanes.forEach((laneX, i) => { const p = buildHumanoid({ type: 'basket', name: '' }, false); const startZ = courtZ - 8 + i * 8; p.position.set(laneX, 0.05, startZ); group.add(p); basket.push({ mesh: p, laneX, z: startZ, dir: i % 2 ? 1 : -1, speed: 3.4 + i * 0.5, endA: courtZ - 11.5, endB: courtZ + 11.5, }); counts.basket++; }); const ball = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12), new THREE.MeshStandardMaterial({ color: 0xe07b20, roughness: 0.75 })); ball.castShadow = true; group.add(ball); const shot = { timer: 4, phase: 'follow', t: 0, from: new THREE.Vector3(), to: new THREE.Vector3(), target: 0 }; const towerGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false); towerGuard.position.set(towerX, 2.62, towerZ); group.add(towerGuard); counts.lifeguards++; const patrolGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false); patrolGuard.position.set(sandTo - 9, 0.05, -60); group.add(patrolGuard); counts.lifeguards++; worldGroup.add(group); return { counts: () => counts, update(dt, time) { // Waves: displace the sea plane along its normal (local Z). const pos = seaGeo.attributes.position; const arr = pos.array; for (let i = 0; i < pos.count; i++) { const lx = seaBase[i * 3]; const ly = seaBase[i * 3 + 1]; arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, seaCenterZ - ly, time); } pos.needsUpdate = true; foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2; for (const b of bathers) { b.mesh.position.y = -0.28 + Math.sin(time * 1.6 + b.phase) * 0.07; b.mesh.position.x = b.baseX + Math.sin(time * 0.5 + b.phase) * 0.7; b.mesh.rotation.y = Math.PI / 2 + Math.sin(time * 0.4 + b.phase) * 0.5; } for (const s of swimmers) { s.mesh.position.y = -0.55 + Math.sin(time * 2 + s.phase) * 0.1; s.mesh.position.z = s.baseZ + Math.sin(time * 0.35 + s.phase) * 5; } for (const s of surfers) { const w = time * 0.14 + s.phase; s.mesh.position.z = s.baseZ + Math.sin(w) * s.span; s.mesh.position.x = s.baseX + Math.cos(w) * 5; s.mesh.position.y = 0.14 + beachWaveY(s.mesh.position.x, s.mesh.position.z, time); // Face the direction of travel (derivative of the path). s.mesh.rotation.y = Math.atan2( -Math.sin(w) * 5 * 0.14, Math.cos(w) * s.span * 0.14 ); } // Basket players patrol the court; the ball follows the lead // player and takes an arc shot whenever they reach a hoop. for (const p of basket) { p.z += p.dir * p.speed * dt; if (p.z >= p.endB) { p.z = p.endB; p.dir = -1; } if (p.z <= p.endA) { p.z = p.endA; p.dir = 1; } p.mesh.position.z = p.z; p.mesh.rotation.y = p.dir > 0 ? 0 : Math.PI; } const lead = basket[shot.target % basket.length]; if (shot.phase === 'follow') { ball.position.set( lead.mesh.position.x + 0.45, 0.95 + Math.abs(Math.sin(time * 5)) * 0.5, lead.mesh.position.z + 0.2 ); shot.timer -= dt; const nearHoop = Math.abs(lead.z - lead.endA) < 1.6 || Math.abs(lead.z - lead.endB) < 1.6; if (shot.timer <= 0 && nearHoop) { const hoopZ = Math.abs(lead.z - lead.endA) < 1.6 ? lead.endA : lead.endB; shot.phase = 'shoot'; shot.t = 0; shot.from.copy(ball.position); shot.to.set(courtX, 2.9, hoopZ + (hoopZ < courtZ ? 0.75 : -0.75)); shot.timer = 6 + Math.random() * 3; } else if (shot.timer <= 0) { shot.timer = 2; // retry soon when they reach an end } } else if (shot.phase === 'shoot') { shot.t += dt / 1.1; const t = Math.min(1, shot.t); ball.position.lerpVectors(shot.from, shot.to, t); ball.position.y += Math.sin(Math.PI * t) * 1.4; if (t >= 1) { shot.phase = 'return'; shot.t = 0; shot.from.copy(ball.position); shot.target = (shot.target + 1) % basket.length; const next = basket[shot.target]; shot.to.set(next.mesh.position.x + 0.45, 1.1, next.mesh.position.z); } } else { // return/pass shot.t += dt / 0.9; const t = Math.min(1, shot.t); ball.position.lerpVectors(shot.from, shot.to, t); ball.position.y += Math.sin(Math.PI * t) * 0.8; if (t >= 1) shot.phase = 'follow'; } // Lifeguards: the tower guard scans the water, the other // patrols the shoreline facing the sea. towerGuard.rotation.y = Math.sin(time * 0.5) * 0.7; patrolGuard.position.z = Math.sin(time * 0.16) * 70; patrolGuard.position.x = sandTo - 9 + Math.sin(time * 0.3) * 1.5; patrolGuard.rotation.y = Math.PI / 2 + Math.sin(time * 0.16) * 0.15; }, }; } function nearestGymEquipment(maxDist = 2.8) { let best = null; for (const eq of gymEquipment) { const d = Math.hypot(player.pos.x - eq.x, player.pos.z - eq.z); if (d <= maxDist && (!best || d < best.dist)) best = { eq, dist: d }; } return best; } function setExercisePrompt(text) { const el = document.getElementById('exercise-prompt'); if (!el) return; if (!text) { el.style.display = 'none'; return; } el.style.display = 'block'; el.textContent = text; } function animateExercise(dt) { if (!exercise.active || !meMesh) return; exercise.animPhase += dt * 6; const u = meMesh.userData; if (!u?.legs || !u?.arms) return; const swing = Math.sin(exercise.animPhase) * 0.9; const lift = Math.abs(Math.sin(exercise.animPhase)); u.legs[0].rotation.x = swing; u.legs[1].rotation.x = -swing; u.arms[0].rotation.x = -Math.PI / 2 + lift * 0.4; u.arms[1].rotation.x = -Math.PI / 2 + lift * 0.4; u.arms[0].rotation.z = -0.6 - lift * 0.5; u.arms[1].rotation.z = 0.6 + lift * 0.5; meMesh.position.y = lift * 0.08; } async function startExerciseSession(eq) { if (!meCharacter || exercise.active) return; exercise.active = true; exercise.equipmentId = eq.id; exercise.repTimer = 0; meCharacter.state = 'exercising'; if (stateEl) stateEl.textContent = 'exercising'; flashCharMsg(i18n.exerciseStart || 'Exercising…'); cityAudio.play('work'); try { const data = await postJson('/city/actions/exercise', { pos_x: player.pos.x, pos_z: player.pos.z, }); if (data.character) { updateCharacterHud(data.character); if ((data.character.health ?? 100) >= 100) { flashCharMsg(data.message || i18n.exerciseMaxHealth || 'Max health'); await stopExerciseSession(false); } } } catch (e) { exercise.active = false; exercise.equipmentId = null; flashCharMsg(e.message, true); if (meCharacter) meCharacter.state = 'awake'; } } async function stopExerciseSession(showMsg = true) { if (!exercise.active) return; exercise.active = false; exercise.equipmentId = null; if (meMesh) meMesh.position.y = 0; if (meMesh?.userData?.arms) { meMesh.userData.arms.forEach(a => { a.rotation.z = 0; a.rotation.x = 0; }); } try { const data = await postJson('/city/actions/stop-exercise', {}); if (data.character) updateCharacterHud(data.character); if (showMsg) flashCharMsg(i18n.exerciseStop || 'Stopped exercising'); } catch (_) { /* still leave local state */ } if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake'; } // ------------------------------------------------------------------ // Beach relax session: sit on the sand and recover a bit per server tick. // ------------------------------------------------------------------ function animateRelax(dt) { relax.animPhase += dt; if (!meMesh) return; const u = meMesh.userData; if (!u?.legs || !u?.arms) return; // Sitting with legs stretched forward and gentle breathing. u.legs[0].rotation.x = -1.35; u.legs[1].rotation.x = -1.35; u.arms[0].rotation.x = 0.25; u.arms[1].rotation.x = 0.25; u.arms[0].rotation.z = -0.18; u.arms[1].rotation.z = 0.18; meMesh.position.y = 0.42 + Math.sin(relax.animPhase * 1.6) * 0.02; } function stopRelaxSession() { if (!relax.active) return; relax.active = false; if (meMesh) { meMesh.position.y = 0; if (meMesh.userData?.legs) meMesh.userData.legs.forEach(l => { l.rotation.x = 0; }); if (meMesh.userData?.arms) meMesh.userData.arms.forEach(ar => { ar.rotation.x = 0; ar.rotation.z = 0; }); } } function startRelaxSession() { if (!meCharacter || relax.active || exercise.active) return; relax.active = true; relax.timer = 0; relax.animPhase = 0; flashCharMsg(i18n.beachStart || 'Resting on the sand…'); cityAudio.play('ok'); } function updateExercise(dt) { if (isHospitalizedNow()) { if (exercise.active) stopExerciseSession(false); if (relax.active) stopRelaxSession(); setExercisePrompt(null); return; } // Drone mode owns the controls: no ground sessions or prompts. if (drone.active) { if (exercise.active) stopExerciseSession(false); if (relax.active) stopRelaxSession(); setExercisePrompt(null); return; } // ---- Beach leisure (east shore) ---- const onBeach = !!beach && player.pos.x >= beach.sand_from - 1 && player.pos.x <= beach.sand_to + 2 && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max; const isMoving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD'] || keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']; if (relax.active) { if (isMoving || !onBeach) { stopRelaxSession(); setExercisePrompt(null); } else { setExercisePrompt(`🏖️ ${i18n.beachActive || 'Relaxing at the beach'} · E ${i18n.exerciseStopHint || 'stop'}`); animateRelax(dt); relax.timer += dt; if (relax.timer >= 1.5) { relax.timer = 0; postJson('/city/actions/beach', { pos_x: player.pos.x, pos_z: player.pos.z, }) .then((data) => { if (data.character) updateCharacterHud(data.character); if (data.full) { flashCharMsg(data.message); stopRelaxSession(); } }) .catch((e) => { flashCharMsg(e.message, true); stopRelaxSession(); }); } if (keys['KeyE']) { keys['KeyE'] = false; stopRelaxSession(); } return; } } const near = spawnApplied ? nearestGymEquipment(2.8) : null; if (exercise.active) { const still = near && near.eq.id === exercise.equipmentId || (gymEquipment.some(e => e.id === exercise.equipmentId) && Math.hypot(player.pos.x - (gymEquipment.find(e => e.id === exercise.equipmentId)?.x ?? 999), player.pos.z - (gymEquipment.find(e => e.id === exercise.equipmentId)?.z ?? 999)) < 4); // Moving with WASD cancels the session. const moving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD'] || keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']; if (!still || moving) { stopExerciseSession(); setExercisePrompt(null); return; } setExercisePrompt(`🏃 ${i18n.exerciseActive || 'Exercising…'} · E ${i18n.exerciseStopHint || 'stop'}`); animateExercise(dt); exercise.repTimer += dt; if (exercise.repTimer >= 1.0) { exercise.repTimer = 0; postJson('/city/actions/exercise', { pos_x: player.pos.x, pos_z: player.pos.z, }) .then((data) => { if (data.character) { updateCharacterHud(data.character); if (data.character.health >= 100) { flashCharMsg(i18n.exerciseMaxHealth || 'Max health'); stopExerciseSession(false); } } }) .catch((e) => { flashCharMsg(e.message, true); stopExerciseSession(false); }); } return; } if (near && meCharacter && meCharacter.state !== 'sleeping') { setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`); } else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') { setExercisePrompt(`🏖️ ${i18n.beachPrompt || 'Relax at the beach'} · E`); } else { setExercisePrompt(null); } // Toggle with E. if (keys['KeyE']) { keys['KeyE'] = false; if (exercise.active) { stopExerciseSession(); } else if (near) { startExerciseSession(near.eq); } else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') { startRelaxSession(); } } } function buildCity(data) { clearGroup(worldGroup); colliders.length = 0; storeMeshes.length = 0; agentMeshes.length = 0; crowdMeshes.length = 0; facadeMaterials.length = 0; lampHeads.length = 0; signMaterials.length = 0; signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 }; trafficSystem = null; vehicleSystem = null; pedestrianSystem = null; airplaneSystem = null; trainSystem = null; riverSystem = null; shopSystem = null; mayorSystem = null; meMesh = null; spawnApplied = false; crosswalkCount = 0; sidewalkSegmentCount = 0; plazas = []; gymEquipment = []; exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null }; relax = { active: false, timer: 0, animPhase: 0 }; beach = null; beachSystem = null; airportsCfg = null; portCfg = null; portSystem = null; const layout = data.layout || { roads: [], blocks: [] }; const stores = data.stores || []; const agents = data.agents || []; meCharacter = data.me || null; cityBounds = data.city?.bounds || null; if (meCharacter) { player.pos.set(meCharacter.position[0], player.eyeHeight, meCharacter.position[2]); // If the persisted spot is inside a building (e.g. a house built on // the spawn), push the player out onto the nearest open ground. if (colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) { const push = 14; const candidates = [ [meCharacter.position[0], meCharacter.position[2] + push], [meCharacter.position[0], meCharacter.position[2] - push], [meCharacter.position[0] + push, meCharacter.position[2]], [meCharacter.position[0] - push, meCharacter.position[2]], [0, 12], [8, 4], ]; for (const [x, z] of candidates) { player.pos.x = x; player.pos.z = z; if (!colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) { break; } } } updateCharacterHud(meCharacter); populateJobSelect(data.jobs || []); populateCitizenSelect(data.characters || [], meCharacter); spawnApplied = true; } if (!layout.roads.length) { statsEl.textContent = ''; return; } grid = buildGrid(layout); // Synthetic roads: bridge approach (west) + level-crossing extension (north). const zMid = grid.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.zs[0]); const xMid = grid.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.xs[0]); layoutExtras.bridgeRoad = { from: [grid.minX, 0, zMid], to: [grid.minX - 55, 0, zMid], width: 8, axis: 'x', }; layoutExtras.crossRoad = { from: [xMid, 0, grid.maxZ], to: [xMid, 0, grid.maxZ + 40], width: 8, axis: 'z', }; layout.roads.push(layoutExtras.bridgeRoad, layoutExtras.crossRoad); // ---- Ground ---- const ground = new THREE.Mesh( new THREE.PlaneGeometry(2000, 2000), new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.05; ground.receiveShadow = true; worldGroup.add(ground); // ---- River (under the bridge road) ---- initRiver(grid); // ---- Roads ---- const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 }); const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 }); walkMat.map.repeat.set(4, 1); // Static same-material geometry is merged into single draw calls. const staticBatch = makeStaticBatcher(); layout.roads.forEach(r => { const [x1,, z1] = r.from; const [x2,, z2] = r.to; const w = r.width; const len = Math.hypot(x2 - x1, z2 - z1); const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2; const geo = r.axis === 'z' ? new THREE.PlaneGeometry(w, len) : new THREE.PlaneGeometry(len, w); const mesh = new THREE.Mesh(geo, roadMat); mesh.rotation.x = -Math.PI / 2; mesh.position.set(mx, 0.01, mz); if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07; mesh.receiveShadow = true; mesh.updateMatrix(); staticBatch.add(geo, roadMat, mesh.matrix, false, true); if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck const swWidth = 2; const offset = w / 2 + swWidth / 2; // Sidewalks stop at each perpendicular roadway: the crossing band // itself is painted as a zebra crosswalk below. const crossingLines = r.axis === 'z' ? grid.zs : grid.xs; const travel1 = r.axis === 'z' ? z1 : x1; const travel2 = r.axis === 'z' ? z2 : x2; const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2); [-1, 1].forEach(side => { for (const [a, b] of spans) { const segLen = b - a; const cmid = (a + b) / 2; const sg = r.axis === 'z' ? new THREE.PlaneGeometry(swWidth, segLen) : new THREE.PlaneGeometry(segLen, swWidth); const sm = new THREE.Mesh(sg, walkMat); sm.rotation.x = -Math.PI / 2; if (r.axis === 'z') { sm.position.set(mx + side * offset, 0.12, cmid); } else { sm.position.set(cmid, 0.12, mz + side * offset); } sm.receiveShadow = true; sm.updateMatrix(); staticBatch.add(sg, walkMat, sm.matrix, false, true); sidewalkSegmentCount++; } }); }); // ---- Curbs (interrupted at crosswalks — dropped-curb look) ---- const curbMat = new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }); layout.roads.forEach(r => { if (r === layoutExtras.bridgeRoad) return; const [x1,, z1] = r.from; const [x2,, z2] = r.to; const w = r.width; const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2; const offset = w / 2 + 0.1; const crossingLines = r.axis === 'z' ? grid.zs : grid.xs; const travel1 = r.axis === 'z' ? z1 : x1; const travel2 = r.axis === 'z' ? z2 : x2; const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2); [-1, 1].forEach(side => { for (const [a, b] of spans) { const segLen = b - a; const cmid = (a + b) / 2; const curbGeo = r.axis === 'z' ? new THREE.BoxGeometry(0.2, 0.14, segLen) : new THREE.BoxGeometry(segLen, 0.14, 0.2); const curb = new THREE.Mesh(curbGeo, curbMat); if (r.axis === 'z') { curb.position.set(mx + side * offset, 0.07, cmid); } else { curb.position.set(cmid, 0.07, mz + side * offset); } curb.updateMatrix(); staticBatch.add(curbGeo, curbMat, curb.matrix); } }); }); // ---- Zebra crosswalks (real markings, not sidewalk-coloured bands) ---- // // Four approaches per intersection; each band sits exactly where the // perpendicular sidewalk used to cross the roadway (2 units deep = // the sidewalk width), directly over the asphalt. Bars run parallel // to vehicle travel (MUTCD longitudinal-bar zebra design). const zebraMatOverZ = new THREE.MeshStandardMaterial({ map: zebraTexOverZRoad, transparent: true, alphaTest: 0.4, roughness: 0.9, }); const zebraMatOverX = new THREE.MeshStandardMaterial({ map: zebraTexOverXRoad, transparent: true, alphaTest: 0.4, roughness: 0.9, }); const crossHalf = grid.roadW / 2; const crossBand = 2; const eps = 0.01; const crossRoadX = layoutExtras.crossRoad ? layoutExtras.crossRoad.from[0] : null; for (const vx of grid.xs) { for (const vz of grid.zs) { // Crossings over the vertical (z) road — south & north bands. const zSpans = [ { side: 1, valid: vz + crossHalf + crossBand <= grid.maxZ + eps || vx === crossRoadX }, { side: -1, valid: vz - crossHalf - crossBand >= grid.minZ - eps }, ]; for (const { side, valid } of zSpans) { if (!valid) continue; const zebra = new THREE.Mesh( new THREE.PlaneGeometry(grid.roadW, crossBand), zebraMatOverZ ); zebra.rotation.x = -Math.PI / 2; zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2)); zebra.receiveShadow = true; zebra.updateMatrix(); staticBatch.add(zebra.geometry, zebraMatOverZ, zebra.matrix, false, true); crosswalkCount++; } // Crossings over the horizontal (x) road — east & west bands. // At the bridge corner the west approach rides on the deck. const xSpans = [ { side: 1, valid: vx + crossHalf + crossBand <= grid.maxX + eps }, { side: -1, valid: vx - crossHalf - crossBand >= grid.minX - eps }, ]; for (const { side, valid } of xSpans) { if (!valid) continue; const zebra = new THREE.Mesh( new THREE.PlaneGeometry(crossBand, grid.roadW), zebraMatOverX ); zebra.rotation.x = -Math.PI / 2; zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz); zebra.receiveShadow = true; zebra.updateMatrix(); staticBatch.add(zebra.geometry, zebraMatOverX, zebra.matrix, false, true); crosswalkCount++; } } } // ---- Street lamps (on the sidewalk, never in the roadway) ---- const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 }); // One shared material for every cobra head: all fade together at night. const streetHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 }); lampHeads.push(streetHeadMat); const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre const nearIntersection = (lx, lz) => { // Skip spots within 7 units of any crossing so lamps never sit // in the intersection or on the crosswalk. return grid.xs.some(vx => Math.abs(lx - vx) < 7) && grid.zs.some(vz => Math.abs(lz - vz) < 7); }; layout.roads.forEach(r => { if (r === layoutExtras.bridgeRoad || r === layoutExtras.crossRoad) return; const [x1,, z1] = r.from; const [x2,, z2] = r.to; const len = Math.hypot(x2 - x1, z2 - z1); const spacing = 28; const count = Math.max(1, Math.floor(len / spacing)); for (let i = 0; i <= count; i++) { const t = i / count; const lx = x1 + (x2 - x1) * t; const lz = z1 + (z2 - z1) * t; const side = (i % 2 === 0) ? 1 : -1; // Plant the pole on the sidewalk centre-line. const px = r.axis === 'z' ? lx + lampSwOffset * side : lx; const pz = r.axis === 'x' ? lz + lampSwOffset * side : lz; if (nearIntersection(px, pz)) continue; const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat); pole.position.set(px, 2.25, pz); pole.updateMatrix(); staticBatch.add(pole.geometry, lampPoleMat, pole.matrix, true); // Curved arm reaching over the curb toward the road. const dirX = r.axis === 'z' ? -side : 0; const dirZ = r.axis === 'x' ? -side : 0; const armGeo = new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1); const arm = new THREE.Mesh(armGeo, lampPoleMat); arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6); arm.updateMatrix(); staticBatch.add(armGeo, lampPoleMat, arm.matrix); const headGeo = new THREE.BoxGeometry(0.55, 0.18, 0.28); // Compact cobra-style head sitting at the arm tip, hanging slightly down. const head = new THREE.Mesh(headGeo, streetHeadMat); head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2); head.updateMatrix(); staticBatch.add(headGeo, streetHeadMat, head.matrix); } }); // ---- Traffic lights ---- initTrafficLights(grid, staticBatch); // Merge every static bucket collected so far (roadsides, poles, // housings, lamp heads) into one mesh per material. staticBatch.flush(worldGroup); // ---- Stores ---- const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a']; stores.forEach((s, idx) => { const [x, y, z] = s.position; const [w, h, d] = s.size; const color = palette[idx % palette.length]; const floors = Math.max(2, Math.round(h / 3)); const pair = facadePair(color, floors, Math.max(2, Math.round(w / 3))); const geo = new THREE.BoxGeometry(w, h, d); const sideMat = new THREE.MeshStandardMaterial({ map: pair.map, emissiveMap: pair.emissiveMap, emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, }); facadeMaterials.push(sideMat); const mats = [ sideMat, sideMat, new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9 }), new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9 }), sideMat, sideMat, ]; const mesh = new THREE.Mesh(geo, mats); mesh.position.set(x, y + h / 2, z); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData = { store: s }; worldGroup.add(mesh); storeMeshes.push(mesh); addCollider(x, y, z, w, h, d); // Vertical neon blade letreiro over the roofline, facing the // nearest street (the front OPEN/CLOSED board stays as is). const bladeFace = faceNearestRoad(x, z, grid); const [bOffX, bOffZ] = facingOffset(bladeFace, w / 2 + 0.25, d / 2 + 0.25); const bladeH = Math.min(4.6, h * 0.7 + 1.4); const blade = buildLitSign(s.name, { vertical: true, w: 1.5, h: bladeH, accent: ['#38bdf8', '#f472b6', '#facc15', '#4ade80'][idx % 4], }); blade.position.set(x + bOffX, y + h + bladeH / 2 - 0.6, z + bOffZ); blade.rotation.y = bladeFace; worldGroup.add(blade); signCounts.stores++; const roof = new THREE.Mesh( new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4), new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 }) ); roof.position.set(x, y + h + 0.17, z); roof.castShadow = true; worldGroup.add(roof); const door = new THREE.Mesh( new THREE.BoxGeometry(1.4, 2.4, 0.1), new THREE.MeshStandardMaterial({ map: doorTexture(), roughness: 0.7 }) ); door.position.set(x, y + 1.2, z + d / 2 + 0.05); worldGroup.add(door); const ring = new THREE.Mesh( new THREE.RingGeometry(Math.max(w, d) / 2 + 0.6, Math.max(w, d) / 2 + 1.1, 32), new THREE.MeshBasicMaterial({ color: 0x0ea5e9, side: THREE.DoubleSide, transparent: true, opacity: 0.5 }) ); ring.rotation.x = -Math.PI / 2; ring.position.set(x, 0.15, z); worldGroup.add(ring); }); // ---- Beach (east shore, sea also covers the port basin) ---- beach = data.beach || null; portCfg = data.port || null; beachSystem = beach ? initBeach(beach, portCfg) : null; // ---- Airports (north/south) + flight operations ---- airportsCfg = data.airports || null; buildAirports(airportsCfg); // ---- Port (far half of the north-east coast) ---- portSystem = portCfg ? initPort(portCfg) : null; // ---- Filler buildings ---- // Fillers only exist on empty ground: skip cells near stores AND near // any placed building (hospital, school, city hall, player houses) so // nothing ever spawns on top of a real structure. const occupied = [ ...stores.map(s => ({ x: s.position[0], z: s.position[2] })), ...(data.buildings || []).map(b => ({ x: b.position[0], z: b.position[2] })), ]; const fillerRoofMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }); const roofBatch = makeStaticBatcher(); (layout.blocks || []).forEach((b, bi) => { const cx = (b.min[0] + b.max[0]) / 2; const cz = (b.min[2] + b.max[2]) / 2; const nearStore = occupied.some(o => Math.abs(o.x - cx) < 25 && Math.abs(o.z - cz) < 25); if (nearStore) return; const bw = 12 + (bi * 7) % 14; const bd = 12 + (bi * 11) % 14; const bh = 6 + (bi * 13) % 20; const color = palette[(bi + 2) % palette.length]; const floors = Math.max(2, Math.round(bh / 3)); // Filler blocks repeat across the whole city — half-resolution // facades keep VRAM flat while the city grows (stores keep 512). const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)), 256); const geo = new THREE.BoxGeometry(bw, bh, bd); const sideMat = new THREE.MeshStandardMaterial({ map: pair.map, emissiveMap: pair.emissiveMap, emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85, }); facadeMaterials.push(sideMat); // One material → one draw call per building (the roof slab covers // the top face; multi-material boxes cost up to six calls each). const m = new THREE.Mesh(geo, sideMat); m.position.set(cx, bh / 2, cz); m.castShadow = true; m.receiveShadow = true; worldGroup.add(m); addCollider(cx, 0, cz, bw, bh, bd); const roof = new THREE.Mesh( new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3), fillerRoofMat ); roof.position.set(cx, bh + 0.15, cz); roof.updateMatrix(); roofBatch.add(roof.geometry, fillerRoofMat, roof.matrix); // Decorative neon letreiro on every third block (abstract glow, // no text — needs no translation). if (bi % 3 === 0) { const neonAccent = ['#22d3ee', '#f472b6', '#a3e635', '#fb923c'][(Math.floor(bi / 3)) % 4]; const neonFace = faceNearestRoad(cx, cz, grid); const [nOffX, nOffZ] = facingOffset(neonFace, bw / 2 + 0.3, bd / 2 + 0.3); const neon = buildLitSign('', { w: 4.8, h: 1.1, decor: true, accent: neonAccent, }); neon.position.set(cx + nOffX, bh * 0.72, cz + nOffZ); neon.rotation.y = neonFace; worldGroup.add(neon); signCounts.filler++; } }); roofBatch.flush(worldGroup); // ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ---- const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper', 'worker_robot']); const placed = new Set(); // Shopkeepers + mayor + pedestrians get behaviours; any leftover static agents render in place. initShops(stores, agents); initMayor(agents, stores, grid); initPedestrians(grid, agents); for (const a of agents) { if (walkingTypes.has(a.type)) { placed.add(a.id); continue; } const [x, y, z] = a.position; const char = buildHumanoid(a, true); char.position.set(x, y, z); char.rotation.y = a.rotation_y || 0; worldGroup.add(char); agentMeshes.push(char); } // ---- Player-constructed buildings + hospital ---- if (Array.isArray(data.buildings)) { for (const b of data.buildings) { const [bx, , bz] = b.position; const [bw, bh, bd] = b.size; const isHospital = b.type === 'hospital'; const landmarkBodyColors = { university: 0x7b87c9, fire_station: 0xc0442e }; const mat = new THREE.MeshStandardMaterial({ color: isHospital ? 0xe8eef5 : (landmarkBodyColors[b.type] ?? (b.is_complete ? 0x8b6f47 : 0x6b7280)), roughness: 0.9, transparent: !b.is_complete, opacity: b.is_complete ? 1 : 0.75, }); const mesh = new THREE.Mesh(new THREE.BoxGeometry(bw, b.is_complete ? bh : Math.max(1, bh * (b.build_progress / 100)), bd), mat); mesh.position.set(bx, (b.is_complete ? bh : Math.max(1, bh * (b.build_progress / 100))) / 2, bz); mesh.castShadow = true; worldGroup.add(mesh); addCollider(bx, 0, bz, bw, b.is_complete ? bh : 1, bd); if (isHospital) { // Red cross marker on the facade. const crossMat = new THREE.MeshStandardMaterial({ color: 0xdc2626, emissive: 0xdc2626, emissiveIntensity: 0.6 }); const h = new THREE.Mesh(new THREE.BoxGeometry(0.6, 2.4, 0.2), crossMat); h.position.set(bx, bh * 0.55, bz + bd / 2 + 0.15); worldGroup.add(h); const v = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.6, 0.2), crossMat); v.position.set(bx, bh * 0.55, bz + bd / 2 + 0.15); worldGroup.add(v); } // Luminous roofline letreiro for the landmark buildings. const landmarkStyle = { hospital: { label: i18n.signHospital || 'Hospital', accent: '#f87171' }, school: { label: i18n.signSchool || 'Escola', accent: '#4ade80' }, city_hall: { label: i18n.signCityhall || 'Prefeitura', accent: '#7dd3fc' }, university: { label: i18n.signUniversity || 'Universidade', accent: '#a5b4fc' }, fire_station: { label: i18n.signFirestation || 'Corpo de Bombeiros', accent: '#fb923c' }, }; const landmark = landmarkStyle[b.type]; if (b.is_complete && landmark) { const lFace = faceNearestRoad(bx, bz, grid); const [lOffX, lOffZ] = facingOffset(lFace, bw / 2 + 0.35, bd / 2 + 0.35); const landmarkSign = buildLitSign(landmark.label, { w: Math.min(11, Math.max(bw, bd) * 0.8), h: 1.6, accent: landmark.accent, }); landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ); landmarkSign.rotation.y = lFace; worldGroup.add(landmarkSign); signCounts.buildings++; } } } // ---- Public plazas with outdoor gym equipment ---- initPlazas(data.plazas || []); // ---- Citizen characters (all characters in this city) ---- if (Array.isArray(data.characters)) { for (const c of data.characters) { const [cx, , cz] = c.position; const humanoid = buildHumanoid({ name: c.name, type: c.is_me ? 'mayor' : (c.type === 'child' ? 'crowd' : 'pedestrian'), status: c.status || 'active', id: c.id, }, !c.is_me); humanoid.position.set(cx, 0, cz); if (c.is_me) { humanoid.userData.isMe = true; meMesh = humanoid; } worldGroup.add(humanoid); citizenMeshes.push(humanoid); if (!c.is_me) { crowdMeshes.push(humanoid); } } } // ---- Simulation systems ---- initVehicles(grid); initAirplanes(airportsCfg); initTrain(grid); statsEl.textContent = buildStatsText(); } function buildStatsText() { const stores = cityData?.stores?.length || 0; const agents = cityData?.agents?.length || 0; const veh = vehicleSystem?.count() || 0; const peds = pedestrianSystem?.count() || 0; return [ `${stores} ${i18n.stores || ''}`, `${agents} ${i18n.agents || ''}`, `${veh} ${i18n.vehicles || ''}`, `${peds} ${i18n.pedestrians || ''}`, ].join(' · '); } // ------------------------------------------------------------------ // CameraController — first person WASD + pointer lock + AABB collision // ------------------------------------------------------------------ const player = { pos: new THREE.Vector3(0, 1.7, 25), vel: new THREE.Vector3(), yaw: 0, pitch: 0, speed: 6, radius: 0.35, eyeHeight: 1.7, onGround: true, }; const keys = {}; let locked = false; // Gate dismissed without pointer lock (touch devices, or when the browser // denies pointer lock). The camera is then steered with drag-to-look. let touchMode = false; let lookDrag = null; function applyLook(dx, dy, sens) { player.yaw -= dx * sens; player.pitch -= dy * sens; player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch)); } function dismissGate() { touchMode = true; startGate.style.display = 'none'; posPanel.style.display = 'block'; } function tryRequestLock() { try { const p = renderer.domElement.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => dismissGate()); } catch (_) { dismissGate(); } } renderer.domElement.addEventListener('click', () => { if (locked || touchMode) return; tryRequestLock(); }); startGate.addEventListener('click', () => { // Touch devices have no pointer lock: start immediately and steer the // camera by dragging on the scene (the virtual joystick drives walking). if (window.matchMedia?.('(pointer: coarse)')?.matches) { dismissGate(); return; } tryRequestLock(); }); document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === renderer.domElement; startGate.style.display = (locked || touchMode) ? 'none' : 'grid'; posPanel.style.display = (locked || touchMode) ? 'block' : 'none'; }); document.addEventListener('pointerlockerror', () => { // Lock denied (embedded context, automation, permissions): fall back to // drag-to-look so the player is never stuck behind the gate. dismissGate(); }); document.addEventListener('mousemove', (e) => { if (!locked) return; applyLook(e.movementX, e.movementY, 0.0022); }); // Drag-to-look fallback when pointer lock is not in effect: drag anywhere // on the scene to turn (standard mobile first-person controls). renderer.domElement.addEventListener('pointerdown', (e) => { if (locked) return; if (e.pointerType === 'mouse' && e.button !== 0) return; lookDrag = { id: e.pointerId, x: e.clientX, y: e.clientY }; try { renderer.domElement.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } }); renderer.domElement.addEventListener('pointermove', (e) => { if (locked || !lookDrag || lookDrag.id !== e.pointerId) return; applyLook(e.clientX - lookDrag.x, e.clientY - lookDrag.y, 0.005); lookDrag.x = e.clientX; lookDrag.y = e.clientY; }); const endLookDrag = (e) => { if (lookDrag && lookDrag.id === e.pointerId) lookDrag = null; }; renderer.domElement.addEventListener('pointerup', endLookDrag); renderer.domElement.addEventListener('pointercancel', endLookDrag); renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); function isTypingTarget(el) { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable; } document.addEventListener('keydown', (e) => { if (isTypingTarget(e.target)) return; // Keep arrows/space from scrolling the page while the scene controls // are active (arrows turn the camera, space jumps). if ((locked || touchMode) && (e.code === 'ArrowLeft' || e.code === 'ArrowRight' || e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'Space')) { e.preventDefault(); } keys[e.code] = true; }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); function playerAABB(px, py, pz) { const r = player.radius; return { minX: px - r, maxX: px + r, minY: py - 1.5, maxY: py + 0.3, minZ: pz - r, maxZ: pz + r, }; } function overlaps(a, b) { return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ && a.minY < b.maxY && a.maxY > b.minY; } function vehicleBoxes() { if (!vehicleSystem) return []; const boxes = []; for (const v of vehicleSystem.vehicles) { const p = new THREE.Vector3(); let rot = 0; if (v.pts) { posOnPath(v.pts, v.s, v.L, p); const ahead = new THREE.Vector3(); posOnPath(v.pts, v.s + 0.6, v.L, ahead); rot = yawFromDelta(ahead.x - p.x, ahead.z - p.z); } else { p.lerpVectors(v.a, v.b, v.t); const dirSign = v.dir >= 0 ? 1 : -1; rot = yawFromDelta((v.b.x - v.a.x) * dirSign, (v.b.z - v.a.z) * dirSign); } const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0); const halfW = v.mesh?.userData?.halfW ?? 1.0; const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot)); const ex = halfL * c + halfW * s; const ez = halfL * s + halfW * c; boxes.push({ minX: p.x - ex, maxX: p.x + ex, minZ: p.z - ez, maxZ: p.z + ez, minY: 0, maxY: 2, }); } return boxes; } function resolveCollision(px, py, pz) { const box = playerAABB(px, py, pz); const cur = playerAABB(player.pos.x, player.pos.y, player.pos.z); const inside = (c) => overlaps(cur, c); // Static buildings: if the player already overlaps one (spawned inside // or built a house on their feet), allow moving so they can walk out. for (const c of colliders) { if (overlaps(box, c) && !inside(c)) return true; } // Hitting a vehicle is an accident (only once the world is live). if (window.__city10?.ready && spawnApplied && meCharacter && meCharacter.state !== 'hospital') { for (const c of vehicleBoxes()) { if (overlaps(box, c)) { reportVehicleAccident(); if (!inside(c)) return true; } } } if (trainSystem) { const tb = { minX: trainSystem.s - 18, maxX: trainSystem.s + 18, minZ: trainSystem.crossZ - 1.6, maxZ: trainSystem.crossZ + 1.6, minY: 0, maxY: 4, }; if (overlaps(box, tb) && !inside(tb)) return true; } return false; } /** * Detect vehicles driving into a stationary player and report the accident. * Runs only after the city is fully built and the player spawn is applied. * Players on public plazas (gym parks) are safe from traffic. */ function checkVehicleHitOnPlayer() { if (!window.__city10?.ready) return; if (!meCharacter || hospitalBusy) return; if (meCharacter.state === 'hospital') return; if (!spawnApplied) return; if (exercise.active) return; // Soft zone: plazas are fenced parks off the roadway. if (plazas.some(p => { const [px, pz] = p.position; const [pw, pd] = p.size || [22, 22]; return Math.abs(player.pos.x - px) <= pw / 2 && Math.abs(player.pos.z - pz) <= pd / 2; })) { return; } const box = playerAABB(player.pos.x, player.pos.y, player.pos.z); for (const c of vehicleBoxes()) { if (overlaps(box, c)) { reportVehicleAccident(); return; } } } /** * Tell the server the character was hit by a vehicle (2-day hospital stay). */ function reportVehicleAccident() { if (!meCharacter || hospitalBusy) return; if (meCharacter.state === 'hospital') return; hospitalBusy = true; cityAudio.play('horn'); postJson('/city/actions/accident', {}) .then((data) => { if (data?.character) updateCharacterHud(data.character); if (data?.bed) { player.pos.x = data.bed.pos_x; player.pos.z = data.bed.pos_z; player.vel.set(0, 0, 0); } flashCharMsg(data?.message || i18n.hospitalHit || 'hospital', true); cityAudio.play('hospital'); showHospitalBanner(data?.hospitalized_until); updateCameraFromPlayer(); }) .catch((e) => { flashCharMsg(e.message, true); cityAudio.play('error'); }) .finally(() => { hospitalBusy = false; }); } function showHospitalBanner(untilIso) { const el = document.getElementById('hospital-banner'); if (!el) return; if (!untilIso) { el.style.display = 'none'; return; } el.style.display = 'block'; const tick = () => { const until = new Date(untilIso).getTime(); const left = until - Date.now(); if (left <= 0 || meCharacter?.state !== 'hospital') { el.style.display = 'none'; return; } const totalSec = Math.ceil(left / 1000); const mm = String(Math.floor(totalSec / 60)).padStart(2, '0'); const ss = String(totalSec % 60).padStart(2, '0'); const label = i18n.hospitalLeft || 'Discharge in'; el.textContent = `🏥 ${i18n.hospitalTitle || 'Hospitalized'} · ${label} ${mm}:${ss}`; setTimeout(tick, 1000); }; tick(); } function isHospitalizedNow() { if (meCharacter?.state !== 'hospital') return false; if (!meCharacter.hospitalized_until) return true; return new Date(meCharacter.hospitalized_until).getTime() > Date.now(); } let lastPedBlockAt = 0; /** * Check whether the player may step from their current position to (toX, toZ). * * Crossing onto a road is gated by the pedestrian signal for that road's * axis: vehicles green ⇒ pedestrians red. Players already mid-road may * finish the crossing so they never get stuck in the asphalt. */ function playerCrossingAllowed(toX, toZ) { if (!grid || !trafficSystem) return true; const fromOnRoad = onRoad(player.pos.x, player.pos.z, grid); const toOnRoad = onRoad(toX, toZ, grid); if (!toOnRoad) return true; if (fromOnRoad) return true; // already committed to the road const half = grid.roadW / 2; const enteringZRoad = grid.xs.some(vx => Math.abs(toX - vx) <= half); const enteringXRoad = grid.zs.some(vz => Math.abs(toZ - vz) <= half); // Blocked when the matching pedestrian signal is red. if (enteringZRoad && trafficSystem.pedState('z') === 'red') return false; if (enteringXRoad && trafficSystem.pedState('x') === 'red') return false; return true; } function flashPedRed() { const now = performance.now(); if (now - lastPedBlockAt < 1200) return; lastPedBlockAt = now; const el = document.getElementById('ped-wait'); if (el) { el.style.display = 'block'; el.textContent = `🚶 ${i18n.pedRed || 'Red pedestrian light — wait.'}`; clearTimeout(flashPedRed._t); flashPedRed._t = setTimeout(() => { el.style.display = 'none'; }, 1600); } } function updatePlayer(dt) { // Hospitalized characters stay in bed until discharge. if (isHospitalizedNow()) { player.vel.set(0, 0, 0); player.pos.y = player.eyeHeight; return; } // Auto-release when the hospital stay elapses while online. if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) { meCharacter.state = 'awake'; meCharacter.hospitalized_until = null; const banner = document.getElementById('hospital-banner'); if (banner) banner.style.display = 'none'; flashCharMsg(i18n.hospitalReleased || 'Released'); } const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw)); const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw)); const wish = new THREE.Vector3(); // While the drone is airborne the keys pilot IT (updateDrone) — the // grounded character stays put but keeps gravity and collisions. if (!drone.active) { // Allow walking with WASD even before pointer lock so the character is // never completely stuck on the overlay screen. A/D strafe; the arrow // keys turn the first-person camera instead of strafing, so every // forward step always follows the direction the camera is facing. if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward); if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward); if (keys['KeyA']) wish.sub(right); if (keys['KeyD']) wish.add(right); const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0); if (turn) player.yaw += turn * 2.4 * dt; if (mobileInput.active) { // Joystick: vertical walks along the camera facing, horizontal // turns the camera (mobile first-person convention). wish.addScaledVector(forward, -mobileInput.y); if (Math.abs(mobileInput.x) > 0.15) { player.yaw -= mobileInput.x * 2.4 * dt; } } } if (wish.lengthSq() > 0) { wish.normalize(); const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed; const step = wish.multiplyScalar(speed * dt); const tryX = player.pos.x + step.x; if (!resolveCollision(tryX, player.pos.y, player.pos.z) && playerCrossingAllowed(tryX, player.pos.z)) { player.pos.x = tryX; } else if (playerCrossingAllowed(tryX, player.pos.z) === false) { flashPedRed(); } const tryZ = player.pos.z + step.z; if (!resolveCollision(player.pos.x, player.pos.y, tryZ) && playerCrossingAllowed(player.pos.x, tryZ)) { player.pos.z = tryZ; } else if (playerCrossingAllowed(player.pos.x, tryZ) === false) { flashPedRed(); } } player.vel.y -= 18 * dt; player.pos.y += player.vel.y * dt; if (player.pos.y <= player.eyeHeight) { player.pos.y = player.eyeHeight; player.vel.y = 0; player.onGround = true; } if (!drone.active && keys['Space'] && player.onGround) { player.vel.y = 6; player.onGround = false; } // City bounds, extended over the east beach: players may walk onto // the sand and wade at the shoreline, but never into the deep sea. let maxX = cityBounds ? cityBounds.max_x : 400; if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) { maxX = Math.max(maxX, beach.sand_to + 1); } if (portCfg?.visit && player.pos.z <= portCfg.visit.max_z && player.pos.z >= portCfg.visit.min_z) { maxX = Math.max(maxX, portCfg.visit.max_x); } player.pos.x = cityBounds ? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x)) : Math.max(-400, Math.min(maxX, player.pos.x)); // Z bounds: the airport strips (north/south) are walkable beyond the // city while the player stays inside their X band. let minZ = cityBounds ? cityBounds.min_z : -400; let maxZ = cityBounds ? cityBounds.max_z : 400; if (airportsCfg?.visit && Math.abs(player.pos.x) <= airportsCfg.visit.half_width) { minZ = airportsCfg.visit.min_z; maxZ = airportsCfg.visit.max_z; } if (portCfg?.visit && player.pos.x >= portCfg.visit.min_x) { minZ = Math.min(minZ, portCfg.visit.min_z); } player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z)); } function updateCameraFromPlayer() { camera.position.copy(player.pos); const dir = new THREE.Vector3( -Math.sin(player.yaw) * Math.cos(player.pitch), Math.sin(player.pitch), -Math.cos(player.yaw) * Math.cos(player.pitch) ); camera.lookAt(camera.position.clone().add(dir)); } // ------------------------------------------------------------------ // Click-to-interact // ------------------------------------------------------------------ const ray = new THREE.Raycaster(); const ndc = new THREE.Vector2(); let pointerDown = null; renderer.domElement.addEventListener('pointerdown', (e) => { pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() }; }); renderer.domElement.addEventListener('pointerup', (e) => { if (!pointerDown) return; const dx = Math.abs(e.clientX - pointerDown.x); const dy = Math.abs(e.clientY - pointerDown.y); const dt = Date.now() - pointerDown.t; pointerDown = null; if (dx > 6 || dy > 6 || dt > 400) return; const rect = renderer.domElement.getBoundingClientRect(); ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; ray.setFromCamera(ndc, camera); const agentHits = ray.intersectObjects(agentMeshes, true); if (agentHits.length) { let obj = agentHits[0].object; while (obj && !obj.userData?.agent) obj = obj.parent; if (obj?.userData?.agent) { const agent = obj.userData.agent; openTalkChat({ kind: 'agent', id: agent.id, name: agent.name, status: agent.status || 'active', }); return; } } const storeHits = ray.intersectObjects(storeMeshes, false); if (storeHits.length) { const s = storeHits[0].object.userData.store; selectedEl.style.display = 'inline-block'; const open = shopOpenNow(s); selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`; } else { selectedEl.style.display = 'none'; } }); // ------------------------------------------------------------------ // ChatPanel — street conversations via /talk/person // // Opens automatically when the player bumps into another citizen // (human peer) or agentic NPC, polls for replies while open, closes // when the pair moves apart, and renders the daily history that backs // the persistent context of future conversations. // ------------------------------------------------------------------ const chatPanel = document.getElementById('chat-panel'); const chatDot = document.getElementById('chat-dot'); const chatName = document.getElementById('chat-name'); const chatStatus = document.getElementById('chat-status'); const chatTime = document.getElementById('chat-time'); const chatClose = document.getElementById('chat-close'); const chatMessages = document.getElementById('chat-messages'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); const TALK_OPEN_RADIUS = 2.6; const TALK_CLOSE_RADIUS = 3.6; const TALK_POLL_MS = 2500; let currentPeer = null; // { kind, id, name, status, key } let talkPollTimer = null; let talkLastDay = null; let talkErrorShown = false; let dismissedPeerKey = null; const talkSeenIds = new Set(); function peerKey(kind, id) { return `${kind}:${id}`; } function clockNow() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function addBubble(text, who) { const div = document.createElement('div'); div.className = `chat-bubble ${who}`; div.textContent = text; chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; } function addDaySeparator(day) { const div = document.createElement('div'); div.className = 'chat-bubble meta'; const parsed = new Date(`${day}T12:00:00`); div.textContent = Number.isNaN(parsed.getTime()) ? day : parsed.toLocaleDateString(); chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; } function setStatus(status) { if (!currentPeer) return; currentPeer.status = status; const map = { thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'], responding: ['#f59e0b', i18n.chatResponding || 'responding…'], active: ['#22c55e', 'online'], error: ['#ef4444', 'error'], }; const [color, label] = map[status] || map.active; chatDot.style.background = color; chatStatus.textContent = label; if (currentPeer.kind === 'agent') { const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentPeer.id); if (mesh) refreshLabel(mesh, status); } } /** Poll the daily conversation and append anything not seen yet. */ async function pullTalk() { if (!currentPeer) return; const peer = currentPeer; try { const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-store', }); if (!res.ok || !currentPeer || currentPeer.key !== peer.key) return; const data = await res.json(); const messages = data.messages || []; let arrived = false; for (const m of messages) { if (talkSeenIds.has(m.id)) continue; if (m.talked_on && m.talked_on !== talkLastDay) { talkLastDay = m.talked_on; addDaySeparator(m.talked_on); } talkSeenIds.add(m.id); addBubble(m.body, m.fallback ? 'meta' : (m.from === 'me' ? 'user' : 'npc')); if (m.at) chatTime.textContent = new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); arrived = true; } if (data.thinking) { setStatus('thinking'); } else if (peer.kind === 'agent') { setStatus(data.peer?.status || 'active'); const last = messages[messages.length - 1]; if (data.peer?.status === 'error' && last?.from === 'me' && !talkErrorShown) { talkErrorShown = true; addBubble(i18n.chatError || 'error', 'meta'); } } else if (currentPeer.status === 'thinking') { setStatus('active'); } if (arrived) talkErrorShown = false; } catch (_) { /* offline-safe */ } } async function openTalkChat(peer) { if (currentPeer && currentPeer.key === peerKey(peer.kind, peer.id)) return; currentPeer = { ...peer, key: peerKey(peer.kind, peer.id) }; talkSeenIds.clear(); talkLastDay = null; talkErrorShown = false; chatMessages.innerHTML = ''; chatPanel.style.display = 'flex'; chatName.textContent = peer.name; chatTime.textContent = clockNow(); setStatus(peer.status || 'active'); if (talkPollTimer) clearInterval(talkPollTimer); talkPollTimer = setInterval(pullTalk, TALK_POLL_MS); await pullTalk(); } function closeChat(manual = false) { chatPanel.style.display = 'none'; if (manual && currentPeer) dismissedPeerKey = currentPeer.key; currentPeer = null; if (talkPollTimer) { clearInterval(talkPollTimer); talkPollTimer = null; } } // ------------------------------------------------------------------ // Bump detection: auto-open near a citizen, auto-close when apart // ------------------------------------------------------------------ const _talkPos = new THREE.Vector3(); let talkProximityTimer = 0; function talkCandidates() { const out = []; for (const mesh of agentMeshes) { const agent = mesh.userData?.agent; if (!agent || typeof agent.id !== 'number') continue; mesh.getWorldPosition(_talkPos); out.push({ kind: 'agent', id: agent.id, name: agent.name, status: agent.status || 'active', d: Math.hypot(_talkPos.x - player.pos.x, _talkPos.z - player.pos.z), }); } for (const mesh of remoteCitizens.values()) { const citizen = mesh.userData?.citizen; if (!citizen) continue; out.push({ kind: 'character', id: citizen.id, name: citizen.name, status: 'active', d: Math.hypot(mesh.position.x - player.pos.x, mesh.position.z - player.pos.z), }); } return out; } function updateTalkProximity(dt) { talkProximityTimer -= dt; if (talkProximityTimer > 0) return; talkProximityTimer = 0.25; const candidates = talkCandidates(); // A manually dismissed chat only reopens after a fresh bump. if (dismissedPeerKey) { const dis = candidates.find(c => peerKey(c.kind, c.id) === dismissedPeerKey); if (!dis || dis.d > TALK_CLOSE_RADIUS) dismissedPeerKey = null; } if (currentPeer) { const cur = candidates.find(c => peerKey(c.kind, c.id) === currentPeer.key); if (!cur || cur.d > TALK_CLOSE_RADIUS) closeChat(false); return; } let best = null; for (const c of candidates) { if (!best || c.d < best.d) best = c; } if (best && best.d <= TALK_OPEN_RADIUS && peerKey(best.kind, best.id) !== dismissedPeerKey) { openTalkChat(best); } } chatClose.addEventListener('click', () => closeChat(true)); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const msg = chatInput.value.trim(); if (!msg || !currentPeer) return; chatInput.value = ''; if (currentPeer.kind === 'agent') setStatus('thinking'); talkErrorShown = false; try { const res = await fetch('/talk/person', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' }, body: JSON.stringify({ kind: currentPeer.kind, peer: currentPeer.id, message: msg }), }); if (res.ok) { const data = await res.json(); // Day separator first so live and polled rows read in order. if (data.talked_on && data.talked_on !== talkLastDay) { talkLastDay = data.talked_on; addDaySeparator(data.talked_on); } addBubble(msg, 'user'); if (data.id) talkSeenIds.add(data.id); if (currentPeer.kind !== 'agent') chatTime.textContent = clockNow(); } else { // 429 has its own cause — do not reuse the AI fallback text. addBubble(res.status === 429 ? (i18n.talkRateLimited || 'rate limited') : (i18n.chatError || 'error'), 'meta'); if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active'); } } catch (_) { addBubble(i18n.chatError || 'error', 'meta'); if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active'); } }); // ------------------------------------------------------------------ // HUD // ------------------------------------------------------------------ function updateHUD() { const g = player.pos.y <= player.eyeHeight + 0.01; posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`; posGround.textContent = g ? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m` : `airborne · ${player.pos.y.toFixed(1)}m`; posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`; const hh = String(Math.floor(sim.hour)).padStart(2, '0'); const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0'); if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`; if (weatherEl) { const label = i18n[`weather_${weatherState.state}`] || weatherState.state; weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`; } if (meMesh) { meMesh.position.x = player.pos.x; meMesh.position.z = player.pos.z; } } // ------------------------------------------------------------------ // Character HUD + player actions (server-backed lifecycle) // ------------------------------------------------------------------ const charNameEl = document.getElementById('char-name'); const charMsgEl = document.getElementById('char-msg'); const hungerEl = document.getElementById('hud-hunger'); const thirstEl = document.getElementById('hud-thirst'); const energyEl = document.getElementById('hud-energy'); const healthEl = document.getElementById('hud-health'); const exerciseCountEl = document.getElementById('hud-exercise-count'); const moneyEl = document.getElementById('hud-money'); const stateEl = document.getElementById('hud-char-state'); const educationEl = document.getElementById('hud-education'); const invEl = document.getElementById('hud-inv'); let lastPositionSync = 0; function updateCharacterHud(me) { meCharacter = me; if (!me) return; if (charNameEl) charNameEl.textContent = `👤 ${me.name}`; if (hungerEl) hungerEl.textContent = `${me.hunger}%`; if (thirstEl) thirstEl.textContent = `${me.thirst}%`; if (energyEl) energyEl.textContent = `${me.energy}%`; if (healthEl) healthEl.textContent = `${me.health ?? 100}%`; if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`; if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`; if (stateEl) stateEl.textContent = me.state || '—'; if (educationEl) educationEl.textContent = `${me.education ?? 0}%`; if (invEl) { const inv = me.inventory || {}; invEl.textContent = Object.keys(inv).length ? Object.entries(inv).map(([k, v]) => `${k}:${v}`).join(' · ') : '—'; } if (me.state === 'hospital' && me.hospitalized_until) { showHospitalBanner(me.hospitalized_until); const bed = me.position; if (bed) { player.pos.x = bed[0]; player.pos.z = bed[2]; player.vel.set(0, 0, 0); } } else if (me.state !== 'hospital') { const banner = document.getElementById('hospital-banner'); if (banner) banner.style.display = 'none'; } } function flashCharMsg(text, isError = false) { if (!charMsgEl) return; charMsgEl.textContent = text; charMsgEl.style.color = isError ? '#fca5a5' : '#93c5fd'; // Errors always beep; success chimes are played by the action itself. if (isError) cityAudio.play('error'); clearTimeout(flashCharMsg._t); flashCharMsg._t = setTimeout(() => { charMsgEl.textContent = ''; }, 4000); } async function postJson(url, body = {}) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.message || i18n.notFound || 'error'); err.data = data; err.status = res.status; throw err; } return data; } const ACTION_SFX = { eat: 'eat', drink: 'drink', sleep: 'sleep', wake: 'wake', work: 'work', 'stop-work': 'ui', study: 'work', buy: 'money', sell: 'money', build: 'build', 'apply-job': 'ok', }; async function runAction(act) { if (!meCharacter) return null; cityAudio.unlock(); try { let body = {}; if (act === 'buy') { await postJson('/city/actions/buy', { item: 'food', qty: 1, price: 4 }); await postJson('/city/actions/buy', { item: 'water', qty: 1, price: 3 }); const data = await postJson('/city/actions/buy', { item: 'materials', qty: 10, price: 1 }); if (data.character) updateCharacterHud(data.character); flashCharMsg(data.message || i18n.actionOk); return data; } if (act === 'sell') body = { item: 'product', qty: 1, price: 8 }; if (act === 'build') { // Place the house a few metres ahead so it never sits on the player. const ahead = 12; body = { pos_x: Math.round(player.pos.x - Math.sin(player.yaw) * ahead), pos_z: Math.round(player.pos.z - Math.cos(player.yaw) * ahead), name: `${meCharacter.name}`, }; } const data = await postJson(`/city/actions/${act}`, body); if (data.character) updateCharacterHud(data.character); if (ACTION_SFX[act]) cityAudio.play(ACTION_SFX[act]); flashCharMsg(data.message || i18n.actionOk); if (act === 'build') { loadCity(); } return data; } catch (e) { flashCharMsg(e.message, true); return { error: e.message }; } } function populateJobSelect(jobs) { const sel = document.getElementById('job-select'); if (!sel) return; sel.innerHTML = ''; for (const j of jobs) { const opt = document.createElement('option'); opt.value = j.id; opt.textContent = `${j.title} ($${j.salary})`; sel.appendChild(opt); } } function populateCitizenSelect(characters, me) { const sel = document.getElementById('citizen-select'); if (!sel) return; sel.innerHTML = ''; for (const c of characters) { if (me && c.id === me.id) continue; const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.name + (c.is_online ? ' ●' : ''); sel.appendChild(opt); } } function bindCharacterUI() { document.querySelectorAll('.char-act').forEach(btn => { btn.addEventListener('click', () => { cityAudio.unlock(); cityAudio.play('ui'); runAction(btn.dataset.act); }); }); document.getElementById('job-apply')?.addEventListener('click', async () => { cityAudio.unlock(); cityAudio.play('ui'); const sel = document.getElementById('job-select'); if (!sel?.value) return; try { const data = await postJson('/city/actions/apply-job', { job_id: Number(sel.value) }); if (data.character) updateCharacterHud(data.character); flashCharMsg(data.message || i18n.actionOk); } catch (e) { flashCharMsg(e.message, true); } }); const social = async (verb, extra = {}) => { cityAudio.unlock(); cityAudio.play('ui'); const sel = document.getElementById('citizen-select'); if (!sel?.value) return; try { const data = await postJson(`/city/social/${verb}`, { character_id: Number(sel.value), ...extra, }); flashCharMsg(data.message || i18n.actionOk); } catch (e) { flashCharMsg(e.message, true); } }; document.getElementById('soc-friend')?.addEventListener('click', () => social('friend')); document.getElementById('soc-marry')?.addEventListener('click', () => social('marry')); document.getElementById('soc-child')?.addEventListener('click', () => social('child')); } // ------------------------------------------------------------------ // Audio panel (master / music / SFX + mute) // ------------------------------------------------------------------ function bindAudioUI() { const panel = document.getElementById('audio-panel'); if (!panel) return; const master = document.getElementById('vol-master'); const music = document.getElementById('vol-music'); const sfx = document.getElementById('vol-sfx'); const muteBtn = document.getElementById('vol-mute'); const masterOut = document.getElementById('vol-master-out'); const musicOut = document.getElementById('vol-music-out'); const sfxOut = document.getElementById('vol-sfx-out'); const pct = (v) => `${Math.round(v * 100)}%`; const syncLabels = () => { if (masterOut) masterOut.textContent = pct(cityAudio.getVolume('master')); if (musicOut) musicOut.textContent = pct(cityAudio.getVolume('music')); if (sfxOut) sfxOut.textContent = pct(cityAudio.getVolume('sfx')); if (muteBtn) { muteBtn.textContent = cityAudio.muted ? '🔇' : '🔊'; muteBtn.setAttribute('aria-pressed', String(cityAudio.muted)); } if (master) master.value = String(cityAudio.getVolume('master')); if (music) music.value = String(cityAudio.getVolume('music')); if (sfx) sfx.value = String(cityAudio.getVolume('sfx')); }; const onSlide = (channel) => (e) => { cityAudio.unlock(); cityAudio.setVolume(channel, e.target.value); syncLabels(); }; master?.addEventListener('input', onSlide('master')); music?.addEventListener('input', onSlide('music')); sfx?.addEventListener('input', onSlide('sfx')); muteBtn?.addEventListener('click', () => { cityAudio.unlock(); cityAudio.toggleMute(); if (!cityAudio.muted) cityAudio.play('ui'); syncLabels(); }); document.getElementById('audio-toggle')?.addEventListener('click', () => { cityAudio.unlock(); cityAudio.play('ui'); const hidden = panel.style.display === 'none' || panel.style.display === ''; panel.style.display = hidden ? 'block' : 'none'; }); syncLabels(); panel.style.display = 'none'; } function syncPositionThrottled() { if (!meCharacter) return; const now = performance.now(); if (now - lastPositionSync < 2000) return; lastPositionSync = now; postJson('/city/actions/position', { pos_x: player.pos.x, pos_z: player.pos.z, }).then((data) => { if (data && meCharacter) { meCharacter.position = [data.pos_x, 0, data.pos_z]; } }).catch(() => { /* offline-safe */ }); } // ------------------------------------------------------------------ // Resize + animation loop // ------------------------------------------------------------------ function resize() { const w = host.clientWidth, h = host.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false); } new ResizeObserver(resize).observe(host); resize(); let lastT = performance.now(); let simTimeAcc = 0; let statsTimer = 0; function animate() { requestAnimationFrame(animate); const now = performance.now(); const dt = Math.min(0.05, (now - lastT) / 1000); lastT = now; simTimeAcc += dt; const tUpdate = performance.now(); updateClock(dt); updateWeather(dt); updateDayNight(); if (trafficSystem) trafficSystem.update(dt); if (vehicleSystem) vehicleSystem.update(dt); if (pedestrianSystem) pedestrianSystem.update(dt); if (mayorSystem) mayorSystem.update(dt); if (shopSystem) shopSystem.update(dt); if (airplaneSystem) airplaneSystem.update(dt); if (trainSystem) trainSystem.update(dt); if (riverSystem) riverSystem.update(dt, simTimeAcc); if (beachSystem) { try { beachSystem.update(dt, simTimeAcc); } catch (err) { console.error('beach update', err); } } if (portSystem) { // A port glitch must never abort the frame (renderer.render below). try { portSystem.update(dt, simTimeAcc); } catch (err) { console.error('port update', err); } } updatePlayer(dt); updateDrone(dt); checkVehicleHitOnPlayer(); updateExercise(dt); updateCameraFromPlayer(); updateHUD(); updateVehicleHeadlights(); syncPositionThrottled(); updateTalkProximity(dt); drawMinimap(); // Footsteps while the local player is actually walking (not just turning). const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD'] || keys['ArrowUp'] || keys['ArrowDown'] || (mobileInput.active && Math.abs(mobileInput.y) > 0.15)); cityAudio.footsteps(dt, walking && !isHospitalizedNow()); statsTimer += dt; if (statsTimer > 1 && cityData) { statsTimer = 0; statsEl.textContent = buildStatsText(); } perfFrame.update = performance.now() - tUpdate; const tRender = performance.now(); renderer.render(scene, camera); perfFrame.render = performance.now() - tRender; // Drone command-panel feed: scissored pass every frame so the visor // never alternates between the drone view and the ground view. if (drone.active && drone.mesh) { renderDroneView(); } } animate(); // ------------------------------------------------------------------ // Multiplayer presence (other citizens in near-real-time) // ------------------------------------------------------------------ function upsertRemoteCitizen(c) { if (c.is_me) return; let mesh = remoteCitizens.get(c.id); if (!mesh) { mesh = buildHumanoid({ name: c.name, type: c.type === 'child' ? 'crowd' : 'pedestrian', status: c.status || 'active', id: c.id, }, true); // Human peer marker used by the bump-to-talk detection. mesh.userData.citizen = { kind: 'character', id: c.id, name: c.name }; const [cx, , cz] = c.position; mesh.position.set(cx, 0, cz); worldGroup.add(mesh); crowdMeshes.push(mesh); remoteCitizens.set(c.id, mesh); } else { const [cx, , cz] = c.position; const dx = cx - mesh.position.x; const dz = cz - mesh.position.z; mesh.position.x = cx; mesh.position.z = cz; if (Math.hypot(dx, dz) > 0.05) { mesh.rotation.y = Math.atan2(dx, dz); animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4)); } } } async function pollPresence() { if (!window.__city10?.ready || hospitalBusy) return; try { const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : ''; const res = await fetch(`/city/presence${qs}`, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-store', }); if (!res.ok) return; const data = await res.json(); const seen = new Set(); for (const c of data.citizens || []) { if (c.is_me) continue; seen.add(c.id); upsertRemoteCitizen(c); } for (const [id, mesh] of remoteCitizens) { if (!seen.has(id)) { worldGroup.remove(mesh); remoteCitizens.delete(id); } } lastPresenceOk = performance.now(); } catch (_) { /* offline-safe */ } } function startPresenceLoop() { if (presenceTimer) clearInterval(presenceTimer); presenceTimer = setInterval(pollPresence, 2500); pollPresence(); } // ------------------------------------------------------------------ // Minimap (canvas overlay) // ------------------------------------------------------------------ function initMinimap() { const canvas = document.getElementById('minimap'); if (!canvas) return; minimapCtx = canvas.getContext('2d'); } function drawMinimap() { if (!minimapCtx || !cityBounds) return; const canvas = minimapCtx.canvas; const w = canvas.width; const h = canvas.height; const minX = cityBounds.min_x; const maxX = cityBounds.max_x; const minZ = cityBounds.min_z; const maxZ = cityBounds.max_z; const spanX = Math.max(1, maxX - minX); const spanZ = Math.max(1, maxZ - minZ); const toMap = (x, z) => [ ((x - minX) / spanX) * (w - 8) + 4, ((z - minZ) / spanZ) * (h - 8) + 4, ]; minimapCtx.clearRect(0, 0, w, h); minimapCtx.fillStyle = 'rgba(11,18,32,.85)'; minimapCtx.fillRect(0, 0, w, h); // Roads if (grid) { minimapCtx.strokeStyle = 'rgba(148,163,184,.45)'; minimapCtx.lineWidth = 2; for (const vx of grid.xs) { const [mx] = toMap(vx, minZ); minimapCtx.beginPath(); minimapCtx.moveTo(mx, 4); minimapCtx.lineTo(mx, h - 4); minimapCtx.stroke(); } for (const vz of grid.zs) { const [, mz] = toMap(minX, vz); minimapCtx.beginPath(); minimapCtx.moveTo(4, mz); minimapCtx.lineTo(w - 4, mz); minimapCtx.stroke(); } } // Plazas (static parks — green) for (const p of plazas) { const [mx, mz] = toMap(p.position[0], p.position[1]); minimapCtx.fillStyle = 'rgba(34,197,94,.55)'; minimapCtx.beginPath(); minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2); minimapCtx.fill(); } // Stores (static shops — orange) for (const s of cityData?.stores || []) { const [mx, mz] = toMap(s.position[0], s.position[2]); minimapCtx.fillStyle = '#f59e0b'; minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5); } // NPC pedestrians (local crowd — yellow, they walk) if (pedestrianSystem) { minimapCtx.fillStyle = '#facc15'; for (const p of pedestrianSystem.positions()) { const [mx, mz] = toMap(p.x, p.z); minimapCtx.beginPath(); minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2); minimapCtx.fill(); } } // Vehicles (moving — colored by kind) if (vehicleSystem?.vehicles) { for (const v of vehicleSystem.vehicles) { const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z }; if (p.x == null || p.z == null) continue; const kind = v.kind || v.mesh?.userData?.kind || 'car'; minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa' : kind === 'bike' ? '#4ade80' : kind === 'bus' ? '#60a5fa' : '#f472b6'; // Player is the bright pink; other cars use a softer white-blue. if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue; minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa' : kind === 'bike' ? '#4ade80' : kind === 'bus' ? '#60a5fa' : '#94a3b8'; const [mx, mz] = toMap(p.x, p.z); minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4); } } // Remote citizens (online players — cyan) minimapCtx.fillStyle = '#38bdf8'; for (const mesh of remoteCitizens.values()) { const [mx, mz] = toMap(mesh.position.x, mesh.position.z); minimapCtx.beginPath(); minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2); minimapCtx.fill(); } // Player (you — pink with white ring) const [px, pz] = toMap(player.pos.x, player.pos.z); minimapCtx.fillStyle = '#f472b6'; minimapCtx.beginPath(); minimapCtx.arc(px, pz, 4, 0, Math.PI * 2); minimapCtx.fill(); minimapCtx.strokeStyle = '#fff'; minimapCtx.lineWidth = 1.5; minimapCtx.stroke(); } // ------------------------------------------------------------------ // Vehicle headlights at night // ------------------------------------------------------------------ function initVehicleHeadlights() { // Collect materials tagged on each mesh (car/bus/moto/bike). for (const v of vehicleSystem?.vehicles || []) { const mesh = v.mesh; if (!mesh || mesh.userData.lightsCollected) continue; if (mesh.userData.headMats) vehicleHeadlights.push(...mesh.userData.headMats); if (mesh.userData.tailMats) vehicleTailLights.push(...mesh.userData.tailMats); if (mesh.userData.beams) vehicleBeams.push(...mesh.userData.beams); mesh.userData.lightsCollected = true; } } function updateVehicleHeadlights() { const night = sim.nightFactor; // Heads fade in from dusk; beams only once fully dark. const head = night > 0 ? 0.25 + night * 3.0 : 0.2; const tail = night > 0 ? 0.5 + night * 1.6 : 0.4; const beam = night > 0.55 ? (night - 0.55) / 0.45 * 0.22 : 0; for (const m of vehicleHeadlights) m.emissiveIntensity = head; for (const m of vehicleTailLights) m.emissiveIntensity = tail; for (const m of vehicleBeams) m.opacity = beam; // Luminous letreiros: visible glow by day, brighter with a subtle // shimmer once night falls. const t = performance.now() * 0.0025; for (const s of signMaterials) { s.mat.emissiveIntensity = s.base + night * s.nightBoost + Math.sin(t + s.phase) * s.pulse; } } // ------------------------------------------------------------------ // Mobile virtual joystick // ------------------------------------------------------------------ function bindMobileJoystick() { const base = document.getElementById('joystick-base'); const knob = document.getElementById('joystick-knob'); if (!base || !knob) return; let activeId = null; const radius = 42; const setKnob = (dx, dy) => { const len = Math.hypot(dx, dy) || 1; const clamped = Math.min(len, radius); const nx = (dx / len) * clamped; const ny = (dy / len) * clamped; knob.style.transform = `translate(${nx}px, ${ny}px)`; mobileInput.x = nx / radius; mobileInput.y = ny / radius; mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15; }; const center = () => { activeId = null; mobileInput = { x: 0, y: 0, active: false }; knob.style.transform = 'translate(0px, 0px)'; }; base.addEventListener('pointerdown', (e) => { activeId = e.pointerId; try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ } const rect = base.getBoundingClientRect(); setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2)); e.preventDefault(); }); base.addEventListener('pointermove', (e) => { if (activeId !== e.pointerId) return; const rect = base.getBoundingClientRect(); setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2)); }); base.addEventListener('pointerup', center); base.addEventListener('pointercancel', center); } // ------------------------------------------------------------------ // Market / missions / customize / ride UI // ------------------------------------------------------------------ async function refreshMarketPanel() { const listEl = document.getElementById('market-list'); const missionEl = document.getElementById('mission-list'); if (!listEl && !missionEl) return; try { const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!res.ok) return; const data = await res.json(); if (listEl) { listEl.innerHTML = (data.listings || []).length ? data.listings.map(l => `
${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} (${l.seller_name}) ${l.is_mine ? '' : ``}
`).join('') : `
—
`; listEl.querySelectorAll('.market-buy').forEach(btn => { btn.addEventListener('click', async () => { try { const out = await postJson('/city/market/buy', { listing_id: Number(btn.dataset.id) }); flashCharMsg(out.message || i18n.actionOk); cityAudio.play('money'); if (out.inventory && meCharacter) { meCharacter.money = out.money; meCharacter.inventory = out.inventory; updateCharacterHud(meCharacter); } refreshMarketPanel(); } catch (e) { flashCharMsg(e.message, true); } }); }); } if (missionEl) { missionEl.innerHTML = (data.missions || []).map(m => `
${m.title}
${m.description}
${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)} ${m.is_completed ? '✓' : ''}
`).join('') || '
—
'; } } catch (_) { /* ignore */ } } function bindWorldUI() { document.getElementById('btn-drone')?.addEventListener('click', () => toggleDrone()); document.getElementById('drone-close')?.addEventListener('click', () => { if (drone.active) toggleDrone(); }); document.getElementById('btn-market')?.addEventListener('click', () => { const panel = document.getElementById('market-panel'); if (!panel) return; const show = panel.style.display === 'none' || panel.style.display === ''; panel.style.display = show ? 'block' : 'none'; if (show) refreshMarketPanel(); }); document.getElementById('market-sell')?.addEventListener('click', async () => { const item = document.getElementById('market-item')?.value || 'product'; const qty = Number(document.getElementById('market-qty')?.value || 1); const price = Number(document.getElementById('market-price')?.value || 5); try { const out = await postJson('/city/market/sell', { item, qty, unit_price: price }); flashCharMsg(out.message || i18n.actionOk); cityAudio.play('money'); refreshMarketPanel(); } catch (e) { flashCharMsg(e.message, true); } }); document.getElementById('btn-customize')?.addEventListener('click', async () => { const panel = document.getElementById('customize-panel'); if (!panel) return; const show = panel.style.display === 'none' || panel.style.display === ''; panel.style.display = show ? 'block' : 'none'; if (!show) return; try { const res = await fetch('/city/customize', { headers: { Accept: 'application/json' } }); if (!res.ok) return; const data = await res.json(); ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory'].forEach(k => { const el = document.getElementById(`cz-${k}`); if (el) el.value = String(data[k]); }); } catch (_) { /* ignore */ } }); document.getElementById('customize-save')?.addEventListener('click', async () => { const body = {}; for (const k of ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory']) { body[k] = Number(document.getElementById(`cz-${k}`)?.value || 1); } try { const res = await fetch('/city/customize', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf, 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(body), }); const out = await res.json().catch(() => ({})); if (!res.ok) throw new Error(out.message || 'error'); flashCharMsg(out.message || i18n.actionOk); cityAudio.play('ok'); loadCity(); } catch (e) { flashCharMsg(e.message, true); } }); document.getElementById('btn-ride-bus')?.addEventListener('click', async () => { try { const out = await postJson('/city/actions/ride', { kind: 'bus', pos_x: player.pos.x, pos_z: player.pos.z, }); if (out.character) updateCharacterHud(out.character); flashCharMsg(out.message || i18n.actionOk); cityAudio.play('ok'); } catch (e) { flashCharMsg(e.message, true); } }); document.getElementById('btn-ride-train')?.addEventListener('click', async () => { try { const out = await postJson('/city/actions/ride', { kind: 'train', pos_x: player.pos.x, pos_z: cityBounds ? cityBounds.max_z + 14 : player.pos.z, }); if (out.character) updateCharacterHud(out.character); flashCharMsg(out.message || i18n.actionOk); cityAudio.play('ok'); } catch (e) { flashCharMsg(e.message, true); } }); } // ------------------------------------------------------------------ // Test/debug API // ------------------------------------------------------------------ window.__city10 = { ready: false, get hour() { return sim.hour; }, get weather() { return weatherState.state; }, get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; }, get pedStateX() { return trafficSystem ? trafficSystem.pedState('x') : null; }, get pedStateZ() { return trafficSystem ? trafficSystem.pedState('z') : null; }, setTrafficPhase(name) { if (trafficSystem) trafficSystem.setPhase(name); return this.trafficPhase; }, /** * Snapshot of every signal head (position, facing, axis and the lamp * currently lit) so automated tests can assert placement and states. */ trafficLightHeads() { if (!trafficSystem) return { vehicle: [], pedestrian: [] }; const vehicle = trafficSystem.heads.map(({ head, axis }) => { const intensities = head.userData.lamps.map( l => +l.material.emissiveIntensity.toFixed(2) ); const active = ['red', 'yellow', 'green'][intensities.indexOf(Math.max(...intensities))]; return { axis, x: +head.position.x.toFixed(2), y: +head.position.y.toFixed(2), z: +head.position.z.toFixed(2), rotationY: +head.rotation.y.toFixed(3), active, intensities, }; }); const pedestrian = trafficSystem.pedHeads.map(({ head, axis }) => { const red = +head.userData.lamps.red.material.emissiveIntensity.toFixed(2); const green = +head.userData.lamps.green.material.emissiveIntensity.toFixed(2); return { axis, x: +head.position.x.toFixed(2), y: +head.position.y.toFixed(2), z: +head.position.z.toFixed(2), rotationY: +head.rotation.y.toFixed(3), active: red > green ? 'red' : 'green', }; }); return { vehicle, pedestrian }; }, /** * Per-vehicle stop-line probe: travel axis, distance to the next stop * line and current speed, for red-light compliance checks. */ trafficProbe() { if (!vehicleSystem) return []; const out = []; for (const v of vehicleSystem.vehicles) { if (!v.pts) continue; const probe = probeAhead(v.pts, v.s, v.L); const cur = new THREE.Vector3(); posOnPath(v.pts, v.s, v.L, cur); out.push({ x: +cur.x.toFixed(2), z: +cur.z.toFixed(2), speed: +(v.speed || 0).toFixed(2), axis: probe.axis, distToStop: +distanceToStopLine(probe, cur).toFixed(2), state: trafficSystem ? trafficSystem.axisState(probe.axis) : null, }); } return out; }, playerCrossingAllowed, /** Debug: the WebGL renderer (frame forensics for automated tests). */ get gl() { return renderer; }, /** Debug: live scene background/fog hex + camera pose. */ get sceneInfo() { return { bg: scene.background ? scene.background.getHexString() : null, fog: scene.fog ? scene.fog.color.getHexString() : null, fogNear: scene.fog ? scene.fog.near : null, fogFar: scene.fog ? scene.fog.far : null, cam: { x: +camera.position.x.toFixed(2), y: +camera.position.y.toFixed(2), z: +camera.position.z.toFixed(2), }, toneMapping: renderer.toneMapping, exposure: renderer.toneMappingExposure, }; }, /** Current bump-to-talk peer (null when the chat is closed). */ get talkPeer() { return currentPeer; }, /** Local player pose for movement/look tests. */ playerPose() { return { x: +player.pos.x.toFixed(3), y: +player.pos.y.toFixed(3), z: +player.pos.z.toFixed(3), yaw: +player.yaw.toFixed(4), pitch: +player.pitch.toFixed(4), locked, touchMode, }; }, get barriersDown() { return trainSystem ? trainSystem.blocks : null; }, setHour(h) { sim.hour = ((h % 24) + 24) % 24; updateDayNight(); updateHUD(); }, setWeather, audio: { state: () => cityAudio.state(), setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); }, setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); }, unlock: () => cityAudio.unlock(), play: (name) => { cityAudio.unlock(); cityAudio.play(name); }, }, teleport(x, z, yaw = null, pitch = 0, y = null) { player.pos.x = x; player.pos.z = z; player.pos.y = y !== null ? y : player.eyeHeight; player.vel.set(0, 0, 0); if (yaw !== null) player.yaw = yaw; player.pitch = pitch; updateCameraFromPlayer(); }, stats() { return { ready: this.ready, hour: +sim.hour.toFixed(2), weather: weatherState.state, trafficPhase: this.trafficPhase, vehicles: vehicleSystem ? vehicleSystem.count() : 0, vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 }, plazas: plazas.length, gymEquipment: gymEquipment.length, exercising: exercise.active, nearGym: (() => { const n = spawnApplied ? nearestGymEquipment(4) : null; return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null; })(), pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0, trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0, crosswalks: crosswalkCount, sidewalkSegments: sidewalkSegmentCount, hour: +(sim.hour ?? 0).toFixed(1), night: +(sim.nightFactor ?? 0).toFixed(2), beach: beachSystem ? beachSystem.counts() : null, flights: airplaneSystem ? airplaneSystem.flights() : null, port: portSystem ? portSystem.counts() : null, drone: drone.mesh ? { active: drone.active, x: +drone.pos.x.toFixed(1), y: +drone.pos.y.toFixed(1), z: +drone.pos.z.toFixed(1), yaw: +drone.yaw.toFixed(2), fogCleared: !!drone.fogCleared, view: drone.view || { x: 0, y: 0, w: 0, h: 0 }, } : null, robots: pedestrianSystem ? pedestrianSystem.walkers.filter(w => w.robot).length : 0, robotWalkers: pedestrianSystem ? pedestrianSystem.walkers .filter(w => w.robot) .map(w => ({ x: +w.mesh.position.x.toFixed(1), z: +w.mesh.position.z.toFixed(1) })) : [], signs: { stores: signCounts.stores, plazas: signCounts.plazas, beach: signCounts.beach, airports: signCounts.airports, buildings: signCounts.buildings, filler: signCounts.filler, lit: signMaterials.length, avgIntensity: signMaterials.length ? +(signMaterials.reduce((sum, s) => sum + s.mat.emissiveIntensity, 0) / signMaterials.length).toFixed(3) : 0, }, remoteIds: [...remoteCitizens.keys()], render: { calls: renderer.info.render.calls, triangles: renderer.info.render.triangles, geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures, updateMs: +perfFrame.update.toFixed(1), submitMs: +perfFrame.render.toFixed(1), }, airplanes: airplaneSystem ? airplaneSystem.count() : 0, trains: trainSystem ? 1 : 0, river: !!riverSystem, stores: cityData ? cityData.stores.length : 0, storesOpen: shopSystem ? shopSystem.openCount() : 0, agents: cityData ? cityData.agents.length : 0, colliders: colliders.length, }; }, vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; }, /** Sample vehicle heading vs velocity so tests can detect reverse-facing models. */ vehicleOrientations() { if (!vehicleSystem) return []; const out = []; for (const v of vehicleSystem.vehicles) { const p = new THREE.Vector3(); const ahead = new THREE.Vector3(); if (v.pts) { posOnPath(v.pts, v.s, v.L, p); posOnPath(v.pts, v.s + 0.8, v.L, ahead); } else { p.lerpVectors(v.a, v.b, v.t); const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.05)); ahead.lerpVectors(v.a, v.b, lookT); } const dx = ahead.x - p.x; const dz = ahead.z - p.z; const len = Math.hypot(dx, dz) || 1; const vx = dx / len; const vz = dz / len; // Mesh nose is local +X → world forward = (cos yaw, -sin yaw). const yaw = v.mesh.rotation.y; const fx = Math.cos(yaw); const fz = -Math.sin(yaw); const dot = fx * vx + fz * vz; out.push({ kind: v.mesh?.userData?.kind || 'car', speed: +(v.speed || 0).toFixed(2), dot: +dot.toFixed(3), aligned: dot > 0.5, }); } return out; }, pedestrianPositions() { return pedestrianSystem ? pedestrianSystem.positions() : []; }, get me() { return meCharacter; }, refreshMe: async function () { try { const res = await fetch(cfg.dataUrl, { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!res.ok) return null; const data = await res.json(); updateCharacterHud(data.me); return data.me; } catch (_) { return null; } }, action: runAction, presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk }, minimap: () => !!minimapCtx, joystickBound: () => !!document.getElementById('joystick-base'), agentPositions() { return agentMeshes.map(m => ({ id: m.userData?.agent?.id ?? null, name: m.userData?.agent?.name ?? '', type: m.userData?.agent?.type ?? '', x: +m.position.x.toFixed(2), z: +m.position.z.toFixed(2), })); }, }; // ------------------------------------------------------------------ // Data loading // ------------------------------------------------------------------ async function loadCity() { loadingEl.style.display = 'grid'; errorEl.style.display = 'none'; window.__city10.ready = false; try { const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); cityData = await res.json(); buildCity(cityData); applyWeatherVisuals(); updateDayNight(); loadingEl.style.display = 'none'; window.__city10.ready = true; initVehicleHeadlights(); startPresenceLoop(); refreshMarketPanel(); } catch (err) { console.error(err); loadingEl.style.display = 'none'; errorEl.style.display = 'grid'; } } reloadBtn.addEventListener('click', () => { cityAudio.unlock(); cityAudio.play('reload'); loadCity(); }); bindCharacterUI(); bindAudioUI(); bindWorldUI(); initMinimap(); bindMobileJoystick(); // Unlock audio on the first gesture anywhere (autoplay policy). const unlockAudio = () => { cityAudio.unlock(); }; document.addEventListener('pointerdown', unlockAudio, { once: false }); document.addEventListener('keydown', unlockAudio, { once: false }); startGate?.addEventListener('click', () => { cityAudio.unlock(); cityAudio.play('ok'); }); loadCity(); initWeather(); applyWeatherVisuals();