Files
City10/public/js/city/app.js
T
marcocosta10000 090b530481 feat(city): add plaza exercise system with health tracking
- Adds CityPlazas support class for deterministic public plaza generation with outdoor gym equipment
- Implements exercise and stop-exercise actions with proximity validation to equipment
- Introduces health (0-100) and exercise_count attributes to Character model
- Reduces hospital stay duration from two game days to one game hour
- Adds 3D gym equipment rendering (bench, bars, bike, treadmill) in city viewer
- Includes exercise state machine handling in world ticker with energy drain
- Adds comprehensive PlazaExerciseTest and updates HospitalAdmissionTest
- Provides localized UI strings for health HUD and exercise prompts
2026-10-17 22:10:50 -03:00

3394 lines
123 KiB
JavaScript
Executable File

import * as THREE from '/vendor/three/three.module.min.js';
import { cityAudio } from './audio.js';
const cfg = window.CITY_CONFIG || {};
const i18n = cfg.i18n || {};
const host = document.getElementById('scene');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const startGate = document.getElementById('start-gate');
const statsEl = document.getElementById('stats');
const selectedEl = document.getElementById('selected');
const reloadBtn = document.getElementById('reload');
const posPanel = document.getElementById('hud-pos');
const posXYZ = document.getElementById('pos-xyz');
const posGround = document.getElementById('pos-ground');
const posTris = document.getElementById('pos-tris');
const clockEl = document.getElementById('hud-clock');
const weatherEl = document.getElementById('hud-weather');
const dataUrl = cfg.dataUrl;
const csrf = cfg.csrf;
// ------------------------------------------------------------------
// Scene setup
// ------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
camera.position.set(0, 1.7, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
host.appendChild(renderer.domElement);
const hemiLight = new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55);
scene.add(hemiLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.35);
scene.add(ambientLight);
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
sun.position.set(80, 140, 60);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -200;
sun.shadow.camera.right = 200;
sun.shadow.camera.top = 200;
sun.shadow.camera.bottom = -200;
sun.shadow.camera.far = 500;
scene.add(sun);
// ------------------------------------------------------------------
// Texture helpers
// ------------------------------------------------------------------
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
draw(ctx, size);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(repeatX, repeatY);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
const asphaltTex = canvasTexture(512, (ctx, s) => {
ctx.fillStyle = '#1a1a1e';
ctx.fillRect(0, 0, s, s);
for (let i = 0; i < 9000; i++) {
const v = 20 + Math.random() * 30;
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
}
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(s * 0.44, 0, 4, s);
ctx.fillRect(s * 0.54, 0, 4, s);
ctx.fillStyle = '#d0d0d0';
for (let y = 0; y < s; y += 48) {
ctx.fillRect(s * 0.08, y, 4, 26);
ctx.fillRect(s * 0.90, y, 4, 26);
}
});
const sidewalkTex = canvasTexture(256, (ctx, s) => {
ctx.fillStyle = '#b8bcc4';
ctx.fillRect(0, 0, s, s);
const tile = 64;
ctx.strokeStyle = '#9aa0a8';
ctx.lineWidth = 2;
for (let i = 0; i <= s; i += tile) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
}
for (let i = 0; i < 40; i++) {
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
ctx.fill();
}
}, 4, 1);
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
const w = 512, h = 512;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
const e = document.createElement('canvas');
e.width = w; e.height = h;
const ectx = e.getContext('2d');
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, w, h);
ectx.fillStyle = '#000000';
ectx.fillRect(0, 0, w, h);
const floorH = h / floorCount;
for (let f = 0; f < floorCount; f++) {
ctx.fillStyle = 'rgba(30,30,30,0.85)';
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
for (let wi = 0; wi < windowsPerFloor; wi++) {
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
const wy = f * floorH + floorH * 0.18;
const ww = (w / windowsPerFloor) * 0.5;
const wh = floorH * 0.5;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
ctx.fillRect(wx, wy, ww, wh);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
if (Math.random() > 0.45) {
ectx.fillStyle = Math.random() > 0.5 ? '#ffd9a0' : '#ffcc80';
ectx.fillRect(wx, wy, ww, wh);
}
}
}
ctx.fillStyle = 'rgba(40,40,40,0.7)';
ctx.fillRect(0, 0, 6, h);
ctx.fillRect(w - 6, 0, 6, h);
const map = new THREE.CanvasTexture(c);
const emissiveMap = new THREE.CanvasTexture(e);
map.anisotropy = renderer.capabilities.getMaxAnisotropy();
emissiveMap.anisotropy = renderer.capabilities.getMaxAnisotropy();
return { map, emissiveMap };
}
function facadeTexture(baseColor, floorCount, windowsPerFloor) {
return facadePair(baseColor, floorCount, windowsPerFloor).map;
}
function doorTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, 128, 256);
ctx.fillStyle = '#3a2f1a';
ctx.fillRect(8, 8, 112, 240);
ctx.fillStyle = '#c9a84c';
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
return new THREE.CanvasTexture(c);
}
// ------------------------------------------------------------------
// World state
// ------------------------------------------------------------------
let cityData = null;
let meCharacter = null;
let cityBounds = null;
let citizenMeshes = [];
const colliders = [];
const worldGroup = new THREE.Group();
scene.add(worldGroup);
const storeMeshes = [];
const agentMeshes = [];
const crowdMeshes = [];
const facadeMaterials = [];
const lampHeads = [];
let grid = null;
let trafficSystem = null;
let vehicleSystem = null;
let pedestrianSystem = null;
let airplaneSystem = null;
let trainSystem = null;
let riverSystem = null;
let shopSystem = null;
let mayorSystem = null;
let meMesh = null;
let hospitalBusy = false;
let spawnApplied = false;
let plazas = [];
let gymEquipment = [];
let exercise = {
active: false,
equipmentId: null,
repTimer: 0,
animPhase: 0,
nearId: null,
};
function clearGroup(g) {
while (g.children.length) {
const c = g.children.pop();
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
}
citizenMeshes = [];
}
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;
const night = isDay ? 0 : 1;
sim.nightFactor = night;
const weatherDim = weatherSunDim();
const sky = skyColorForHour(hour);
const gray = new THREE.Color(0x4a5560);
const dim = weatherState.state === 'clear' ? 0
: weatherState.state === 'cloudy' ? 0.35
: weatherState.state === 'rain' ? 0.55 : 0.7;
sky.lerp(gray, dim * (isDay ? 1 : 0.4));
if (lightningFlash > 0) {
sky.lerp(new THREE.Color(0xdfe8ff), Math.min(1, lightningFlash * 2));
}
scene.background = sky;
scene.fog.color.copy(sky);
const ang = ((hour - 6) / 12) * Math.PI;
sun.position.set(Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), 60);
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
hemiLight.intensity = 0.12 + (isDay ? elevation * 0.5 : 0) * weatherDim;
ambientLight.intensity = 0.14 + (isDay ? elevation * 0.28 : 0) * weatherDim + lightningFlash;
const fogNear = weatherState.state === 'rain' ? 50 : weatherState.state === 'storm' ? 35 : (night ? 70 : 110);
const fogFar = weatherState.state === 'rain' ? 340 : weatherState.state === 'storm' ? 220 : (night ? 380 : 600);
scene.fog.near = fogNear;
scene.fog.far = fogFar;
const lampGlow = 0.05 + night * 1.5;
for (const m of lampHeads) m.emissiveIntensity = lampGlow;
for (const m of facadeMaterials) m.emissiveIntensity = night * 0.85;
}
// ------------------------------------------------------------------
// Weather system
// ------------------------------------------------------------------
const WEATHER_TYPES = ['clear', 'cloudy', 'rain', 'storm'];
const WEATHER_ICONS = { clear: '☀️', cloudy: '☁️', rain: '🌧️', storm: '⛈️' };
const weatherState = {
state: 'clear',
timer: 0,
duration: 50,
sequence: ['clear', 'cloudy', 'rain', 'storm', 'cloudy', 'clear'],
index: 0,
};
let lightningFlash = 0;
let lightningTimer = 3;
let rainPoints = null;
let cloudSprites = [];
function weatherSunDim() {
switch (weatherState.state) {
case 'cloudy': return 0.55;
case 'rain': return 0.3;
case 'storm': return 0.15;
default: return 1;
}
}
function makeCloudTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 128;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(64, 64, 8, 64, 64, 60);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(0.6, 'rgba(230,235,240,0.55)');
g.addColorStop(1, 'rgba(220,225,230,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 128, 128);
return new THREE.CanvasTexture(c);
}
function initWeather() {
const rainGeo = new THREE.BufferGeometry();
const N = 2500;
const pos = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
pos[i * 3] = (Math.random() - 0.5) * 70;
pos[i * 3 + 1] = Math.random() * 40;
pos[i * 3 + 2] = (Math.random() - 0.5) * 70;
}
rainGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const rainMat = new THREE.PointsMaterial({
color: 0x9ab8cc, size: 0.14, transparent: true, opacity: 0.65, depthWrite: false,
});
rainPoints = new THREE.Points(rainGeo, rainMat);
rainPoints.visible = false;
scene.add(rainPoints);
const cloudTex = makeCloudTexture();
cloudSprites = [];
for (let i = 0; i < 14; i++) {
const mat = new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.3, depthWrite: false });
const s = new THREE.Sprite(mat);
s.position.set((Math.random() - 0.5) * 500, 55 + Math.random() * 35, (Math.random() - 0.5) * 500);
const sc = 45 + Math.random() * 55;
s.scale.set(sc, sc * 0.45, 1);
scene.add(s);
cloudSprites.push(s);
}
}
function applyWeatherVisuals() {
const w = weatherState.state;
const raining = w === 'rain' || w === 'storm';
if (rainPoints) {
rainPoints.visible = raining;
rainPoints.material.opacity = w === 'storm' ? 0.85 : 0.6;
}
const cloudOpacity = { clear: 0.12, cloudy: 0.65, rain: 0.85, storm: 0.95 }[w] ?? 0.3;
const cloudTint = w === 'storm' ? 0x6a7280 : w === 'rain' ? 0x9aa3ad : 0xffffff;
for (const s of cloudSprites) {
s.material.opacity = cloudOpacity;
s.material.color.setHex(cloudTint);
}
cityAudio.setWeather(w);
}
function setWeather(type) {
if (!WEATHER_TYPES.includes(type)) return;
weatherState.state = type;
weatherState.timer = 0;
applyWeatherVisuals();
}
function updateWeather(dt) {
weatherState.timer += dt;
if (weatherState.timer >= weatherState.duration) {
weatherState.timer = 0;
weatherState.index = (weatherState.index + 1) % weatherState.sequence.length;
weatherState.state = weatherState.sequence[weatherState.index];
applyWeatherVisuals();
}
for (const s of cloudSprites) {
s.position.x += dt * (weatherState.state === 'storm' ? 3.5 : 1.2);
if (s.position.x > 320) s.position.x = -320;
}
if (rainPoints && rainPoints.visible) {
rainPoints.position.x = camera.position.x;
rainPoints.position.z = camera.position.z;
const arr = rainPoints.geometry.attributes.position.array;
const fall = weatherState.state === 'storm' ? 38 : 26;
for (let i = 1; i < arr.length; i += 3) {
arr[i] -= fall * dt;
if (arr[i] < 0) arr[i] = 40;
}
rainPoints.geometry.attributes.position.needsUpdate = true;
}
if (lightningFlash > 0) lightningFlash = Math.max(0, lightningFlash - dt * 4);
if (weatherState.state === 'storm') {
lightningTimer -= dt;
if (lightningTimer <= 0) {
lightningTimer = 2.5 + Math.random() * 5;
lightningFlash = 1;
cityAudio.play('thunder');
}
}
}
// ------------------------------------------------------------------
// City grid helpers
// ------------------------------------------------------------------
function buildGrid(layout) {
const xs = [...new Set(layout.roads.filter(r => r.axis === 'z').map(r => r.from[0]))]
.sort((a, b) => a - b);
const zs = [...new Set(layout.roads.filter(r => r.axis === 'x').map(r => r.from[2]))]
.sort((a, b) => a - b);
const roadW = layout.roads[0]?.width ?? 8;
const squares = [];
for (let i = 0; i < xs.length - 1; i++) {
for (let j = 0; j < zs.length - 1; j++) {
squares.push({ x0: xs[i], x1: xs[i + 1], z0: zs[j], z1: zs[j + 1] });
}
}
return { xs, zs, roadW, squares, minX: xs[0], maxX: xs[xs.length - 1], minZ: zs[0], maxZ: zs[zs.length - 1] };
}
function onRoad(x, z, g) {
const lim = g.roadW / 2 - 0.4;
for (const vx of g.xs) if (Math.abs(x - vx) < lim) return true;
for (const vz of g.zs) if (Math.abs(z - vz) < lim) return true;
return false;
}
// ------------------------------------------------------------------
// Traffic light system
// ------------------------------------------------------------------
const PHASES = [
{ name: 'NS_GREEN', dur: 10, z: 'green', x: 'red' },
{ name: 'NS_YELLOW', dur: 2, z: 'yellow', x: 'red' },
{ name: 'ALL_RED1', dur: 1, z: 'red', x: 'red' },
{ name: 'EW_GREEN', dur: 10, z: 'red', x: 'green' },
{ name: 'EW_YELLOW', dur: 2, z: 'red', x: 'yellow' },
{ name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' },
];
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 poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
for (const x of g.xs) {
for (const z of g.zs) {
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat);
pole.position.set(x + g.roadW / 2 + 1.6, 2.1, z + g.roadW / 2 + 1.6);
pole.castShadow = true;
group.add(pole);
const headNS = makeSignalHead();
headNS.position.set(x + g.roadW / 2 + 1.6, 3.8, z + g.roadW / 2 + 0.7);
group.add(headNS);
heads.push({ head: headNS, axis: 'z' });
const headEW = makeSignalHead();
headEW.position.set(x + g.roadW / 2 + 0.7, 3.8, z + g.roadW / 2 + 1.6);
headEW.rotation.y = Math.PI / 2;
group.add(headEW);
heads.push({ head: headEW, axis: 'x' });
}
}
worldGroup.add(group);
trafficSystem = {
phaseIndex: 0,
phaseTimer: 0,
heads,
axisState(axis) {
return PHASES[this.phaseIndex][axis];
},
get phaseName() { return PHASES[this.phaseIndex].name; },
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;
}
},
};
}
// ------------------------------------------------------------------
// 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.0;
const bodyW = isBus ? 2.2 : 1.8;
const bodyH = isBus ? 1.8 : 0.65;
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.35 });
const body = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), mat);
body.position.y = isBus ? 1.1 : 0.55;
body.castShadow = true;
g.add(body);
if (isBus) {
const winMat = new THREE.MeshStandardMaterial({ color: 0x88ccee, roughness: 0.2, metalness: 0.5 });
const win = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.6, bodyW + 0.02), winMat);
win.position.y = 1.55;
g.add(win);
} else {
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(bodyLen * 0.5, 0.5, bodyW * 0.85),
new THREE.MeshStandardMaterial({ color: 0x223344, roughness: 0.2, metalness: 0.4 })
);
cabin.position.set(-0.15, 1.05, 0);
cabin.castShadow = true;
g.add(cabin);
}
const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.24, 12);
wheelGeo.rotateX(Math.PI / 2);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 });
const wheels = [];
const wx = bodyLen * 0.32;
const wz = bodyW / 2 + 0.02;
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
const w = new THREE.Mesh(wheelGeo, wheelMat);
w.position.set(px, 0.32, pz);
g.add(w);
wheels.push(w);
});
g.userData.wheels = wheels;
g.userData.kind = isBus ? 'bus' : 'car';
g.userData.halfL = bodyLen * 0.45;
g.userData.halfW = bodyW * 0.55;
return g;
}
function buildMotoMesh(colorHex) {
const g = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.55 });
const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.25, metalness: 0.85 });
const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 });
const tank = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.35, 0.4), bodyMat);
tank.position.set(0.1, 0.72, 0);
tank.castShadow = true;
g.add(tank);
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.12, 0.35), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }));
seat.position.set(-0.35, 0.82, 0);
g.add(seat);
const engine = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.3, 0.35), chrome);
engine.position.set(0.15, 0.42, 0);
g.add(engine);
const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.55, 6), 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.08, 0.08, 0.55), chrome);
bar.position.set(0.62, 0.85, 0);
g.add(bar);
const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.12, 12);
wheelGeo.rotateX(Math.PI / 2);
const wheels = [];
[[0.72, 0], [-0.55, 0]].forEach(([px]) => {
const w = new THREE.Mesh(wheelGeo, rubber);
w.position.set(px, 0.32, 0);
g.add(w);
wheels.push(w);
});
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.9, 6), chrome);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-0.2, 0.35, 0.2);
g.add(exhaust);
// Tiny rider silhouette so the moto is not empty.
const rider = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.45, 4, 6),
new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 })
);
rider.position.set(-0.15, 1.15, 0);
rider.rotation.z = -0.25;
rider.castShadow = true;
g.add(rider);
const helmet = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 10, 10),
new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.4 })
);
helmet.position.set(0.05, 1.5, 0);
g.add(helmet);
g.userData.wheels = wheels;
g.userData.kind = 'moto';
g.userData.halfL = 0.95;
g.userData.halfW = 0.35;
return g;
}
function buildBikeMesh(colorHex) {
const g = new THREE.Group();
const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.45, metalness: 0.5 });
const rubber = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 });
const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.3, metalness: 0.8 });
const tube = (len, x, y, rz = 0) => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 6), frameMat);
m.position.set(x, y, 0);
m.rotation.z = rz;
g.add(m);
return m;
};
tube(1.05, 0, 0.55, Math.PI / 2.4); // down tube
tube(0.55, -0.15, 0.75, 0); // seat post
tube(0.45, 0.35, 0.85, Math.PI / 2); // top tube-ish
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.06, 0.16), new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 }));
seat.position.set(-0.2, 1.0, 0);
g.add(seat);
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.5), chrome);
bar.position.set(0.5, 1.0, 0);
g.add(bar);
const wheelGeo = new THREE.TorusGeometry(0.32, 0.045, 8, 18);
const wheels = [];
[[0.55], [-0.55]].forEach(([px]) => {
const w = new THREE.Mesh(wheelGeo, rubber);
w.position.set(px, 0.32, 0);
g.add(w);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.08, 8), chrome);
hub.rotation.x = Math.PI / 2;
hub.position.set(px, 0.32, 0);
g.add(hub);
wheels.push(w);
});
// Rider pedalling.
const torso = new THREE.Mesh(
new THREE.CapsuleGeometry(0.16, 0.4, 4, 6),
new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 })
);
torso.position.set(-0.05, 1.25, 0);
torso.rotation.z = -0.35;
torso.castShadow = true;
g.add(torso);
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 10, 10),
new THREE.MeshStandardMaterial({ color: 0xf1c27d, roughness: 0.7 })
);
head.position.set(0.12, 1.55, 0);
g.add(head);
const legGeo = new THREE.CapsuleGeometry(0.06, 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.75, side * 0.12);
leg.rotation.z = side * 0.4;
g.add(leg);
wheels.push(leg); // animate legs with "wheel" spin for a pedalling look
});
g.userData.wheels = wheels;
g.userData.kind = 'bike';
g.userData.halfL = 0.85;
g.userData.halfW = 0.3;
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 = [];
for (const sq of g.squares) {
const dx = sq.x1 - sq.x0;
const dz = sq.z1 - sq.z0;
if (dx < 30 || dz < 30) continue;
// Inner rectangle = one circulation direction (right-hand lanes);
// outer rectangle = the opposite direction on the far side of the roads.
const ptsA = squarePath(sq, g.roadW / 4);
const ptsB = squarePathOuter(sq, g.roadW / 4);
loops.push({ pts: ptsA, L: pathLength(ptsA) });
loops.push({ pts: ptsB, L: pathLength(ptsB) });
}
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;
}
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 };
}
rem -= seg;
}
return { distToCorner: 999, axis: 'x', corner: pts[0] };
}
function updateLoopVehicle(v, dt) {
const probe = probeAhead(v.pts, v.s, v.L);
const distToCorner = probe.distToCorner;
let wantStop = false;
if (trafficSystem && distToCorner < 7.5) {
const state = trafficSystem.axisState(probe.axis);
if (state === 'red') wantStop = true;
else if (state === 'yellow' && distToCorner > 4) wantStop = true;
}
const gap = vehicleAheadGap(v);
if (gap < 9) wantStop = true;
if (wantStop) {
v.speed = Math.max(0, v.speed - v.decel * dt);
if (distToCorner < 2.2 && v.speed < 0.5) v.speed = 0;
} else {
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
}
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);
posOnPath(v.pts, v.s + 0.6, v.L, tmp);
v.mesh.position.copy(pos);
if (tmp.distanceToSquared(pos) > 0.0001) {
v.mesh.rotation.y = Math.atan2(tmp.x - pos.x, tmp.z - pos.z) + Math.PI / 2;
}
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;
}
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 = Math.atan2(look.x - pos.x, look.z - pos.z) + Math.PI / 2;
}
for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3;
}
// ------------------------------------------------------------------
// Humanoid builder
// ------------------------------------------------------------------
function buildHumanoid(agent, showLabel = true) {
const g = new THREE.Group();
const type = agent.type || 'pedestrian';
const configs = {
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
};
const cfgc = configs[type] || configs.pedestrian;
const s = cfgc.scale;
const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 });
const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 });
const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 });
const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
head.position.y = 1.62 * s;
head.castShadow = true;
g.add(head);
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
hair.position.y = 1.65 * s;
hair.castShadow = true;
g.add(hair);
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
torso.position.y = 1.12 * s;
torso.castShadow = true;
g.add(torso);
const arms = [];
[-1, 1].forEach(side => {
const shoulder = new THREE.Group();
shoulder.position.set(side * 0.32 * s, 1.38 * s, 0);
shoulder.rotation.z = side * 0.12;
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
arm.position.y = -0.28 * s;
arm.castShadow = true;
shoulder.add(arm);
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
hand.position.y = -0.56 * s;
shoulder.add(hand);
g.add(shoulder);
arms.push(shoulder);
});
const legs = [];
[-1, 1].forEach(side => {
const hip = new THREE.Group();
hip.position.set(side * 0.13 * s, 0.78 * s, 0);
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
leg.position.y = -0.36 * s;
leg.castShadow = true;
hip.add(leg);
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
shoe.position.set(0, -0.72 * s, 0.05 * s);
shoe.castShadow = true;
hip.add(shoe);
g.add(hip);
legs.push(hip);
});
let label = null;
if (showLabel) {
label = makeLabel(agent.name, agent.status || 'active');
label.position.y = 2.15 * s;
g.add(label);
}
g.userData = { agent, label, legs, arms, animPhase: Math.random() * Math.PI * 2, moving: false };
return g;
}
function animateHumanoid(mesh, dt, speed) {
const u = mesh.userData;
const moving = speed > 0.05;
u.moving = moving;
if (moving) {
u.animPhase += dt * (4 + speed * 2.5);
const swing = Math.sin(u.animPhase) * 0.55;
if (u.legs) {
u.legs[0].rotation.x = swing;
u.legs[1].rotation.x = -swing;
u.arms[0].rotation.x = -swing * 0.8;
u.arms[1].rotation.x = swing * 0.8;
}
} else {
if (u.legs) {
u.legs[0].rotation.x *= 0.85;
u.legs[1].rotation.x *= 0.85;
u.arms[0].rotation.x *= 0.85;
u.arms[1].rotation.x *= 0.85;
}
}
}
function makeLabel(name, status) {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(10,15,28,0.85)';
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
ctx.fill();
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
const nm = name.length > 20 ? name.slice(0, 19) + '…' : name;
ctx.fillText(nm, 128, 30);
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
ctx.fillStyle = dotColor;
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#94a3b8';
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(status || 'active', 56, 53);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.scale.set(3.2, 0.8, 1);
return sprite;
}
function refreshLabel(mesh, status) {
const u = mesh.userData;
if (!u || !u.agent) return;
u.agent.status = status;
if (u.label) {
mesh.remove(u.label);
u.label.material.map?.dispose();
u.label.material.dispose();
}
const l = makeLabel(u.agent.name, status);
l.position.y = 2.15;
mesh.add(l);
u.label = l;
}
// ------------------------------------------------------------------
// Pedestrian routes + system
// ------------------------------------------------------------------
function segmentCrossAxis(a, b, g) {
const dx = Math.abs(b.x - a.x);
const dz = Math.abs(b.z - a.z);
if (dx < 7 && dz < 7) return null;
if (dx >= dz) {
for (const vx of g.xs) {
if (vx > Math.min(a.x, b.x) + 1 && vx < Math.max(a.x, b.x) - 1) return 'z';
}
return null;
}
for (const vz of g.zs) {
if (vz > Math.min(a.z, b.z) + 1 && vz < Math.max(a.z, b.z) - 1) return 'x';
}
return null;
}
function makeRoute(pts, g) {
const waypoints = pts.map(p => ({ x: p.x ?? p[0], z: p.z ?? p[1] }));
const segments = [];
for (let i = 0; i < waypoints.length; i++) {
const a = waypoints[i];
const b = waypoints[(i + 1) % waypoints.length];
segments.push({
crossAxis: segmentCrossAxis(
new THREE.Vector3(a.x, 0, a.z),
new THREE.Vector3(b.x, 0, b.z),
g
),
midX: (a.x + b.x) / 2,
midZ: (a.z + b.z) / 2,
});
}
return { waypoints, segments };
}
function ringRoute(sq, g, inset) {
const o = inset;
return makeRoute([
{ x: sq.x0 + o, z: sq.z0 + o },
{ x: sq.x1 - o, z: sq.z0 + o },
{ x: sq.x1 - o, z: sq.z1 - o },
{ x: sq.x0 + o, z: sq.z1 - o },
], g);
}
function crossingRoute(sq, g) {
const o = g.roadW / 2 + 1.2;
const zc = (sq.z0 + sq.z1) / 2;
return makeRoute([
{ x: sq.x0 + o, z: zc },
{ x: sq.x0 - o, z: zc },
{ x: sq.x0 - o, z: zc + 5 },
{ x: sq.x0 + o, z: zc + 5 },
], g);
}
function vehiclesNear(x, z, radius) {
if (!vehicleSystem) return false;
const r2 = radius * radius;
for (const v of vehicleSystem.vehicles) {
const p = new THREE.Vector3();
if (v.pts) posOnPath(v.pts, v.s, v.L, p);
else p.lerpVectors(v.a, v.b, v.t);
const dx = p.x - x, dz = p.z - z;
if (dx * dx + dz * dz < r2) return true;
}
return false;
}
function initPedestrians(g, agents) {
const walkers = [];
const routes = [];
g.squares.forEach((sq, i) => {
routes.push({ route: ringRoute(sq, g, g.roadW / 2 + 1.2), kind: 'ring' });
if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' });
});
const pedAgents = agents.filter(a => a.type === 'pedestrian');
pedAgents.forEach((a, i) => {
const pick = routes[i % routes.length];
const mesh = buildHumanoid(a, true);
worldGroup.add(mesh);
agentMeshes.push(mesh);
const w = {
mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4,
wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0,
};
const start = w.route.waypoints[0];
mesh.position.set(start.x, 0, start.z);
walkers.push(w);
});
const CROWD_COUNT = 14;
for (let i = 0; i < CROWD_COUNT; i++) {
const pick = routes[(i + pedAgents.length + 1) % routes.length];
const mesh = buildHumanoid({ name: '', type: 'crowd', status: 'active' }, false);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
const start = pick.route.waypoints[i % pick.route.waypoints.length];
mesh.position.set(start.x, 0, start.z);
walkers.push({
mesh, route: pick.route, wp: i % pick.route.waypoints.length,
speed: 1.0 + Math.random() * 0.5, wait: Math.random() * 3,
pauseAt: 5 + Math.random() * 10, pauseTimer: 0,
});
}
pedestrianSystem = {
walkers,
count() { return walkers.length; },
positions() {
return walkers.map(w => ({ x: w.mesh.position.x, z: w.mesh.position.z }));
},
update(dt) {
for (const w of walkers) {
if (w.wait > 0) {
w.wait -= dt;
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (w.pauseTimer > 0) {
w.pauseTimer -= dt;
animateHumanoid(w.mesh, dt, 0);
continue;
}
const wps = w.route.waypoints;
const segIdx = w.wp % w.route.segments.length;
const seg = w.route.segments[segIdx];
const target = wps[(w.wp + 1) % wps.length];
if (seg.crossAxis && trafficSystem) {
const state = trafficSystem.axisState(seg.crossAxis);
const meshPos = w.mesh.position;
const distToMid = Math.hypot(meshPos.x - seg.midX, meshPos.z - seg.midZ);
const approaching = distToMid > 1.5;
if (approaching && distToMid < 14) {
if (state !== 'red') {
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (vehiclesNear(seg.midX, seg.midZ, 8)) {
animateHumanoid(w.mesh, dt, 0);
continue;
}
}
}
const dx = target.x - w.mesh.position.x;
const dz = target.z - w.mesh.position.z;
const dist = Math.hypot(dx, dz);
if (onRoad(w.mesh.position.x, w.mesh.position.z, g) && vehiclesNear(w.mesh.position.x, w.mesh.position.z, 5)) {
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (dist < 0.35) {
w.wp = (w.wp + 1) % wps.length;
if (Math.random() < 0.22) w.pauseTimer = w.pauseAt * (0.4 + Math.random() * 0.6);
animateHumanoid(w.mesh, dt, 0);
continue;
}
const step = Math.min(dist, w.speed * dt);
w.mesh.position.x += (dx / dist) * step;
w.mesh.position.z += (dz / dist) * step;
w.mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(w.mesh, dt, w.speed);
}
},
};
}
// ------------------------------------------------------------------
// Shop system (open/close by hour, signs, shopkeepers)
// ------------------------------------------------------------------
function makeStoreSign(store, isOpen) {
const c = document.createElement('canvas');
c.width = 512; c.height = 128;
const ctx = c.getContext('2d');
ctx.fillStyle = '#0d1118';
ctx.fillRect(0, 0, 512, 128);
ctx.strokeStyle = isOpen ? '#22c55e' : '#ef4444';
ctx.lineWidth = 6;
ctx.strokeRect(4, 4, 504, 120);
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 34px sans-serif';
ctx.textAlign = 'center';
const nm = store.name.length > 24 ? store.name.slice(0, 23) + '…' : store.name;
ctx.fillText(nm, 256, 55);
ctx.font = 'bold 30px sans-serif';
ctx.fillStyle = isOpen ? '#4ade80' : '#f87171';
ctx.fillText(isOpen ? (i18n.open || 'OPEN') : (i18n.closed || 'CLOSED'), 256, 100);
const tex = new THREE.CanvasTexture(c);
const mat = new THREE.MeshStandardMaterial({
map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.4,
roughness: 0.6,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 0.9), mat);
return mesh;
}
function initShops(stores, agents) {
const entries = [];
for (const s of stores) {
const mesh = storeMeshes.find(m => m.userData?.store?.id === s.id);
if (!mesh) continue;
const [x, y, z] = s.position;
const [w, h, d] = s.size;
const sign = makeStoreSign(s, shopOpenNow(s));
sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
worldGroup.add(sign);
entries.push({ store: s, sign, lastOpen: shopOpenNow(s) });
const light = new THREE.PointLight(0xffe0a0, 0, 8, 2);
light.position.set(x, y + 2.6, z + d / 2 + 1.2);
worldGroup.add(light);
entries[entries.length - 1].light = light;
}
const keepers = [];
const shopAgents = agents.filter(a => a.type === 'shopkeeper');
for (const a of shopAgents) {
const store = stores.find(s => s.name?.includes(a.name.split('—').pop()?.trim() || '§'))
|| stores.reduce((best, s) => {
const d = Math.hypot(s.position[0] - a.position[0], s.position[2] - a.position[2]);
return (!best || d < best.d) ? { s, d } : best;
}, null)?.s
|| stores[0];
if (!store) continue;
const mesh = buildHumanoid(a, true);
const [sx, , sz] = store.position;
const [, , sd] = store.size;
mesh.position.set(sx + 1.5, 0, sz + sd / 2 + 1.6);
mesh.rotation.y = Math.PI;
worldGroup.add(mesh);
agentMeshes.push(mesh);
keepers.push({ mesh, home: mesh.position.clone(), t: Math.random() * 10 });
}
shopSystem = {
entries, keepers,
openCount() { return entries.filter(e => shopOpenNow(e.store)).length; },
update(dt) {
const hour = sim.hour;
for (const e of entries) {
const open = shopOpenNow(e.store);
if (open !== e.lastOpen) {
e.lastOpen = open;
worldGroup.remove(e.sign);
e.sign.geometry.dispose();
e.sign.material.map?.dispose();
e.sign.material.emissiveMap?.dispose();
e.sign.material.dispose();
e.sign = makeStoreSign(e.store, open);
const [x, y, z] = e.store.position;
const d = e.store.size[2];
e.sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
worldGroup.add(e.sign);
}
const night = sim.nightFactor;
e.sign.material.emissiveIntensity = open ? 0.5 + night * 1.3 : 0.15 + night * 0.3;
if (e.light) e.light.intensity = open ? night * 1.6 : night * 0.3;
}
for (const k of this.keepers) {
k.t += dt;
const store = entries.find(e => Math.abs(e.store.position[0] - k.home.x) < 6);
const open = store ? shopOpenNow(store.store) : true;
if (open) {
const sway = Math.sin(k.t * 1.5) * 0.4;
k.mesh.position.x = k.home.x + sway * 0.3;
k.mesh.rotation.y = Math.PI + Math.sin(k.t * 0.7) * 0.3;
animateHumanoid(k.mesh, dt, 0.15);
} else {
k.mesh.position.x = k.home.x;
animateHumanoid(k.mesh, dt, 0);
}
}
},
};
}
function shopOpenNow(store) {
if (!store.is_open) return false;
return sim.hour >= 8 && sim.hour < 20;
}
// ------------------------------------------------------------------
// Mayor patrol system
// ------------------------------------------------------------------
function initMayor(agents, stores, g) {
const mayor = agents.find(a => a.type === 'mayor');
if (!mayor) { mayorSystem = { update() {}, mesh: null }; return; }
const pts = [];
for (const s of stores) {
pts.push({ x: s.position[0] + s.size[0] / 2 + 2, z: s.position[2] + s.size[2] / 2 + 2.2 });
}
pts.push({ x: g.xs[0] + g.roadW / 2 + 1.2, z: g.zs[0] + g.roadW / 2 + 1.2 });
pts.push({ x: g.xs[Math.floor(g.xs.length / 2)] + g.roadW / 2 + 1.2, z: g.zs[Math.floor(g.zs.length / 2)] + g.roadW / 2 + 1.2 });
const route = makeRoute(pts, g);
const mesh = buildHumanoid(mayor, true);
mesh.position.set(route.waypoints[0].x, 0, route.waypoints[0].z);
worldGroup.add(mesh);
agentMeshes.push(mesh);
mayorSystem = {
mesh, route, wp: 0, speed: 1.05,
pauseTimer: 2, decideTimer: 8, status: 'active',
update(dt) {
this.decideTimer -= dt;
if (this.decideTimer <= 0) {
this.decideTimer = 8 + Math.random() * 7;
this.status = this.status === 'active' ? 'thinking' : 'active';
refreshLabel(mesh, this.status);
}
if (this.pauseTimer > 0) {
this.pauseTimer -= dt;
animateHumanoid(mesh, dt, 0);
return;
}
const seg = this.route.segments[this.wp % this.route.segments.length];
const target = this.route.waypoints[(this.wp + 1) % this.route.waypoints.length];
if (seg.crossAxis && trafficSystem) {
const state = trafficSystem.axisState(seg.crossAxis);
const dMid = Math.hypot(mesh.position.x - seg.midX, mesh.position.z - seg.midZ);
if (dMid > 1.5 && dMid < 14) {
if (state !== 'red' || vehiclesNear(seg.midX, seg.midZ, 8)) {
animateHumanoid(mesh, dt, 0);
return;
}
}
}
const dx = target.x - mesh.position.x;
const dz = target.z - mesh.position.z;
const dist = Math.hypot(dx, dz);
if (dist < 0.4) {
this.wp = (this.wp + 1) % this.route.waypoints.length;
this.pauseTimer = 3 + Math.random() * 3;
if (this.status !== 'thinking') {
this.status = 'thinking';
refreshLabel(mesh, this.status);
}
animateHumanoid(mesh, dt, 0);
return;
}
const step = Math.min(dist, this.speed * dt);
mesh.position.x += (dx / dist) * step;
mesh.position.z += (dz / dist) * step;
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, dt, this.speed);
},
};
}
// ------------------------------------------------------------------
// Airplane system
// ------------------------------------------------------------------
function buildAirplane(colorHex) {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.5 });
const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 9, 10), mat);
fus.rotation.z = Math.PI / 2;
g.add(fus);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.8, 10), mat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 5.3;
g.add(nose);
const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.15, 11), mat);
wing.position.x = 0.5;
g.add(wing);
const tailW = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 4), mat);
tailW.position.x = -4;
g.add(tailW);
const tailF = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.12), mat);
tailF.position.set(-4.1, 0.8, 0);
g.add(tailF);
const beacon = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
);
beacon.position.set(0.5, 0.5, 0);
g.add(beacon);
g.userData.beacon = beacon;
return g;
}
function initAirplanes() {
const planes = [];
const defs = [
{ r: 260, y: 85, speed: 0.12, a0: 0, color: 0xdfe4ea },
{ r: 330, y: 110, speed: -0.09, a0: 2, color: 0xc0c8d2 },
{ r: 200, y: 70, speed: 0.15, a0: 4, color: 0xe8eef5 },
];
for (const d of defs) {
const mesh = buildAirplane(d.color);
scene.add(mesh);
planes.push({ mesh, ...d, angle: d.a0 });
}
airplaneSystem = {
planes,
count() { return planes.length; },
update(dt) {
for (const p of planes) {
p.angle += p.speed * dt;
const x = Math.cos(p.angle) * p.r;
const z = Math.sin(p.angle) * p.r;
p.mesh.position.set(x, p.y, z);
const tx = -Math.sin(p.angle) * Math.sign(p.speed);
const tz = Math.cos(p.angle) * Math.sign(p.speed);
p.mesh.rotation.y = Math.atan2(-tz, tx);
p.mesh.rotation.z = -0.3 * Math.sign(p.speed);
if (p.mesh.userData.beacon) {
p.mesh.userData.beacon.material.emissiveIntensity =
(Math.sin(performance.now() * 0.01) > 0) ? 2.5 : 0.2;
}
}
},
};
}
// ------------------------------------------------------------------
// Train system + level crossing
// ------------------------------------------------------------------
function initTrain(g) {
const trackZ = g.maxZ + 14;
const xMid = g.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.xs[0]);
const trackFrom = g.minX - 70;
const trackTo = g.maxX + 70;
const railMat = new THREE.MeshStandardMaterial({ color: 0x6a6a72, roughness: 0.4, metalness: 0.8 });
const bedMat = new THREE.MeshStandardMaterial({ color: 0x5a5248, roughness: 1 });
const bed = new THREE.Mesh(
new THREE.BoxGeometry(trackTo - trackFrom, 0.18, 3.6),
bedMat
);
bed.position.set((trackFrom + trackTo) / 2, 0.09, trackZ);
worldGroup.add(bed);
for (const off of [-0.75, 0.75]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(trackTo - trackFrom, 0.14, 0.12), railMat);
rail.position.set((trackFrom + trackTo) / 2, 0.24, trackZ + off);
worldGroup.add(rail);
}
const sleeperGeo = new THREE.BoxGeometry(0.35, 0.1, 2.6);
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3d2f22, roughness: 0.9 });
const count = Math.floor((trackTo - trackFrom) / 2);
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
const m4 = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
m4.setPosition(trackFrom + i * 2 + 1, 0.16, trackZ);
sleepers.setMatrixAt(i, m4);
}
worldGroup.add(sleepers);
const train = new THREE.Group();
const locoMat = new THREE.MeshStandardMaterial({ color: 0xb03030, roughness: 0.5, metalness: 0.3 });
const loco = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.4), locoMat);
loco.position.y = 1.5;
loco.castShadow = true;
train.add(loco);
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 2.2), new THREE.MeshStandardMaterial({ color: 0x882222 }));
cab.position.set(2.4, 3.2, 0);
train.add(cab);
const carColors = [0x2a5a8a, 0x2a5a8a, 0x3a7a4a, 0x2a5a8a];
carColors.forEach((col, i) => {
const car = new THREE.Mesh(
new THREE.BoxGeometry(6.5, 2.2, 2.3),
new THREE.MeshStandardMaterial({ color: col, roughness: 0.6 })
);
car.position.set(-6.5 - i * 7.2, 1.4, 0);
car.castShadow = true;
train.add(car);
});
worldGroup.add(train);
// Level crossing barriers on the vertical road that crosses the track.
const barriers = [];
for (const side of [-1, 1]) {
const pivot = new THREE.Group();
pivot.position.set(xMid + side * (g.roadW / 2 + 2.2), 1.1, trackZ + side * 2.6);
const armGeo = new THREE.BoxGeometry(7, 0.18, 0.25);
// Shift geometry so the pivot sits at the post end of the arm.
armGeo.translate(side > 0 ? -3.5 : 3.5, 0, 0);
const armMesh = new THREE.Mesh(
armGeo,
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 })
);
pivot.add(armMesh);
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff2222, emissiveIntensity: 0.3 })
);
lamp.position.y = 1.1;
pivot.add(lamp);
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.1, 0.12, 2.2, 6),
new THREE.MeshStandardMaterial({ color: 0x888888 })
);
pivot.add(post);
// Start raised (open).
pivot.rotation.z = -Math.PI / 2;
worldGroup.add(pivot);
barriers.push({ pivot, lamp });
}
const state = {
s: trackFrom + 30,
dir: 1,
speed: 14,
pause: 0,
blocks: false,
crossZ: trackZ,
xMid,
trackFrom,
trackTo,
train,
barriers,
};
trainSystem = {
...state,
blocksCrossing() { return this.blocks; },
update(dt) {
if (this.pause > 0) {
this.pause -= dt;
} else {
this.s += this.dir * this.speed * dt;
if (this.s >= this.trackTo - 40) {
this.s = this.trackTo - 40;
this.dir = -1;
this.pause = 3;
this.train.rotation.y = Math.PI;
} else if (this.s <= this.trackFrom + 40) {
this.s = this.trackFrom + 40;
this.dir = 1;
this.pause = 3;
this.train.rotation.y = 0;
}
}
const noseX = this.s + this.dir * 4;
this.train.position.set(this.s, 0, this.trackZ ?? this.crossZ);
const distToCross = Math.abs(noseX - this.xMid);
this.blocks = distToCross < 45 && this.pause <= 0;
const blink = Math.sin(performance.now() * 0.012) > 0;
for (const b of this.barriers) {
const target = this.blocks ? 0 : -Math.PI / 2;
b.pivot.rotation.z += (target - b.pivot.rotation.z) * Math.min(1, dt * 4);
b.lamp.material.emissiveIntensity = this.blocks ? (blink ? 3 : 0.2) : 0.2;
}
},
};
trainSystem.crossZ = trackZ;
trainSystem.trackZ = trackZ;
}
// ------------------------------------------------------------------
// River + bridge
// ------------------------------------------------------------------
function initRiver(g) {
const riverX = g.minX - 30;
const riverW = 16;
const zMid = g.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.zs[0]);
const len = (g.maxZ - g.minZ) + 140;
const waterTex = canvasTexture(256, (ctx, s) => {
const grad = ctx.createLinearGradient(0, 0, s, s);
grad.addColorStop(0, '#1a5f8a');
grad.addColorStop(0.5, '#2a7fb0');
grad.addColorStop(1, '#1a5f8a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 3;
for (let i = 0; i < 14; i++) {
const y = Math.random() * s;
ctx.beginPath();
ctx.moveTo(0, y);
for (let x = 0; x <= s; x += 16) {
ctx.lineTo(x, y + Math.sin(x * 0.08 + i) * 6);
}
ctx.stroke();
}
}, 3, 10);
const geo = new THREE.PlaneGeometry(riverW, len, 6, 40);
const water = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
map: waterTex, color: 0x88bbdd, roughness: 0.25, metalness: 0.35,
transparent: true, opacity: 0.92,
})
);
water.rotation.x = -Math.PI / 2;
water.position.set(riverX, 0.04, (g.minZ + g.maxZ) / 2);
water.receiveShadow = true;
worldGroup.add(water);
const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7f66, roughness: 1 });
for (const side of [-1, 1]) {
const bank = new THREE.Mesh(new THREE.BoxGeometry(4, 0.3, len), bankMat);
bank.position.set(riverX + side * (riverW / 2 + 1.5), 0.1, (g.minZ + g.maxZ) / 2);
worldGroup.add(bank);
}
const basePos = Float32Array.from(geo.attributes.position.array);
// Bridge where the mid horizontal road extension crosses the river.
const bridge = new THREE.Mesh(
new THREE.BoxGeometry(riverW + 8, 0.25, 9),
new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.9 })
);
bridge.position.set(riverX, 0.05, zMid);
bridge.receiveShadow = true;
worldGroup.add(bridge);
const railMat = new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.6, metalness: 0.4 });
for (const side of [-1, 1]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(riverW + 8, 0.7, 0.15), railMat);
rail.position.set(riverX, 0.5, zMid + side * 4.4);
worldGroup.add(rail);
const pier = new THREE.Mesh(
new THREE.BoxGeometry(1.2, 2.2, 1.2),
new THREE.MeshStandardMaterial({ color: 0x555560, roughness: 0.9 })
);
pier.position.set(riverX, -0.9, zMid + side * 3);
worldGroup.add(pier);
}
riverSystem = {
riverX, riverW, zMid, water, basePos,
update(dt, t) {
waterTex.offset.y = (waterTex.offset.y + dt * 0.04) % 1;
waterTex.offset.x = (waterTex.offset.x + dt * 0.012) % 1;
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const bx = basePos[i * 3];
const by = basePos[i * 3 + 1];
pos.array[i * 3 + 2] =
Math.sin(bx * 0.6 + t * 2.2) * 0.09 +
Math.sin(by * 0.35 + t * 1.6) * 0.07;
}
pos.needsUpdate = true;
},
};
}
// ------------------------------------------------------------------
// City builder
// ------------------------------------------------------------------
const layoutExtras = { bridgeRoad: null, crossRoad: null };
// ------------------------------------------------------------------
// Public plazas + outdoor gym equipment
// ------------------------------------------------------------------
function buildGymEquipment(kind, x, z) {
const g = new THREE.Group();
const metal = new THREE.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.45, metalness: 0.6 });
const accent = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.5, metalness: 0.3 });
const pad = new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.9 });
if (kind === 'bench') {
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.12, 0.5), pad);
seat.position.y = 0.55;
g.add(seat);
[-0.6, 0.6].forEach(px => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.4), metal);
leg.position.set(px, 0.28, 0);
g.add(leg);
});
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
bar.rotation.z = Math.PI / 2;
bar.position.set(0, 0.95, 0);
g.add(bar);
[-0.7, 0.7].forEach(px => {
const plate = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.1, 10), accent);
plate.rotation.z = Math.PI / 2;
plate.position.set(px, 0.95, 0);
g.add(plate);
});
} else if (kind === 'bars') {
[-0.8, 0.8].forEach(px => {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.2, 8), metal);
post.position.set(px, 1.1, 0);
g.add(post);
});
const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
top.rotation.z = Math.PI / 2;
top.position.set(0, 2.15, 0);
g.add(top);
const mid = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.8, 8), accent);
mid.rotation.z = Math.PI / 2;
mid.position.set(0, 1.5, 0);
g.add(mid);
} else if (kind === 'bike') {
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.12), metal);
frame.position.set(0, 0.55, 0);
g.add(frame);
const seatB = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.1, 0.25), pad);
seatB.position.set(-0.15, 1.1, 0);
g.add(seatB);
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.5), accent);
handle.position.set(0.3, 1.15, 0);
g.add(handle);
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 8, 16), metal);
wheel.position.set(0.1, 0.4, 0);
g.add(wheel);
g.userData.spin = wheel;
} else if (kind === 'treadmill') {
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.9), pad);
deck.position.set(0, 0.18, 0);
g.add(deck);
const railL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.08), metal);
railL.position.set(0, 0.7, 0.4);
g.add(railL);
const railR = railL.clone();
railR.position.z = -0.4;
g.add(railR);
const consoleBox = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.55, 0.7), accent);
consoleBox.position.set(1.1, 0.55, 0);
g.add(consoleBox);
}
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.45, 24),
new THREE.MeshBasicMaterial({ color: 0x22c55e, side: THREE.DoubleSide, transparent: true, opacity: 0.45 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.08;
g.add(ring);
g.userData.glowRing = ring;
g.position.set(x, 0, z);
g.userData.kind = kind;
g.userData.baseY = 0;
worldGroup.add(g);
// Soft collider so the player stands next to equipment without walking through the frame.
addCollider(x, 0, z, 1.4, 1.2, 1.4);
const item = { id: `${kind}-${x.toFixed(1)}-${z.toFixed(1)}`, kind, x, z, mesh: g };
gymEquipment.push(item);
return item;
}
function initPlazas(list) {
plazas = Array.isArray(list) ? list : [];
gymEquipment = [];
const grassMat = new THREE.MeshStandardMaterial({ color: 0x3f7d3a, roughness: 1 });
const pathMat = new THREE.MeshStandardMaterial({ color: 0xc4b8a0, roughness: 0.95 });
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.9 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f6b2a, roughness: 0.85 });
for (const p of plazas) {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
const lawn = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.12, pd), grassMat);
lawn.position.set(px, 0.06, pz);
lawn.receiveShadow = true;
worldGroup.add(lawn);
// Cross paths through the plaza.
const pathA = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.04, 2.2), pathMat);
pathA.position.set(px, 0.13, pz);
worldGroup.add(pathA);
const pathB = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.04, pd), pathMat);
pathB.position.set(px, 0.13, pz);
worldGroup.add(pathB);
// Trees on plaza corners.
const corners = [
[px - pw / 2 + 2.5, pz - pd / 2 + 2.5],
[px + pw / 2 - 2.5, pz - pd / 2 + 2.5],
[px - pw / 2 + 2.5, pz + pd / 2 - 2.5],
[px + pw / 2 - 2.5, pz + pd / 2 - 2.5],
];
corners.forEach(([tx, tz], i) => {
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.28, 1.6, 6), trunkMat);
trunk.position.set(tx, 0.9, tz);
trunk.castShadow = true;
worldGroup.add(trunk);
const crown = new THREE.Mesh(new THREE.SphereGeometry(1.1 + (i % 2) * 0.2, 10, 10), leafMat);
crown.position.set(tx, 2.4, tz);
crown.castShadow = true;
worldGroup.add(crown);
});
// Central fountain basin.
const basin = new THREE.Mesh(
new THREE.CylinderGeometry(2.2, 2.4, 0.45, 16),
new THREE.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.7 })
);
basin.position.set(px, 0.25, pz);
basin.castShadow = true;
worldGroup.add(basin);
const water = new THREE.Mesh(
new THREE.CylinderGeometry(1.9, 1.9, 0.1, 16),
new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.2, metalness: 0.3, transparent: true, opacity: 0.85 })
);
water.position.set(px, 0.42, pz);
worldGroup.add(water);
for (const eq of p.equipment || []) {
buildGymEquipment(eq.kind, eq.position[0], eq.position[1]);
}
}
}
function nearestGymEquipment(maxDist = 2.8) {
let best = null;
for (const eq of gymEquipment) {
const d = Math.hypot(player.pos.x - eq.x, player.pos.z - eq.z);
if (d <= maxDist && (!best || d < best.dist)) best = { eq, dist: d };
}
return best;
}
function setExercisePrompt(text) {
const el = document.getElementById('exercise-prompt');
if (!el) return;
if (!text) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
el.textContent = text;
}
function animateExercise(dt) {
if (!exercise.active || !meMesh) return;
exercise.animPhase += dt * 6;
const u = meMesh.userData;
if (!u?.legs || !u?.arms) return;
const swing = Math.sin(exercise.animPhase) * 0.9;
const lift = Math.abs(Math.sin(exercise.animPhase));
u.legs[0].rotation.x = swing;
u.legs[1].rotation.x = -swing;
u.arms[0].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[1].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[0].rotation.z = -0.6 - lift * 0.5;
u.arms[1].rotation.z = 0.6 + lift * 0.5;
meMesh.position.y = lift * 0.08;
}
async function startExerciseSession(eq) {
if (!meCharacter || exercise.active) return;
exercise.active = true;
exercise.equipmentId = eq.id;
exercise.repTimer = 0;
meCharacter.state = 'exercising';
if (stateEl) stateEl.textContent = 'exercising';
flashCharMsg(i18n.exerciseStart || 'Exercising…');
cityAudio.play('work');
try {
const data = await postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (data.character) {
updateCharacterHud(data.character);
if ((data.character.health ?? 100) >= 100) {
flashCharMsg(data.message || i18n.exerciseMaxHealth || 'Max health');
await stopExerciseSession(false);
}
}
} catch (e) {
exercise.active = false;
exercise.equipmentId = null;
flashCharMsg(e.message, true);
if (meCharacter) meCharacter.state = 'awake';
}
}
async function stopExerciseSession(showMsg = true) {
if (!exercise.active) return;
exercise.active = false;
exercise.equipmentId = null;
if (meMesh) meMesh.position.y = 0;
if (meMesh?.userData?.arms) {
meMesh.userData.arms.forEach(a => { a.rotation.z = 0; a.rotation.x = 0; });
}
try {
const data = await postJson('/city/actions/stop-exercise', {});
if (data.character) updateCharacterHud(data.character);
if (showMsg) flashCharMsg(i18n.exerciseStop || 'Stopped exercising');
} catch (_) { /* still leave local state */ }
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
}
function updateExercise(dt) {
if (isHospitalizedNow()) {
if (exercise.active) stopExerciseSession(false);
setExercisePrompt(null);
return;
}
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
if (exercise.active) {
const still = near && near.eq.id === exercise.equipmentId
|| (gymEquipment.some(e => e.id === exercise.equipmentId)
&& Math.hypot(player.pos.x - (gymEquipment.find(e => e.id === exercise.equipmentId)?.x ?? 999),
player.pos.z - (gymEquipment.find(e => e.id === exercise.equipmentId)?.z ?? 999)) < 4);
// Moving with WASD cancels the session.
const moving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
if (!still || moving) {
stopExerciseSession();
setExercisePrompt(null);
return;
}
setExercisePrompt(`🏃 ${i18n.exerciseActive || 'Exercising…'} · E ${i18n.exerciseStopHint || 'stop'}`);
animateExercise(dt);
exercise.repTimer += dt;
if (exercise.repTimer >= 1.0) {
exercise.repTimer = 0;
postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
})
.then((data) => {
if (data.character) {
updateCharacterHud(data.character);
if (data.character.health >= 100) {
flashCharMsg(i18n.exerciseMaxHealth || 'Max health');
stopExerciseSession(false);
}
}
})
.catch((e) => {
flashCharMsg(e.message, true);
stopExerciseSession(false);
});
}
return;
}
if (near && meCharacter && meCharacter.state !== 'sleeping') {
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
} else {
setExercisePrompt(null);
}
// Toggle with E.
if (keys['KeyE']) {
keys['KeyE'] = false;
if (exercise.active) {
stopExerciseSession();
} else if (near) {
startExerciseSession(near.eq);
}
}
}
function buildCity(data) {
clearGroup(worldGroup);
colliders.length = 0;
storeMeshes.length = 0;
agentMeshes.length = 0;
crowdMeshes.length = 0;
facadeMaterials.length = 0;
lampHeads.length = 0;
trafficSystem = null;
vehicleSystem = null;
pedestrianSystem = null;
airplaneSystem = null;
trainSystem = null;
riverSystem = null;
shopSystem = null;
mayorSystem = null;
meMesh = null;
spawnApplied = false;
plazas = [];
gymEquipment = [];
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
const agents = data.agents || [];
meCharacter = data.me || null;
cityBounds = data.city?.bounds || null;
if (meCharacter) {
player.pos.set(meCharacter.position[0], player.eyeHeight, meCharacter.position[2]);
// If the persisted spot is inside a building (e.g. a house built on
// the spawn), push the player out onto the nearest open ground.
if (colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
const push = 14;
const candidates = [
[meCharacter.position[0], meCharacter.position[2] + push],
[meCharacter.position[0], meCharacter.position[2] - push],
[meCharacter.position[0] + push, meCharacter.position[2]],
[meCharacter.position[0] - push, meCharacter.position[2]],
[0, 12],
[8, 4],
];
for (const [x, z] of candidates) {
player.pos.x = x;
player.pos.z = z;
if (!colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
break;
}
}
}
updateCharacterHud(meCharacter);
populateJobSelect(data.jobs || []);
populateCitizenSelect(data.characters || [], meCharacter);
spawnApplied = true;
}
if (!layout.roads.length) {
statsEl.textContent = '';
return;
}
grid = buildGrid(layout);
// Synthetic roads: bridge approach (west) + level-crossing extension (north).
const zMid = grid.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.zs[0]);
const xMid = grid.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.xs[0]);
layoutExtras.bridgeRoad = {
from: [grid.minX, 0, zMid], to: [grid.minX - 55, 0, zMid], width: 8, axis: 'x',
};
layoutExtras.crossRoad = {
from: [xMid, 0, grid.maxZ], to: [xMid, 0, grid.maxZ + 40], width: 8, axis: 'z',
};
layout.roads.push(layoutExtras.bridgeRoad, layoutExtras.crossRoad);
// ---- Ground ----
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2000, 2000),
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.05;
ground.receiveShadow = true;
worldGroup.add(ground);
// ---- River (under the bridge road) ----
initRiver(grid);
// ---- Roads ----
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
walkMat.map.repeat.set(4, 1);
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const geo = r.axis === 'z'
? new THREE.PlaneGeometry(w, len)
: new THREE.PlaneGeometry(len, w);
const mesh = new THREE.Mesh(geo, roadMat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(mx, 0.01, mz);
if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07;
mesh.receiveShadow = true;
worldGroup.add(mesh);
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
const swWidth = 2;
const offset = w / 2 + swWidth / 2;
[-1, 1].forEach(side => {
const sg = r.axis === 'z'
? new THREE.PlaneGeometry(swWidth, len)
: new THREE.PlaneGeometry(len, swWidth);
const sm = new THREE.Mesh(sg, walkMat);
sm.rotation.x = -Math.PI / 2;
if (r.axis === 'z') {
sm.position.set(mx + side * offset, 0.12, mz);
} else {
sm.position.set(mx, 0.12, mz + side * offset);
}
sm.receiveShadow = true;
worldGroup.add(sm);
});
});
// ---- Curbs ----
layout.roads.forEach(r => {
if (r === layoutExtras.bridgeRoad) return;
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const offset = w / 2 + 0.1;
[-1, 1].forEach(side => {
const curbGeo = r.axis === 'z'
? new THREE.BoxGeometry(0.2, 0.14, len)
: new THREE.BoxGeometry(len, 0.14, 0.2);
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
if (r.axis === 'z') {
curb.position.set(mx + side * offset, 0.07, mz);
} else {
curb.position.set(mx, 0.07, mz + side * offset);
}
worldGroup.add(curb);
});
});
// ---- Street lamps ----
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
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 w = r.width;
const spacing = 30;
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 ox = r.axis === 'z' ? w / 2 + 1.4 : 0;
const oz = r.axis === 'x' ? w / 2 + 1.4 : 0;
const side = (i % 2 === 0) ? 1 : -1;
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
pole.position.set(lx + ox * side, 2.25, lz + oz * side);
pole.castShadow = true;
worldGroup.add(pole);
const arm = new THREE.Mesh(new THREE.BoxGeometry(ox ? 1.4 : 0.15, 0.1, oz ? 1.4 : 0.15), lampPoleMat);
arm.position.set(lx + ox * side * 0.7, 4.5, lz + oz * side * 0.7);
worldGroup.add(arm);
const headMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.22, 0.5), headMat);
head.position.set(lx + ox * side * 1.3, 4.4, lz + oz * side * 1.3);
worldGroup.add(head);
lampHeads.push(headMat);
}
});
// ---- Traffic lights ----
initTrafficLights(grid);
// ---- Stores ----
const palette = ['#c9a84c', '#b8763a', '#8a7a5a', '#9a9a8a', '#a08060', '#7a8a6a'];
stores.forEach((s, idx) => {
const [x, y, z] = s.position;
const [w, h, d] = s.size;
const color = palette[idx % palette.length];
const floors = Math.max(2, Math.round(h / 3));
const pair = facadePair(color, floors, Math.max(2, Math.round(w / 3)));
const geo = new THREE.BoxGeometry(w, h, d);
const sideMat = new THREE.MeshStandardMaterial({
map: pair.map, emissiveMap: pair.emissiveMap,
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
});
facadeMaterials.push(sideMat);
const mats = [
sideMat, sideMat,
new THREE.MeshStandardMaterial({ color: 0x3a3a3a, roughness: 0.9 }),
new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9 }),
sideMat, sideMat,
];
const mesh = new THREE.Mesh(geo, mats);
mesh.position.set(x, y + h / 2, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { store: s };
worldGroup.add(mesh);
storeMeshes.push(mesh);
addCollider(x, y, z, w, h, d);
const roof = new THREE.Mesh(
new THREE.BoxGeometry(w + 0.4, 0.35, d + 0.4),
new THREE.MeshStandardMaterial({ color: 0x1e1e1e, roughness: 0.9 })
);
roof.position.set(x, y + h + 0.17, z);
roof.castShadow = true;
worldGroup.add(roof);
const door = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 2.4, 0.1),
new THREE.MeshStandardMaterial({ map: doorTexture(), roughness: 0.7 })
);
door.position.set(x, y + 1.2, z + d / 2 + 0.05);
worldGroup.add(door);
const ring = new THREE.Mesh(
new THREE.RingGeometry(Math.max(w, d) / 2 + 0.6, Math.max(w, d) / 2 + 1.1, 32),
new THREE.MeshBasicMaterial({ color: 0x0ea5e9, side: THREE.DoubleSide, transparent: true, opacity: 0.5 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(x, 0.15, z);
worldGroup.add(ring);
});
// ---- Filler buildings ----
const occupied = stores.map(s => ({ x: s.position[0], z: s.position[2] }));
(layout.blocks || []).forEach((b, bi) => {
const cx = (b.min[0] + b.max[0]) / 2;
const cz = (b.min[2] + b.max[2]) / 2;
const nearStore = occupied.some(o => Math.abs(o.x - cx) < 25 && Math.abs(o.z - cz) < 25);
if (nearStore) return;
const bw = 12 + (bi * 7) % 14;
const bd = 12 + (bi * 11) % 14;
const bh = 6 + (bi * 13) % 20;
const color = palette[(bi + 2) % palette.length];
const floors = Math.max(2, Math.round(bh / 3));
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)));
const geo = new THREE.BoxGeometry(bw, bh, bd);
const sideMat = new THREE.MeshStandardMaterial({
map: pair.map, emissiveMap: pair.emissiveMap,
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
});
facadeMaterials.push(sideMat);
const mats = Array.from({ length: 6 }, () => sideMat);
mats[2] = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 });
mats[3] = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9 });
const m = new THREE.Mesh(geo, mats);
m.position.set(cx, bh / 2, cz);
m.castShadow = true;
m.receiveShadow = true;
worldGroup.add(m);
addCollider(cx, 0, cz, bw, bh, bd);
const roof = new THREE.Mesh(
new THREE.BoxGeometry(bw + 0.3, 0.3, bd + 0.3),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 })
);
roof.position.set(cx, bh + 0.15, cz);
worldGroup.add(roof);
});
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper']);
const placed = new Set();
// Shopkeepers + mayor + pedestrians get behaviours; any leftover static agents render in place.
initShops(stores, agents);
initMayor(agents, stores, grid);
initPedestrians(grid, agents);
for (const a of agents) {
if (walkingTypes.has(a.type)) { placed.add(a.id); continue; }
const [x, y, z] = a.position;
const char = buildHumanoid(a, true);
char.position.set(x, y, z);
char.rotation.y = a.rotation_y || 0;
worldGroup.add(char);
agentMeshes.push(char);
}
// ---- Player-constructed buildings + hospital ----
if (Array.isArray(data.buildings)) {
for (const b of data.buildings) {
const [bx, , bz] = b.position;
const [bw, bh, bd] = b.size;
const isHospital = b.type === 'hospital';
const mat = new THREE.MeshStandardMaterial({
color: isHospital ? 0xe8eef5 : (b.is_complete ? 0x8b6f47 : 0x6b7280),
roughness: 0.9,
transparent: !b.is_complete,
opacity: b.is_complete ? 1 : 0.75,
});
const mesh = new THREE.Mesh(new THREE.BoxGeometry(bw, b.is_complete ? bh : Math.max(1, bh * (b.build_progress / 100)), bd), mat);
mesh.position.set(bx, (b.is_complete ? bh : Math.max(1, bh * (b.build_progress / 100))) / 2, bz);
mesh.castShadow = true;
worldGroup.add(mesh);
addCollider(bx, 0, bz, bw, b.is_complete ? bh : 1, bd);
if (isHospital) {
// Red cross marker on the facade.
const crossMat = new THREE.MeshStandardMaterial({ color: 0xdc2626, emissive: 0xdc2626, emissiveIntensity: 0.6 });
const h = new THREE.Mesh(new THREE.BoxGeometry(0.6, 2.4, 0.2), crossMat);
h.position.set(bx, bh * 0.55, bz + bd / 2 + 0.15);
worldGroup.add(h);
const v = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.6, 0.2), crossMat);
v.position.set(bx, bh * 0.55, bz + bd / 2 + 0.15);
worldGroup.add(v);
}
}
}
// ---- Public plazas with outdoor gym equipment ----
initPlazas(data.plazas || []);
// ---- Citizen characters (all characters in this city) ----
if (Array.isArray(data.characters)) {
for (const c of data.characters) {
const [cx, , cz] = c.position;
const humanoid = buildHumanoid({
name: c.name,
type: c.is_me ? 'mayor' : (c.type === 'child' ? 'crowd' : 'pedestrian'),
status: c.status || 'active',
id: c.id,
}, !c.is_me);
humanoid.position.set(cx, 0, cz);
if (c.is_me) {
humanoid.userData.isMe = true;
meMesh = humanoid;
}
worldGroup.add(humanoid);
citizenMeshes.push(humanoid);
if (!c.is_me) {
crowdMeshes.push(humanoid);
}
}
}
// ---- Simulation systems ----
initVehicles(grid);
if (!airplaneSystem) initAirplanes();
else for (const p of airplaneSystem.planes) scene.add(p.mesh);
initTrain(grid);
statsEl.textContent = buildStatsText();
}
function buildStatsText() {
const stores = cityData?.stores?.length || 0;
const agents = cityData?.agents?.length || 0;
const veh = vehicleSystem?.count() || 0;
const peds = pedestrianSystem?.count() || 0;
return [
`${stores} ${i18n.stores || ''}`,
`${agents} ${i18n.agents || ''}`,
`${veh} ${i18n.vehicles || ''}`,
`${peds} ${i18n.pedestrians || ''}`,
].join(' · ');
}
// ------------------------------------------------------------------
// CameraController — first person WASD + pointer lock + AABB collision
// ------------------------------------------------------------------
const player = {
pos: new THREE.Vector3(0, 1.7, 25),
vel: new THREE.Vector3(),
yaw: 0,
pitch: 0,
speed: 6,
radius: 0.35,
eyeHeight: 1.7,
onGround: true,
};
const keys = {};
let locked = false;
renderer.domElement.addEventListener('click', () => {
if (!locked) renderer.domElement.requestPointerLock();
});
startGate.addEventListener('click', () => {
renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange', () => {
locked = document.pointerLockElement === renderer.domElement;
startGate.style.display = locked ? 'none' : 'grid';
posPanel.style.display = locked ? 'block' : 'none';
});
document.addEventListener('mousemove', (e) => {
if (!locked) return;
player.yaw -= e.movementX * 0.0022;
player.pitch -= e.movementY * 0.0022;
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
});
function isTypingTarget(el) {
if (!el) return false;
const tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
}
document.addEventListener('keydown', (e) => {
if (isTypingTarget(e.target)) return;
keys[e.code] = true;
});
document.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
function playerAABB(px, py, pz) {
const r = player.radius;
return {
minX: px - r, maxX: px + r,
minY: py - 1.5, maxY: py + 0.3,
minZ: pz - r, maxZ: pz + r,
};
}
function overlaps(a, b) {
return a.minX < b.maxX && a.maxX > b.minX &&
a.minZ < b.maxZ && a.maxZ > b.minZ &&
a.minY < b.maxY && a.maxY > b.minY;
}
function vehicleBoxes() {
if (!vehicleSystem) return [];
const boxes = [];
for (const v of vehicleSystem.vehicles) {
const p = new THREE.Vector3();
let rot = 0;
if (v.pts) {
posOnPath(v.pts, v.s, v.L, p);
const ahead = new THREE.Vector3();
posOnPath(v.pts, v.s + 0.6, v.L, ahead);
rot = Math.atan2(ahead.x - p.x, ahead.z - p.z);
} else {
p.lerpVectors(v.a, v.b, v.t);
rot = Math.atan2(v.b.x - v.a.x, v.b.z - v.a.z) + Math.PI / 2;
}
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();
}
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 two-game-day stay elapses while online.
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
meCharacter.state = 'awake';
meCharacter.hospitalized_until = null;
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
flashCharMsg(i18n.hospitalReleased || 'Released');
}
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3();
// Allow walking with WASD/arrows even before pointer lock so the
// character is never completely stuck on the overlay screen.
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
if (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)) player.pos.x = tryX;
const tryZ = player.pos.z + step.z;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
}
player.vel.y -= 18 * dt;
player.pos.y += player.vel.y * dt;
if (player.pos.y <= player.eyeHeight) {
player.pos.y = player.eyeHeight;
player.vel.y = 0;
player.onGround = true;
}
if (keys['Space'] && player.onGround) {
player.vel.y = 6;
player.onGround = false;
}
player.pos.x = cityBounds
? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x))
: Math.max(-400, Math.min(400, player.pos.x));
player.pos.z = cityBounds
? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z))
: Math.max(-400, Math.min(400, player.pos.z));
}
function updateCameraFromPlayer() {
camera.position.copy(player.pos);
const dir = new THREE.Vector3(
-Math.sin(player.yaw) * Math.cos(player.pitch),
Math.sin(player.pitch),
-Math.cos(player.yaw) * Math.cos(player.pitch)
);
camera.lookAt(camera.position.clone().add(dir));
}
// ------------------------------------------------------------------
// Click-to-interact
// ------------------------------------------------------------------
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let pointerDown = null;
renderer.domElement.addEventListener('pointerdown', (e) => {
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (!pointerDown) return;
const dx = Math.abs(e.clientX - pointerDown.x);
const dy = Math.abs(e.clientY - pointerDown.y);
const dt = Date.now() - pointerDown.t;
pointerDown = null;
if (dx > 6 || dy > 6 || dt > 400) return;
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
ray.setFromCamera(ndc, camera);
const agentHits = ray.intersectObjects(agentMeshes, true);
if (agentHits.length) {
let obj = agentHits[0].object;
while (obj && !obj.userData?.agent) obj = obj.parent;
if (obj?.userData?.agent) {
openChat(obj.userData.agent);
return;
}
}
const storeHits = ray.intersectObjects(storeMeshes, false);
if (storeHits.length) {
const s = storeHits[0].object.userData.store;
selectedEl.style.display = 'inline-block';
const open = shopOpenNow(s);
selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`;
} else {
selectedEl.style.display = 'none';
}
});
// ------------------------------------------------------------------
// ChatPanel
// ------------------------------------------------------------------
const chatPanel = document.getElementById('chat-panel');
const chatDot = document.getElementById('chat-dot');
const chatName = document.getElementById('chat-name');
const chatStatus = document.getElementById('chat-status');
const chatTime = document.getElementById('chat-time');
const chatClose = document.getElementById('chat-close');
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
let currentAgent = null;
let eventSource = null;
function addBubble(text, who) {
const div = document.createElement('div');
div.className = `chat-bubble ${who}`;
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(status) {
if (!currentAgent) return;
currentAgent.status = status;
const map = {
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
active: ['#22c55e', 'online'],
error: ['#ef4444', 'error'],
};
const [color, label] = map[status] || map.active;
chatDot.style.background = color;
chatStatus.textContent = label;
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
if (mesh) refreshLabel(mesh, status);
}
function openChat(agent) {
currentAgent = agent;
chatPanel.style.display = 'flex';
chatName.textContent = agent.name;
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
chatMessages.innerHTML = '';
const history = agent.last_decision || [];
history.forEach(m => {
if (m.role === 'user') addBubble(m.content, 'user');
else if (m.role === 'assistant') addBubble(m.content, 'npc');
});
setStatus(agent.status || 'active');
if (eventSource) { eventSource.close(); eventSource = null; }
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
eventSource.addEventListener('message', (e) => {
try {
const payload = JSON.parse(e.data);
if (payload.type === 'thinking') {
setStatus('thinking');
addBubble(i18n.chatThinking || 'thinking…', 'meta');
} else if (payload.type === 'reply') {
setStatus('active');
addBubble(payload.reply, 'npc');
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (payload.type === 'error') {
setStatus('error');
addBubble(payload.reply || i18n.chatError || 'error', 'meta');
}
} catch (_) { /* ignore */ }
});
eventSource.onerror = () => { /* auto-retry */ };
}
function closeChat() {
chatPanel.style.display = 'none';
currentAgent = null;
if (eventSource) { eventSource.close(); eventSource = null; }
}
chatClose.addEventListener('click', closeChat);
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg || !currentAgent) return;
chatInput.value = '';
addBubble(msg, 'user');
setStatus('thinking');
try {
await fetch(`/city/chat/${currentAgent.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: msg }),
});
} catch (_) {
addBubble(i18n.chatError || 'error', 'meta');
}
});
// ------------------------------------------------------------------
// HUD
// ------------------------------------------------------------------
function updateHUD() {
const g = player.pos.y <= player.eyeHeight + 0.01;
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
posGround.textContent = g
? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m`
: `airborne · ${player.pos.y.toFixed(1)}m`;
posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`;
const hh = String(Math.floor(sim.hour)).padStart(2, '0');
const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0');
if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`;
if (weatherEl) {
const label = i18n[`weather_${weatherState.state}`] || weatherState.state;
weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`;
}
if (meMesh) {
meMesh.position.x = player.pos.x;
meMesh.position.z = player.pos.z;
}
}
// ------------------------------------------------------------------
// Character HUD + player actions (server-backed lifecycle)
// ------------------------------------------------------------------
const charNameEl = document.getElementById('char-name');
const charMsgEl = document.getElementById('char-msg');
const hungerEl = document.getElementById('hud-hunger');
const thirstEl = document.getElementById('hud-thirst');
const energyEl = document.getElementById('hud-energy');
const healthEl = document.getElementById('hud-health');
const exerciseCountEl = document.getElementById('hud-exercise-count');
const moneyEl = document.getElementById('hud-money');
const stateEl = document.getElementById('hud-char-state');
const educationEl = document.getElementById('hud-education');
const invEl = document.getElementById('hud-inv');
let lastPositionSync = 0;
function updateCharacterHud(me) {
meCharacter = me;
if (!me) return;
if (charNameEl) charNameEl.textContent = `👤 ${me.name}`;
if (hungerEl) hungerEl.textContent = `${me.hunger}%`;
if (thirstEl) thirstEl.textContent = `${me.thirst}%`;
if (energyEl) energyEl.textContent = `${me.energy}%`;
if (healthEl) healthEl.textContent = `${me.health ?? 100}%`;
if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`;
if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`;
if (stateEl) stateEl.textContent = me.state || '—';
if (educationEl) educationEl.textContent = `${me.education ?? 0}%`;
if (invEl) {
const inv = me.inventory || {};
invEl.textContent = Object.keys(inv).length
? Object.entries(inv).map(([k, v]) => `${k}:${v}`).join(' · ')
: '—';
}
if (me.state === 'hospital' && me.hospitalized_until) {
showHospitalBanner(me.hospitalized_until);
const bed = me.position;
if (bed) {
player.pos.x = bed[0];
player.pos.z = bed[2];
player.vel.set(0, 0, 0);
}
} else if (me.state !== 'hospital') {
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
}
}
function flashCharMsg(text, isError = false) {
if (!charMsgEl) return;
charMsgEl.textContent = text;
charMsgEl.style.color = isError ? '#fca5a5' : '#93c5fd';
// Errors always beep; success chimes are played by the action itself.
if (isError) cityAudio.play('error');
clearTimeout(flashCharMsg._t);
flashCharMsg._t = setTimeout(() => { charMsgEl.textContent = ''; }, 4000);
}
async function postJson(url, body = {}) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.message || i18n.notFound || 'error');
err.data = data;
err.status = res.status;
throw err;
}
return data;
}
const ACTION_SFX = {
eat: 'eat',
drink: 'drink',
sleep: 'sleep',
wake: 'wake',
work: 'work',
'stop-work': 'ui',
study: 'work',
buy: 'money',
sell: 'money',
build: 'build',
'apply-job': 'ok',
};
async function runAction(act) {
if (!meCharacter) return null;
cityAudio.unlock();
try {
let body = {};
if (act === 'buy') {
await postJson('/city/actions/buy', { item: 'food', qty: 1, price: 4 });
await postJson('/city/actions/buy', { item: 'water', qty: 1, price: 3 });
const data = await postJson('/city/actions/buy', { item: 'materials', qty: 10, price: 1 });
if (data.character) updateCharacterHud(data.character);
flashCharMsg(data.message || i18n.actionOk);
return data;
}
if (act === 'sell') body = { item: 'product', qty: 1, price: 8 };
if (act === 'build') {
// Place the house a few metres ahead so it never sits on the player.
const ahead = 12;
body = {
pos_x: Math.round(player.pos.x - Math.sin(player.yaw) * ahead),
pos_z: Math.round(player.pos.z - Math.cos(player.yaw) * ahead),
name: `${meCharacter.name}`,
};
}
const data = await postJson(`/city/actions/${act}`, body);
if (data.character) updateCharacterHud(data.character);
if (ACTION_SFX[act]) cityAudio.play(ACTION_SFX[act]);
flashCharMsg(data.message || i18n.actionOk);
if (act === 'build') {
loadCity();
}
return data;
} catch (e) {
flashCharMsg(e.message, true);
return { error: e.message };
}
}
function populateJobSelect(jobs) {
const sel = document.getElementById('job-select');
if (!sel) return;
sel.innerHTML = '<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();
syncPositionThrottled();
// Footsteps while the local player is actually walking.
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
statsTimer += dt;
if (statsTimer > 1 && cityData) {
statsTimer = 0;
statsEl.textContent = buildStatsText();
}
renderer.render(scene, camera);
}
animate();
// ------------------------------------------------------------------
// 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 barriersDown() { return trainSystem ? trainSystem.blocks : null; },
setHour(h) {
sim.hour = ((h % 24) + 24) % 24;
updateDayNight();
updateHUD();
},
setWeather,
audio: {
state: () => cityAudio.state(),
setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); },
setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); },
unlock: () => cityAudio.unlock(),
play: (name) => { cityAudio.unlock(); cityAudio.play(name); },
},
teleport(x, z, yaw = null, pitch = 0, y = null) {
player.pos.x = x;
player.pos.z = z;
player.pos.y = y !== null ? y : player.eyeHeight;
player.vel.set(0, 0, 0);
if (yaw !== null) player.yaw = yaw;
player.pitch = pitch;
updateCameraFromPlayer();
},
stats() {
return {
ready: this.ready,
hour: +sim.hour.toFixed(2),
weather: weatherState.state,
trafficPhase: this.trafficPhase,
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
plazas: plazas.length,
gymEquipment: gymEquipment.length,
exercising: exercise.active,
nearGym: (() => {
const n = spawnApplied ? nearestGymEquipment(4) : null;
return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null;
})(),
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
trains: trainSystem ? 1 : 0,
river: !!riverSystem,
stores: cityData ? cityData.stores.length : 0,
storesOpen: shopSystem ? shopSystem.openCount() : 0,
agents: cityData ? cityData.agents.length : 0,
colliders: colliders.length,
};
},
vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; },
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,
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;
} catch (err) {
console.error(err);
loadingEl.style.display = 'none';
errorEl.style.display = 'grid';
}
}
reloadBtn.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.play('reload');
loadCity();
});
bindCharacterUI();
bindAudioUI();
// 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();