Files
City10/public/js/city/app.js
T
marcocosta10000 a620f83dd8 feat(city): add player marketplace, mission journal, character customization and presence
- Player-to-player marketplace with listings, buying and selling
- Mission system with progress tracking and money rewards
- Character appearance customization (skin, hair, face, beard, accessory)
- Multiplayer presence feed showing nearby citizens in near-real-time
- Admin world dashboard with live platform metrics
- Bus and train transportation with temporary ride state
- Mobile joystick controls and minimap HUD
- Education bonus applied to work salary
2026-10-17 22:51:20 -03:00

4004 lines
146 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from '/vendor/three/three.module.min.js';
import { cityAudio } from './audio.js';
const cfg = window.CITY_CONFIG || {};
const i18n = cfg.i18n || {};
const host = document.getElementById('scene');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const startGate = document.getElementById('start-gate');
const statsEl = document.getElementById('stats');
const selectedEl = document.getElementById('selected');
const reloadBtn = document.getElementById('reload');
const posPanel = document.getElementById('hud-pos');
const posXYZ = document.getElementById('pos-xyz');
const posGround = document.getElementById('pos-ground');
const posTris = document.getElementById('pos-tris');
const clockEl = document.getElementById('hud-clock');
const weatherEl = document.getElementById('hud-weather');
const dataUrl = cfg.dataUrl;
const csrf = cfg.csrf;
// ------------------------------------------------------------------
// Scene setup
// ------------------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
camera.position.set(0, 1.7, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
host.appendChild(renderer.domElement);
const hemiLight = new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55);
scene.add(hemiLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.35);
scene.add(ambientLight);
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
sun.position.set(80, 140, 60);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -200;
sun.shadow.camera.right = 200;
sun.shadow.camera.top = 200;
sun.shadow.camera.bottom = -200;
sun.shadow.camera.far = 500;
scene.add(sun);
// ------------------------------------------------------------------
// Texture helpers
// ------------------------------------------------------------------
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
const c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
draw(ctx, size);
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(repeatX, repeatY);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
const asphaltTex = canvasTexture(512, (ctx, s) => {
ctx.fillStyle = '#1a1a1e';
ctx.fillRect(0, 0, s, s);
for (let i = 0; i < 9000; i++) {
const v = 20 + Math.random() * 30;
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
}
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(s * 0.44, 0, 4, s);
ctx.fillRect(s * 0.54, 0, 4, s);
ctx.fillStyle = '#d0d0d0';
for (let y = 0; y < s; y += 48) {
ctx.fillRect(s * 0.08, y, 4, 26);
ctx.fillRect(s * 0.90, y, 4, 26);
}
});
const sidewalkTex = canvasTexture(256, (ctx, s) => {
ctx.fillStyle = '#b8bcc4';
ctx.fillRect(0, 0, s, s);
const tile = 64;
ctx.strokeStyle = '#9aa0a8';
ctx.lineWidth = 2;
for (let i = 0; i <= s; i += tile) {
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
}
for (let i = 0; i < 40; i++) {
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
ctx.fill();
}
}, 4, 1);
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
const w = 512, h = 512;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
const e = document.createElement('canvas');
e.width = w; e.height = h;
const ectx = e.getContext('2d');
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, w, h);
ectx.fillStyle = '#000000';
ectx.fillRect(0, 0, w, h);
const floorH = h / floorCount;
for (let f = 0; f < floorCount; f++) {
ctx.fillStyle = 'rgba(30,30,30,0.85)';
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
for (let wi = 0; wi < windowsPerFloor; wi++) {
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
const wy = f * floorH + floorH * 0.18;
const ww = (w / windowsPerFloor) * 0.5;
const wh = floorH * 0.5;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
ctx.fillRect(wx, wy, ww, wh);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
if (Math.random() > 0.45) {
ectx.fillStyle = Math.random() > 0.5 ? '#ffd9a0' : '#ffcc80';
ectx.fillRect(wx, wy, ww, wh);
}
}
}
ctx.fillStyle = 'rgba(40,40,40,0.7)';
ctx.fillRect(0, 0, 6, h);
ctx.fillRect(w - 6, 0, 6, h);
const map = new THREE.CanvasTexture(c);
const emissiveMap = new THREE.CanvasTexture(e);
map.anisotropy = renderer.capabilities.getMaxAnisotropy();
emissiveMap.anisotropy = renderer.capabilities.getMaxAnisotropy();
return { map, emissiveMap };
}
function facadeTexture(baseColor, floorCount, windowsPerFloor) {
return facadePair(baseColor, floorCount, windowsPerFloor).map;
}
function doorTexture() {
const c = document.createElement('canvas');
c.width = 128; c.height = 256;
const ctx = c.getContext('2d');
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, 128, 256);
ctx.fillStyle = '#3a2f1a';
ctx.fillRect(8, 8, 112, 240);
ctx.fillStyle = '#c9a84c';
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
return new THREE.CanvasTexture(c);
}
// ------------------------------------------------------------------
// World state
// ------------------------------------------------------------------
let cityData = null;
let meCharacter = null;
let cityBounds = null;
let citizenMeshes = [];
const colliders = [];
const worldGroup = new THREE.Group();
scene.add(worldGroup);
const storeMeshes = [];
const agentMeshes = [];
const crowdMeshes = [];
const facadeMaterials = [];
const lampHeads = [];
let grid = null;
let trafficSystem = null;
let vehicleSystem = null;
let pedestrianSystem = null;
let airplaneSystem = null;
let trainSystem = null;
let riverSystem = null;
let shopSystem = null;
let mayorSystem = null;
let meMesh = null;
let hospitalBusy = false;
let spawnApplied = false;
let plazas = [];
let gymEquipment = [];
let exercise = {
active: false,
equipmentId: null,
repTimer: 0,
animPhase: 0,
nearId: null,
};
let remoteCitizens = new Map(); // characterId -> mesh
let presenceTimer = null;
let vehicleHeadlights = [];
let mobileInput = { x: 0, y: 0, active: false };
let minimapCtx = null;
let lastPresenceOk = 0;
function clearGroup(g) {
while (g.children.length) {
const c = g.children.pop();
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
}
citizenMeshes = [];
remoteCitizens = new Map();
vehicleHeadlights = [];
}
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' },
];
/**
* Pedestrian signal for a road axis.
*
* Pedestrians may cross a road when vehicles on that road are stopped (red).
* Yellow is treated as "do not start crossing" for safety.
*
* @param {string} axis
* @returns {'green'|'red'}
*/
function pedStateForAxis(axis) {
if (!trafficSystem) return 'green';
const vehicle = trafficSystem.axisState(axis);
return vehicle === 'red' ? 'green' : 'red';
}
function makePedSignal() {
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.28, 0.7, 0.22),
new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 })
);
g.add(box);
// Top = red "don't walk", bottom = green "walk".
const red = new THREE.Mesh(
new THREE.SphereGeometry(0.09, 10, 10),
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 0.08 })
);
red.position.set(0, 0.16, 0.13);
g.add(red);
const green = new THREE.Mesh(
new THREE.SphereGeometry(0.09, 10, 10),
new THREE.MeshStandardMaterial({ color: 0x22dd66, emissive: 0x22dd66, emissiveIntensity: 0.08 })
);
green.position.set(0, -0.16, 0.13);
g.add(green);
g.userData.lamps = { red, green };
return g;
}
function makeSignalHead() {
const g = new THREE.Group();
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.32, 1.0, 0.26),
new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 })
);
g.add(box);
const colors = [0xff2222, 0xffcc00, 0x22cc44];
const lamps = [];
colors.forEach((col, i) => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 10, 10),
new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 0.08 })
);
m.position.set(0, 0.32 - i * 0.32, 0.15);
g.add(m);
lamps.push(m);
});
g.userData.lamps = lamps;
return g;
}
function initTrafficLights(g) {
const group = new THREE.Group();
const heads = [];
const pedHeads = [];
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
const pedPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.65, metalness: 0.4 });
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' });
// Pedestrian signals on the SE corner, facing the crosswalks.
// Cross-Z head: allows walking E-W across the vertical (z-axis) road.
const pedZ = makePedSignal();
const pedZPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedZPole.position.set(x + g.roadW / 2 + 2.4, 1.2, z + g.roadW / 2 - 1.2);
group.add(pedZPole);
pedZ.position.set(x + g.roadW / 2 + 2.4, 2.5, z + g.roadW / 2 - 1.2);
group.add(pedZ);
pedHeads.push({ head: pedZ, axis: 'z' });
// Cross-X head: allows walking N-S across the horizontal (x-axis) road.
const pedX = makePedSignal();
const pedXPole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6), pedPoleMat);
pedXPole.position.set(x + g.roadW / 2 - 1.2, 1.2, z + g.roadW / 2 + 2.4);
group.add(pedXPole);
pedX.position.set(x + g.roadW / 2 - 1.2, 2.5, z + g.roadW / 2 + 2.4);
pedX.rotation.y = Math.PI / 2;
group.add(pedX);
pedHeads.push({ head: pedX, axis: 'x' });
}
}
worldGroup.add(group);
trafficSystem = {
phaseIndex: 0,
phaseTimer: 0,
heads,
pedHeads,
axisState(axis) {
return PHASES[this.phaseIndex][axis];
},
pedState(axis) {
return pedStateForAxis(axis);
},
get phaseName() { return PHASES[this.phaseIndex].name; },
/** Jump to a named phase (used by tests / debug). */
setPhase(name) {
const idx = PHASES.findIndex(p => p.name === name);
if (idx >= 0) {
this.phaseIndex = idx;
this.phaseTimer = 0;
}
},
update(dt) {
this.phaseTimer += dt;
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
this.phaseTimer -= PHASES[this.phaseIndex].dur;
this.phaseIndex = (this.phaseIndex + 1) % PHASES.length;
}
for (const { head, axis } of this.heads) {
const state = PHASES[this.phaseIndex][axis];
const lamps = head.userData.lamps;
lamps[0].material.emissiveIntensity = state === 'red' ? 2.2 : 0.06;
lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06;
}
for (const { head, axis } of this.pedHeads) {
const ped = pedStateForAxis(axis);
const lamps = head.userData.lamps;
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
}
},
};
}
// ------------------------------------------------------------------
// Vehicle system
// ------------------------------------------------------------------
const CAR_COLORS = [0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400];
const MOTO_COLORS = [0x111827, 0xb91c1c, 0x1d4ed8, 0x166534, 0x7c2d12, 0x4b5563];
const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827];
function buildCarMesh(colorHex, isBus = false) {
const g = new THREE.Group();
const bodyLen = isBus ? 7.5 : 4.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] };
}
/**
* Yaw so a mesh whose nose is local +X faces the world direction (dx, dz).
*
* Three.js: local +X after rotation.y=θ becomes (cos θ, 0, -sin θ).
*/
function yawFromDelta(dx, dz) {
return Math.atan2(-dz, dx);
}
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 = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z);
}
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 = yawFromDelta(look.x - pos.x, look.z - pos.z);
}
for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3;
}
// ------------------------------------------------------------------
// Humanoid builder
// ------------------------------------------------------------------
function buildHumanoid(agent, showLabel = true) {
const g = new THREE.Group();
const type = agent.type || 'pedestrian';
const configs = {
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
};
const cfgc = configs[type] || configs.pedestrian;
const s = cfgc.scale;
const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 });
const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 });
const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 });
const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
head.position.y = 1.62 * s;
head.castShadow = true;
g.add(head);
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
hair.position.y = 1.65 * s;
hair.castShadow = true;
g.add(hair);
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
torso.position.y = 1.12 * s;
torso.castShadow = true;
g.add(torso);
const arms = [];
[-1, 1].forEach(side => {
const shoulder = new THREE.Group();
shoulder.position.set(side * 0.32 * s, 1.38 * s, 0);
shoulder.rotation.z = side * 0.12;
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
arm.position.y = -0.28 * s;
arm.castShadow = true;
shoulder.add(arm);
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
hand.position.y = -0.56 * s;
shoulder.add(hand);
g.add(shoulder);
arms.push(shoulder);
});
const legs = [];
[-1, 1].forEach(side => {
const hip = new THREE.Group();
hip.position.set(side * 0.13 * s, 0.78 * s, 0);
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
leg.position.y = -0.36 * s;
leg.castShadow = true;
hip.add(leg);
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
shoe.position.set(0, -0.72 * s, 0.05 * s);
shoe.castShadow = true;
hip.add(shoe);
g.add(hip);
legs.push(hip);
});
let label = null;
if (showLabel) {
label = makeLabel(agent.name, agent.status || 'active');
label.position.y = 2.15 * s;
g.add(label);
}
g.userData = { agent, label, legs, arms, animPhase: Math.random() * Math.PI * 2, moving: false };
return g;
}
function animateHumanoid(mesh, dt, speed) {
const u = mesh.userData;
const moving = speed > 0.05;
u.moving = moving;
if (moving) {
u.animPhase += dt * (4 + speed * 2.5);
const swing = Math.sin(u.animPhase) * 0.55;
if (u.legs) {
u.legs[0].rotation.x = swing;
u.legs[1].rotation.x = -swing;
u.arms[0].rotation.x = -swing * 0.8;
u.arms[1].rotation.x = swing * 0.8;
}
} else {
if (u.legs) {
u.legs[0].rotation.x *= 0.85;
u.legs[1].rotation.x *= 0.85;
u.arms[0].rotation.x *= 0.85;
u.arms[1].rotation.x *= 0.85;
}
}
}
function makeLabel(name, status) {
const c = document.createElement('canvas');
c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(10,15,28,0.85)';
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
ctx.fill();
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
const nm = name.length > 20 ? name.slice(0, 19) + '…' : name;
ctx.fillText(nm, 128, 30);
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
ctx.fillStyle = dotColor;
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#94a3b8';
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(status || 'active', 56, 53);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
sprite.scale.set(3.2, 0.8, 1);
return sprite;
}
function refreshLabel(mesh, status) {
const u = mesh.userData;
if (!u || !u.agent) return;
u.agent.status = status;
if (u.label) {
mesh.remove(u.label);
u.label.material.map?.dispose();
u.label.material.dispose();
}
const l = makeLabel(u.agent.name, status);
l.position.y = 2.15;
mesh.add(l);
u.label = l;
}
// ------------------------------------------------------------------
// Pedestrian routes + system
// ------------------------------------------------------------------
function segmentCrossAxis(a, b, g) {
const dx = Math.abs(b.x - a.x);
const dz = Math.abs(b.z - a.z);
if (dx < 7 && dz < 7) return null;
if (dx >= dz) {
for (const vx of g.xs) {
if (vx > Math.min(a.x, b.x) + 1 && vx < Math.max(a.x, b.x) - 1) return 'z';
}
return null;
}
for (const vz of g.zs) {
if (vz > Math.min(a.z, b.z) + 1 && vz < Math.max(a.z, b.z) - 1) return 'x';
}
return null;
}
function makeRoute(pts, g) {
const waypoints = pts.map(p => ({ x: p.x ?? p[0], z: p.z ?? p[1] }));
const segments = [];
for (let i = 0; i < waypoints.length; i++) {
const a = waypoints[i];
const b = waypoints[(i + 1) % waypoints.length];
segments.push({
crossAxis: segmentCrossAxis(
new THREE.Vector3(a.x, 0, a.z),
new THREE.Vector3(b.x, 0, b.z),
g
),
midX: (a.x + b.x) / 2,
midZ: (a.z + b.z) / 2,
});
}
return { waypoints, segments };
}
function ringRoute(sq, g, inset) {
const o = inset;
return makeRoute([
{ x: sq.x0 + o, z: sq.z0 + o },
{ x: sq.x1 - o, z: sq.z0 + o },
{ x: sq.x1 - o, z: sq.z1 - o },
{ x: sq.x0 + o, z: sq.z1 - o },
], g);
}
function crossingRoute(sq, g) {
const o = g.roadW / 2 + 1.2;
const zc = (sq.z0 + sq.z1) / 2;
return makeRoute([
{ x: sq.x0 + o, z: zc },
{ x: sq.x0 - o, z: zc },
{ x: sq.x0 - o, z: zc + 5 },
{ x: sq.x0 + o, z: zc + 5 },
], g);
}
function vehiclesNear(x, z, radius) {
if (!vehicleSystem) return false;
const r2 = radius * radius;
for (const v of vehicleSystem.vehicles) {
const p = new THREE.Vector3();
if (v.pts) posOnPath(v.pts, v.s, v.L, p);
else p.lerpVectors(v.a, v.b, v.t);
const dx = p.x - x, dz = p.z - z;
if (dx * dx + dz * dz < r2) return true;
}
return false;
}
function initPedestrians(g, agents) {
const walkers = [];
const routes = [];
g.squares.forEach((sq, i) => {
routes.push({ route: ringRoute(sq, g, g.roadW / 2 + 1.2), kind: 'ring' });
if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' });
});
const pedAgents = agents.filter(a => a.type === 'pedestrian');
pedAgents.forEach((a, i) => {
const pick = routes[i % routes.length];
const mesh = buildHumanoid(a, true);
worldGroup.add(mesh);
agentMeshes.push(mesh);
const w = {
mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4,
wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0,
};
const start = w.route.waypoints[0];
mesh.position.set(start.x, 0, start.z);
walkers.push(w);
});
const CROWD_COUNT = 14;
for (let i = 0; i < CROWD_COUNT; i++) {
const pick = routes[(i + pedAgents.length + 1) % routes.length];
const mesh = buildHumanoid({ name: '', type: 'crowd', status: 'active' }, false);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
const start = pick.route.waypoints[i % pick.route.waypoints.length];
mesh.position.set(start.x, 0, start.z);
walkers.push({
mesh, route: pick.route, wp: i % pick.route.waypoints.length,
speed: 1.0 + Math.random() * 0.5, wait: Math.random() * 3,
pauseAt: 5 + Math.random() * 10, pauseTimer: 0,
});
}
pedestrianSystem = {
walkers,
count() { return walkers.length; },
positions() {
return walkers.map(w => ({ x: w.mesh.position.x, z: w.mesh.position.z }));
},
update(dt) {
for (const w of walkers) {
if (w.wait > 0) {
w.wait -= dt;
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (w.pauseTimer > 0) {
w.pauseTimer -= dt;
animateHumanoid(w.mesh, dt, 0);
continue;
}
const wps = w.route.waypoints;
const segIdx = w.wp % w.route.segments.length;
const seg = w.route.segments[segIdx];
const target = wps[(w.wp + 1) % wps.length];
if (seg.crossAxis && trafficSystem) {
const state = trafficSystem.axisState(seg.crossAxis);
const meshPos = w.mesh.position;
const distToMid = Math.hypot(meshPos.x - seg.midX, meshPos.z - seg.midZ);
const approaching = distToMid > 1.5;
if (approaching && distToMid < 14) {
if (state !== 'red') {
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (vehiclesNear(seg.midX, seg.midZ, 8)) {
animateHumanoid(w.mesh, dt, 0);
continue;
}
}
}
const dx = target.x - w.mesh.position.x;
const dz = target.z - w.mesh.position.z;
const dist = Math.hypot(dx, dz);
if (onRoad(w.mesh.position.x, w.mesh.position.z, g) && vehiclesNear(w.mesh.position.x, w.mesh.position.z, 5)) {
animateHumanoid(w.mesh, dt, 0);
continue;
}
if (dist < 0.35) {
w.wp = (w.wp + 1) % wps.length;
if (Math.random() < 0.22) w.pauseTimer = w.pauseAt * (0.4 + Math.random() * 0.6);
animateHumanoid(w.mesh, dt, 0);
continue;
}
const step = Math.min(dist, w.speed * dt);
w.mesh.position.x += (dx / dist) * step;
w.mesh.position.z += (dz / dist) * step;
w.mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(w.mesh, dt, w.speed);
}
},
};
}
// ------------------------------------------------------------------
// Shop system (open/close by hour, signs, shopkeepers)
// ------------------------------------------------------------------
function makeStoreSign(store, isOpen) {
const c = document.createElement('canvas');
c.width = 512; c.height = 128;
const ctx = c.getContext('2d');
ctx.fillStyle = '#0d1118';
ctx.fillRect(0, 0, 512, 128);
ctx.strokeStyle = isOpen ? '#22c55e' : '#ef4444';
ctx.lineWidth = 6;
ctx.strokeRect(4, 4, 504, 120);
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 34px sans-serif';
ctx.textAlign = 'center';
const nm = store.name.length > 24 ? store.name.slice(0, 23) + '…' : store.name;
ctx.fillText(nm, 256, 55);
ctx.font = 'bold 30px sans-serif';
ctx.fillStyle = isOpen ? '#4ade80' : '#f87171';
ctx.fillText(isOpen ? (i18n.open || 'OPEN') : (i18n.closed || 'CLOSED'), 256, 100);
const tex = new THREE.CanvasTexture(c);
const mat = new THREE.MeshStandardMaterial({
map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.4,
roughness: 0.6,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 0.9), mat);
return mesh;
}
function initShops(stores, agents) {
const entries = [];
for (const s of stores) {
const mesh = storeMeshes.find(m => m.userData?.store?.id === s.id);
if (!mesh) continue;
const [x, y, z] = s.position;
const [w, h, d] = s.size;
const sign = makeStoreSign(s, shopOpenNow(s));
sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
worldGroup.add(sign);
entries.push({ store: s, sign, lastOpen: shopOpenNow(s) });
const light = new THREE.PointLight(0xffe0a0, 0, 8, 2);
light.position.set(x, y + 2.6, z + d / 2 + 1.2);
worldGroup.add(light);
entries[entries.length - 1].light = light;
}
const keepers = [];
const shopAgents = agents.filter(a => a.type === 'shopkeeper');
for (const a of shopAgents) {
const store = stores.find(s => s.name?.includes(a.name.split('—').pop()?.trim() || '§'))
|| stores.reduce((best, s) => {
const d = Math.hypot(s.position[0] - a.position[0], s.position[2] - a.position[2]);
return (!best || d < best.d) ? { s, d } : best;
}, null)?.s
|| stores[0];
if (!store) continue;
const mesh = buildHumanoid(a, true);
const [sx, , sz] = store.position;
const [, , sd] = store.size;
mesh.position.set(sx + 1.5, 0, sz + sd / 2 + 1.6);
mesh.rotation.y = Math.PI;
worldGroup.add(mesh);
agentMeshes.push(mesh);
keepers.push({ mesh, home: mesh.position.clone(), t: Math.random() * 10 });
}
shopSystem = {
entries, keepers,
openCount() { return entries.filter(e => shopOpenNow(e.store)).length; },
update(dt) {
const hour = sim.hour;
for (const e of entries) {
const open = shopOpenNow(e.store);
if (open !== e.lastOpen) {
e.lastOpen = open;
worldGroup.remove(e.sign);
e.sign.geometry.dispose();
e.sign.material.map?.dispose();
e.sign.material.emissiveMap?.dispose();
e.sign.material.dispose();
e.sign = makeStoreSign(e.store, open);
const [x, y, z] = e.store.position;
const d = e.store.size[2];
e.sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
worldGroup.add(e.sign);
}
const night = sim.nightFactor;
e.sign.material.emissiveIntensity = open ? 0.5 + night * 1.3 : 0.15 + night * 0.3;
if (e.light) e.light.intensity = open ? night * 1.6 : night * 0.3;
}
for (const k of this.keepers) {
k.t += dt;
const store = entries.find(e => Math.abs(e.store.position[0] - k.home.x) < 6);
const open = store ? shopOpenNow(store.store) : true;
if (open) {
const sway = Math.sin(k.t * 1.5) * 0.4;
k.mesh.position.x = k.home.x + sway * 0.3;
k.mesh.rotation.y = Math.PI + Math.sin(k.t * 0.7) * 0.3;
animateHumanoid(k.mesh, dt, 0.15);
} else {
k.mesh.position.x = k.home.x;
animateHumanoid(k.mesh, dt, 0);
}
}
},
};
}
function shopOpenNow(store) {
if (!store.is_open) return false;
return sim.hour >= 8 && sim.hour < 20;
}
// ------------------------------------------------------------------
// Mayor patrol system
// ------------------------------------------------------------------
function initMayor(agents, stores, g) {
const mayor = agents.find(a => a.type === 'mayor');
if (!mayor) { mayorSystem = { update() {}, mesh: null }; return; }
const pts = [];
for (const s of stores) {
pts.push({ x: s.position[0] + s.size[0] / 2 + 2, z: s.position[2] + s.size[2] / 2 + 2.2 });
}
pts.push({ x: g.xs[0] + g.roadW / 2 + 1.2, z: g.zs[0] + g.roadW / 2 + 1.2 });
pts.push({ x: g.xs[Math.floor(g.xs.length / 2)] + g.roadW / 2 + 1.2, z: g.zs[Math.floor(g.zs.length / 2)] + g.roadW / 2 + 1.2 });
const route = makeRoute(pts, g);
const mesh = buildHumanoid(mayor, true);
mesh.position.set(route.waypoints[0].x, 0, route.waypoints[0].z);
worldGroup.add(mesh);
agentMeshes.push(mesh);
mayorSystem = {
mesh, route, wp: 0, speed: 1.05,
pauseTimer: 2, decideTimer: 8, status: 'active',
update(dt) {
this.decideTimer -= dt;
if (this.decideTimer <= 0) {
this.decideTimer = 8 + Math.random() * 7;
this.status = this.status === 'active' ? 'thinking' : 'active';
refreshLabel(mesh, this.status);
}
if (this.pauseTimer > 0) {
this.pauseTimer -= dt;
animateHumanoid(mesh, dt, 0);
return;
}
const seg = this.route.segments[this.wp % this.route.segments.length];
const target = this.route.waypoints[(this.wp + 1) % this.route.waypoints.length];
if (seg.crossAxis && trafficSystem) {
const state = trafficSystem.axisState(seg.crossAxis);
const dMid = Math.hypot(mesh.position.x - seg.midX, mesh.position.z - seg.midZ);
if (dMid > 1.5 && dMid < 14) {
if (state !== 'red' || vehiclesNear(seg.midX, seg.midZ, 8)) {
animateHumanoid(mesh, dt, 0);
return;
}
}
}
const dx = target.x - mesh.position.x;
const dz = target.z - mesh.position.z;
const dist = Math.hypot(dx, dz);
if (dist < 0.4) {
this.wp = (this.wp + 1) % this.route.waypoints.length;
this.pauseTimer = 3 + Math.random() * 3;
if (this.status !== 'thinking') {
this.status = 'thinking';
refreshLabel(mesh, this.status);
}
animateHumanoid(mesh, dt, 0);
return;
}
const step = Math.min(dist, this.speed * dt);
mesh.position.x += (dx / dist) * step;
mesh.position.z += (dz / dist) * step;
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, dt, this.speed);
},
};
}
// ------------------------------------------------------------------
// Airplane system
// ------------------------------------------------------------------
function buildAirplane(colorHex) {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.5 });
const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 9, 10), mat);
fus.rotation.z = Math.PI / 2;
g.add(fus);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.8, 10), mat);
nose.rotation.z = -Math.PI / 2;
nose.position.x = 5.3;
g.add(nose);
const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.15, 11), mat);
wing.position.x = 0.5;
g.add(wing);
const tailW = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 4), mat);
tailW.position.x = -4;
g.add(tailW);
const tailF = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.12), mat);
tailF.position.set(-4.1, 0.8, 0);
g.add(tailF);
const beacon = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
);
beacon.position.set(0.5, 0.5, 0);
g.add(beacon);
g.userData.beacon = beacon;
return g;
}
function initAirplanes() {
const planes = [];
const defs = [
{ r: 260, y: 85, speed: 0.12, a0: 0, color: 0xdfe4ea },
{ r: 330, y: 110, speed: -0.09, a0: 2, color: 0xc0c8d2 },
{ r: 200, y: 70, speed: 0.15, a0: 4, color: 0xe8eef5 },
];
for (const d of defs) {
const mesh = buildAirplane(d.color);
scene.add(mesh);
planes.push({ mesh, ...d, angle: d.a0 });
}
airplaneSystem = {
planes,
count() { return planes.length; },
update(dt) {
for (const p of planes) {
p.angle += p.speed * dt;
const x = Math.cos(p.angle) * p.r;
const z = Math.sin(p.angle) * p.r;
p.mesh.position.set(x, p.y, z);
const tx = -Math.sin(p.angle) * Math.sign(p.speed);
const tz = Math.cos(p.angle) * Math.sign(p.speed);
p.mesh.rotation.y = Math.atan2(-tz, tx);
p.mesh.rotation.z = -0.3 * Math.sign(p.speed);
if (p.mesh.userData.beacon) {
p.mesh.userData.beacon.material.emissiveIntensity =
(Math.sin(performance.now() * 0.01) > 0) ? 2.5 : 0.2;
}
}
},
};
}
// ------------------------------------------------------------------
// Train system + level crossing
// ------------------------------------------------------------------
function initTrain(g) {
const trackZ = g.maxZ + 14;
const xMid = g.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.xs[0]);
const trackFrom = g.minX - 70;
const trackTo = g.maxX + 70;
const railMat = new THREE.MeshStandardMaterial({ color: 0x6a6a72, roughness: 0.4, metalness: 0.8 });
const bedMat = new THREE.MeshStandardMaterial({ color: 0x5a5248, roughness: 1 });
const bed = new THREE.Mesh(
new THREE.BoxGeometry(trackTo - trackFrom, 0.18, 3.6),
bedMat
);
bed.position.set((trackFrom + trackTo) / 2, 0.09, trackZ);
worldGroup.add(bed);
for (const off of [-0.75, 0.75]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(trackTo - trackFrom, 0.14, 0.12), railMat);
rail.position.set((trackFrom + trackTo) / 2, 0.24, trackZ + off);
worldGroup.add(rail);
}
const sleeperGeo = new THREE.BoxGeometry(0.35, 0.1, 2.6);
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3d2f22, roughness: 0.9 });
const count = Math.floor((trackTo - trackFrom) / 2);
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
const m4 = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
m4.setPosition(trackFrom + i * 2 + 1, 0.16, trackZ);
sleepers.setMatrixAt(i, m4);
}
worldGroup.add(sleepers);
const train = new THREE.Group();
const locoMat = new THREE.MeshStandardMaterial({ color: 0xb03030, roughness: 0.5, metalness: 0.3 });
const loco = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.4), locoMat);
loco.position.y = 1.5;
loco.castShadow = true;
train.add(loco);
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 2.2), new THREE.MeshStandardMaterial({ color: 0x882222 }));
cab.position.set(2.4, 3.2, 0);
train.add(cab);
const carColors = [0x2a5a8a, 0x2a5a8a, 0x3a7a4a, 0x2a5a8a];
carColors.forEach((col, i) => {
const car = new THREE.Mesh(
new THREE.BoxGeometry(6.5, 2.2, 2.3),
new THREE.MeshStandardMaterial({ color: col, roughness: 0.6 })
);
car.position.set(-6.5 - i * 7.2, 1.4, 0);
car.castShadow = true;
train.add(car);
});
worldGroup.add(train);
// Level crossing barriers on the vertical road that crosses the track.
const barriers = [];
for (const side of [-1, 1]) {
const pivot = new THREE.Group();
pivot.position.set(xMid + side * (g.roadW / 2 + 2.2), 1.1, trackZ + side * 2.6);
const armGeo = new THREE.BoxGeometry(7, 0.18, 0.25);
// Shift geometry so the pivot sits at the post end of the arm.
armGeo.translate(side > 0 ? -3.5 : 3.5, 0, 0);
const armMesh = new THREE.Mesh(
armGeo,
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 })
);
pivot.add(armMesh);
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff2222, emissiveIntensity: 0.3 })
);
lamp.position.y = 1.1;
pivot.add(lamp);
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.1, 0.12, 2.2, 6),
new THREE.MeshStandardMaterial({ color: 0x888888 })
);
pivot.add(post);
// Start raised (open).
pivot.rotation.z = -Math.PI / 2;
worldGroup.add(pivot);
barriers.push({ pivot, lamp });
}
const state = {
s: trackFrom + 30,
dir: 1,
speed: 14,
pause: 0,
blocks: false,
crossZ: trackZ,
xMid,
trackFrom,
trackTo,
train,
barriers,
};
trainSystem = {
...state,
blocksCrossing() { return this.blocks; },
update(dt) {
if (this.pause > 0) {
this.pause -= dt;
} else {
this.s += this.dir * this.speed * dt;
if (this.s >= this.trackTo - 40) {
this.s = this.trackTo - 40;
this.dir = -1;
this.pause = 3;
this.train.rotation.y = Math.PI;
} else if (this.s <= this.trackFrom + 40) {
this.s = this.trackFrom + 40;
this.dir = 1;
this.pause = 3;
this.train.rotation.y = 0;
}
}
const noseX = this.s + this.dir * 4;
this.train.position.set(this.s, 0, this.trackZ ?? this.crossZ);
const distToCross = Math.abs(noseX - this.xMid);
this.blocks = distToCross < 45 && this.pause <= 0;
const blink = Math.sin(performance.now() * 0.012) > 0;
for (const b of this.barriers) {
const target = this.blocks ? 0 : -Math.PI / 2;
b.pivot.rotation.z += (target - b.pivot.rotation.z) * Math.min(1, dt * 4);
b.lamp.material.emissiveIntensity = this.blocks ? (blink ? 3 : 0.2) : 0.2;
}
},
};
trainSystem.crossZ = trackZ;
trainSystem.trackZ = trackZ;
}
// ------------------------------------------------------------------
// River + bridge
// ------------------------------------------------------------------
function initRiver(g) {
const riverX = g.minX - 30;
const riverW = 16;
const zMid = g.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.zs[0]);
const len = (g.maxZ - g.minZ) + 140;
const waterTex = canvasTexture(256, (ctx, s) => {
const grad = ctx.createLinearGradient(0, 0, s, s);
grad.addColorStop(0, '#1a5f8a');
grad.addColorStop(0.5, '#2a7fb0');
grad.addColorStop(1, '#1a5f8a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, s, s);
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 3;
for (let i = 0; i < 14; i++) {
const y = Math.random() * s;
ctx.beginPath();
ctx.moveTo(0, y);
for (let x = 0; x <= s; x += 16) {
ctx.lineTo(x, y + Math.sin(x * 0.08 + i) * 6);
}
ctx.stroke();
}
}, 3, 10);
const geo = new THREE.PlaneGeometry(riverW, len, 6, 40);
const water = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
map: waterTex, color: 0x88bbdd, roughness: 0.25, metalness: 0.35,
transparent: true, opacity: 0.92,
})
);
water.rotation.x = -Math.PI / 2;
water.position.set(riverX, 0.04, (g.minZ + g.maxZ) / 2);
water.receiveShadow = true;
worldGroup.add(water);
const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7f66, roughness: 1 });
for (const side of [-1, 1]) {
const bank = new THREE.Mesh(new THREE.BoxGeometry(4, 0.3, len), bankMat);
bank.position.set(riverX + side * (riverW / 2 + 1.5), 0.1, (g.minZ + g.maxZ) / 2);
worldGroup.add(bank);
}
const basePos = Float32Array.from(geo.attributes.position.array);
// Bridge where the mid horizontal road extension crosses the river.
const bridge = new THREE.Mesh(
new THREE.BoxGeometry(riverW + 8, 0.25, 9),
new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.9 })
);
bridge.position.set(riverX, 0.05, zMid);
bridge.receiveShadow = true;
worldGroup.add(bridge);
const railMat = new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.6, metalness: 0.4 });
for (const side of [-1, 1]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(riverW + 8, 0.7, 0.15), railMat);
rail.position.set(riverX, 0.5, zMid + side * 4.4);
worldGroup.add(rail);
const pier = new THREE.Mesh(
new THREE.BoxGeometry(1.2, 2.2, 1.2),
new THREE.MeshStandardMaterial({ color: 0x555560, roughness: 0.9 })
);
pier.position.set(riverX, -0.9, zMid + side * 3);
worldGroup.add(pier);
}
riverSystem = {
riverX, riverW, zMid, water, basePos,
update(dt, t) {
waterTex.offset.y = (waterTex.offset.y + dt * 0.04) % 1;
waterTex.offset.x = (waterTex.offset.x + dt * 0.012) % 1;
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const bx = basePos[i * 3];
const by = basePos[i * 3 + 1];
pos.array[i * 3 + 2] =
Math.sin(bx * 0.6 + t * 2.2) * 0.09 +
Math.sin(by * 0.35 + t * 1.6) * 0.07;
}
pos.needsUpdate = true;
},
};
}
// ------------------------------------------------------------------
// City builder
// ------------------------------------------------------------------
const layoutExtras = { bridgeRoad: null, crossRoad: null };
// ------------------------------------------------------------------
// Public plazas + outdoor gym equipment
// ------------------------------------------------------------------
function buildGymEquipment(kind, x, z) {
const g = new THREE.Group();
const metal = new THREE.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.45, metalness: 0.6 });
const accent = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.5, metalness: 0.3 });
const pad = new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.9 });
if (kind === 'bench') {
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.12, 0.5), pad);
seat.position.y = 0.55;
g.add(seat);
[-0.6, 0.6].forEach(px => {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.4), metal);
leg.position.set(px, 0.28, 0);
g.add(leg);
});
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
bar.rotation.z = Math.PI / 2;
bar.position.set(0, 0.95, 0);
g.add(bar);
[-0.7, 0.7].forEach(px => {
const plate = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.1, 10), accent);
plate.rotation.z = Math.PI / 2;
plate.position.set(px, 0.95, 0);
g.add(plate);
});
} else if (kind === 'bars') {
[-0.8, 0.8].forEach(px => {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.2, 8), metal);
post.position.set(px, 1.1, 0);
g.add(post);
});
const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
top.rotation.z = Math.PI / 2;
top.position.set(0, 2.15, 0);
g.add(top);
const mid = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.8, 8), accent);
mid.rotation.z = Math.PI / 2;
mid.position.set(0, 1.5, 0);
g.add(mid);
} else if (kind === 'bike') {
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.12), metal);
frame.position.set(0, 0.55, 0);
g.add(frame);
const seatB = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.1, 0.25), pad);
seatB.position.set(-0.15, 1.1, 0);
g.add(seatB);
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.5), accent);
handle.position.set(0.3, 1.15, 0);
g.add(handle);
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 8, 16), metal);
wheel.position.set(0.1, 0.4, 0);
g.add(wheel);
g.userData.spin = wheel;
} else if (kind === 'treadmill') {
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.9), pad);
deck.position.set(0, 0.18, 0);
g.add(deck);
const railL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.08), metal);
railL.position.set(0, 0.7, 0.4);
g.add(railL);
const railR = railL.clone();
railR.position.z = -0.4;
g.add(railR);
const consoleBox = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.55, 0.7), accent);
consoleBox.position.set(1.1, 0.55, 0);
g.add(consoleBox);
}
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.1, 1.45, 24),
new THREE.MeshBasicMaterial({ color: 0x22c55e, side: THREE.DoubleSide, transparent: true, opacity: 0.45 })
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.08;
g.add(ring);
g.userData.glowRing = ring;
g.position.set(x, 0, z);
g.userData.kind = kind;
g.userData.baseY = 0;
worldGroup.add(g);
// Soft collider so the player stands next to equipment without walking through the frame.
addCollider(x, 0, z, 1.4, 1.2, 1.4);
const item = { id: `${kind}-${x.toFixed(1)}-${z.toFixed(1)}`, kind, x, z, mesh: g };
gymEquipment.push(item);
return item;
}
function initPlazas(list) {
plazas = Array.isArray(list) ? list : [];
gymEquipment = [];
const grassMat = new THREE.MeshStandardMaterial({ color: 0x3f7d3a, roughness: 1 });
const pathMat = new THREE.MeshStandardMaterial({ color: 0xc4b8a0, roughness: 0.95 });
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.9 });
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f6b2a, roughness: 0.85 });
for (const p of plazas) {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
const lawn = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.12, pd), grassMat);
lawn.position.set(px, 0.06, pz);
lawn.receiveShadow = true;
worldGroup.add(lawn);
// Cross paths through the plaza.
const pathA = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.04, 2.2), pathMat);
pathA.position.set(px, 0.13, pz);
worldGroup.add(pathA);
const pathB = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.04, pd), pathMat);
pathB.position.set(px, 0.13, pz);
worldGroup.add(pathB);
// Trees on plaza corners.
const corners = [
[px - pw / 2 + 2.5, pz - pd / 2 + 2.5],
[px + pw / 2 - 2.5, pz - pd / 2 + 2.5],
[px - pw / 2 + 2.5, pz + pd / 2 - 2.5],
[px + pw / 2 - 2.5, pz + pd / 2 - 2.5],
];
corners.forEach(([tx, tz], i) => {
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.28, 1.6, 6), trunkMat);
trunk.position.set(tx, 0.9, tz);
trunk.castShadow = true;
worldGroup.add(trunk);
const crown = new THREE.Mesh(new THREE.SphereGeometry(1.1 + (i % 2) * 0.2, 10, 10), leafMat);
crown.position.set(tx, 2.4, tz);
crown.castShadow = true;
worldGroup.add(crown);
});
// Central fountain basin.
const basin = new THREE.Mesh(
new THREE.CylinderGeometry(2.2, 2.4, 0.45, 16),
new THREE.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.7 })
);
basin.position.set(px, 0.25, pz);
basin.castShadow = true;
worldGroup.add(basin);
const water = new THREE.Mesh(
new THREE.CylinderGeometry(1.9, 1.9, 0.1, 16),
new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.2, metalness: 0.3, transparent: true, opacity: 0.85 })
);
water.position.set(px, 0.42, pz);
worldGroup.add(water);
for (const eq of p.equipment || []) {
buildGymEquipment(eq.kind, eq.position[0], eq.position[1]);
}
}
}
function nearestGymEquipment(maxDist = 2.8) {
let best = null;
for (const eq of gymEquipment) {
const d = Math.hypot(player.pos.x - eq.x, player.pos.z - eq.z);
if (d <= maxDist && (!best || d < best.dist)) best = { eq, dist: d };
}
return best;
}
function setExercisePrompt(text) {
const el = document.getElementById('exercise-prompt');
if (!el) return;
if (!text) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
el.textContent = text;
}
function animateExercise(dt) {
if (!exercise.active || !meMesh) return;
exercise.animPhase += dt * 6;
const u = meMesh.userData;
if (!u?.legs || !u?.arms) return;
const swing = Math.sin(exercise.animPhase) * 0.9;
const lift = Math.abs(Math.sin(exercise.animPhase));
u.legs[0].rotation.x = swing;
u.legs[1].rotation.x = -swing;
u.arms[0].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[1].rotation.x = -Math.PI / 2 + lift * 0.4;
u.arms[0].rotation.z = -0.6 - lift * 0.5;
u.arms[1].rotation.z = 0.6 + lift * 0.5;
meMesh.position.y = lift * 0.08;
}
async function startExerciseSession(eq) {
if (!meCharacter || exercise.active) return;
exercise.active = true;
exercise.equipmentId = eq.id;
exercise.repTimer = 0;
meCharacter.state = 'exercising';
if (stateEl) stateEl.textContent = 'exercising';
flashCharMsg(i18n.exerciseStart || 'Exercising…');
cityAudio.play('work');
try {
const data = await postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (data.character) {
updateCharacterHud(data.character);
if ((data.character.health ?? 100) >= 100) {
flashCharMsg(data.message || i18n.exerciseMaxHealth || 'Max health');
await stopExerciseSession(false);
}
}
} catch (e) {
exercise.active = false;
exercise.equipmentId = null;
flashCharMsg(e.message, true);
if (meCharacter) meCharacter.state = 'awake';
}
}
async function stopExerciseSession(showMsg = true) {
if (!exercise.active) return;
exercise.active = false;
exercise.equipmentId = null;
if (meMesh) meMesh.position.y = 0;
if (meMesh?.userData?.arms) {
meMesh.userData.arms.forEach(a => { a.rotation.z = 0; a.rotation.x = 0; });
}
try {
const data = await postJson('/city/actions/stop-exercise', {});
if (data.character) updateCharacterHud(data.character);
if (showMsg) flashCharMsg(i18n.exerciseStop || 'Stopped exercising');
} catch (_) { /* still leave local state */ }
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
}
function updateExercise(dt) {
if (isHospitalizedNow()) {
if (exercise.active) stopExerciseSession(false);
setExercisePrompt(null);
return;
}
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
if (exercise.active) {
const still = near && near.eq.id === exercise.equipmentId
|| (gymEquipment.some(e => e.id === exercise.equipmentId)
&& Math.hypot(player.pos.x - (gymEquipment.find(e => e.id === exercise.equipmentId)?.x ?? 999),
player.pos.z - (gymEquipment.find(e => e.id === exercise.equipmentId)?.z ?? 999)) < 4);
// Moving with WASD cancels the session.
const moving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
if (!still || moving) {
stopExerciseSession();
setExercisePrompt(null);
return;
}
setExercisePrompt(`🏃 ${i18n.exerciseActive || 'Exercising…'} · E ${i18n.exerciseStopHint || 'stop'}`);
animateExercise(dt);
exercise.repTimer += dt;
if (exercise.repTimer >= 1.0) {
exercise.repTimer = 0;
postJson('/city/actions/exercise', {
pos_x: player.pos.x,
pos_z: player.pos.z,
})
.then((data) => {
if (data.character) {
updateCharacterHud(data.character);
if (data.character.health >= 100) {
flashCharMsg(i18n.exerciseMaxHealth || 'Max health');
stopExerciseSession(false);
}
}
})
.catch((e) => {
flashCharMsg(e.message, true);
stopExerciseSession(false);
});
}
return;
}
if (near && meCharacter && meCharacter.state !== 'sleeping') {
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
} else {
setExercisePrompt(null);
}
// Toggle with E.
if (keys['KeyE']) {
keys['KeyE'] = false;
if (exercise.active) {
stopExerciseSession();
} else if (near) {
startExerciseSession(near.eq);
}
}
}
function buildCity(data) {
clearGroup(worldGroup);
colliders.length = 0;
storeMeshes.length = 0;
agentMeshes.length = 0;
crowdMeshes.length = 0;
facadeMaterials.length = 0;
lampHeads.length = 0;
trafficSystem = null;
vehicleSystem = null;
pedestrianSystem = null;
airplaneSystem = null;
trainSystem = null;
riverSystem = null;
shopSystem = null;
mayorSystem = null;
meMesh = null;
spawnApplied = false;
plazas = [];
gymEquipment = [];
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
const layout = data.layout || { roads: [], blocks: [] };
const stores = data.stores || [];
const agents = data.agents || [];
meCharacter = data.me || null;
cityBounds = data.city?.bounds || null;
if (meCharacter) {
player.pos.set(meCharacter.position[0], player.eyeHeight, meCharacter.position[2]);
// If the persisted spot is inside a building (e.g. a house built on
// the spawn), push the player out onto the nearest open ground.
if (colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
const push = 14;
const candidates = [
[meCharacter.position[0], meCharacter.position[2] + push],
[meCharacter.position[0], meCharacter.position[2] - push],
[meCharacter.position[0] + push, meCharacter.position[2]],
[meCharacter.position[0] - push, meCharacter.position[2]],
[0, 12],
[8, 4],
];
for (const [x, z] of candidates) {
player.pos.x = x;
player.pos.z = z;
if (!colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
break;
}
}
}
updateCharacterHud(meCharacter);
populateJobSelect(data.jobs || []);
populateCitizenSelect(data.characters || [], meCharacter);
spawnApplied = true;
}
if (!layout.roads.length) {
statsEl.textContent = '';
return;
}
grid = buildGrid(layout);
// Synthetic roads: bridge approach (west) + level-crossing extension (north).
const zMid = grid.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.zs[0]);
const xMid = grid.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.xs[0]);
layoutExtras.bridgeRoad = {
from: [grid.minX, 0, zMid], to: [grid.minX - 55, 0, zMid], width: 8, axis: 'x',
};
layoutExtras.crossRoad = {
from: [xMid, 0, grid.maxZ], to: [xMid, 0, grid.maxZ + 40], width: 8, axis: 'z',
};
layout.roads.push(layoutExtras.bridgeRoad, layoutExtras.crossRoad);
// ---- Ground ----
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2000, 2000),
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.05;
ground.receiveShadow = true;
worldGroup.add(ground);
// ---- River (under the bridge road) ----
initRiver(grid);
// ---- Roads ----
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
walkMat.map.repeat.set(4, 1);
layout.roads.forEach(r => {
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const geo = r.axis === 'z'
? new THREE.PlaneGeometry(w, len)
: new THREE.PlaneGeometry(len, w);
const mesh = new THREE.Mesh(geo, roadMat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(mx, 0.01, mz);
if (r === layoutExtras.bridgeRoad) mesh.position.y = 0.07;
mesh.receiveShadow = true;
worldGroup.add(mesh);
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
const swWidth = 2;
const offset = w / 2 + swWidth / 2;
[-1, 1].forEach(side => {
const sg = r.axis === 'z'
? new THREE.PlaneGeometry(swWidth, len)
: new THREE.PlaneGeometry(len, swWidth);
const sm = new THREE.Mesh(sg, walkMat);
sm.rotation.x = -Math.PI / 2;
if (r.axis === 'z') {
sm.position.set(mx + side * offset, 0.12, mz);
} else {
sm.position.set(mx, 0.12, mz + side * offset);
}
sm.receiveShadow = true;
worldGroup.add(sm);
});
});
// ---- Curbs ----
layout.roads.forEach(r => {
if (r === layoutExtras.bridgeRoad) return;
const [x1,, z1] = r.from;
const [x2,, z2] = r.to;
const w = r.width;
const len = Math.hypot(x2 - x1, z2 - z1);
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
const offset = w / 2 + 0.1;
[-1, 1].forEach(side => {
const curbGeo = r.axis === 'z'
? new THREE.BoxGeometry(0.2, 0.14, len)
: new THREE.BoxGeometry(len, 0.14, 0.2);
const curb = new THREE.Mesh(curbGeo, new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 }));
if (r.axis === 'z') {
curb.position.set(mx + side * offset, 0.07, mz);
} else {
curb.position.set(mx, 0.07, mz + side * offset);
}
worldGroup.add(curb);
});
});
// ---- Street lamps ----
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 = yawFromDelta(ahead.x - p.x, ahead.z - p.z);
} else {
p.lerpVectors(v.a, v.b, v.t);
const dirSign = v.dir >= 0 ? 1 : -1;
rot = yawFromDelta((v.b.x - v.a.x) * dirSign, (v.b.z - v.a.z) * dirSign);
}
const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0);
const halfW = v.mesh?.userData?.halfW ?? 1.0;
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
const ex = halfL * c + halfW * s;
const ez = halfL * s + halfW * c;
boxes.push({
minX: p.x - ex, maxX: p.x + ex,
minZ: p.z - ez, maxZ: p.z + ez,
minY: 0, maxY: 2,
});
}
return boxes;
}
function resolveCollision(px, py, pz) {
const box = playerAABB(px, py, pz);
const cur = playerAABB(player.pos.x, player.pos.y, player.pos.z);
const inside = (c) => overlaps(cur, c);
// Static buildings: if the player already overlaps one (spawned inside
// or built a house on their feet), allow moving so they can walk out.
for (const c of colliders) {
if (overlaps(box, c) && !inside(c)) return true;
}
// Hitting a vehicle is an accident (only once the world is live).
if (window.__city10?.ready && spawnApplied && meCharacter && meCharacter.state !== 'hospital') {
for (const c of vehicleBoxes()) {
if (overlaps(box, c)) {
reportVehicleAccident();
if (!inside(c)) return true;
}
}
}
if (trainSystem) {
const tb = {
minX: trainSystem.s - 18, maxX: trainSystem.s + 18,
minZ: trainSystem.crossZ - 1.6, maxZ: trainSystem.crossZ + 1.6,
minY: 0, maxY: 4,
};
if (overlaps(box, tb) && !inside(tb)) return true;
}
return false;
}
/**
* Detect vehicles driving into a stationary player and report the accident.
* Runs only after the city is fully built and the player spawn is applied.
* Players on public plazas (gym parks) are safe from traffic.
*/
function checkVehicleHitOnPlayer() {
if (!window.__city10?.ready) return;
if (!meCharacter || hospitalBusy) return;
if (meCharacter.state === 'hospital') return;
if (!spawnApplied) return;
if (exercise.active) return;
// Soft zone: plazas are fenced parks off the roadway.
if (plazas.some(p => {
const [px, pz] = p.position;
const [pw, pd] = p.size || [22, 22];
return Math.abs(player.pos.x - px) <= pw / 2 && Math.abs(player.pos.z - pz) <= pd / 2;
})) {
return;
}
const box = playerAABB(player.pos.x, player.pos.y, player.pos.z);
for (const c of vehicleBoxes()) {
if (overlaps(box, c)) {
reportVehicleAccident();
return;
}
}
}
/**
* Tell the server the character was hit by a vehicle (2-day hospital stay).
*/
function reportVehicleAccident() {
if (!meCharacter || hospitalBusy) return;
if (meCharacter.state === 'hospital') return;
hospitalBusy = true;
cityAudio.play('horn');
postJson('/city/actions/accident', {})
.then((data) => {
if (data?.character) updateCharacterHud(data.character);
if (data?.bed) {
player.pos.x = data.bed.pos_x;
player.pos.z = data.bed.pos_z;
player.vel.set(0, 0, 0);
}
flashCharMsg(data?.message || i18n.hospitalHit || 'hospital', true);
cityAudio.play('hospital');
showHospitalBanner(data?.hospitalized_until);
updateCameraFromPlayer();
})
.catch((e) => {
flashCharMsg(e.message, true);
cityAudio.play('error');
})
.finally(() => {
hospitalBusy = false;
});
}
function showHospitalBanner(untilIso) {
const el = document.getElementById('hospital-banner');
if (!el) return;
if (!untilIso) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
const tick = () => {
const until = new Date(untilIso).getTime();
const left = until - Date.now();
if (left <= 0 || meCharacter?.state !== 'hospital') {
el.style.display = 'none';
return;
}
const totalSec = Math.ceil(left / 1000);
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
const label = i18n.hospitalLeft || 'Discharge in';
el.textContent = `🏥 ${i18n.hospitalTitle || 'Hospitalized'} · ${label} ${mm}:${ss}`;
setTimeout(tick, 1000);
};
tick();
}
function isHospitalizedNow() {
if (meCharacter?.state !== 'hospital') return false;
if (!meCharacter.hospitalized_until) return true;
return new Date(meCharacter.hospitalized_until).getTime() > Date.now();
}
let lastPedBlockAt = 0;
/**
* Check whether the player may step from their current position to (toX, toZ).
*
* Crossing onto a road is gated by the pedestrian signal for that road's
* axis: vehicles green ⇒ pedestrians red. Players already mid-road may
* finish the crossing so they never get stuck in the asphalt.
*/
function playerCrossingAllowed(toX, toZ) {
if (!grid || !trafficSystem) return true;
const fromOnRoad = onRoad(player.pos.x, player.pos.z, grid);
const toOnRoad = onRoad(toX, toZ, grid);
if (!toOnRoad) return true;
if (fromOnRoad) return true; // already committed to the road
const half = grid.roadW / 2;
const enteringZRoad = grid.xs.some(vx => Math.abs(toX - vx) <= half);
const enteringXRoad = grid.zs.some(vz => Math.abs(toZ - vz) <= half);
// Blocked when the matching pedestrian signal is red.
if (enteringZRoad && trafficSystem.pedState('z') === 'red') return false;
if (enteringXRoad && trafficSystem.pedState('x') === 'red') return false;
return true;
}
function flashPedRed() {
const now = performance.now();
if (now - lastPedBlockAt < 1200) return;
lastPedBlockAt = now;
const el = document.getElementById('ped-wait');
if (el) {
el.style.display = 'block';
el.textContent = `🚶 ${i18n.pedRed || 'Red pedestrian light — wait.'}`;
clearTimeout(flashPedRed._t);
flashPedRed._t = setTimeout(() => { el.style.display = 'none'; }, 1600);
}
}
function updatePlayer(dt) {
// Hospitalized characters stay in bed until discharge.
if (isHospitalizedNow()) {
player.vel.set(0, 0, 0);
player.pos.y = player.eyeHeight;
return;
}
// Auto-release when the hospital stay elapses while online.
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
meCharacter.state = 'awake';
meCharacter.hospitalized_until = null;
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
flashCharMsg(i18n.hospitalReleased || 'Released');
}
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
const wish = new THREE.Vector3();
// Allow walking with WASD/arrows even before pointer lock so the
// character is never completely stuck on the overlay screen.
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
if (mobileInput.active) {
// Joystick: +y on screen is forward for the player.
wish.addScaledVector(forward, -mobileInput.y);
wish.addScaledVector(right, mobileInput.x);
}
if (wish.lengthSq() > 0) {
wish.normalize();
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
const step = wish.multiplyScalar(speed * dt);
const tryX = player.pos.x + step.x;
if (!resolveCollision(tryX, player.pos.y, player.pos.z)
&& playerCrossingAllowed(tryX, player.pos.z)) {
player.pos.x = tryX;
} else if (playerCrossingAllowed(tryX, player.pos.z) === false) {
flashPedRed();
}
const tryZ = player.pos.z + step.z;
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)
&& playerCrossingAllowed(player.pos.x, tryZ)) {
player.pos.z = tryZ;
} else if (playerCrossingAllowed(player.pos.x, tryZ) === false) {
flashPedRed();
}
}
player.vel.y -= 18 * dt;
player.pos.y += player.vel.y * dt;
if (player.pos.y <= player.eyeHeight) {
player.pos.y = player.eyeHeight;
player.vel.y = 0;
player.onGround = true;
}
if (keys['Space'] && player.onGround) {
player.vel.y = 6;
player.onGround = false;
}
player.pos.x = cityBounds
? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x))
: Math.max(-400, Math.min(400, player.pos.x));
player.pos.z = cityBounds
? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z))
: Math.max(-400, Math.min(400, player.pos.z));
}
function updateCameraFromPlayer() {
camera.position.copy(player.pos);
const dir = new THREE.Vector3(
-Math.sin(player.yaw) * Math.cos(player.pitch),
Math.sin(player.pitch),
-Math.cos(player.yaw) * Math.cos(player.pitch)
);
camera.lookAt(camera.position.clone().add(dir));
}
// ------------------------------------------------------------------
// Click-to-interact
// ------------------------------------------------------------------
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let pointerDown = null;
renderer.domElement.addEventListener('pointerdown', (e) => {
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (!pointerDown) return;
const dx = Math.abs(e.clientX - pointerDown.x);
const dy = Math.abs(e.clientY - pointerDown.y);
const dt = Date.now() - pointerDown.t;
pointerDown = null;
if (dx > 6 || dy > 6 || dt > 400) return;
const rect = renderer.domElement.getBoundingClientRect();
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
ray.setFromCamera(ndc, camera);
const agentHits = ray.intersectObjects(agentMeshes, true);
if (agentHits.length) {
let obj = agentHits[0].object;
while (obj && !obj.userData?.agent) obj = obj.parent;
if (obj?.userData?.agent) {
openChat(obj.userData.agent);
return;
}
}
const storeHits = ray.intersectObjects(storeMeshes, false);
if (storeHits.length) {
const s = storeHits[0].object.userData.store;
selectedEl.style.display = 'inline-block';
const open = shopOpenNow(s);
selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`;
} else {
selectedEl.style.display = 'none';
}
});
// ------------------------------------------------------------------
// ChatPanel
// ------------------------------------------------------------------
const chatPanel = document.getElementById('chat-panel');
const chatDot = document.getElementById('chat-dot');
const chatName = document.getElementById('chat-name');
const chatStatus = document.getElementById('chat-status');
const chatTime = document.getElementById('chat-time');
const chatClose = document.getElementById('chat-close');
const chatMessages = document.getElementById('chat-messages');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
let currentAgent = null;
let eventSource = null;
function addBubble(text, who) {
const div = document.createElement('div');
div.className = `chat-bubble ${who}`;
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function setStatus(status) {
if (!currentAgent) return;
currentAgent.status = status;
const map = {
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
active: ['#22c55e', 'online'],
error: ['#ef4444', 'error'],
};
const [color, label] = map[status] || map.active;
chatDot.style.background = color;
chatStatus.textContent = label;
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
if (mesh) refreshLabel(mesh, status);
}
function openChat(agent) {
currentAgent = agent;
chatPanel.style.display = 'flex';
chatName.textContent = agent.name;
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
chatMessages.innerHTML = '';
const history = agent.last_decision || [];
history.forEach(m => {
if (m.role === 'user') addBubble(m.content, 'user');
else if (m.role === 'assistant') addBubble(m.content, 'npc');
});
setStatus(agent.status || 'active');
if (eventSource) { eventSource.close(); eventSource = null; }
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
eventSource.addEventListener('message', (e) => {
try {
const payload = JSON.parse(e.data);
if (payload.type === 'thinking') {
setStatus('thinking');
addBubble(i18n.chatThinking || 'thinking…', 'meta');
} else if (payload.type === 'reply') {
setStatus('active');
addBubble(payload.reply, 'npc');
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (payload.type === 'error') {
setStatus('error');
addBubble(payload.reply || i18n.chatError || 'error', 'meta');
}
} catch (_) { /* ignore */ }
});
eventSource.onerror = () => { /* auto-retry */ };
}
function closeChat() {
chatPanel.style.display = 'none';
currentAgent = null;
if (eventSource) { eventSource.close(); eventSource = null; }
}
chatClose.addEventListener('click', closeChat);
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = chatInput.value.trim();
if (!msg || !currentAgent) return;
chatInput.value = '';
addBubble(msg, 'user');
setStatus('thinking');
try {
await fetch(`/city/chat/${currentAgent.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: msg }),
});
} catch (_) {
addBubble(i18n.chatError || 'error', 'meta');
}
});
// ------------------------------------------------------------------
// HUD
// ------------------------------------------------------------------
function updateHUD() {
const g = player.pos.y <= player.eyeHeight + 0.01;
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
posGround.textContent = g
? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m`
: `airborne · ${player.pos.y.toFixed(1)}m`;
posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`;
const hh = String(Math.floor(sim.hour)).padStart(2, '0');
const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0');
if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`;
if (weatherEl) {
const label = i18n[`weather_${weatherState.state}`] || weatherState.state;
weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`;
}
if (meMesh) {
meMesh.position.x = player.pos.x;
meMesh.position.z = player.pos.z;
}
}
// ------------------------------------------------------------------
// Character HUD + player actions (server-backed lifecycle)
// ------------------------------------------------------------------
const charNameEl = document.getElementById('char-name');
const charMsgEl = document.getElementById('char-msg');
const hungerEl = document.getElementById('hud-hunger');
const thirstEl = document.getElementById('hud-thirst');
const energyEl = document.getElementById('hud-energy');
const healthEl = document.getElementById('hud-health');
const exerciseCountEl = document.getElementById('hud-exercise-count');
const moneyEl = document.getElementById('hud-money');
const stateEl = document.getElementById('hud-char-state');
const educationEl = document.getElementById('hud-education');
const invEl = document.getElementById('hud-inv');
let lastPositionSync = 0;
function updateCharacterHud(me) {
meCharacter = me;
if (!me) return;
if (charNameEl) charNameEl.textContent = `👤 ${me.name}`;
if (hungerEl) hungerEl.textContent = `${me.hunger}%`;
if (thirstEl) thirstEl.textContent = `${me.thirst}%`;
if (energyEl) energyEl.textContent = `${me.energy}%`;
if (healthEl) healthEl.textContent = `${me.health ?? 100}%`;
if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`;
if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`;
if (stateEl) stateEl.textContent = me.state || '—';
if (educationEl) educationEl.textContent = `${me.education ?? 0}%`;
if (invEl) {
const inv = me.inventory || {};
invEl.textContent = Object.keys(inv).length
? Object.entries(inv).map(([k, v]) => `${k}:${v}`).join(' · ')
: '—';
}
if (me.state === 'hospital' && me.hospitalized_until) {
showHospitalBanner(me.hospitalized_until);
const bed = me.position;
if (bed) {
player.pos.x = bed[0];
player.pos.z = bed[2];
player.vel.set(0, 0, 0);
}
} else if (me.state !== 'hospital') {
const banner = document.getElementById('hospital-banner');
if (banner) banner.style.display = 'none';
}
}
function flashCharMsg(text, isError = false) {
if (!charMsgEl) return;
charMsgEl.textContent = text;
charMsgEl.style.color = isError ? '#fca5a5' : '#93c5fd';
// Errors always beep; success chimes are played by the action itself.
if (isError) cityAudio.play('error');
clearTimeout(flashCharMsg._t);
flashCharMsg._t = setTimeout(() => { charMsgEl.textContent = ''; }, 4000);
}
async function postJson(url, body = {}) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.message || i18n.notFound || 'error');
err.data = data;
err.status = res.status;
throw err;
}
return data;
}
const ACTION_SFX = {
eat: 'eat',
drink: 'drink',
sleep: 'sleep',
wake: 'wake',
work: 'work',
'stop-work': 'ui',
study: 'work',
buy: 'money',
sell: 'money',
build: 'build',
'apply-job': 'ok',
};
async function runAction(act) {
if (!meCharacter) return null;
cityAudio.unlock();
try {
let body = {};
if (act === 'buy') {
await postJson('/city/actions/buy', { item: 'food', qty: 1, price: 4 });
await postJson('/city/actions/buy', { item: 'water', qty: 1, price: 3 });
const data = await postJson('/city/actions/buy', { item: 'materials', qty: 10, price: 1 });
if (data.character) updateCharacterHud(data.character);
flashCharMsg(data.message || i18n.actionOk);
return data;
}
if (act === 'sell') body = { item: 'product', qty: 1, price: 8 };
if (act === 'build') {
// Place the house a few metres ahead so it never sits on the player.
const ahead = 12;
body = {
pos_x: Math.round(player.pos.x - Math.sin(player.yaw) * ahead),
pos_z: Math.round(player.pos.z - Math.cos(player.yaw) * ahead),
name: `${meCharacter.name}`,
};
}
const data = await postJson(`/city/actions/${act}`, body);
if (data.character) updateCharacterHud(data.character);
if (ACTION_SFX[act]) cityAudio.play(ACTION_SFX[act]);
flashCharMsg(data.message || i18n.actionOk);
if (act === 'build') {
loadCity();
}
return data;
} catch (e) {
flashCharMsg(e.message, true);
return { error: e.message };
}
}
function populateJobSelect(jobs) {
const sel = document.getElementById('job-select');
if (!sel) return;
sel.innerHTML = '<option value="">—</option>';
for (const j of jobs) {
const opt = document.createElement('option');
opt.value = j.id;
opt.textContent = `${j.title} ($${j.salary})`;
sel.appendChild(opt);
}
}
function populateCitizenSelect(characters, me) {
const sel = document.getElementById('citizen-select');
if (!sel) return;
sel.innerHTML = '<option value="">—</option>';
for (const c of characters) {
if (me && c.id === me.id) continue;
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name + (c.is_online ? ' ●' : '');
sel.appendChild(opt);
}
}
function bindCharacterUI() {
document.querySelectorAll('.char-act').forEach(btn => {
btn.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.play('ui');
runAction(btn.dataset.act);
});
});
document.getElementById('job-apply')?.addEventListener('click', async () => {
cityAudio.unlock();
cityAudio.play('ui');
const sel = document.getElementById('job-select');
if (!sel?.value) return;
try {
const data = await postJson('/city/actions/apply-job', { job_id: Number(sel.value) });
if (data.character) updateCharacterHud(data.character);
flashCharMsg(data.message || i18n.actionOk);
} catch (e) {
flashCharMsg(e.message, true);
}
});
const social = async (verb, extra = {}) => {
cityAudio.unlock();
cityAudio.play('ui');
const sel = document.getElementById('citizen-select');
if (!sel?.value) return;
try {
const data = await postJson(`/city/social/${verb}`, {
character_id: Number(sel.value),
...extra,
});
flashCharMsg(data.message || i18n.actionOk);
} catch (e) {
flashCharMsg(e.message, true);
}
};
document.getElementById('soc-friend')?.addEventListener('click', () => social('friend'));
document.getElementById('soc-marry')?.addEventListener('click', () => social('marry'));
document.getElementById('soc-child')?.addEventListener('click', () => social('child'));
}
// ------------------------------------------------------------------
// Audio panel (master / music / SFX + mute)
// ------------------------------------------------------------------
function bindAudioUI() {
const panel = document.getElementById('audio-panel');
if (!panel) return;
const master = document.getElementById('vol-master');
const music = document.getElementById('vol-music');
const sfx = document.getElementById('vol-sfx');
const muteBtn = document.getElementById('vol-mute');
const masterOut = document.getElementById('vol-master-out');
const musicOut = document.getElementById('vol-music-out');
const sfxOut = document.getElementById('vol-sfx-out');
const pct = (v) => `${Math.round(v * 100)}%`;
const syncLabels = () => {
if (masterOut) masterOut.textContent = pct(cityAudio.getVolume('master'));
if (musicOut) musicOut.textContent = pct(cityAudio.getVolume('music'));
if (sfxOut) sfxOut.textContent = pct(cityAudio.getVolume('sfx'));
if (muteBtn) {
muteBtn.textContent = cityAudio.muted ? '🔇' : '🔊';
muteBtn.setAttribute('aria-pressed', String(cityAudio.muted));
}
if (master) master.value = String(cityAudio.getVolume('master'));
if (music) music.value = String(cityAudio.getVolume('music'));
if (sfx) sfx.value = String(cityAudio.getVolume('sfx'));
};
const onSlide = (channel) => (e) => {
cityAudio.unlock();
cityAudio.setVolume(channel, e.target.value);
syncLabels();
};
master?.addEventListener('input', onSlide('master'));
music?.addEventListener('input', onSlide('music'));
sfx?.addEventListener('input', onSlide('sfx'));
muteBtn?.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.toggleMute();
if (!cityAudio.muted) cityAudio.play('ui');
syncLabels();
});
document.getElementById('audio-toggle')?.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.play('ui');
const hidden = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = hidden ? 'block' : 'none';
});
syncLabels();
panel.style.display = 'none';
}
function syncPositionThrottled() {
if (!meCharacter) return;
const now = performance.now();
if (now - lastPositionSync < 2000) return;
lastPositionSync = now;
postJson('/city/actions/position', {
pos_x: player.pos.x,
pos_z: player.pos.z,
}).then((data) => {
if (data && meCharacter) {
meCharacter.position = [data.pos_x, 0, data.pos_z];
}
}).catch(() => { /* offline-safe */ });
}
// ------------------------------------------------------------------
// Resize + animation loop
// ------------------------------------------------------------------
function resize() {
const w = host.clientWidth, h = host.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}
new ResizeObserver(resize).observe(host);
resize();
let lastT = performance.now();
let simTimeAcc = 0;
let statsTimer = 0;
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
simTimeAcc += dt;
updateClock(dt);
updateWeather(dt);
updateDayNight();
if (trafficSystem) trafficSystem.update(dt);
if (vehicleSystem) vehicleSystem.update(dt);
if (pedestrianSystem) pedestrianSystem.update(dt);
if (mayorSystem) mayorSystem.update(dt);
if (shopSystem) shopSystem.update(dt);
if (airplaneSystem) airplaneSystem.update(dt);
if (trainSystem) trainSystem.update(dt);
if (riverSystem) riverSystem.update(dt, simTimeAcc);
updatePlayer(dt);
checkVehicleHitOnPlayer();
updateExercise(dt);
updateCameraFromPlayer();
updateHUD();
updateVehicleHeadlights();
syncPositionThrottled();
drawMinimap();
// Footsteps while the local player is actually walking.
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
statsTimer += dt;
if (statsTimer > 1 && cityData) {
statsTimer = 0;
statsEl.textContent = buildStatsText();
}
renderer.render(scene, camera);
}
animate();
// ------------------------------------------------------------------
// Multiplayer presence (other citizens in near-real-time)
// ------------------------------------------------------------------
function upsertRemoteCitizen(c) {
if (c.is_me) return;
let mesh = remoteCitizens.get(c.id);
if (!mesh) {
mesh = buildHumanoid({
name: c.name,
type: c.type === 'child' ? 'crowd' : 'pedestrian',
status: c.status || 'active',
id: c.id,
}, true);
const [cx, , cz] = c.position;
mesh.position.set(cx, 0, cz);
worldGroup.add(mesh);
crowdMeshes.push(mesh);
remoteCitizens.set(c.id, mesh);
} else {
const [cx, , cz] = c.position;
const dx = cx - mesh.position.x;
const dz = cz - mesh.position.z;
mesh.position.x = cx;
mesh.position.z = cz;
if (Math.hypot(dx, dz) > 0.05) {
mesh.rotation.y = Math.atan2(dx, dz);
animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4));
}
}
}
async function pollPresence() {
if (!window.__city10?.ready || hospitalBusy) return;
try {
const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : '';
const res = await fetch(`/city/presence${qs}`, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
cache: 'no-store',
});
if (!res.ok) return;
const data = await res.json();
const seen = new Set();
for (const c of data.citizens || []) {
if (c.is_me) continue;
seen.add(c.id);
upsertRemoteCitizen(c);
}
for (const [id, mesh] of remoteCitizens) {
if (!seen.has(id)) {
worldGroup.remove(mesh);
remoteCitizens.delete(id);
}
}
lastPresenceOk = performance.now();
} catch (_) { /* offline-safe */ }
}
function startPresenceLoop() {
if (presenceTimer) clearInterval(presenceTimer);
presenceTimer = setInterval(pollPresence, 2500);
pollPresence();
}
// ------------------------------------------------------------------
// Minimap (canvas overlay)
// ------------------------------------------------------------------
function initMinimap() {
const canvas = document.getElementById('minimap');
if (!canvas) return;
minimapCtx = canvas.getContext('2d');
}
function drawMinimap() {
if (!minimapCtx || !cityBounds) return;
const canvas = minimapCtx.canvas;
const w = canvas.width;
const h = canvas.height;
const minX = cityBounds.min_x;
const maxX = cityBounds.max_x;
const minZ = cityBounds.min_z;
const maxZ = cityBounds.max_z;
const spanX = Math.max(1, maxX - minX);
const spanZ = Math.max(1, maxZ - minZ);
const toMap = (x, z) => [
((x - minX) / spanX) * (w - 8) + 4,
((z - minZ) / spanZ) * (h - 8) + 4,
];
minimapCtx.clearRect(0, 0, w, h);
minimapCtx.fillStyle = 'rgba(11,18,32,.85)';
minimapCtx.fillRect(0, 0, w, h);
// Roads
if (grid) {
minimapCtx.strokeStyle = 'rgba(148,163,184,.45)';
minimapCtx.lineWidth = 2;
for (const vx of grid.xs) {
const [mx] = toMap(vx, minZ);
minimapCtx.beginPath();
minimapCtx.moveTo(mx, 4);
minimapCtx.lineTo(mx, h - 4);
minimapCtx.stroke();
}
for (const vz of grid.zs) {
const [, mz] = toMap(minX, vz);
minimapCtx.beginPath();
minimapCtx.moveTo(4, mz);
minimapCtx.lineTo(w - 4, mz);
minimapCtx.stroke();
}
}
// Plazas (static parks — green)
for (const p of plazas) {
const [mx, mz] = toMap(p.position[0], p.position[1]);
minimapCtx.fillStyle = 'rgba(34,197,94,.55)';
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2);
minimapCtx.fill();
}
// Stores (static shops — orange)
for (const s of cityData?.stores || []) {
const [mx, mz] = toMap(s.position[0], s.position[2]);
minimapCtx.fillStyle = '#f59e0b';
minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5);
}
// NPC pedestrians (local crowd — yellow, they walk)
if (pedestrianSystem) {
minimapCtx.fillStyle = '#facc15';
for (const p of pedestrianSystem.positions()) {
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2);
minimapCtx.fill();
}
}
// Vehicles (moving — colored by kind)
if (vehicleSystem?.vehicles) {
for (const v of vehicleSystem.vehicles) {
const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z };
if (p.x == null || p.z == null) continue;
const kind = v.kind || v.mesh?.userData?.kind || 'car';
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#f472b6';
// Player is the bright pink; other cars use a softer white-blue.
if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue;
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
: kind === 'bike' ? '#4ade80'
: kind === 'bus' ? '#60a5fa'
: '#94a3b8';
const [mx, mz] = toMap(p.x, p.z);
minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4);
}
}
// Remote citizens (online players — cyan)
minimapCtx.fillStyle = '#38bdf8';
for (const mesh of remoteCitizens.values()) {
const [mx, mz] = toMap(mesh.position.x, mesh.position.z);
minimapCtx.beginPath();
minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2);
minimapCtx.fill();
}
// Player (you — pink with white ring)
const [px, pz] = toMap(player.pos.x, player.pos.z);
minimapCtx.fillStyle = '#f472b6';
minimapCtx.beginPath();
minimapCtx.arc(px, pz, 4, 0, Math.PI * 2);
minimapCtx.fill();
minimapCtx.strokeStyle = '#fff';
minimapCtx.lineWidth = 1.5;
minimapCtx.stroke();
}
// ------------------------------------------------------------------
// Vehicle headlights at night
// ------------------------------------------------------------------
function initVehicleHeadlights() {
for (const v of vehicleSystem?.vehicles || []) {
if (!v.mesh || v.mesh.userData.headlights) continue;
if (v.mesh.userData.kind === 'bike') continue;
const lights = [];
const halfL = v.mesh.userData.halfL ?? 2;
for (const side of [-1, 1]) {
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 8, 8),
new THREE.MeshStandardMaterial({
color: 0xfff6c8,
emissive: 0xfff2b0,
emissiveIntensity: 0,
})
);
lamp.position.set(halfL + 0.05, 0.55, side * 0.55);
v.mesh.add(lamp);
lights.push(lamp.material);
}
v.mesh.userData.headlights = lights;
vehicleHeadlights.push(...lights);
}
}
function updateVehicleHeadlights() {
const night = sim.nightFactor;
const on = night > 0.4 ? (night - 0.4) / 0.6 * 2.2 : 0.05;
for (const m of vehicleHeadlights) {
m.emissiveIntensity = on;
}
}
// ------------------------------------------------------------------
// Mobile virtual joystick
// ------------------------------------------------------------------
function bindMobileJoystick() {
const base = document.getElementById('joystick-base');
const knob = document.getElementById('joystick-knob');
if (!base || !knob) return;
let activeId = null;
const radius = 42;
const setKnob = (dx, dy) => {
const len = Math.hypot(dx, dy) || 1;
const clamped = Math.min(len, radius);
const nx = (dx / len) * clamped;
const ny = (dy / len) * clamped;
knob.style.transform = `translate(${nx}px, ${ny}px)`;
mobileInput.x = nx / radius;
mobileInput.y = ny / radius;
mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15;
};
const center = () => {
activeId = null;
mobileInput = { x: 0, y: 0, active: false };
knob.style.transform = 'translate(0px, 0px)';
};
base.addEventListener('pointerdown', (e) => {
activeId = e.pointerId;
base.setPointerCapture(e.pointerId);
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
e.preventDefault();
});
base.addEventListener('pointermove', (e) => {
if (activeId !== e.pointerId) return;
const rect = base.getBoundingClientRect();
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
});
base.addEventListener('pointerup', center);
base.addEventListener('pointercancel', center);
}
// ------------------------------------------------------------------
// Market / missions / customize / ride UI
// ------------------------------------------------------------------
async function refreshMarketPanel() {
const listEl = document.getElementById('market-list');
const missionEl = document.getElementById('mission-list');
if (!listEl && !missionEl) return;
try {
const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (listEl) {
listEl.innerHTML = (data.listings || []).length
? data.listings.map(l => `
<div style="display:flex; justify-content:space-between; gap:.4rem; align-items:center; margin:.25rem 0;">
<span>${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} <span style="opacity:.7">(${l.seller_name})</span></span>
${l.is_mine ? '' : `<button type="button" class="btn market-buy" data-id="${l.id}" style="padding:.15rem .4rem; font-size:.7rem;">${i18n.marketBuy || 'Buy'}</button>`}
</div>`).join('')
: `<div style="opacity:.6;">—</div>`;
listEl.querySelectorAll('.market-buy').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const out = await postJson('/city/market/buy', { listing_id: Number(btn.dataset.id) });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
if (out.inventory && meCharacter) {
meCharacter.money = out.money;
meCharacter.inventory = out.inventory;
updateCharacterHud(meCharacter);
}
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
});
}
if (missionEl) {
missionEl.innerHTML = (data.missions || []).map(m => `
<div style="margin:.3rem 0; ${m.is_completed ? 'opacity:.55;' : ''}">
<strong style="color:#fff;">${m.title}</strong>
<div style="font-size:.75rem;">${m.description}</div>
<div style="font-size:.75rem;">${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)}
${m.is_completed ? '✓' : ''}</div>
</div>`).join('') || '<div style="opacity:.6;">—</div>';
}
} catch (_) { /* ignore */ }
}
function bindWorldUI() {
document.getElementById('btn-market')?.addEventListener('click', () => {
const panel = document.getElementById('market-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (show) refreshMarketPanel();
});
document.getElementById('market-sell')?.addEventListener('click', async () => {
const item = document.getElementById('market-item')?.value || 'product';
const qty = Number(document.getElementById('market-qty')?.value || 1);
const price = Number(document.getElementById('market-price')?.value || 5);
try {
const out = await postJson('/city/market/sell', { item, qty, unit_price: price });
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('money');
refreshMarketPanel();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-customize')?.addEventListener('click', async () => {
const panel = document.getElementById('customize-panel');
if (!panel) return;
const show = panel.style.display === 'none' || panel.style.display === '';
panel.style.display = show ? 'block' : 'none';
if (!show) return;
try {
const res = await fetch('/city/customize', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const data = await res.json();
['skin', 'hair', 'shirt', 'face', 'beard', 'accessory'].forEach(k => {
const el = document.getElementById(`cz-${k}`);
if (el) el.value = String(data[k]);
});
} catch (_) { /* ignore */ }
});
document.getElementById('customize-save')?.addEventListener('click', async () => {
const body = {};
for (const k of ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory']) {
body[k] = Number(document.getElementById(`cz-${k}`)?.value || 1);
}
try {
const res = await fetch('/city/customize', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.message || 'error');
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
loadCity();
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-bus')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'bus',
pos_x: player.pos.x,
pos_z: player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
document.getElementById('btn-ride-train')?.addEventListener('click', async () => {
try {
const out = await postJson('/city/actions/ride', {
kind: 'train',
pos_x: player.pos.x,
pos_z: cityBounds ? cityBounds.max_z + 14 : player.pos.z,
});
if (out.character) updateCharacterHud(out.character);
flashCharMsg(out.message || i18n.actionOk);
cityAudio.play('ok');
} catch (e) {
flashCharMsg(e.message, true);
}
});
}
// ------------------------------------------------------------------
// Test/debug API
// ------------------------------------------------------------------
window.__city10 = {
ready: false,
get hour() { return sim.hour; },
get weather() { return weatherState.state; },
get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; },
get pedStateX() { return trafficSystem ? trafficSystem.pedState('x') : null; },
get pedStateZ() { return trafficSystem ? trafficSystem.pedState('z') : null; },
setTrafficPhase(name) {
if (trafficSystem) trafficSystem.setPhase(name);
return this.trafficPhase;
},
playerCrossingAllowed,
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
setHour(h) {
sim.hour = ((h % 24) + 24) % 24;
updateDayNight();
updateHUD();
},
setWeather,
audio: {
state: () => cityAudio.state(),
setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); },
setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); },
unlock: () => cityAudio.unlock(),
play: (name) => { cityAudio.unlock(); cityAudio.play(name); },
},
teleport(x, z, yaw = null, pitch = 0, y = null) {
player.pos.x = x;
player.pos.z = z;
player.pos.y = y !== null ? y : player.eyeHeight;
player.vel.set(0, 0, 0);
if (yaw !== null) player.yaw = yaw;
player.pitch = pitch;
updateCameraFromPlayer();
},
stats() {
return {
ready: this.ready,
hour: +sim.hour.toFixed(2),
weather: weatherState.state,
trafficPhase: this.trafficPhase,
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
plazas: plazas.length,
gymEquipment: gymEquipment.length,
exercising: exercise.active,
nearGym: (() => {
const n = spawnApplied ? nearestGymEquipment(4) : null;
return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null;
})(),
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
trains: trainSystem ? 1 : 0,
river: !!riverSystem,
stores: cityData ? cityData.stores.length : 0,
storesOpen: shopSystem ? shopSystem.openCount() : 0,
agents: cityData ? cityData.agents.length : 0,
colliders: colliders.length,
};
},
vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; },
/** Sample vehicle heading vs velocity so tests can detect reverse-facing models. */
vehicleOrientations() {
if (!vehicleSystem) return [];
const out = [];
for (const v of vehicleSystem.vehicles) {
const p = new THREE.Vector3();
const ahead = new THREE.Vector3();
if (v.pts) {
posOnPath(v.pts, v.s, v.L, p);
posOnPath(v.pts, v.s + 0.8, v.L, ahead);
} else {
p.lerpVectors(v.a, v.b, v.t);
const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.05));
ahead.lerpVectors(v.a, v.b, lookT);
}
const dx = ahead.x - p.x;
const dz = ahead.z - p.z;
const len = Math.hypot(dx, dz) || 1;
const vx = dx / len;
const vz = dz / len;
// Mesh nose is local +X → world forward = (cos yaw, -sin yaw).
const yaw = v.mesh.rotation.y;
const fx = Math.cos(yaw);
const fz = -Math.sin(yaw);
const dot = fx * vx + fz * vz;
out.push({
kind: v.mesh?.userData?.kind || 'car',
speed: +(v.speed || 0).toFixed(2),
dot: +dot.toFixed(3),
aligned: dot > 0.5,
});
}
return out;
},
pedestrianPositions() { return pedestrianSystem ? pedestrianSystem.positions() : []; },
get me() { return meCharacter; },
refreshMe: async function () {
try {
const res = await fetch(cfg.dataUrl, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) return null;
const data = await res.json();
updateCharacterHud(data.me);
return data.me;
} catch (_) {
return null;
}
},
action: runAction,
presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk },
minimap: () => !!minimapCtx,
joystickBound: () => !!document.getElementById('joystick-base'),
agentPositions() {
return agentMeshes.map(m => ({
id: m.userData?.agent?.id ?? null,
name: m.userData?.agent?.name ?? '',
type: m.userData?.agent?.type ?? '',
x: +m.position.x.toFixed(2),
z: +m.position.z.toFixed(2),
}));
},
};
// ------------------------------------------------------------------
// Data loading
// ------------------------------------------------------------------
async function loadCity() {
loadingEl.style.display = 'grid';
errorEl.style.display = 'none';
window.__city10.ready = false;
try {
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
cityData = await res.json();
buildCity(cityData);
applyWeatherVisuals();
updateDayNight();
loadingEl.style.display = 'none';
window.__city10.ready = true;
initVehicleHeadlights();
startPresenceLoop();
refreshMarketPanel();
} catch (err) {
console.error(err);
loadingEl.style.display = 'none';
errorEl.style.display = 'grid';
}
}
reloadBtn.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.play('reload');
loadCity();
});
bindCharacterUI();
bindAudioUI();
bindWorldUI();
initMinimap();
bindMobileJoystick();
// Unlock audio on the first gesture anywhere (autoplay policy).
const unlockAudio = () => {
cityAudio.unlock();
};
document.addEventListener('pointerdown', unlockAudio, { once: false });
document.addEventListener('keydown', unlockAudio, { once: false });
startGate?.addEventListener('click', () => {
cityAudio.unlock();
cityAudio.play('ok');
});
loadCity();
initWeather();
applyWeatherVisuals();