Files
City10/public/js/city/app.js
T
marcocosta10000 b4f80cee24 feat(city): add zebra crosswalks and segmented sidewalk rendering
Renders zebra crosswalks with longitudinal bars and updates sidewalk
generation to stop at crossing bands instead of spanning across roadways.
2026-10-31 18:56:36 -03:00

4935 lines
184 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
// Zebra crosswalk: white longitudinal bars parallel to vehicle travel
// (MUTCD "longitudinal bar" design), transparent gaps so the asphalt
// underneath shows through. Six bars (~0.6 unit each with ~0.6 gaps,
// NZTA proportions) across the 8-unit road width. `barsAcrossU` draws
// bars along V (for planes spanning roadW × band) — otherwise along U.
function zebraTexture(barsAcrossU) {
return canvasTexture(512, (ctx, s) => {
ctx.clearRect(0, 0, s, s);
ctx.fillStyle = '#f5f5f5';
const bars = 6, bar = 38, pitch = 76;
const start = (s - (bars * bar + (bars - 1) * (pitch - bar))) / 2;
for (let i = 0; i < bars; i++) {
const p = start + i * pitch;
if (barsAcrossU) {
ctx.fillRect(p, 0, bar, s);
} else {
ctx.fillRect(0, p, s, bar);
}
}
}, 1, 1);
}
const zebraTexOverZRoad = zebraTexture(true);
const zebraTexOverXRoad = zebraTexture(false);
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
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 crosswalkCount = 0;
let sidewalkSegmentCount = 0;
let trafficSystem = null;
let vehicleSystem = null;
let pedestrianSystem = null;
let airplaneSystem = null;
let trainSystem = null;
let riverSystem = null;
let shopSystem = null;
let mayorSystem = null;
let meMesh = null;
let hospitalBusy = false;
let spawnApplied = false;
let plazas = [];
let gymEquipment = [];
let exercise = {
active: false,
equipmentId: null,
repTimer: 0,
animPhase: 0,
nearId: null,
};
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;
}
/**
* Split the interval [a, b] of a road's travel axis into segments that
* exclude ±half bands around every perpendicular crossing line. Sidewalks
* and curbs use this so they stop at each crosswalk instead of running
* across the roadway like a plain walkway.
*
* @param {number[]} lines grid lines of the perpendicular roads
* @return {Array<[number, number]>}
*/
function stripSegments(lines, a, b, half) {
if (a > b) [a, b] = [b, a];
const gaps = [];
for (const line of lines) {
const g0 = line - half;
const g1 = line + half;
if (g1 > a && g0 < b) gaps.push([Math.max(g0, a), Math.min(g1, b)]);
}
gaps.sort((p, q) => p[0] - q[0]);
const merged = [];
for (const gap of gaps) {
const last = merged[merged.length - 1];
if (last && gap[0] <= last[1] + 1e-6) {
last[1] = Math.max(last[1], gap[1]);
} else {
merged.push([gap[0], gap[1]]);
}
}
const segs = [];
let cursor = a;
for (const [g0, g1] of merged) {
if (g0 - cursor > 0.05) segs.push([cursor, g0]);
cursor = Math.max(cursor, g1);
}
if (b - cursor > 0.05) segs.push([cursor, b]);
return segs;
}
// ------------------------------------------------------------------
// 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. Northbound
// drives on the east half (right-hand traffic); the SE-corner
// pole sits on that same (right) side.
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 = 0; // face +Z (south — toward northbound traffic)
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.
//
// The east crosswalk crosses the horizontal (x) road, so its
// signal follows the x-axis vehicle state. It sits on the east
// sidewalk facing north, for walkers arriving from the north.
const pedEast = makePedSignal();
const pedEastPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
group.add(pedEastPole);
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
group.add(pedEast);
pedHeads.push({ head: pedEast, axis: 'x' });
// The south crosswalk crosses the vertical (z) road, so its
// signal follows the z-axis vehicle state. It sits on the south
// sidewalk facing west, for walkers arriving from the east.
const pedSouth = makePedSignal();
const pedSouthPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
group.add(pedSouthPole);
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
group.add(pedSouth);
pedHeads.push({ head: pedSouth, axis: 'z' });
}
}
worldGroup.add(group);
trafficSystem = {
phaseIndex: 0,
phaseTimer: 0,
heads,
pedHeads,
axisState(axis) {
return PHASES[this.phaseIndex][axis];
},
pedState(axis) {
return pedStateForAxis(axis);
},
get phaseName() { return PHASES[this.phaseIndex].name; },
/** Jump to a named phase (used by tests / debug). */
setPhase(name) {
const idx = PHASES.findIndex(p => p.name === name);
if (idx >= 0) {
this.phaseIndex = idx;
this.phaseTimer = 0;
}
},
update(dt) {
this.phaseTimer += dt;
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
this.phaseTimer -= PHASES[this.phaseIndex].dur;
this.phaseIndex = (this.phaseIndex + 1) % PHASES.length;
}
for (const { head, axis } of this.heads) {
const state = PHASES[this.phaseIndex][axis];
const lamps = head.userData.lamps;
lamps[0].material.emissiveIntensity = state === 'red' ? 2.2 : 0.06;
lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06;
}
for (const { head, axis } of this.pedHeads) {
const ped = pedStateForAxis(axis);
const lamps = head.userData.lamps;
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
}
},
};
}
// ------------------------------------------------------------------
// 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;
crosswalkCount = 0;
sidewalkSegmentCount = 0;
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;
// Sidewalks stop at each perpendicular roadway: the crossing band
// itself is painted as a zebra crosswalk below.
const crossingLines = r.axis === 'z' ? grid.zs : grid.xs;
const travel1 = r.axis === 'z' ? z1 : x1;
const travel2 = r.axis === 'z' ? z2 : x2;
const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2);
[-1, 1].forEach(side => {
for (const [a, b] of spans) {
const segLen = b - a;
const cmid = (a + b) / 2;
const sg = r.axis === 'z'
? new THREE.PlaneGeometry(swWidth, segLen)
: new THREE.PlaneGeometry(segLen, swWidth);
const sm = new THREE.Mesh(sg, walkMat);
sm.rotation.x = -Math.PI / 2;
if (r.axis === 'z') {
sm.position.set(mx + side * offset, 0.12, cmid);
} else {
sm.position.set(cmid, 0.12, mz + side * offset);
}
sm.receiveShadow = true;
worldGroup.add(sm);
sidewalkSegmentCount++;
}
});
});
// ---- Curbs (interrupted at crosswalks — dropped-curb look) ----
layout.roads.forEach(r => {
if (r === layoutExtras.bridgeRoad) return;
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const offset = w / 2 + 0.1;
const crossingLines = r.axis === 'z' ? grid.zs : grid.xs;
const travel1 = r.axis === 'z' ? z1 : x1;
const travel2 = r.axis === 'z' ? z2 : x2;
const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2);
[-1, 1].forEach(side => {
for (const [a, b] of spans) {
const segLen = b - a;
const cmid = (a + b) / 2;
const curbGeo = r.axis === 'z'
? new THREE.BoxGeometry(0.2, 0.14, segLen)
: new THREE.BoxGeometry(segLen, 0.14, 0.2);
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
if (r.axis === 'z') {
curb.position.set(mx + side * offset, 0.07, cmid);
} else {
curb.position.set(cmid, 0.07, mz + side * offset);
}
worldGroup.add(curb);
}
});
});
// ---- Zebra crosswalks (real markings, not sidewalk-coloured bands) ----
//
// Four approaches per intersection; each band sits exactly where the
// perpendicular sidewalk used to cross the roadway (2 units deep =
// the sidewalk width), directly over the asphalt. Bars run parallel
// to vehicle travel (MUTCD longitudinal-bar zebra design).
const zebraMatOverZ = new THREE.MeshStandardMaterial({
map: zebraTexOverZRoad, transparent: true, alphaTest: 0.4, roughness: 0.9,
});
const zebraMatOverX = new THREE.MeshStandardMaterial({
map: zebraTexOverXRoad, transparent: true, alphaTest: 0.4, roughness: 0.9,
});
const crossHalf = grid.roadW / 2;
const crossBand = 2;
const eps = 0.01;
const crossRoadX = layoutExtras.crossRoad ? layoutExtras.crossRoad.from[0] : null;
for (const vx of grid.xs) {
for (const vz of grid.zs) {
// Crossings over the vertical (z) road — south & north bands.
const zSpans = [
{ side: 1, valid: vz + crossHalf + crossBand <= grid.maxZ + eps || vx === crossRoadX },
{ side: -1, valid: vz - crossHalf - crossBand >= grid.minZ - eps },
];
for (const { side, valid } of zSpans) {
if (!valid) continue;
const zebra = new THREE.Mesh(
new THREE.PlaneGeometry(grid.roadW, crossBand), zebraMatOverZ
);
zebra.rotation.x = -Math.PI / 2;
zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2));
zebra.receiveShadow = true;
worldGroup.add(zebra);
crosswalkCount++;
}
// Crossings over the horizontal (x) road — east & west bands.
// At the bridge corner the west approach rides on the deck.
const xSpans = [
{ side: 1, valid: vx + crossHalf + crossBand <= grid.maxX + eps },
{ side: -1, valid: vx - crossHalf - crossBand >= grid.minX - eps },
];
for (const { side, valid } of xSpans) {
if (!valid) continue;
const zebra = new THREE.Mesh(
new THREE.PlaneGeometry(crossBand, grid.roadW), zebraMatOverX
);
zebra.rotation.x = -Math.PI / 2;
zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz);
zebra.receiveShadow = true;
worldGroup.add(zebra);
crosswalkCount++;
}
}
}
// ---- 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;
// Gate dismissed without pointer lock (touch devices, or when the browser
// denies pointer lock). The camera is then steered with drag-to-look.
let touchMode = false;
let lookDrag = null;
function applyLook(dx, dy, sens) {
player.yaw -= dx * sens;
player.pitch -= dy * sens;
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
}
function dismissGate() {
touchMode = true;
startGate.style.display = 'none';
posPanel.style.display = 'block';
}
function tryRequestLock() {
try {
const p = renderer.domElement.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => dismissGate());
} catch (_) {
dismissGate();
}
}
renderer.domElement.addEventListener('click', () => {
if (locked || touchMode) return;
tryRequestLock();
});
startGate.addEventListener('click', () => {
// Touch devices have no pointer lock: start immediately and steer the
// camera by dragging on the scene (the virtual joystick drives walking).
if (window.matchMedia?.('(pointer: coarse)')?.matches) {
dismissGate();
return;
}
tryRequestLock();
});
document.addEventListener('pointerlockchange', () => {
locked = document.pointerLockElement === renderer.domElement;
startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
posPanel.style.display = (locked || touchMode) ? 'block' : 'none';
});
document.addEventListener('pointerlockerror', () => {
// Lock denied (embedded context, automation, permissions): fall back to
// drag-to-look so the player is never stuck behind the gate.
dismissGate();
});
document.addEventListener('mousemove', (e) => {
if (!locked) return;
applyLook(e.movementX, e.movementY, 0.0022);
});
// Drag-to-look fallback when pointer lock is not in effect: drag anywhere
// on the scene to turn (standard mobile first-person controls).
renderer.domElement.addEventListener('pointerdown', (e) => {
if (locked) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
lookDrag = { id: e.pointerId, x: e.clientX, y: e.clientY };
try { renderer.domElement.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (locked || !lookDrag || lookDrag.id !== e.pointerId) return;
applyLook(e.clientX - lookDrag.x, e.clientY - lookDrag.y, 0.005);
lookDrag.x = e.clientX;
lookDrag.y = e.clientY;
});
const endLookDrag = (e) => {
if (lookDrag && lookDrag.id === e.pointerId) lookDrag = null;
};
renderer.domElement.addEventListener('pointerup', endLookDrag);
renderer.domElement.addEventListener('pointercancel', endLookDrag);
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
function isTypingTarget(el) {
if (!el) return false;
const tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
}
document.addEventListener('keydown', (e) => {
if (isTypingTarget(e.target)) return;
// Keep arrows/space from scrolling the page while the scene controls
// are active (arrows turn the camera, space jumps).
if ((locked || touchMode)
&& (e.code === 'ArrowLeft' || e.code === 'ArrowRight'
|| e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'Space')) {
e.preventDefault();
}
keys[e.code] = true;
});
document.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
function playerAABB(px, py, pz) {
const r = player.radius;
return {
minX: px - r, maxX: px + r,
minY: py - 1.5, maxY: py + 0.3,
minZ: pz - r, maxZ: pz + r,
};
}
function overlaps(a, b) {
return a.minX < b.maxX && a.maxX > b.minX &&
a.minZ < b.maxZ && a.maxZ > b.minZ &&
a.minY < b.maxY && a.maxY > b.minY;
}
function vehicleBoxes() {
if (!vehicleSystem) return [];
const boxes = [];
for (const v of vehicleSystem.vehicles) {
const p = new THREE.Vector3();
let rot = 0;
if (v.pts) {
posOnPath(v.pts, v.s, v.L, p);
const ahead = new THREE.Vector3();
posOnPath(v.pts, v.s + 0.6, v.L, ahead);
rot = yawFromDelta(ahead.x - p.x, ahead.z - p.z);
} else {
p.lerpVectors(v.a, v.b, v.t);
const dirSign = v.dir >= 0 ? 1 : -1;
rot = yawFromDelta((v.b.x - v.a.x) * dirSign, (v.b.z - v.a.z) * dirSign);
}
const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0);
const halfW = v.mesh?.userData?.halfW ?? 1.0;
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
const ex = halfL * c + halfW * s;
const ez = halfL * s + halfW * c;
boxes.push({
minX: p.x - ex, maxX: p.x + ex,
minZ: p.z - ez, maxZ: p.z + ez,
minY: 0, maxY: 2,
});
}
return boxes;
}
function resolveCollision(px, py, pz) {
const box = playerAABB(px, py, pz);
const cur = playerAABB(player.pos.x, player.pos.y, player.pos.z);
const inside = (c) => overlaps(cur, c);
// Static buildings: if the player already overlaps one (spawned inside
// or built a house on their feet), allow moving so they can walk out.
for (const c of colliders) {
if (overlaps(box, c) && !inside(c)) return true;
}
// Hitting a vehicle is an accident (only once the world is live).
if (window.__city10?.ready && spawnApplied && meCharacter && meCharacter.state !== 'hospital') {
for (const c of vehicleBoxes()) {
if (overlaps(box, c)) {
reportVehicleAccident();
if (!inside(c)) return true;
}
}
}
if (trainSystem) {
const tb = {
minX: trainSystem.s - 18, maxX: trainSystem.s + 18,
minZ: trainSystem.crossZ - 1.6, maxZ: trainSystem.crossZ + 1.6,
minY: 0, maxY: 4,
};
if (overlaps(box, tb) && !inside(tb)) return true;
}
return false;
}
/**
* Detect vehicles driving into a stationary player and report the accident.
* Runs only after the city is fully built and the player spawn is applied.
* Players on public plazas (gym parks) are safe from traffic.
*/
function checkVehicleHitOnPlayer() {
if (!window.__city10?.ready) return;
if (!meCharacter || hospitalBusy) return;
if (meCharacter.state === 'hospital') return;
if (!spawnApplied) return;
if (exercise.active) return;
// Soft zone: plazas are fenced parks off the roadway.
if (plazas.some(p => {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
return Math.abs(player.pos.x - px) <= pw / 2 && Math.abs(player.pos.z - pz) <= pd / 2;
})) {
return;
}
const box = playerAABB(player.pos.x, player.pos.y, player.pos.z);
for (const c of vehicleBoxes()) {
if (overlaps(box, c)) {
reportVehicleAccident();
return;
}
}
}
/**
* Tell the server the character was hit by a vehicle (2-day hospital stay).
*/
function reportVehicleAccident() {
if (!meCharacter || hospitalBusy) return;
if (meCharacter.state === 'hospital') return;
hospitalBusy = true;
cityAudio.play('horn');
postJson('/city/actions/accident', {})
.then((data) => {
if (data?.character) updateCharacterHud(data.character);
if (data?.bed) {
player.pos.x = data.bed.pos_x;
player.pos.z = data.bed.pos_z;
player.vel.set(0, 0, 0);
}
flashCharMsg(data?.message || i18n.hospitalHit || 'hospital', true);
cityAudio.play('hospital');
showHospitalBanner(data?.hospitalized_until);
updateCameraFromPlayer();
})
.catch((e) => {
flashCharMsg(e.message, true);
cityAudio.play('error');
})
.finally(() => {
hospitalBusy = false;
});
}
function showHospitalBanner(untilIso) {
const el = document.getElementById('hospital-banner');
if (!el) return;
if (!untilIso) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
const tick = () => {
const until = new Date(untilIso).getTime();
const left = until - Date.now();
if (left <= 0 || meCharacter?.state !== 'hospital') {
el.style.display = 'none';
return;
}
const totalSec = Math.ceil(left / 1000);
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
const label = i18n.hospitalLeft || 'Discharge in';
el.textContent = `🏥 ${i18n.hospitalTitle || 'Hospitalized'} · ${label} ${mm}:${ss}`;
setTimeout(tick, 1000);
};
tick();
}
function isHospitalizedNow() {
if (meCharacter?.state !== 'hospital') return false;
if (!meCharacter.hospitalized_until) return true;
return new Date(meCharacter.hospitalized_until).getTime() > Date.now();
}
let lastPedBlockAt = 0;
/**
* Check whether the player may step from their current position to (toX, toZ).
*
* Crossing onto a road is gated by the pedestrian signal for that road's
* axis: vehicles green ⇒ pedestrians red. Players already mid-road may
* finish the crossing so they never get stuck in the asphalt.
*/
function playerCrossingAllowed(toX, toZ) {
if (!grid || !trafficSystem) return true;
const fromOnRoad = onRoad(player.pos.x, player.pos.z, grid);
const toOnRoad = onRoad(toX, toZ, grid);
if (!toOnRoad) return true;
if (fromOnRoad) return true; // already committed to the road
const half = grid.roadW / 2;
const enteringZRoad = grid.xs.some(vx => Math.abs(toX - vx) <= half);
const enteringXRoad = grid.zs.some(vz => Math.abs(toZ - vz) <= half);
// Blocked when the matching pedestrian signal is red.
if (enteringZRoad && trafficSystem.pedState('z') === 'red') return false;
if (enteringXRoad && trafficSystem.pedState('x') === 'red') return false;
return true;
}
function flashPedRed() {
const now = performance.now();
if (now - lastPedBlockAt < 1200) return;
lastPedBlockAt = now;
const el = document.getElementById('ped-wait');
if (el) {
el.style.display = 'block';
el.textContent = `🚶 ${i18n.pedRed || 'Red pedestrian light — wait.'}`;
clearTimeout(flashPedRed._t);
flashPedRed._t = setTimeout(() => { el.style.display = 'none'; }, 1600);
}
}
function updatePlayer(dt) {
// Hospitalized characters stay in bed until discharge.
if (isHospitalizedNow()) {
player.vel.set(0, 0, 0);
player.pos.y = player.eyeHeight;
return;
}
// Auto-release when the hospital stay elapses while online.
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
meCharacter.state = 'awake';
meCharacter.hospitalized_until = null;
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
flashCharMsg(i18n.hospitalReleased || 'Released');
}
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3();
// Allow walking with WASD even before pointer lock so the character is
// never completely stuck on the overlay screen. A/D strafe; the arrow
// keys turn the first-person camera instead of strafing, so every
// forward step always follows the direction the camera is facing.
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA']) wish.sub(right);
if (keys['KeyD']) wish.add(right);
const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0);
if (turn) player.yaw += turn * 2.4 * dt;
if (mobileInput.active) {
// Joystick: vertical walks along the camera facing, horizontal
// turns the camera (mobile first-person convention).
wish.addScaledVector(forward, -mobileInput.y);
if (Math.abs(mobileInput.x) > 0.15) {
player.yaw -= mobileInput.x * 2.4 * dt;
}
}
if (wish.lengthSq() > 0) {
wish.normalize();
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
const step = wish.multiplyScalar(speed * dt);
const tryX = player.pos.x + step.x;
if (!resolveCollision(tryX, player.pos.y, player.pos.z)
&& playerCrossingAllowed(tryX, player.pos.z)) {
player.pos.x = tryX;
} else if (playerCrossingAllowed(tryX, player.pos.z) === false) {
flashPedRed();
}
const tryZ = player.pos.z + step.z;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)
&& playerCrossingAllowed(player.pos.x, tryZ)) {
player.pos.z = tryZ;
} else if (playerCrossingAllowed(player.pos.x, tryZ) === false) {
flashPedRed();
}
}
player.vel.y -= 18 * dt;
player.pos.y += player.vel.y * dt;
if (player.pos.y <= player.eyeHeight) {
player.pos.y = player.eyeHeight;
player.vel.y = 0;
player.onGround = true;
}
if (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) {
const agent = obj.userData.agent;
openTalkChat({
kind: 'agent',
id: agent.id,
name: agent.name,
status: agent.status || 'active',
});
return;
}
}
const storeHits = ray.intersectObjects(storeMeshes, false);
if (storeHits.length) {
const s = storeHits[0].object.userData.store;
selectedEl.style.display = 'inline-block';
const open = shopOpenNow(s);
selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`;
} else {
selectedEl.style.display = 'none';
}
});
// ------------------------------------------------------------------
// ChatPanel — street conversations via /talk/person
//
// Opens automatically when the player bumps into another citizen
// (human peer) or agentic NPC, polls for replies while open, closes
// when the pair moves apart, and renders the daily history that backs
// the persistent context of future conversations.
// ------------------------------------------------------------------
const chatPanel = document.getElementById('chat-panel');
const chatDot = document.getElementById('chat-dot');
const chatName = document.getElementById('chat-name');
const chatStatus = document.getElementById('chat-status');
const chatTime = document.getElementById('chat-time');
const chatClose = document.getElementById('chat-close');
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const TALK_OPEN_RADIUS = 2.6;
const TALK_CLOSE_RADIUS = 3.6;
const TALK_POLL_MS = 2500;
let currentPeer = null; // { kind, id, name, status, key }
let talkPollTimer = null;
let talkLastDay = null;
let talkErrorShown = false;
let dismissedPeerKey = null;
const talkSeenIds = new Set();
function peerKey(kind, id) {
return `${kind}:${id}`;
}
function clockNow() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function addBubble(text, who) {
const div = document.createElement('div');
div.className = `chat-bubble ${who}`;
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addDaySeparator(day) {
const div = document.createElement('div');
div.className = 'chat-bubble meta';
const parsed = new Date(`${day}T12:00:00`);
div.textContent = Number.isNaN(parsed.getTime()) ? day : parsed.toLocaleDateString();
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(status) {
if (!currentPeer) return;
currentPeer.status = status;
const map = {
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
active: ['#22c55e', 'online'],
error: ['#ef4444', 'error'],
};
const [color, label] = map[status] || map.active;
chatDot.style.background = color;
chatStatus.textContent = label;
if (currentPeer.kind === 'agent') {
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentPeer.id);
if (mesh) refreshLabel(mesh, status);
}
}
/** Poll the daily conversation and append anything not seen yet. */
async function pullTalk() {
if (!currentPeer) return;
const peer = currentPeer;
try {
const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
if (!res.ok || !currentPeer || currentPeer.key !== peer.key) return;
const data = await res.json();
const messages = data.messages || [];
let arrived = false;
for (const m of messages) {
if (talkSeenIds.has(m.id)) continue;
if (m.talked_on && m.talked_on !== talkLastDay) {
talkLastDay = m.talked_on;
addDaySeparator(m.talked_on);
}
talkSeenIds.add(m.id);
addBubble(m.body, m.fallback ? 'meta' : (m.from === 'me' ? 'user' : 'npc'));
if (m.at) chatTime.textContent = new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
arrived = true;
}
if (data.thinking) {
setStatus('thinking');
} else if (peer.kind === 'agent') {
setStatus(data.peer?.status || 'active');
const last = messages[messages.length - 1];
if (data.peer?.status === 'error' && last?.from === 'me' && !talkErrorShown) {
talkErrorShown = true;
addBubble(i18n.chatError || 'error', 'meta');
}
} else if (currentPeer.status === 'thinking') {
setStatus('active');
}
if (arrived) talkErrorShown = false;
} catch (_) { /* offline-safe */ }
}
async function openTalkChat(peer) {
if (currentPeer && currentPeer.key === peerKey(peer.kind, peer.id)) return;
currentPeer = { ...peer, key: peerKey(peer.kind, peer.id) };
talkSeenIds.clear();
talkLastDay = null;
talkErrorShown = false;
chatMessages.innerHTML = '';
chatPanel.style.display = 'flex';
chatName.textContent = peer.name;
chatTime.textContent = clockNow();
setStatus(peer.status || 'active');
if (talkPollTimer) clearInterval(talkPollTimer);
talkPollTimer = setInterval(pullTalk, TALK_POLL_MS);
await pullTalk();
}
function closeChat(manual = false) {
chatPanel.style.display = 'none';
if (manual && currentPeer) dismissedPeerKey = currentPeer.key;
currentPeer = null;
if (talkPollTimer) {
clearInterval(talkPollTimer);
talkPollTimer = null;
}
}
// ------------------------------------------------------------------
// Bump detection: auto-open near a citizen, auto-close when apart
// ------------------------------------------------------------------
const _talkPos = new THREE.Vector3();
let talkProximityTimer = 0;
function talkCandidates() {
const out = [];
for (const mesh of agentMeshes) {
const agent = mesh.userData?.agent;
if (!agent || typeof agent.id !== 'number') continue;
mesh.getWorldPosition(_talkPos);
out.push({
kind: 'agent',
id: agent.id,
name: agent.name,
status: agent.status || 'active',
d: Math.hypot(_talkPos.x - player.pos.x, _talkPos.z - player.pos.z),
});
}
for (const mesh of remoteCitizens.values()) {
const citizen = mesh.userData?.citizen;
if (!citizen) continue;
out.push({
kind: 'character',
id: citizen.id,
name: citizen.name,
status: 'active',
d: Math.hypot(mesh.position.x - player.pos.x, mesh.position.z - player.pos.z),
});
}
return out;
}
function updateTalkProximity(dt) {
talkProximityTimer -= dt;
if (talkProximityTimer > 0) return;
talkProximityTimer = 0.25;
const candidates = talkCandidates();
// A manually dismissed chat only reopens after a fresh bump.
if (dismissedPeerKey) {
const dis = candidates.find(c => peerKey(c.kind, c.id) === dismissedPeerKey);
if (!dis || dis.d > TALK_CLOSE_RADIUS) dismissedPeerKey = null;
}
if (currentPeer) {
const cur = candidates.find(c => peerKey(c.kind, c.id) === currentPeer.key);
if (!cur || cur.d > TALK_CLOSE_RADIUS) closeChat(false);
return;
}
let best = null;
for (const c of candidates) {
if (!best || c.d < best.d) best = c;
}
if (best && best.d <= TALK_OPEN_RADIUS && peerKey(best.kind, best.id) !== dismissedPeerKey) {
openTalkChat(best);
}
}
chatClose.addEventListener('click', () => closeChat(true));
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg || !currentPeer) return;
chatInput.value = '';
if (currentPeer.kind === 'agent') setStatus('thinking');
talkErrorShown = false;
try {
const res = await fetch('/talk/person', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ kind: currentPeer.kind, peer: currentPeer.id, message: msg }),
});
if (res.ok) {
const data = await res.json();
// Day separator first so live and polled rows read in order.
if (data.talked_on && data.talked_on !== talkLastDay) {
talkLastDay = data.talked_on;
addDaySeparator(data.talked_on);
}
addBubble(msg, 'user');
if (data.id) talkSeenIds.add(data.id);
if (currentPeer.kind !== 'agent') chatTime.textContent = clockNow();
} else {
// 429 has its own cause — do not reuse the AI fallback text.
addBubble(res.status === 429
? (i18n.talkRateLimited || 'rate limited')
: (i18n.chatError || 'error'), 'meta');
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
}
} catch (_) {
addBubble(i18n.chatError || 'error', 'meta');
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
}
});
// ------------------------------------------------------------------
// HUD
// ------------------------------------------------------------------
function updateHUD() {
const g = player.pos.y <= player.eyeHeight + 0.01;
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
posGround.textContent = g
? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m`
: `airborne · ${player.pos.y.toFixed(1)}m`;
posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`;
const hh = String(Math.floor(sim.hour)).padStart(2, '0');
const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0');
if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`;
if (weatherEl) {
const label = i18n[`weather_${weatherState.state}`] || weatherState.state;
weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`;
}
if (meMesh) {
meMesh.position.x = player.pos.x;
meMesh.position.z = player.pos.z;
}
}
// ------------------------------------------------------------------
// Character HUD + player actions (server-backed lifecycle)
// ------------------------------------------------------------------
const charNameEl = document.getElementById('char-name');
const charMsgEl = document.getElementById('char-msg');
const hungerEl = document.getElementById('hud-hunger');
const thirstEl = document.getElementById('hud-thirst');
const energyEl = document.getElementById('hud-energy');
const healthEl = document.getElementById('hud-health');
const exerciseCountEl = document.getElementById('hud-exercise-count');
const moneyEl = document.getElementById('hud-money');
const stateEl = document.getElementById('hud-char-state');
const educationEl = document.getElementById('hud-education');
const invEl = document.getElementById('hud-inv');
let lastPositionSync = 0;
function updateCharacterHud(me) {
meCharacter = me;
if (!me) return;
if (charNameEl) charNameEl.textContent = `👤 ${me.name}`;
if (hungerEl) hungerEl.textContent = `${me.hunger}%`;
if (thirstEl) thirstEl.textContent = `${me.thirst}%`;
if (energyEl) energyEl.textContent = `${me.energy}%`;
if (healthEl) healthEl.textContent = `${me.health ?? 100}%`;
if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`;
if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`;
if (stateEl) stateEl.textContent = me.state || '—';
if (educationEl) educationEl.textContent = `${me.education ?? 0}%`;
if (invEl) {
const inv = me.inventory || {};
invEl.textContent = Object.keys(inv).length
? Object.entries(inv).map(([k, v]) => `${k}:${v}`).join(' · ')
: '—';
}
if (me.state === 'hospital' && me.hospitalized_until) {
showHospitalBanner(me.hospitalized_until);
const bed = me.position;
if (bed) {
player.pos.x = bed[0];
player.pos.z = bed[2];
player.vel.set(0, 0, 0);
}
} else if (me.state !== 'hospital') {
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
}
}
function flashCharMsg(text, isError = false) {
if (!charMsgEl) return;
charMsgEl.textContent = text;
charMsgEl.style.color = isError ? '#fca5a5' : '#93c5fd';
// Errors always beep; success chimes are played by the action itself.
if (isError) cityAudio.play('error');
clearTimeout(flashCharMsg._t);
flashCharMsg._t = setTimeout(() => { charMsgEl.textContent = ''; }, 4000);
}
async function postJson(url, body = {}) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.message || i18n.notFound || 'error');
err.data = data;
err.status = res.status;
throw err;
}
return data;
}
const ACTION_SFX = {
eat: 'eat',
drink: 'drink',
sleep: 'sleep',
wake: 'wake',
work: 'work',
'stop-work': 'ui',
study: 'work',
buy: 'money',
sell: 'money',
build: 'build',
'apply-job': 'ok',
};
async function runAction(act) {
if (!meCharacter) return null;
cityAudio.unlock();
try {
let body = {};
if (act === 'buy') {
await postJson('/city/actions/buy', { item: 'food', qty: 1, price: 4 });
await postJson('/city/actions/buy', { item: 'water', qty: 1, price: 3 });
const data = await postJson('/city/actions/buy', { item: 'materials', qty: 10, price: 1 });
if (data.character) updateCharacterHud(data.character);
flashCharMsg(data.message || i18n.actionOk);
return data;
}
if (act === 'sell') body = { item: 'product', qty: 1, price: 8 };
if (act === 'build') {
// Place the house a few metres ahead so it never sits on the player.
const ahead = 12;
body = {
pos_x: Math.round(player.pos.x - Math.sin(player.yaw) * ahead),
pos_z: Math.round(player.pos.z - Math.cos(player.yaw) * ahead),
name: `${meCharacter.name}`,
};
}
const data = await postJson(`/city/actions/${act}`, body);
if (data.character) updateCharacterHud(data.character);
if (ACTION_SFX[act]) cityAudio.play(ACTION_SFX[act]);
flashCharMsg(data.message || i18n.actionOk);
if (act === 'build') {
loadCity();
}
return data;
} catch (e) {
flashCharMsg(e.message, true);
return { error: e.message };
}
}
function populateJobSelect(jobs) {
const sel = document.getElementById('job-select');
if (!sel) return;
sel.innerHTML = '<option value="">—</option>';
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 = '<option value="">—</option>';
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();
updateTalkProximity(dt);
drawMinimap();
// Footsteps while the local player is actually walking (not just turning).
const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown']
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
statsTimer += dt;
if (statsTimer > 1 && cityData) {
statsTimer = 0;
statsEl.textContent = buildStatsText();
}
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);
// Human peer marker used by the bump-to-talk detection.
mesh.userData.citizen = { kind: 'character', id: c.id, name: c.name };
const [cx, , cz] = c.position;
mesh.position.set(cx, 0, cz);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
remoteCitizens.set(c.id, mesh);
} else {
const [cx, , cz] = c.position;
const dx = cx - mesh.position.x;
const dz = cz - mesh.position.z;
mesh.position.x = cx;
mesh.position.z = cz;
if (Math.hypot(dx, dz) > 0.05) {
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4));
}
}
}
async function pollPresence() {
if (!window.__city10?.ready || hospitalBusy) return;
try {
const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : '';
const res = await fetch(`/city/presence${qs}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
const seen = new Set();
for (const c of data.citizens || []) {
if (c.is_me) continue;
seen.add(c.id);
upsertRemoteCitizen(c);
}
for (const [id, mesh] of remoteCitizens) {
if (!seen.has(id)) {
worldGroup.remove(mesh);
remoteCitizens.delete(id);
}
}
lastPresenceOk = performance.now();
} catch (_) { /* offline-safe */ }
}
function startPresenceLoop() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = setInterval(pollPresence, 2500);
pollPresence();
}
// ------------------------------------------------------------------
// Minimap (canvas overlay)
// ------------------------------------------------------------------
function initMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
minimapCtx = canvas.getContext('2d');
}
function drawMinimap() {
if (!minimapCtx || !cityBounds) return;
const canvas = minimapCtx.canvas;
const w = canvas.width;
const h = canvas.height;
const minX = cityBounds.min_x;
const maxX = cityBounds.max_x;
const minZ = cityBounds.min_z;
const maxZ = cityBounds.max_z;
const spanX = Math.max(1, maxX - minX);
const spanZ = Math.max(1, maxZ - minZ);
const toMap = (x, z) => [
((x - minX) / spanX) * (w - 8) + 4,
((z - minZ) / spanZ) * (h - 8) + 4,
];
minimapCtx.clearRect(0, 0, w, h);
minimapCtx.fillStyle = 'rgba(11,18,32,.85)';
minimapCtx.fillRect(0, 0, w, h);
// Roads
if (grid) {
minimapCtx.strokeStyle = 'rgba(148,163,184,.45)';
minimapCtx.lineWidth = 2;
for (const vx of grid.xs) {
const [mx] = toMap(vx, minZ);
minimapCtx.beginPath();
minimapCtx.moveTo(mx, 4);
minimapCtx.lineTo(mx, h - 4);
minimapCtx.stroke();
}
for (const vz of grid.zs) {
const [, mz] = toMap(minX, vz);
minimapCtx.beginPath();
minimapCtx.moveTo(4, mz);
minimapCtx.lineTo(w - 4, mz);
minimapCtx.stroke();
}
}
// Plazas (static parks — green)
for (const p of plazas) {
const [mx, mz] = toMap(p.position[0], p.position[1]);
minimapCtx.fillStyle = 'rgba(34,197,94,.55)';
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2);
minimapCtx.fill();
}
// Stores (static shops — orange)
for (const s of cityData?.stores || []) {
const [mx, mz] = toMap(s.position[0], s.position[2]);
minimapCtx.fillStyle = '#f59e0b';
minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5);
}
// NPC pedestrians (local crowd — yellow, they walk)
if (pedestrianSystem) {
minimapCtx.fillStyle = '#facc15';
for (const p of pedestrianSystem.positions()) {
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2);
minimapCtx.fill();
}
}
// Vehicles (moving — colored by kind)
if (vehicleSystem?.vehicles) {
for (const v of vehicleSystem.vehicles) {
const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z };
if (p.x == null || p.z == null) continue;
const kind = v.kind || v.mesh?.userData?.kind || 'car';
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#f472b6';
// Player is the bright pink; other cars use a softer white-blue.
if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue;
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#94a3b8';
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4);
}
}
// Remote citizens (online players — cyan)
minimapCtx.fillStyle = '#38bdf8';
for (const mesh of remoteCitizens.values()) {
const [mx, mz] = toMap(mesh.position.x, mesh.position.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2);
minimapCtx.fill();
}
// Player (you — pink with white ring)
const [px, pz] = toMap(player.pos.x, player.pos.z);
minimapCtx.fillStyle = '#f472b6';
minimapCtx.beginPath();
minimapCtx.arc(px, pz, 4, 0, Math.PI * 2);
minimapCtx.fill();
minimapCtx.strokeStyle = '#fff';
minimapCtx.lineWidth = 1.5;
minimapCtx.stroke();
}
// ------------------------------------------------------------------
// Vehicle headlights at night
// ------------------------------------------------------------------
function initVehicleHeadlights() {
// Collect materials tagged on each mesh (car/bus/moto/bike).
for (const v of vehicleSystem?.vehicles || []) {
const mesh = v.mesh;
if (!mesh || mesh.userData.lightsCollected) continue;
if (mesh.userData.headMats) vehicleHeadlights.push(...mesh.userData.headMats);
if (mesh.userData.tailMats) vehicleTailLights.push(...mesh.userData.tailMats);
if (mesh.userData.beams) vehicleBeams.push(...mesh.userData.beams);
mesh.userData.lightsCollected = true;
}
}
function updateVehicleHeadlights() {
const night = sim.nightFactor;
// Heads fade in from dusk; beams only once fully dark.
const head = night > 0 ? 0.25 + night * 3.0 : 0.2;
const tail = night > 0 ? 0.5 + night * 1.6 : 0.4;
const beam = night > 0.55 ? (night - 0.55) / 0.45 * 0.22 : 0;
for (const m of vehicleHeadlights) m.emissiveIntensity = head;
for (const m of vehicleTailLights) m.emissiveIntensity = tail;
for (const m of vehicleBeams) m.opacity = beam;
}
// ------------------------------------------------------------------
// Mobile virtual joystick
// ------------------------------------------------------------------
function bindMobileJoystick() {
const base = document.getElementById('joystick-base');
const knob = document.getElementById('joystick-knob');
if (!base || !knob) return;
let activeId = null;
const radius = 42;
const setKnob = (dx, dy) => {
const len = Math.hypot(dx, dy) || 1;
const clamped = Math.min(len, radius);
const nx = (dx / len) * clamped;
const ny = (dy / len) * clamped;
knob.style.transform = `translate(${nx}px, ${ny}px)`;
mobileInput.x = nx / radius;
mobileInput.y = ny / radius;
mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15;
};
const center = () => {
activeId = null;
mobileInput = { x: 0, y: 0, active: false };
knob.style.transform = 'translate(0px, 0px)';
};
base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId;
try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault();
});
base.addEventListener('pointermove', (e) => {
if (activeId !== e.pointerId) return;
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
});
base.addEventListener('pointerup', center);
base.addEventListener('pointercancel', center);
}
// ------------------------------------------------------------------
// Market / missions / customize / ride UI
// ------------------------------------------------------------------
async function refreshMarketPanel() {
const listEl = document.getElementById('market-list');
const missionEl = document.getElementById('mission-list');
if (!listEl && !missionEl) return;
try {
const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (listEl) {
listEl.innerHTML = (data.listings || []).length
? data.listings.map(l => `
<div style="display:flex; justify-content:space-between; gap:.4rem; align-items:center; margin:.25rem 0;">
<span>${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} <span style="opacity:.7">(${l.seller_name})</span></span>
${l.is_mine ? '' : `<button type="button" class="btn market-buy" data-id="${l.id}" style="padding:.15rem .4rem; font-size:.7rem;">${i18n.marketBuy || 'Buy'}</button>`}
</div>`).join('')
: `<div style="opacity:.6;">—</div>`;
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 => `
<div style="margin:.3rem 0; ${m.is_completed ? 'opacity:.55;' : ''}">
<strong style="color:#fff;">${m.title}</strong>
<div style="font-size:.75rem;">${m.description}</div>
<div style="font-size:.75rem;">${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)}
${m.is_completed ? '✓' : ''}</div>
</div>`).join('') || '<div style="opacity:.6;">—</div>';
}
} 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;
},
/**
* Snapshot of every signal head (position, facing, axis and the lamp
* currently lit) so automated tests can assert placement and states.
*/
trafficLightHeads() {
if (!trafficSystem) return { vehicle: [], pedestrian: [] };
const vehicle = trafficSystem.heads.map(({ head, axis }) => {
const intensities = head.userData.lamps.map(
l => +l.material.emissiveIntensity.toFixed(2)
);
const active = ['red', 'yellow', 'green'][intensities.indexOf(Math.max(...intensities))];
return {
axis,
x: +head.position.x.toFixed(2),
y: +head.position.y.toFixed(2),
z: +head.position.z.toFixed(2),
rotationY: +head.rotation.y.toFixed(3),
active,
intensities,
};
});
const pedestrian = trafficSystem.pedHeads.map(({ head, axis }) => {
const red = +head.userData.lamps.red.material.emissiveIntensity.toFixed(2);
const green = +head.userData.lamps.green.material.emissiveIntensity.toFixed(2);
return {
axis,
x: +head.position.x.toFixed(2),
y: +head.position.y.toFixed(2),
z: +head.position.z.toFixed(2),
rotationY: +head.rotation.y.toFixed(3),
active: red > green ? 'red' : 'green',
};
});
return { vehicle, pedestrian };
},
/**
* Per-vehicle stop-line probe: travel axis, distance to the next stop
* line and current speed, for red-light compliance checks.
*/
trafficProbe() {
if (!vehicleSystem) return [];
const out = [];
for (const v of vehicleSystem.vehicles) {
if (!v.pts) continue;
const probe = probeAhead(v.pts, v.s, v.L);
const cur = new THREE.Vector3();
posOnPath(v.pts, v.s, v.L, cur);
out.push({
x: +cur.x.toFixed(2),
z: +cur.z.toFixed(2),
speed: +(v.speed || 0).toFixed(2),
axis: probe.axis,
distToStop: +distanceToStopLine(probe, cur).toFixed(2),
state: trafficSystem ? trafficSystem.axisState(probe.axis) : null,
});
}
return out;
},
playerCrossingAllowed,
/** Local player pose for movement/look tests. */
playerPose() {
return {
x: +player.pos.x.toFixed(3),
y: +player.pos.y.toFixed(3),
z: +player.pos.z.toFixed(3),
yaw: +player.yaw.toFixed(4),
pitch: +player.pitch.toFixed(4),
locked,
touchMode,
};
},
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
setHour(h) {
sim.hour = ((h % 24) + 24) % 24;
updateDayNight();
updateHUD();
},
setWeather,
audio: {
state: () => cityAudio.state(),
setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); },
setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); },
unlock: () => cityAudio.unlock(),
play: (name) => { cityAudio.unlock(); cityAudio.play(name); },
},
teleport(x, z, yaw = null, pitch = 0, y = null) {
player.pos.x = x;
player.pos.z = z;
player.pos.y = y !== null ? y : player.eyeHeight;
player.vel.set(0, 0, 0);
if (yaw !== null) player.yaw = yaw;
player.pitch = pitch;
updateCameraFromPlayer();
},
stats() {
return {
ready: this.ready,
hour: +sim.hour.toFixed(2),
weather: weatherState.state,
trafficPhase: this.trafficPhase,
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
plazas: plazas.length,
gymEquipment: gymEquipment.length,
exercising: exercise.active,
nearGym: (() => {
const n = spawnApplied ? nearestGymEquipment(4) : null;
return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null;
})(),
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
crosswalks: crosswalkCount,
sidewalkSegments: sidewalkSegmentCount,
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();