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); const renderer = new THREE.WebGLRenderer({ antialias: 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); sun.shadow.camera.left = -200; sun.shadow.camera.right = 200; sun.shadow.camera.top = 200; sun.shadow.camera.bottom = -200; sun.shadow.camera.far = 500; scene.add(sun); // ------------------------------------------------------------------ // 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); function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) { const w = 512, h = 512; 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 = []; let grid = null; 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, }; 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; sun.position.set(Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), 60); 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; } // ------------------------------------------------------------------ // 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'; } function makePedSignal() { const g = new THREE.Group(); const box = new THREE.Mesh( new THREE.BoxGeometry(0.28, 0.7, 0.22), new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 }) ); 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), new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 }) ); 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; } function initTrafficLights(g) { 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. const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat); pole.position.set(x + sw, 2.1, z + sw); pole.castShadow = true; group.add(pole); // Mast arm over the vertical road (northbound lane), facing // south so approaching traffic sees the signal. const armNS = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.1, 2.2), poleMat); armNS.position.set(x + sw, 4.0, z + sw - 1.1); group.add(armNS); const headNS = makeSignalHead(); headNS.position.set(x + sw, 3.7, z + sw - 2.2); headNS.rotation.y = Math.PI; // face -Z (south) group.add(headNS); heads.push({ head: headNS, axis: 'z' }); // Mast arm over the horizontal road (eastbound lane), facing west. const armEW = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.1, 0.12), poleMat); armEW.position.set(x + sw - 1.1, 4.0, z + sw); group.add(armEW); 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); heads.push({ head: headEW, axis: 'x' }); // Pedestrian signals: short posts on the same corner, well // inside the sidewalk, facing the crosswalks they control. const pedZ = makePedSignal(); const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat); pedZPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6); group.add(pedZPole); pedZ.position.set(x + sw + 0.6, 2.5, z + sw - 0.6); pedZ.rotation.y = Math.PI; // faces south crosswalk group.add(pedZ); pedHeads.push({ head: pedZ, axis: 'z' }); const pedX = makePedSignal(); const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat); pedXPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6); group.add(pedXPole); pedX.position.set(x + sw - 0.6, 2.5, z + sw + 0.6); pedX.rotation.y = -Math.PI / 2; // faces west crosswalk group.add(pedX); pedHeads.push({ head: pedX, axis: 'x' }); } } 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; } }, }; } // ------------------------------------------------------------------ // 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 = 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 = 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 = 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 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, }; 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); 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) { for (const v of vehicles) { if (v.pts) { updateLoopVehicle(v, dt); } else { updateShuttleVehicle(v, dt); } } }, }; } 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 }, }; const cfgc = configs[type] || configs.pedestrian; const s = cfgc.scale; const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 }); const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 }); const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 }); const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 }); 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); 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'); 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, }; 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; } 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 // ------------------------------------------------------------------ function buildAirplane(colorHex) { const g = new THREE.Group(); const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.5 }); const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 9, 10), mat); fus.rotation.z = Math.PI / 2; g.add(fus); const nose = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.8, 10), mat); nose.rotation.z = -Math.PI / 2; nose.position.x = 5.3; g.add(nose); const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.15, 11), mat); wing.position.x = 0.5; g.add(wing); const tailW = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 4), mat); tailW.position.x = -4; g.add(tailW); const tailF = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.12), mat); tailF.position.set(-4.1, 0.8, 0); g.add(tailF); 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.5, 0.5, 0); g.add(beacon); g.userData.beacon = beacon; return g; } function initAirplanes() { const planes = []; const defs = [ { r: 260, y: 85, speed: 0.12, a0: 0, color: 0xdfe4ea }, { r: 330, y: 110, speed: -0.09, a0: 2, color: 0xc0c8d2 }, { r: 200, y: 70, speed: 0.15, a0: 4, color: 0xe8eef5 }, ]; for (const d of defs) { const mesh = buildAirplane(d.color); scene.add(mesh); planes.push({ mesh, ...d, angle: d.a0 }); } airplaneSystem = { planes, count() { return planes.length; }, update(dt) { for (const p of planes) { p.angle += p.speed * dt; const x = Math.cos(p.angle) * p.r; const z = Math.sin(p.angle) * p.r; p.mesh.position.set(x, p.y, z); const tx = -Math.sin(p.angle) * Math.sign(p.speed); const tz = Math.cos(p.angle) * Math.sign(p.speed); p.mesh.rotation.y = Math.atan2(-tz, tx); p.mesh.rotation.z = -0.3 * Math.sign(p.speed); if (p.mesh.userData.beacon) { p.mesh.userData.beacon.material.emissiveIntensity = (Math.sin(performance.now() * 0.01) > 0) ? 2.5 : 0.2; } } }, }; } // ------------------------------------------------------------------ // 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); for (const eq of p.equipment || []) { buildGymEquipment(eq.kind, eq.position[0], eq.position[1]); } } } 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'; } function updateExercise(dt) { if (isHospitalizedNow()) { if (exercise.active) stopExerciseSession(false); setExercisePrompt(null); 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 { setExercisePrompt(null); } // Toggle with E. if (keys['KeyE']) { keys['KeyE'] = false; if (exercise.active) { stopExerciseSession(); } else if (near) { startExerciseSession(near.eq); } } } function buildCity(data) { clearGroup(worldGroup); colliders.length = 0; storeMeshes.length = 0; agentMeshes.length = 0; crowdMeshes.length = 0; facadeMaterials.length = 0; lampHeads.length = 0; trafficSystem = null; vehicleSystem = null; pedestrianSystem = null; airplaneSystem = null; trainSystem = null; riverSystem = null; shopSystem = null; mayorSystem = null; meMesh = null; spawnApplied = false; plazas = []; gymEquipment = []; exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: 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); 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; worldGroup.add(mesh); if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck const swWidth = 2; const offset = w / 2 + swWidth / 2; [-1, 1].forEach(side => { const sg = r.axis === 'z' ? new THREE.PlaneGeometry(swWidth, len) : new THREE.PlaneGeometry(len, 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, mz); } else { sm.position.set(mx, 0.12, mz + side * offset); } sm.receiveShadow = true; worldGroup.add(sm); }); }); // ---- Curbs ---- layout.roads.forEach(r => { if (r === layoutExtras.bridgeRoad) return; 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 offset = w / 2 + 0.1; [-1, 1].forEach(side => { const curbGeo = r.axis === 'z' ? new THREE.BoxGeometry(0.2, 0.14, len) : new THREE.BoxGeometry(len, 0.14, 0.2); const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 })); if (r.axis === 'z') { curb.position.set(mx + side * offset, 0.07, mz); } else { curb.position.set(mx, 0.07, mz + side * offset); } worldGroup.add(curb); }); }); // ---- Street lamps (on the sidewalk, never in the roadway) ---- const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 }); 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.castShadow = true; worldGroup.add(pole); // Curved arm reaching over the curb toward the road. const dirX = r.axis === 'z' ? -side : 0; const dirZ = r.axis === 'x' ? -side : 0; const arm = new THREE.Mesh( new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1), lampPoleMat ); arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6); worldGroup.add(arm); const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 }); // Compact cobra-style head sitting at the arm tip, hanging slightly down. const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.18, 0.28), headMat); head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2); worldGroup.add(head); lampHeads.push(headMat); } }); // ---- Traffic lights ---- initTrafficLights(grid); // ---- 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); 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); }); // ---- Filler buildings ---- const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] })); (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)); const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3))); 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); const mats = Array.from({ length: 6 }, () => sideMat); mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 }); mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 }); const m = new THREE.Mesh(geo, mats); 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), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }) ); roof.position.set(cx, bh + 0.15, cz); worldGroup.add(roof); }); // ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ---- const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']); 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 mat = new THREE.MeshStandardMaterial({ color: isHospital ? 0xe8eef5 : (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); } } } // ---- 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); if (!airplaneSystem) initAirplanes(); else for (const p of airplaneSystem.planes) scene.add(p.mesh); 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; renderer.domElement.addEventListener('click', () => { if (!locked) renderer.domElement.requestPointerLock(); }); startGate.addEventListener('click', () => { renderer.domElement.requestPointerLock(); }); document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === renderer.domElement; startGate.style.display = locked ? 'none' : 'grid'; posPanel.style.display = locked ? 'block' : 'none'; }); document.addEventListener('mousemove', (e) => { if (!locked) return; player.yaw -= e.movementX * 0.0022; player.pitch -= e.movementY * 0.0022; player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch)); }); 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; 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(); // Allow walking with WASD/arrows even before pointer lock so the // character is never completely stuck on the overlay screen. if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward); if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward); if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right); if (keys['KeyD'] || keys['ArrowRight']) wish.add(right); if (mobileInput.active) { // Joystick: +y on screen is forward for the player. wish.addScaledVector(forward, -mobileInput.y); wish.addScaledVector(right, mobileInput.x); } 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 (keys['Space'] && player.onGround) { player.vel.y = 6; player.onGround = false; } player.pos.x = cityBounds ? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x)) : Math.max(-400, Math.min(400, player.pos.x)); player.pos.z = cityBounds ? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z)) : Math.max(-400, Math.min(400, 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) { openChat(obj.userData.agent); 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 // ------------------------------------------------------------------ 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'); let currentAgent = null; let eventSource = null; 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 setStatus(status) { if (!currentAgent) return; currentAgent.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; const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id); if (mesh) refreshLabel(mesh, status); } function openChat(agent) { currentAgent = agent; chatPanel.style.display = 'flex'; chatName.textContent = agent.name; chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); chatMessages.innerHTML = ''; const history = agent.last_decision || []; history.forEach(m => { if (m.role === 'user') addBubble(m.content, 'user'); else if (m.role === 'assistant') addBubble(m.content, 'npc'); }); setStatus(agent.status || 'active'); if (eventSource) { eventSource.close(); eventSource = null; } eventSource = new EventSource(`/city/chat/${agent.id}/stream`); eventSource.addEventListener('message', (e) => { try { const payload = JSON.parse(e.data); if (payload.type === 'thinking') { setStatus('thinking'); addBubble(i18n.chatThinking || 'thinking…', 'meta'); } else if (payload.type === 'reply') { setStatus('active'); addBubble(payload.reply, 'npc'); chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else if (payload.type === 'error') { setStatus('error'); addBubble(payload.reply || i18n.chatError || 'error', 'meta'); } } catch (_) { /* ignore */ } }); eventSource.onerror = () => { /* auto-retry */ }; } function closeChat() { chatPanel.style.display = 'none'; currentAgent = null; if (eventSource) { eventSource.close(); eventSource = null; } } chatClose.addEventListener('click', closeChat); chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const msg = chatInput.value.trim(); if (!msg || !currentAgent) return; chatInput.value = ''; addBubble(msg, 'user'); setStatus('thinking'); try { await fetch(`/city/chat/${currentAgent.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' }, body: JSON.stringify({ message: msg }), }); } catch (_) { addBubble(i18n.chatError || 'error', 'meta'); } }); // ------------------------------------------------------------------ // 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; 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); updatePlayer(dt); checkVehicleHitOnPlayer(); updateExercise(dt); updateCameraFromPlayer(); updateHUD(); updateVehicleHeadlights(); syncPositionThrottled(); drawMinimap(); // Footsteps while the local player is actually walking. const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD'] || keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']); cityAudio.footsteps(dt, walking && !isHospitalizedNow()); statsTimer += dt; if (statsTimer > 1 && cityData) { statsTimer = 0; statsEl.textContent = buildStatsText(); } renderer.render(scene, camera); } 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); 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; } // ------------------------------------------------------------------ // 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; base.setPointerCapture(e.pointerId); 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-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; }, playerCrossingAllowed, 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, 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();