- Adds Character model with survival needs (hunger, thirst, energy) and state machine - Implements city:tick world ticker for offline progression every minute - Adds city territory expansion and building construction system - Introduces job economy, inventory, and transaction audit trail - Adds social graph (friends, marriage, children) and character chat - Implements admin approval workflow for new registrations - Adds hospital admission system for traffic accidents - Includes comprehensive feature tests and world seeders
3049 lines
110 KiB
JavaScript
Executable File
3049 lines
110 KiB
JavaScript
Executable File
import * as THREE from '/vendor/three/three.module.min.js';
|
|
import { cityAudio } from './audio.js';
|
|
|
|
const cfg = window.CITY_CONFIG || {};
|
|
const i18n = cfg.i18n || {};
|
|
|
|
const host = document.getElementById('scene');
|
|
const loadingEl = document.getElementById('loading');
|
|
const errorEl = document.getElementById('error');
|
|
const startGate = document.getElementById('start-gate');
|
|
const statsEl = document.getElementById('stats');
|
|
const selectedEl = document.getElementById('selected');
|
|
const reloadBtn = document.getElementById('reload');
|
|
const posPanel = document.getElementById('hud-pos');
|
|
const posXYZ = document.getElementById('pos-xyz');
|
|
const posGround = document.getElementById('pos-ground');
|
|
const posTris = document.getElementById('pos-tris');
|
|
const clockEl = document.getElementById('hud-clock');
|
|
const weatherEl = document.getElementById('hud-weather');
|
|
const dataUrl = cfg.dataUrl;
|
|
const csrf = cfg.csrf;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Scene setup
|
|
// ------------------------------------------------------------------
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x87ceeb);
|
|
scene.fog = new THREE.Fog(0x87ceeb, 120, 600);
|
|
|
|
const camera = new THREE.PerspectiveCamera(72, 1, 0.1, 2000);
|
|
camera.position.set(0, 1.7, 0);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.05;
|
|
host.appendChild(renderer.domElement);
|
|
|
|
const hemiLight = new THREE.HemisphereLight(0xbfdfff, 0x556b2f, 0.55);
|
|
scene.add(hemiLight);
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.35);
|
|
scene.add(ambientLight);
|
|
const sun = new THREE.DirectionalLight(0xfff2d9, 1.15);
|
|
sun.position.set(80, 140, 60);
|
|
sun.castShadow = true;
|
|
sun.shadow.mapSize.set(2048, 2048);
|
|
sun.shadow.camera.left = -200;
|
|
sun.shadow.camera.right = 200;
|
|
sun.shadow.camera.top = 200;
|
|
sun.shadow.camera.bottom = -200;
|
|
sun.shadow.camera.far = 500;
|
|
scene.add(sun);
|
|
|
|
// ------------------------------------------------------------------
|
|
// Texture helpers
|
|
// ------------------------------------------------------------------
|
|
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
|
|
const c = document.createElement('canvas');
|
|
c.width = size; c.height = size;
|
|
const ctx = c.getContext('2d');
|
|
draw(ctx, size);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
|
tex.repeat.set(repeatX, repeatY);
|
|
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
return tex;
|
|
}
|
|
|
|
const asphaltTex = canvasTexture(512, (ctx, s) => {
|
|
ctx.fillStyle = '#1a1a1e';
|
|
ctx.fillRect(0, 0, s, s);
|
|
for (let i = 0; i < 9000; i++) {
|
|
const v = 20 + Math.random() * 30;
|
|
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
|
|
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
|
|
}
|
|
ctx.fillStyle = '#e8e8e8';
|
|
ctx.fillRect(s * 0.44, 0, 4, s);
|
|
ctx.fillRect(s * 0.54, 0, 4, s);
|
|
ctx.fillStyle = '#d0d0d0';
|
|
for (let y = 0; y < s; y += 48) {
|
|
ctx.fillRect(s * 0.08, y, 4, 26);
|
|
ctx.fillRect(s * 0.90, y, 4, 26);
|
|
}
|
|
});
|
|
|
|
const sidewalkTex = canvasTexture(256, (ctx, s) => {
|
|
ctx.fillStyle = '#b8bcc4';
|
|
ctx.fillRect(0, 0, s, s);
|
|
const tile = 64;
|
|
ctx.strokeStyle = '#9aa0a8';
|
|
ctx.lineWidth = 2;
|
|
for (let i = 0; i <= s; i += tile) {
|
|
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
|
|
}
|
|
for (let i = 0; i < 40; i++) {
|
|
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
|
|
ctx.beginPath();
|
|
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}, 4, 1);
|
|
|
|
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4) {
|
|
const w = 512, h = 512;
|
|
const c = document.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
const ctx = c.getContext('2d');
|
|
const e = document.createElement('canvas');
|
|
e.width = w; e.height = h;
|
|
const ectx = e.getContext('2d');
|
|
|
|
ctx.fillStyle = baseColor;
|
|
ctx.fillRect(0, 0, w, h);
|
|
ectx.fillStyle = '#000000';
|
|
ectx.fillRect(0, 0, w, h);
|
|
|
|
const floorH = h / floorCount;
|
|
for (let f = 0; f < floorCount; f++) {
|
|
ctx.fillStyle = 'rgba(30,30,30,0.85)';
|
|
ctx.fillRect(0, f * floorH + floorH - 4, w, 4);
|
|
for (let wi = 0; wi < windowsPerFloor; wi++) {
|
|
const wx = wi * (w / windowsPerFloor) + w / windowsPerFloor * 0.25;
|
|
const wy = f * floorH + floorH * 0.18;
|
|
const ww = (w / windowsPerFloor) * 0.5;
|
|
const wh = floorH * 0.5;
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.fillRect(wx - 3, wy - 3, ww + 6, wh + 6);
|
|
ctx.fillStyle = Math.random() > 0.5 ? '#7fb3d5' : '#5a8ab0';
|
|
ctx.fillRect(wx, wy, ww, wh);
|
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
|
ctx.fillRect(wx, wy, ww * 0.4, wh * 0.4);
|
|
if (Math.random() > 0.45) {
|
|
ectx.fillStyle = Math.random() > 0.5 ? '#ffd9a0' : '#ffcc80';
|
|
ectx.fillRect(wx, wy, ww, wh);
|
|
}
|
|
}
|
|
}
|
|
ctx.fillStyle = 'rgba(40,40,40,0.7)';
|
|
ctx.fillRect(0, 0, 6, h);
|
|
ctx.fillRect(w - 6, 0, 6, h);
|
|
|
|
const map = new THREE.CanvasTexture(c);
|
|
const emissiveMap = new THREE.CanvasTexture(e);
|
|
map.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
emissiveMap.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
return { map, emissiveMap };
|
|
}
|
|
|
|
function facadeTexture(baseColor, floorCount, windowsPerFloor) {
|
|
return facadePair(baseColor, floorCount, windowsPerFloor).map;
|
|
}
|
|
|
|
function doorTexture() {
|
|
const c = document.createElement('canvas');
|
|
c.width = 128; c.height = 256;
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.fillRect(0, 0, 128, 256);
|
|
ctx.fillStyle = '#3a2f1a';
|
|
ctx.fillRect(8, 8, 112, 240);
|
|
ctx.fillStyle = '#c9a84c';
|
|
ctx.beginPath(); ctx.arc(96, 130, 6, 0, Math.PI * 2); ctx.fill();
|
|
return new THREE.CanvasTexture(c);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// World state
|
|
// ------------------------------------------------------------------
|
|
let cityData = null;
|
|
let meCharacter = null;
|
|
let cityBounds = null;
|
|
let citizenMeshes = [];
|
|
const colliders = [];
|
|
const worldGroup = new THREE.Group();
|
|
scene.add(worldGroup);
|
|
const storeMeshes = [];
|
|
const agentMeshes = [];
|
|
const crowdMeshes = [];
|
|
const facadeMaterials = [];
|
|
const lampHeads = [];
|
|
let grid = null;
|
|
let trafficSystem = null;
|
|
let vehicleSystem = null;
|
|
let pedestrianSystem = null;
|
|
let airplaneSystem = null;
|
|
let trainSystem = null;
|
|
let riverSystem = null;
|
|
let shopSystem = null;
|
|
let mayorSystem = null;
|
|
let meMesh = null;
|
|
let hospitalBusy = false;
|
|
let spawnApplied = false;
|
|
|
|
function clearGroup(g) {
|
|
while (g.children.length) {
|
|
const c = g.children.pop();
|
|
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
|
|
}
|
|
citizenMeshes = [];
|
|
}
|
|
|
|
function addCollider(cx, cy, cz, w, h, d) {
|
|
colliders.push({
|
|
minX: cx - w / 2, maxX: cx + w / 2,
|
|
minY: cy, maxY: cy + h,
|
|
minZ: cz - d / 2, maxZ: cz + d / 2,
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Simulation clock (day/night)
|
|
// ------------------------------------------------------------------
|
|
const DAY_LENGTH = 240; // real seconds per simulated day
|
|
const sim = {
|
|
hour: 9,
|
|
nightFactor: 0,
|
|
};
|
|
|
|
function updateClock(dt) {
|
|
sim.hour = (sim.hour + dt * (24 / DAY_LENGTH)) % 24;
|
|
}
|
|
|
|
function skyColorForHour(hour) {
|
|
const stops = [
|
|
[0, 0x0a1226], [4.5, 0x101a36], [6, 0x4a3a6a], [7, 0xff9a5c],
|
|
[8.5, 0x87ceeb], [16.5, 0x87ceeb], [18.2, 0xff7a45],
|
|
[19.5, 0x2a2550], [21, 0x0c1428], [24, 0x0a1226],
|
|
];
|
|
let a = stops[0], b = stops[stops.length - 1];
|
|
for (let i = 0; i < stops.length - 1; i++) {
|
|
if (hour >= stops[i][0] && hour <= stops[i + 1][0]) {
|
|
a = stops[i]; b = stops[i + 1]; break;
|
|
}
|
|
}
|
|
const t = b[0] === a[0] ? 0 : (hour - a[0]) / (b[0] - a[0]);
|
|
const ca = new THREE.Color(a[1]);
|
|
const cb = new THREE.Color(b[1]);
|
|
return ca.lerp(cb, t);
|
|
}
|
|
|
|
function updateDayNight() {
|
|
const hour = sim.hour;
|
|
const dayT = (hour - 6) / 12;
|
|
const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI);
|
|
const isDay = hour >= 6 && hour <= 18;
|
|
const night = isDay ? 0 : 1;
|
|
sim.nightFactor = night;
|
|
|
|
const weatherDim = weatherSunDim();
|
|
const sky = skyColorForHour(hour);
|
|
const gray = new THREE.Color(0x4a5560);
|
|
const dim = weatherState.state === 'clear' ? 0
|
|
: weatherState.state === 'cloudy' ? 0.35
|
|
: weatherState.state === 'rain' ? 0.55 : 0.7;
|
|
sky.lerp(gray, dim * (isDay ? 1 : 0.4));
|
|
|
|
if (lightningFlash > 0) {
|
|
sky.lerp(new THREE.Color(0xdfe8ff), Math.min(1, lightningFlash * 2));
|
|
}
|
|
|
|
scene.background = sky;
|
|
scene.fog.color.copy(sky);
|
|
|
|
const ang = ((hour - 6) / 12) * Math.PI;
|
|
sun.position.set(Math.cos(ang) * 120, Math.max(8, Math.sin(ang) * 150), 60);
|
|
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
|
|
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
|
|
|
|
hemiLight.intensity = 0.12 + (isDay ? elevation * 0.5 : 0) * weatherDim;
|
|
ambientLight.intensity = 0.14 + (isDay ? elevation * 0.28 : 0) * weatherDim + lightningFlash;
|
|
|
|
const fogNear = weatherState.state === 'rain' ? 50 : weatherState.state === 'storm' ? 35 : (night ? 70 : 110);
|
|
const fogFar = weatherState.state === 'rain' ? 340 : weatherState.state === 'storm' ? 220 : (night ? 380 : 600);
|
|
scene.fog.near = fogNear;
|
|
scene.fog.far = fogFar;
|
|
|
|
const lampGlow = 0.05 + night * 1.5;
|
|
for (const m of lampHeads) m.emissiveIntensity = lampGlow;
|
|
for (const m of facadeMaterials) m.emissiveIntensity = night * 0.85;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Weather system
|
|
// ------------------------------------------------------------------
|
|
const WEATHER_TYPES = ['clear', 'cloudy', 'rain', 'storm'];
|
|
const WEATHER_ICONS = { clear: '☀️', cloudy: '☁️', rain: '🌧️', storm: '⛈️' };
|
|
const weatherState = {
|
|
state: 'clear',
|
|
timer: 0,
|
|
duration: 50,
|
|
sequence: ['clear', 'cloudy', 'rain', 'storm', 'cloudy', 'clear'],
|
|
index: 0,
|
|
};
|
|
let lightningFlash = 0;
|
|
let lightningTimer = 3;
|
|
let rainPoints = null;
|
|
let cloudSprites = [];
|
|
|
|
function weatherSunDim() {
|
|
switch (weatherState.state) {
|
|
case 'cloudy': return 0.55;
|
|
case 'rain': return 0.3;
|
|
case 'storm': return 0.15;
|
|
default: return 1;
|
|
}
|
|
}
|
|
|
|
function makeCloudTexture() {
|
|
const c = document.createElement('canvas');
|
|
c.width = 128; c.height = 128;
|
|
const ctx = c.getContext('2d');
|
|
const g = ctx.createRadialGradient(64, 64, 8, 64, 64, 60);
|
|
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
|
g.addColorStop(0.6, 'rgba(230,235,240,0.55)');
|
|
g.addColorStop(1, 'rgba(220,225,230,0)');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, 128, 128);
|
|
return new THREE.CanvasTexture(c);
|
|
}
|
|
|
|
function initWeather() {
|
|
const rainGeo = new THREE.BufferGeometry();
|
|
const N = 2500;
|
|
const pos = new Float32Array(N * 3);
|
|
for (let i = 0; i < N; i++) {
|
|
pos[i * 3] = (Math.random() - 0.5) * 70;
|
|
pos[i * 3 + 1] = Math.random() * 40;
|
|
pos[i * 3 + 2] = (Math.random() - 0.5) * 70;
|
|
}
|
|
rainGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
const rainMat = new THREE.PointsMaterial({
|
|
color: 0x9ab8cc, size: 0.14, transparent: true, opacity: 0.65, depthWrite: false,
|
|
});
|
|
rainPoints = new THREE.Points(rainGeo, rainMat);
|
|
rainPoints.visible = false;
|
|
scene.add(rainPoints);
|
|
|
|
const cloudTex = makeCloudTexture();
|
|
cloudSprites = [];
|
|
for (let i = 0; i < 14; i++) {
|
|
const mat = new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.3, depthWrite: false });
|
|
const s = new THREE.Sprite(mat);
|
|
s.position.set((Math.random() - 0.5) * 500, 55 + Math.random() * 35, (Math.random() - 0.5) * 500);
|
|
const sc = 45 + Math.random() * 55;
|
|
s.scale.set(sc, sc * 0.45, 1);
|
|
scene.add(s);
|
|
cloudSprites.push(s);
|
|
}
|
|
}
|
|
|
|
function applyWeatherVisuals() {
|
|
const w = weatherState.state;
|
|
const raining = w === 'rain' || w === 'storm';
|
|
if (rainPoints) {
|
|
rainPoints.visible = raining;
|
|
rainPoints.material.opacity = w === 'storm' ? 0.85 : 0.6;
|
|
}
|
|
const cloudOpacity = { clear: 0.12, cloudy: 0.65, rain: 0.85, storm: 0.95 }[w] ?? 0.3;
|
|
const cloudTint = w === 'storm' ? 0x6a7280 : w === 'rain' ? 0x9aa3ad : 0xffffff;
|
|
for (const s of cloudSprites) {
|
|
s.material.opacity = cloudOpacity;
|
|
s.material.color.setHex(cloudTint);
|
|
}
|
|
cityAudio.setWeather(w);
|
|
}
|
|
|
|
function setWeather(type) {
|
|
if (!WEATHER_TYPES.includes(type)) return;
|
|
weatherState.state = type;
|
|
weatherState.timer = 0;
|
|
applyWeatherVisuals();
|
|
}
|
|
|
|
function updateWeather(dt) {
|
|
weatherState.timer += dt;
|
|
if (weatherState.timer >= weatherState.duration) {
|
|
weatherState.timer = 0;
|
|
weatherState.index = (weatherState.index + 1) % weatherState.sequence.length;
|
|
weatherState.state = weatherState.sequence[weatherState.index];
|
|
applyWeatherVisuals();
|
|
}
|
|
|
|
for (const s of cloudSprites) {
|
|
s.position.x += dt * (weatherState.state === 'storm' ? 3.5 : 1.2);
|
|
if (s.position.x > 320) s.position.x = -320;
|
|
}
|
|
|
|
if (rainPoints && rainPoints.visible) {
|
|
rainPoints.position.x = camera.position.x;
|
|
rainPoints.position.z = camera.position.z;
|
|
const arr = rainPoints.geometry.attributes.position.array;
|
|
const fall = weatherState.state === 'storm' ? 38 : 26;
|
|
for (let i = 1; i < arr.length; i += 3) {
|
|
arr[i] -= fall * dt;
|
|
if (arr[i] < 0) arr[i] = 40;
|
|
}
|
|
rainPoints.geometry.attributes.position.needsUpdate = true;
|
|
}
|
|
|
|
if (lightningFlash > 0) lightningFlash = Math.max(0, lightningFlash - dt * 4);
|
|
if (weatherState.state === 'storm') {
|
|
lightningTimer -= dt;
|
|
if (lightningTimer <= 0) {
|
|
lightningTimer = 2.5 + Math.random() * 5;
|
|
lightningFlash = 1;
|
|
cityAudio.play('thunder');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// City grid helpers
|
|
// ------------------------------------------------------------------
|
|
function buildGrid(layout) {
|
|
const xs = [...new Set(layout.roads.filter(r => r.axis === 'z').map(r => r.from[0]))]
|
|
.sort((a, b) => a - b);
|
|
const zs = [...new Set(layout.roads.filter(r => r.axis === 'x').map(r => r.from[2]))]
|
|
.sort((a, b) => a - b);
|
|
const roadW = layout.roads[0]?.width ?? 8;
|
|
const squares = [];
|
|
for (let i = 0; i < xs.length - 1; i++) {
|
|
for (let j = 0; j < zs.length - 1; j++) {
|
|
squares.push({ x0: xs[i], x1: xs[i + 1], z0: zs[j], z1: zs[j + 1] });
|
|
}
|
|
}
|
|
return { xs, zs, roadW, squares, minX: xs[0], maxX: xs[xs.length - 1], minZ: zs[0], maxZ: zs[zs.length - 1] };
|
|
}
|
|
|
|
function onRoad(x, z, g) {
|
|
const lim = g.roadW / 2 - 0.4;
|
|
for (const vx of g.xs) if (Math.abs(x - vx) < lim) return true;
|
|
for (const vz of g.zs) if (Math.abs(z - vz) < lim) return true;
|
|
return false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Traffic light system
|
|
// ------------------------------------------------------------------
|
|
const PHASES = [
|
|
{ name: 'NS_GREEN', dur: 10, z: 'green', x: 'red' },
|
|
{ name: 'NS_YELLOW', dur: 2, z: 'yellow', x: 'red' },
|
|
{ name: 'ALL_RED1', dur: 1, z: 'red', x: 'red' },
|
|
{ name: 'EW_GREEN', dur: 10, z: 'red', x: 'green' },
|
|
{ name: 'EW_YELLOW', dur: 2, z: 'red', x: 'yellow' },
|
|
{ name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' },
|
|
];
|
|
|
|
function makeSignalHead() {
|
|
const g = new THREE.Group();
|
|
const box = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.32, 1.0, 0.26),
|
|
new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 })
|
|
);
|
|
g.add(box);
|
|
const colors = [0xff2222, 0xffcc00, 0x22cc44];
|
|
const lamps = [];
|
|
colors.forEach((col, i) => {
|
|
const m = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.1, 10, 10),
|
|
new THREE.MeshStandardMaterial({ color: col, emissive: col, emissiveIntensity: 0.08 })
|
|
);
|
|
m.position.set(0, 0.32 - i * 0.32, 0.15);
|
|
g.add(m);
|
|
lamps.push(m);
|
|
});
|
|
g.userData.lamps = lamps;
|
|
return g;
|
|
}
|
|
|
|
function initTrafficLights(g) {
|
|
const group = new THREE.Group();
|
|
const heads = [];
|
|
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
|
|
|
|
for (const x of g.xs) {
|
|
for (const z of g.zs) {
|
|
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6), poleMat);
|
|
pole.position.set(x + g.roadW / 2 + 1.6, 2.1, z + g.roadW / 2 + 1.6);
|
|
pole.castShadow = true;
|
|
group.add(pole);
|
|
|
|
const headNS = makeSignalHead();
|
|
headNS.position.set(x + g.roadW / 2 + 1.6, 3.8, z + g.roadW / 2 + 0.7);
|
|
group.add(headNS);
|
|
heads.push({ head: headNS, axis: 'z' });
|
|
|
|
const headEW = makeSignalHead();
|
|
headEW.position.set(x + g.roadW / 2 + 0.7, 3.8, z + g.roadW / 2 + 1.6);
|
|
headEW.rotation.y = Math.PI / 2;
|
|
group.add(headEW);
|
|
heads.push({ head: headEW, axis: 'x' });
|
|
}
|
|
}
|
|
|
|
worldGroup.add(group);
|
|
|
|
trafficSystem = {
|
|
phaseIndex: 0,
|
|
phaseTimer: 0,
|
|
heads,
|
|
axisState(axis) {
|
|
return PHASES[this.phaseIndex][axis];
|
|
},
|
|
get phaseName() { return PHASES[this.phaseIndex].name; },
|
|
update(dt) {
|
|
this.phaseTimer += dt;
|
|
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
|
|
this.phaseTimer -= PHASES[this.phaseIndex].dur;
|
|
this.phaseIndex = (this.phaseIndex + 1) % PHASES.length;
|
|
}
|
|
for (const { head, axis } of this.heads) {
|
|
const state = PHASES[this.phaseIndex][axis];
|
|
const lamps = head.userData.lamps;
|
|
lamps[0].material.emissiveIntensity = state === 'red' ? 2.2 : 0.06;
|
|
lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
|
|
lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Vehicle system
|
|
// ------------------------------------------------------------------
|
|
const CAR_COLORS = [0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400];
|
|
const MOTO_COLORS = [0x111827, 0xb91c1c, 0x1d4ed8, 0x166534, 0x7c2d12, 0x4b5563];
|
|
const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827];
|
|
|
|
function buildCarMesh(colorHex, isBus = false) {
|
|
const g = new THREE.Group();
|
|
const bodyLen = isBus ? 7.5 : 4.0;
|
|
const bodyW = isBus ? 2.2 : 1.8;
|
|
const bodyH = isBus ? 1.8 : 0.65;
|
|
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.35 });
|
|
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), mat);
|
|
body.position.y = isBus ? 1.1 : 0.55;
|
|
body.castShadow = true;
|
|
g.add(body);
|
|
|
|
if (isBus) {
|
|
const winMat = new THREE.MeshStandardMaterial({ color: 0x88ccee, roughness: 0.2, metalness: 0.5 });
|
|
const win = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.6, bodyW + 0.02), winMat);
|
|
win.position.y = 1.55;
|
|
g.add(win);
|
|
} else {
|
|
const cabin = new THREE.Mesh(
|
|
new THREE.BoxGeometry(bodyLen * 0.5, 0.5, bodyW * 0.85),
|
|
new THREE.MeshStandardMaterial({ color: 0x223344, roughness: 0.2, metalness: 0.4 })
|
|
);
|
|
cabin.position.set(-0.15, 1.05, 0);
|
|
cabin.castShadow = true;
|
|
g.add(cabin);
|
|
}
|
|
|
|
const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.24, 12);
|
|
wheelGeo.rotateX(Math.PI / 2);
|
|
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 });
|
|
const wheels = [];
|
|
const wx = bodyLen * 0.32;
|
|
const wz = bodyW / 2 + 0.02;
|
|
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
|
const w = new THREE.Mesh(wheelGeo, wheelMat);
|
|
w.position.set(px, 0.32, pz);
|
|
g.add(w);
|
|
wheels.push(w);
|
|
});
|
|
g.userData.wheels = wheels;
|
|
g.userData.kind = isBus ? 'bus' : 'car';
|
|
g.userData.halfL = bodyLen * 0.45;
|
|
g.userData.halfW = bodyW * 0.55;
|
|
return g;
|
|
}
|
|
|
|
function buildMotoMesh(colorHex) {
|
|
const g = new THREE.Group();
|
|
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.55 });
|
|
const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.25, metalness: 0.85 });
|
|
const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 });
|
|
|
|
const tank = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.35, 0.4), bodyMat);
|
|
tank.position.set(0.1, 0.72, 0);
|
|
tank.castShadow = true;
|
|
g.add(tank);
|
|
|
|
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.12, 0.35), new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 }));
|
|
seat.position.set(-0.35, 0.82, 0);
|
|
g.add(seat);
|
|
|
|
const engine = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.3, 0.35), chrome);
|
|
engine.position.set(0.15, 0.42, 0);
|
|
g.add(engine);
|
|
|
|
const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.55, 6), chrome);
|
|
fork.rotation.z = 0.35;
|
|
fork.position.set(0.7, 0.55, 0);
|
|
g.add(fork);
|
|
|
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.55), chrome);
|
|
bar.position.set(0.62, 0.85, 0);
|
|
g.add(bar);
|
|
|
|
const wheelGeo = new THREE.CylinderGeometry(0.32, 0.32, 0.12, 12);
|
|
wheelGeo.rotateX(Math.PI / 2);
|
|
const wheels = [];
|
|
[[0.72, 0], [-0.55, 0]].forEach(([px]) => {
|
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
|
w.position.set(px, 0.32, 0);
|
|
g.add(w);
|
|
wheels.push(w);
|
|
});
|
|
|
|
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.9, 6), chrome);
|
|
exhaust.rotation.z = Math.PI / 2;
|
|
exhaust.position.set(-0.2, 0.35, 0.2);
|
|
g.add(exhaust);
|
|
|
|
// Tiny rider silhouette so the moto is not empty.
|
|
const rider = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.18, 0.45, 4, 6),
|
|
new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 })
|
|
);
|
|
rider.position.set(-0.15, 1.15, 0);
|
|
rider.rotation.z = -0.25;
|
|
rider.castShadow = true;
|
|
g.add(rider);
|
|
const helmet = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.16, 10, 10),
|
|
new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.4 })
|
|
);
|
|
helmet.position.set(0.05, 1.5, 0);
|
|
g.add(helmet);
|
|
|
|
g.userData.wheels = wheels;
|
|
g.userData.kind = 'moto';
|
|
g.userData.halfL = 0.95;
|
|
g.userData.halfW = 0.35;
|
|
return g;
|
|
}
|
|
|
|
function buildBikeMesh(colorHex) {
|
|
const g = new THREE.Group();
|
|
const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.45, metalness: 0.5 });
|
|
const rubber = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 });
|
|
const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.3, metalness: 0.8 });
|
|
|
|
const tube = (len, x, y, rz = 0) => {
|
|
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 6), frameMat);
|
|
m.position.set(x, y, 0);
|
|
m.rotation.z = rz;
|
|
g.add(m);
|
|
return m;
|
|
};
|
|
|
|
tube(1.05, 0, 0.55, Math.PI / 2.4); // down tube
|
|
tube(0.55, -0.15, 0.75, 0); // seat post
|
|
tube(0.45, 0.35, 0.85, Math.PI / 2); // top tube-ish
|
|
|
|
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.06, 0.16), new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 }));
|
|
seat.position.set(-0.2, 1.0, 0);
|
|
g.add(seat);
|
|
|
|
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.5), chrome);
|
|
bar.position.set(0.5, 1.0, 0);
|
|
g.add(bar);
|
|
|
|
const wheelGeo = new THREE.TorusGeometry(0.32, 0.045, 8, 18);
|
|
const wheels = [];
|
|
[[0.55], [-0.55]].forEach(([px]) => {
|
|
const w = new THREE.Mesh(wheelGeo, rubber);
|
|
w.position.set(px, 0.32, 0);
|
|
g.add(w);
|
|
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.08, 8), chrome);
|
|
hub.rotation.x = Math.PI / 2;
|
|
hub.position.set(px, 0.32, 0);
|
|
g.add(hub);
|
|
wheels.push(w);
|
|
});
|
|
|
|
// Rider pedalling.
|
|
const torso = new THREE.Mesh(
|
|
new THREE.CapsuleGeometry(0.16, 0.4, 4, 6),
|
|
new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 })
|
|
);
|
|
torso.position.set(-0.05, 1.25, 0);
|
|
torso.rotation.z = -0.35;
|
|
torso.castShadow = true;
|
|
g.add(torso);
|
|
const head = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.14, 10, 10),
|
|
new THREE.MeshStandardMaterial({ color: 0xf1c27d, roughness: 0.7 })
|
|
);
|
|
head.position.set(0.12, 1.55, 0);
|
|
g.add(head);
|
|
const legGeo = new THREE.CapsuleGeometry(0.06, 0.4, 4, 6);
|
|
[-1, 1].forEach(side => {
|
|
const leg = new THREE.Mesh(legGeo, new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85 }));
|
|
leg.position.set(0.05, 0.75, side * 0.12);
|
|
leg.rotation.z = side * 0.4;
|
|
g.add(leg);
|
|
wheels.push(leg); // animate legs with "wheel" spin for a pedalling look
|
|
});
|
|
|
|
g.userData.wheels = wheels;
|
|
g.userData.kind = 'bike';
|
|
g.userData.halfL = 0.85;
|
|
g.userData.halfW = 0.3;
|
|
return g;
|
|
}
|
|
|
|
function squarePath(sq, inset) {
|
|
const o = inset;
|
|
return [
|
|
new THREE.Vector3(sq.x0 + o, 0, sq.z0 + o),
|
|
new THREE.Vector3(sq.x1 - o, 0, sq.z0 + o),
|
|
new THREE.Vector3(sq.x1 - o, 0, sq.z1 - o),
|
|
new THREE.Vector3(sq.x0 + o, 0, sq.z1 - o),
|
|
];
|
|
}
|
|
|
|
function squarePathOuter(sq, outset) {
|
|
const o = outset;
|
|
return [
|
|
new THREE.Vector3(sq.x0 - o, 0, sq.z0 - o),
|
|
new THREE.Vector3(sq.x0 - o, 0, sq.z1 + o),
|
|
new THREE.Vector3(sq.x1 + o, 0, sq.z1 + o),
|
|
new THREE.Vector3(sq.x1 + o, 0, sq.z0 - o),
|
|
];
|
|
}
|
|
|
|
function pathLength(pts) {
|
|
let L = 0;
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const a = pts[i], b = pts[(i + 1) % pts.length];
|
|
L += a.distanceTo(b);
|
|
}
|
|
return L;
|
|
}
|
|
|
|
function posOnPath(pts, s, L, out) {
|
|
const n = pts.length;
|
|
let rem = ((s % L) + L) % L;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = pts[i], b = pts[(i + 1) % n];
|
|
const seg = a.distanceTo(b);
|
|
if (rem <= seg || i === n - 1) {
|
|
const t = seg === 0 ? 0 : rem / seg;
|
|
out.lerpVectors(a, b, Math.min(1, t));
|
|
return i;
|
|
}
|
|
rem -= seg;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function initVehicles(g) {
|
|
const vehicles = [];
|
|
const loops = [];
|
|
|
|
for (const sq of g.squares) {
|
|
const dx = sq.x1 - sq.x0;
|
|
const dz = sq.z1 - sq.z0;
|
|
if (dx < 30 || dz < 30) continue;
|
|
// Inner rectangle = one circulation direction (right-hand lanes);
|
|
// outer rectangle = the opposite direction on the far side of the roads.
|
|
const ptsA = squarePath(sq, g.roadW / 4);
|
|
const ptsB = squarePathOuter(sq, g.roadW / 4);
|
|
loops.push({ pts: ptsA, L: pathLength(ptsA) });
|
|
loops.push({ pts: ptsB, L: pathLength(ptsB) });
|
|
}
|
|
|
|
const spawnLoop = (loop, offsetS) => {
|
|
const roll = Math.random();
|
|
let kind = 'car';
|
|
if (roll < 0.12) kind = 'moto';
|
|
else if (roll < 0.28) kind = 'bike';
|
|
else if (roll < 0.44) kind = 'bus';
|
|
|
|
const color = kind === 'moto'
|
|
? MOTO_COLORS[Math.floor(Math.random() * MOTO_COLORS.length)]
|
|
: kind === 'bike'
|
|
? BIKE_COLORS[Math.floor(Math.random() * BIKE_COLORS.length)]
|
|
: CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)];
|
|
|
|
let mesh;
|
|
if (kind === 'moto') mesh = buildMotoMesh(color);
|
|
else if (kind === 'bike') mesh = buildBikeMesh(color);
|
|
else mesh = buildCarMesh(color, kind === 'bus');
|
|
|
|
worldGroup.add(mesh);
|
|
const v = {
|
|
mesh, pts: loop.pts, L: loop.L,
|
|
s: offsetS, speed: 0, kind,
|
|
maxSpeed: kind === 'bus' ? 6.5 : kind === 'moto' ? 10 + Math.random() * 3 : kind === 'bike' ? 4.5 + Math.random() * 1.5 : 8 + Math.random() * 3,
|
|
accel: kind === 'moto' ? 7 : kind === 'bike' ? 3.5 : 5,
|
|
decel: 10,
|
|
loopKey: loop,
|
|
};
|
|
vehicles.push(v);
|
|
return v;
|
|
};
|
|
|
|
for (const loop of loops) {
|
|
if (Math.random() < 0.72) {
|
|
const count = 1 + Math.floor(Math.random() * 2);
|
|
for (let i = 0; i < count; i++) {
|
|
spawnLoop(loop, (loop.L / count) * i + Math.random() * 8);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shuttle vehicles on synthetic roads (bridge + level crossing).
|
|
const shuttles = [];
|
|
const ext = layoutExtras;
|
|
if (ext.bridgeRoad) {
|
|
const r = ext.bridgeRoad;
|
|
const a = new THREE.Vector3(r.from[0], 0, r.from[2]);
|
|
const b = new THREE.Vector3(r.to[0], 0, r.to[2]);
|
|
shuttles.push({ a, b, t: 0.3, dir: 1, mesh: buildCarMesh(0x2980b9, false), speed: 0, maxSpeed: 7, axis: 'x' });
|
|
}
|
|
if (ext.crossRoad) {
|
|
const r = ext.crossRoad;
|
|
const a = new THREE.Vector3(r.from[0], 0, r.from[2]);
|
|
const b = new THREE.Vector3(r.to[0], 0, r.to[2]);
|
|
shuttles.push({ a, b, t: 0.2, dir: 1, mesh: buildCarMesh(0xc0392b, false), speed: 0, maxSpeed: 7, axis: 'z' });
|
|
}
|
|
for (const sh of shuttles) {
|
|
worldGroup.add(sh.mesh);
|
|
vehicles.push(sh);
|
|
}
|
|
|
|
vehicleSystem = {
|
|
vehicles,
|
|
positions() {
|
|
return vehicles.map(v => {
|
|
const p = new THREE.Vector3();
|
|
if (v.pts) {
|
|
posOnPath(v.pts, v.s, v.L, p);
|
|
} else {
|
|
p.lerpVectors(v.a, v.b, v.t);
|
|
}
|
|
return { x: p.x, z: p.z };
|
|
});
|
|
},
|
|
count() { return vehicles.length; },
|
|
kinds() {
|
|
const out = { car: 0, bus: 0, moto: 0, bike: 0 };
|
|
for (const v of vehicles) {
|
|
const k = v.kind || v.mesh?.userData?.kind || 'car';
|
|
out[k] = (out[k] || 0) + 1;
|
|
}
|
|
return out;
|
|
},
|
|
update(dt) {
|
|
for (const v of vehicles) {
|
|
if (v.pts) {
|
|
updateLoopVehicle(v, dt);
|
|
} else {
|
|
updateShuttleVehicle(v, dt);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function vehicleAheadGap(v) {
|
|
let minGap = Infinity;
|
|
for (const other of vehicleSystem.vehicles) {
|
|
if (other === v || !other.pts || other.loopKey !== v.loopKey) continue;
|
|
const gap = ((other.s - v.s) % v.L + v.L) % v.L;
|
|
if (gap > 0.01 && gap < minGap) minGap = gap;
|
|
}
|
|
return minGap;
|
|
}
|
|
|
|
function probeAhead(pts, s, L) {
|
|
let rem = ((s % L) + L) % L;
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const a = pts[i];
|
|
const b = pts[(i + 1) % pts.length];
|
|
const seg = a.distanceTo(b);
|
|
if (rem <= seg + 1e-6) {
|
|
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
|
|
return { distToCorner: seg - rem, axis, corner: b };
|
|
}
|
|
rem -= seg;
|
|
}
|
|
return { distToCorner: 999, axis: 'x', corner: pts[0] };
|
|
}
|
|
|
|
function updateLoopVehicle(v, dt) {
|
|
const probe = probeAhead(v.pts, v.s, v.L);
|
|
const distToCorner = probe.distToCorner;
|
|
let wantStop = false;
|
|
|
|
if (trafficSystem && distToCorner < 7.5) {
|
|
const state = trafficSystem.axisState(probe.axis);
|
|
if (state === 'red') wantStop = true;
|
|
else if (state === 'yellow' && distToCorner > 4) wantStop = true;
|
|
}
|
|
|
|
const gap = vehicleAheadGap(v);
|
|
if (gap < 9) wantStop = true;
|
|
|
|
if (wantStop) {
|
|
v.speed = Math.max(0, v.speed - v.decel * dt);
|
|
if (distToCorner < 2.2 && v.speed < 0.5) v.speed = 0;
|
|
} else {
|
|
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
|
}
|
|
|
|
v.s = (v.s + v.speed * dt) % v.L;
|
|
const pos = new THREE.Vector3();
|
|
const tmp = new THREE.Vector3();
|
|
posOnPath(v.pts, v.s, v.L, pos);
|
|
posOnPath(v.pts, v.s + 0.6, v.L, tmp);
|
|
v.mesh.position.copy(pos);
|
|
if (tmp.distanceToSquared(pos) > 0.0001) {
|
|
v.mesh.rotation.y = Math.atan2(tmp.x - pos.x, tmp.z - pos.z) + Math.PI / 2;
|
|
}
|
|
const wheelSpin = v.speed * dt * 3;
|
|
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;
|
|
}
|
|
|
|
function updateShuttleVehicle(v, dt) {
|
|
const trainNear = trainSystem && trainSystem.blocksCrossing();
|
|
const distToEnd = v.dir > 0 ? (1 - v.t) : v.t;
|
|
let wantStop = false;
|
|
|
|
if (v.axis === 'z' && trainNear) {
|
|
const total = v.a.distanceTo(v.b);
|
|
const stopT = (trainSystem.crossZ - v.a.z) / (v.b.z - v.a.z);
|
|
const stopPos = v.a.z + (stopT - 0.12) * (v.b.z - v.a.z);
|
|
const curZ = v.a.z + v.t * (v.b.z - v.a.z);
|
|
if ((v.dir > 0 && curZ < stopPos && stopPos - curZ < 14) ||
|
|
(v.dir < 0 && curZ > stopPos && curZ - stopPos < 14)) {
|
|
wantStop = true;
|
|
}
|
|
void total;
|
|
}
|
|
|
|
if (wantStop) {
|
|
v.speed = Math.max(0, v.speed - 10 * dt);
|
|
} else {
|
|
v.speed = Math.min(v.maxSpeed, v.speed + 5 * dt);
|
|
}
|
|
|
|
if (distToEnd < 0.03) {
|
|
v.dir *= -1;
|
|
v.speed = 0;
|
|
}
|
|
|
|
v.t += v.dir * (v.speed * dt) / v.a.distanceTo(v.b);
|
|
v.t = Math.max(0, Math.min(1, v.t));
|
|
|
|
const pos = new THREE.Vector3().lerpVectors(v.a, v.b, v.t);
|
|
v.mesh.position.copy(pos);
|
|
const look = new THREE.Vector3().lerpVectors(v.a, v.b, Math.max(0, Math.min(1, v.t + v.dir * 0.05)));
|
|
if (look.distanceToSquared(pos) > 0.0001) {
|
|
v.mesh.rotation.y = Math.atan2(look.x - pos.x, look.z - pos.z) + Math.PI / 2;
|
|
}
|
|
for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Humanoid builder
|
|
// ------------------------------------------------------------------
|
|
function buildHumanoid(agent, showLabel = true) {
|
|
const g = new THREE.Group();
|
|
const type = agent.type || 'pedestrian';
|
|
const configs = {
|
|
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
|
|
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
|
|
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
|
|
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
|
|
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
|
|
};
|
|
const cfgc = configs[type] || configs.pedestrian;
|
|
const s = cfgc.scale;
|
|
|
|
const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 });
|
|
const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6 });
|
|
const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 });
|
|
const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 });
|
|
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.7 });
|
|
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.28 * s, 12, 12), skinMat);
|
|
head.position.y = 1.62 * s;
|
|
head.castShadow = true;
|
|
g.add(head);
|
|
|
|
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.30 * s, 12, 8, 0, Math.PI * 2, 0, Math.PI * 0.55), hairMat);
|
|
hair.position.y = 1.65 * s;
|
|
hair.castShadow = true;
|
|
g.add(hair);
|
|
|
|
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.24 * s, 0.55 * s, 4, 8), shirtMat);
|
|
torso.position.y = 1.12 * s;
|
|
torso.castShadow = true;
|
|
g.add(torso);
|
|
|
|
const arms = [];
|
|
[-1, 1].forEach(side => {
|
|
const shoulder = new THREE.Group();
|
|
shoulder.position.set(side * 0.32 * s, 1.38 * s, 0);
|
|
shoulder.rotation.z = side * 0.12;
|
|
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.07 * s, 0.5 * s, 4, 6), shirtMat);
|
|
arm.position.y = -0.28 * s;
|
|
arm.castShadow = true;
|
|
shoulder.add(arm);
|
|
const hand = new THREE.Mesh(new THREE.SphereGeometry(0.08 * s, 8, 8), skinMat);
|
|
hand.position.y = -0.56 * s;
|
|
shoulder.add(hand);
|
|
g.add(shoulder);
|
|
arms.push(shoulder);
|
|
});
|
|
|
|
const legs = [];
|
|
[-1, 1].forEach(side => {
|
|
const hip = new THREE.Group();
|
|
hip.position.set(side * 0.13 * s, 0.78 * s, 0);
|
|
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * s, 0.6 * s, 4, 6), pantsMat);
|
|
leg.position.y = -0.36 * s;
|
|
leg.castShadow = true;
|
|
hip.add(leg);
|
|
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.14 * s, 0.08 * s, 0.24 * s), shoeMat);
|
|
shoe.position.set(0, -0.72 * s, 0.05 * s);
|
|
shoe.castShadow = true;
|
|
hip.add(shoe);
|
|
g.add(hip);
|
|
legs.push(hip);
|
|
});
|
|
|
|
let label = null;
|
|
if (showLabel) {
|
|
label = makeLabel(agent.name, agent.status || 'active');
|
|
label.position.y = 2.15 * s;
|
|
g.add(label);
|
|
}
|
|
|
|
g.userData = { agent, label, legs, arms, animPhase: Math.random() * Math.PI * 2, moving: false };
|
|
return g;
|
|
}
|
|
|
|
function animateHumanoid(mesh, dt, speed) {
|
|
const u = mesh.userData;
|
|
const moving = speed > 0.05;
|
|
u.moving = moving;
|
|
if (moving) {
|
|
u.animPhase += dt * (4 + speed * 2.5);
|
|
const swing = Math.sin(u.animPhase) * 0.55;
|
|
if (u.legs) {
|
|
u.legs[0].rotation.x = swing;
|
|
u.legs[1].rotation.x = -swing;
|
|
u.arms[0].rotation.x = -swing * 0.8;
|
|
u.arms[1].rotation.x = swing * 0.8;
|
|
}
|
|
} else {
|
|
if (u.legs) {
|
|
u.legs[0].rotation.x *= 0.85;
|
|
u.legs[1].rotation.x *= 0.85;
|
|
u.arms[0].rotation.x *= 0.85;
|
|
u.arms[1].rotation.x *= 0.85;
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeLabel(name, status) {
|
|
const c = document.createElement('canvas');
|
|
c.width = 256; c.height = 64;
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = 'rgba(10,15,28,0.85)';
|
|
ctx.roundRect ? ctx.roundRect(0, 0, 256, 64, 12) : ctx.rect(0, 0, 256, 64);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#e2e8f0';
|
|
ctx.font = 'bold 22px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
const nm = name.length > 20 ? name.slice(0, 19) + '…' : name;
|
|
ctx.fillText(nm, 128, 30);
|
|
const dotColor = status === 'thinking' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#22c55e';
|
|
ctx.fillStyle = dotColor;
|
|
ctx.beginPath(); ctx.arc(40, 48, 6, 0, Math.PI * 2); ctx.fill();
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '14px sans-serif';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(status || 'active', 56, 53);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false }));
|
|
sprite.scale.set(3.2, 0.8, 1);
|
|
return sprite;
|
|
}
|
|
|
|
function refreshLabel(mesh, status) {
|
|
const u = mesh.userData;
|
|
if (!u || !u.agent) return;
|
|
u.agent.status = status;
|
|
if (u.label) {
|
|
mesh.remove(u.label);
|
|
u.label.material.map?.dispose();
|
|
u.label.material.dispose();
|
|
}
|
|
const l = makeLabel(u.agent.name, status);
|
|
l.position.y = 2.15;
|
|
mesh.add(l);
|
|
u.label = l;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pedestrian routes + system
|
|
// ------------------------------------------------------------------
|
|
function segmentCrossAxis(a, b, g) {
|
|
const dx = Math.abs(b.x - a.x);
|
|
const dz = Math.abs(b.z - a.z);
|
|
if (dx < 7 && dz < 7) return null;
|
|
if (dx >= dz) {
|
|
for (const vx of g.xs) {
|
|
if (vx > Math.min(a.x, b.x) + 1 && vx < Math.max(a.x, b.x) - 1) return 'z';
|
|
}
|
|
return null;
|
|
}
|
|
for (const vz of g.zs) {
|
|
if (vz > Math.min(a.z, b.z) + 1 && vz < Math.max(a.z, b.z) - 1) return 'x';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makeRoute(pts, g) {
|
|
const waypoints = pts.map(p => ({ x: p.x ?? p[0], z: p.z ?? p[1] }));
|
|
const segments = [];
|
|
for (let i = 0; i < waypoints.length; i++) {
|
|
const a = waypoints[i];
|
|
const b = waypoints[(i + 1) % waypoints.length];
|
|
segments.push({
|
|
crossAxis: segmentCrossAxis(
|
|
new THREE.Vector3(a.x, 0, a.z),
|
|
new THREE.Vector3(b.x, 0, b.z),
|
|
g
|
|
),
|
|
midX: (a.x + b.x) / 2,
|
|
midZ: (a.z + b.z) / 2,
|
|
});
|
|
}
|
|
return { waypoints, segments };
|
|
}
|
|
|
|
function ringRoute(sq, g, inset) {
|
|
const o = inset;
|
|
return makeRoute([
|
|
{ x: sq.x0 + o, z: sq.z0 + o },
|
|
{ x: sq.x1 - o, z: sq.z0 + o },
|
|
{ x: sq.x1 - o, z: sq.z1 - o },
|
|
{ x: sq.x0 + o, z: sq.z1 - o },
|
|
], g);
|
|
}
|
|
|
|
function crossingRoute(sq, g) {
|
|
const o = g.roadW / 2 + 1.2;
|
|
const zc = (sq.z0 + sq.z1) / 2;
|
|
return makeRoute([
|
|
{ x: sq.x0 + o, z: zc },
|
|
{ x: sq.x0 - o, z: zc },
|
|
{ x: sq.x0 - o, z: zc + 5 },
|
|
{ x: sq.x0 + o, z: zc + 5 },
|
|
], g);
|
|
}
|
|
|
|
function vehiclesNear(x, z, radius) {
|
|
if (!vehicleSystem) return false;
|
|
const r2 = radius * radius;
|
|
for (const v of vehicleSystem.vehicles) {
|
|
const p = new THREE.Vector3();
|
|
if (v.pts) posOnPath(v.pts, v.s, v.L, p);
|
|
else p.lerpVectors(v.a, v.b, v.t);
|
|
const dx = p.x - x, dz = p.z - z;
|
|
if (dx * dx + dz * dz < r2) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function initPedestrians(g, agents) {
|
|
const walkers = [];
|
|
|
|
const routes = [];
|
|
g.squares.forEach((sq, i) => {
|
|
routes.push({ route: ringRoute(sq, g, g.roadW / 2 + 1.2), kind: 'ring' });
|
|
if (i % 3 === 0) routes.push({ route: crossingRoute(sq, g), kind: 'cross' });
|
|
});
|
|
|
|
const pedAgents = agents.filter(a => a.type === 'pedestrian');
|
|
pedAgents.forEach((a, i) => {
|
|
const pick = routes[i % routes.length];
|
|
const mesh = buildHumanoid(a, true);
|
|
worldGroup.add(mesh);
|
|
agentMeshes.push(mesh);
|
|
const w = {
|
|
mesh, route: pick.route, wp: 0, speed: 1.15 + Math.random() * 0.4,
|
|
wait: Math.random() * 2, pauseAt: 4 + Math.random() * 8, pauseTimer: 0,
|
|
};
|
|
const start = w.route.waypoints[0];
|
|
mesh.position.set(start.x, 0, start.z);
|
|
walkers.push(w);
|
|
});
|
|
|
|
const CROWD_COUNT = 14;
|
|
for (let i = 0; i < CROWD_COUNT; i++) {
|
|
const pick = routes[(i + pedAgents.length + 1) % routes.length];
|
|
const mesh = buildHumanoid({ name: '', type: 'crowd', status: 'active' }, false);
|
|
worldGroup.add(mesh);
|
|
crowdMeshes.push(mesh);
|
|
const start = pick.route.waypoints[i % pick.route.waypoints.length];
|
|
mesh.position.set(start.x, 0, start.z);
|
|
walkers.push({
|
|
mesh, route: pick.route, wp: i % pick.route.waypoints.length,
|
|
speed: 1.0 + Math.random() * 0.5, wait: Math.random() * 3,
|
|
pauseAt: 5 + Math.random() * 10, pauseTimer: 0,
|
|
});
|
|
}
|
|
|
|
pedestrianSystem = {
|
|
walkers,
|
|
count() { return walkers.length; },
|
|
positions() {
|
|
return walkers.map(w => ({ x: w.mesh.position.x, z: w.mesh.position.z }));
|
|
},
|
|
update(dt) {
|
|
for (const w of walkers) {
|
|
if (w.wait > 0) {
|
|
w.wait -= dt;
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
if (w.pauseTimer > 0) {
|
|
w.pauseTimer -= dt;
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
|
|
const wps = w.route.waypoints;
|
|
const segIdx = w.wp % w.route.segments.length;
|
|
const seg = w.route.segments[segIdx];
|
|
const target = wps[(w.wp + 1) % wps.length];
|
|
|
|
if (seg.crossAxis && trafficSystem) {
|
|
const state = trafficSystem.axisState(seg.crossAxis);
|
|
const meshPos = w.mesh.position;
|
|
const distToMid = Math.hypot(meshPos.x - seg.midX, meshPos.z - seg.midZ);
|
|
const approaching = distToMid > 1.5;
|
|
if (approaching && distToMid < 14) {
|
|
if (state !== 'red') {
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
if (vehiclesNear(seg.midX, seg.midZ, 8)) {
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dx = target.x - w.mesh.position.x;
|
|
const dz = target.z - w.mesh.position.z;
|
|
const dist = Math.hypot(dx, dz);
|
|
|
|
if (onRoad(w.mesh.position.x, w.mesh.position.z, g) && vehiclesNear(w.mesh.position.x, w.mesh.position.z, 5)) {
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
|
|
if (dist < 0.35) {
|
|
w.wp = (w.wp + 1) % wps.length;
|
|
if (Math.random() < 0.22) w.pauseTimer = w.pauseAt * (0.4 + Math.random() * 0.6);
|
|
animateHumanoid(w.mesh, dt, 0);
|
|
continue;
|
|
}
|
|
|
|
const step = Math.min(dist, w.speed * dt);
|
|
w.mesh.position.x += (dx / dist) * step;
|
|
w.mesh.position.z += (dz / dist) * step;
|
|
w.mesh.rotation.y = Math.atan2(dx, dz);
|
|
animateHumanoid(w.mesh, dt, w.speed);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Shop system (open/close by hour, signs, shopkeepers)
|
|
// ------------------------------------------------------------------
|
|
function makeStoreSign(store, isOpen) {
|
|
const c = document.createElement('canvas');
|
|
c.width = 512; c.height = 128;
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = '#0d1118';
|
|
ctx.fillRect(0, 0, 512, 128);
|
|
ctx.strokeStyle = isOpen ? '#22c55e' : '#ef4444';
|
|
ctx.lineWidth = 6;
|
|
ctx.strokeRect(4, 4, 504, 120);
|
|
ctx.fillStyle = '#e2e8f0';
|
|
ctx.font = 'bold 34px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
const nm = store.name.length > 24 ? store.name.slice(0, 23) + '…' : store.name;
|
|
ctx.fillText(nm, 256, 55);
|
|
ctx.font = 'bold 30px sans-serif';
|
|
ctx.fillStyle = isOpen ? '#4ade80' : '#f87171';
|
|
ctx.fillText(isOpen ? (i18n.open || 'OPEN') : (i18n.closed || 'CLOSED'), 256, 100);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.4,
|
|
roughness: 0.6,
|
|
});
|
|
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 0.9), mat);
|
|
return mesh;
|
|
}
|
|
|
|
function initShops(stores, agents) {
|
|
const entries = [];
|
|
for (const s of stores) {
|
|
const mesh = storeMeshes.find(m => m.userData?.store?.id === s.id);
|
|
if (!mesh) continue;
|
|
const [x, y, z] = s.position;
|
|
const [w, h, d] = s.size;
|
|
const sign = makeStoreSign(s, shopOpenNow(s));
|
|
sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
|
|
worldGroup.add(sign);
|
|
entries.push({ store: s, sign, lastOpen: shopOpenNow(s) });
|
|
|
|
const light = new THREE.PointLight(0xffe0a0, 0, 8, 2);
|
|
light.position.set(x, y + 2.6, z + d / 2 + 1.2);
|
|
worldGroup.add(light);
|
|
entries[entries.length - 1].light = light;
|
|
}
|
|
|
|
const keepers = [];
|
|
const shopAgents = agents.filter(a => a.type === 'shopkeeper');
|
|
for (const a of shopAgents) {
|
|
const store = stores.find(s => s.name?.includes(a.name.split('—').pop()?.trim() || '§'))
|
|
|| stores.reduce((best, s) => {
|
|
const d = Math.hypot(s.position[0] - a.position[0], s.position[2] - a.position[2]);
|
|
return (!best || d < best.d) ? { s, d } : best;
|
|
}, null)?.s
|
|
|| stores[0];
|
|
if (!store) continue;
|
|
const mesh = buildHumanoid(a, true);
|
|
const [sx, , sz] = store.position;
|
|
const [, , sd] = store.size;
|
|
mesh.position.set(sx + 1.5, 0, sz + sd / 2 + 1.6);
|
|
mesh.rotation.y = Math.PI;
|
|
worldGroup.add(mesh);
|
|
agentMeshes.push(mesh);
|
|
keepers.push({ mesh, home: mesh.position.clone(), t: Math.random() * 10 });
|
|
}
|
|
|
|
shopSystem = {
|
|
entries, keepers,
|
|
openCount() { return entries.filter(e => shopOpenNow(e.store)).length; },
|
|
update(dt) {
|
|
const hour = sim.hour;
|
|
for (const e of entries) {
|
|
const open = shopOpenNow(e.store);
|
|
if (open !== e.lastOpen) {
|
|
e.lastOpen = open;
|
|
worldGroup.remove(e.sign);
|
|
e.sign.geometry.dispose();
|
|
e.sign.material.map?.dispose();
|
|
e.sign.material.emissiveMap?.dispose();
|
|
e.sign.material.dispose();
|
|
e.sign = makeStoreSign(e.store, open);
|
|
const [x, y, z] = e.store.position;
|
|
const d = e.store.size[2];
|
|
e.sign.position.set(x, y + 3.1, z + d / 2 + 0.12);
|
|
worldGroup.add(e.sign);
|
|
}
|
|
const night = sim.nightFactor;
|
|
e.sign.material.emissiveIntensity = open ? 0.5 + night * 1.3 : 0.15 + night * 0.3;
|
|
if (e.light) e.light.intensity = open ? night * 1.6 : night * 0.3;
|
|
}
|
|
for (const k of this.keepers) {
|
|
k.t += dt;
|
|
const store = entries.find(e => Math.abs(e.store.position[0] - k.home.x) < 6);
|
|
const open = store ? shopOpenNow(store.store) : true;
|
|
if (open) {
|
|
const sway = Math.sin(k.t * 1.5) * 0.4;
|
|
k.mesh.position.x = k.home.x + sway * 0.3;
|
|
k.mesh.rotation.y = Math.PI + Math.sin(k.t * 0.7) * 0.3;
|
|
animateHumanoid(k.mesh, dt, 0.15);
|
|
} else {
|
|
k.mesh.position.x = k.home.x;
|
|
animateHumanoid(k.mesh, dt, 0);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function shopOpenNow(store) {
|
|
if (!store.is_open) return false;
|
|
return sim.hour >= 8 && sim.hour < 20;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Mayor patrol system
|
|
// ------------------------------------------------------------------
|
|
function initMayor(agents, stores, g) {
|
|
const mayor = agents.find(a => a.type === 'mayor');
|
|
if (!mayor) { mayorSystem = { update() {}, mesh: null }; return; }
|
|
|
|
const pts = [];
|
|
for (const s of stores) {
|
|
pts.push({ x: s.position[0] + s.size[0] / 2 + 2, z: s.position[2] + s.size[2] / 2 + 2.2 });
|
|
}
|
|
pts.push({ x: g.xs[0] + g.roadW / 2 + 1.2, z: g.zs[0] + g.roadW / 2 + 1.2 });
|
|
pts.push({ x: g.xs[Math.floor(g.xs.length / 2)] + g.roadW / 2 + 1.2, z: g.zs[Math.floor(g.zs.length / 2)] + g.roadW / 2 + 1.2 });
|
|
|
|
const route = makeRoute(pts, g);
|
|
const mesh = buildHumanoid(mayor, true);
|
|
mesh.position.set(route.waypoints[0].x, 0, route.waypoints[0].z);
|
|
worldGroup.add(mesh);
|
|
agentMeshes.push(mesh);
|
|
|
|
mayorSystem = {
|
|
mesh, route, wp: 0, speed: 1.05,
|
|
pauseTimer: 2, decideTimer: 8, status: 'active',
|
|
update(dt) {
|
|
this.decideTimer -= dt;
|
|
if (this.decideTimer <= 0) {
|
|
this.decideTimer = 8 + Math.random() * 7;
|
|
this.status = this.status === 'active' ? 'thinking' : 'active';
|
|
refreshLabel(mesh, this.status);
|
|
}
|
|
|
|
if (this.pauseTimer > 0) {
|
|
this.pauseTimer -= dt;
|
|
animateHumanoid(mesh, dt, 0);
|
|
return;
|
|
}
|
|
|
|
const seg = this.route.segments[this.wp % this.route.segments.length];
|
|
const target = this.route.waypoints[(this.wp + 1) % this.route.waypoints.length];
|
|
|
|
if (seg.crossAxis && trafficSystem) {
|
|
const state = trafficSystem.axisState(seg.crossAxis);
|
|
const dMid = Math.hypot(mesh.position.x - seg.midX, mesh.position.z - seg.midZ);
|
|
if (dMid > 1.5 && dMid < 14) {
|
|
if (state !== 'red' || vehiclesNear(seg.midX, seg.midZ, 8)) {
|
|
animateHumanoid(mesh, dt, 0);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const dx = target.x - mesh.position.x;
|
|
const dz = target.z - mesh.position.z;
|
|
const dist = Math.hypot(dx, dz);
|
|
if (dist < 0.4) {
|
|
this.wp = (this.wp + 1) % this.route.waypoints.length;
|
|
this.pauseTimer = 3 + Math.random() * 3;
|
|
if (this.status !== 'thinking') {
|
|
this.status = 'thinking';
|
|
refreshLabel(mesh, this.status);
|
|
}
|
|
animateHumanoid(mesh, dt, 0);
|
|
return;
|
|
}
|
|
const step = Math.min(dist, this.speed * dt);
|
|
mesh.position.x += (dx / dist) * step;
|
|
mesh.position.z += (dz / dist) * step;
|
|
mesh.rotation.y = Math.atan2(dx, dz);
|
|
animateHumanoid(mesh, dt, this.speed);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Airplane system
|
|
// ------------------------------------------------------------------
|
|
function buildAirplane(colorHex) {
|
|
const g = new THREE.Group();
|
|
const mat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.5 });
|
|
const fus = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 9, 10), mat);
|
|
fus.rotation.z = Math.PI / 2;
|
|
g.add(fus);
|
|
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.8, 10), mat);
|
|
nose.rotation.z = -Math.PI / 2;
|
|
nose.position.x = 5.3;
|
|
g.add(nose);
|
|
const wing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.15, 11), mat);
|
|
wing.position.x = 0.5;
|
|
g.add(wing);
|
|
const tailW = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.12, 4), mat);
|
|
tailW.position.x = -4;
|
|
g.add(tailW);
|
|
const tailF = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.6, 0.12), mat);
|
|
tailF.position.set(-4.1, 0.8, 0);
|
|
g.add(tailF);
|
|
const beacon = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.18, 8, 8),
|
|
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
|
|
);
|
|
beacon.position.set(0.5, 0.5, 0);
|
|
g.add(beacon);
|
|
g.userData.beacon = beacon;
|
|
return g;
|
|
}
|
|
|
|
function initAirplanes() {
|
|
const planes = [];
|
|
const defs = [
|
|
{ r: 260, y: 85, speed: 0.12, a0: 0, color: 0xdfe4ea },
|
|
{ r: 330, y: 110, speed: -0.09, a0: 2, color: 0xc0c8d2 },
|
|
{ r: 200, y: 70, speed: 0.15, a0: 4, color: 0xe8eef5 },
|
|
];
|
|
for (const d of defs) {
|
|
const mesh = buildAirplane(d.color);
|
|
scene.add(mesh);
|
|
planes.push({ mesh, ...d, angle: d.a0 });
|
|
}
|
|
airplaneSystem = {
|
|
planes,
|
|
count() { return planes.length; },
|
|
update(dt) {
|
|
for (const p of planes) {
|
|
p.angle += p.speed * dt;
|
|
const x = Math.cos(p.angle) * p.r;
|
|
const z = Math.sin(p.angle) * p.r;
|
|
p.mesh.position.set(x, p.y, z);
|
|
const tx = -Math.sin(p.angle) * Math.sign(p.speed);
|
|
const tz = Math.cos(p.angle) * Math.sign(p.speed);
|
|
p.mesh.rotation.y = Math.atan2(-tz, tx);
|
|
p.mesh.rotation.z = -0.3 * Math.sign(p.speed);
|
|
if (p.mesh.userData.beacon) {
|
|
p.mesh.userData.beacon.material.emissiveIntensity =
|
|
(Math.sin(performance.now() * 0.01) > 0) ? 2.5 : 0.2;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Train system + level crossing
|
|
// ------------------------------------------------------------------
|
|
function initTrain(g) {
|
|
const trackZ = g.maxZ + 14;
|
|
const xMid = g.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.xs[0]);
|
|
const trackFrom = g.minX - 70;
|
|
const trackTo = g.maxX + 70;
|
|
|
|
const railMat = new THREE.MeshStandardMaterial({ color: 0x6a6a72, roughness: 0.4, metalness: 0.8 });
|
|
const bedMat = new THREE.MeshStandardMaterial({ color: 0x5a5248, roughness: 1 });
|
|
const bed = new THREE.Mesh(
|
|
new THREE.BoxGeometry(trackTo - trackFrom, 0.18, 3.6),
|
|
bedMat
|
|
);
|
|
bed.position.set((trackFrom + trackTo) / 2, 0.09, trackZ);
|
|
worldGroup.add(bed);
|
|
|
|
for (const off of [-0.75, 0.75]) {
|
|
const rail = new THREE.Mesh(new THREE.BoxGeometry(trackTo - trackFrom, 0.14, 0.12), railMat);
|
|
rail.position.set((trackFrom + trackTo) / 2, 0.24, trackZ + off);
|
|
worldGroup.add(rail);
|
|
}
|
|
|
|
const sleeperGeo = new THREE.BoxGeometry(0.35, 0.1, 2.6);
|
|
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3d2f22, roughness: 0.9 });
|
|
const count = Math.floor((trackTo - trackFrom) / 2);
|
|
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
|
|
const m4 = new THREE.Matrix4();
|
|
for (let i = 0; i < count; i++) {
|
|
m4.setPosition(trackFrom + i * 2 + 1, 0.16, trackZ);
|
|
sleepers.setMatrixAt(i, m4);
|
|
}
|
|
worldGroup.add(sleepers);
|
|
|
|
const train = new THREE.Group();
|
|
const locoMat = new THREE.MeshStandardMaterial({ color: 0xb03030, roughness: 0.5, metalness: 0.3 });
|
|
const loco = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.4), locoMat);
|
|
loco.position.y = 1.5;
|
|
loco.castShadow = true;
|
|
train.add(loco);
|
|
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 2.2), new THREE.MeshStandardMaterial({ color: 0x882222 }));
|
|
cab.position.set(2.4, 3.2, 0);
|
|
train.add(cab);
|
|
|
|
const carColors = [0x2a5a8a, 0x2a5a8a, 0x3a7a4a, 0x2a5a8a];
|
|
carColors.forEach((col, i) => {
|
|
const car = new THREE.Mesh(
|
|
new THREE.BoxGeometry(6.5, 2.2, 2.3),
|
|
new THREE.MeshStandardMaterial({ color: col, roughness: 0.6 })
|
|
);
|
|
car.position.set(-6.5 - i * 7.2, 1.4, 0);
|
|
car.castShadow = true;
|
|
train.add(car);
|
|
});
|
|
worldGroup.add(train);
|
|
|
|
// Level crossing barriers on the vertical road that crosses the track.
|
|
const barriers = [];
|
|
for (const side of [-1, 1]) {
|
|
const pivot = new THREE.Group();
|
|
pivot.position.set(xMid + side * (g.roadW / 2 + 2.2), 1.1, trackZ + side * 2.6);
|
|
|
|
const armGeo = new THREE.BoxGeometry(7, 0.18, 0.25);
|
|
// Shift geometry so the pivot sits at the post end of the arm.
|
|
armGeo.translate(side > 0 ? -3.5 : 3.5, 0, 0);
|
|
const armMesh = new THREE.Mesh(
|
|
armGeo,
|
|
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 })
|
|
);
|
|
pivot.add(armMesh);
|
|
|
|
const lamp = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.16, 8, 8),
|
|
new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff2222, emissiveIntensity: 0.3 })
|
|
);
|
|
lamp.position.y = 1.1;
|
|
pivot.add(lamp);
|
|
|
|
const post = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.1, 0.12, 2.2, 6),
|
|
new THREE.MeshStandardMaterial({ color: 0x888888 })
|
|
);
|
|
pivot.add(post);
|
|
|
|
// Start raised (open).
|
|
pivot.rotation.z = -Math.PI / 2;
|
|
worldGroup.add(pivot);
|
|
barriers.push({ pivot, lamp });
|
|
}
|
|
|
|
const state = {
|
|
s: trackFrom + 30,
|
|
dir: 1,
|
|
speed: 14,
|
|
pause: 0,
|
|
blocks: false,
|
|
crossZ: trackZ,
|
|
xMid,
|
|
trackFrom,
|
|
trackTo,
|
|
train,
|
|
barriers,
|
|
};
|
|
|
|
trainSystem = {
|
|
...state,
|
|
blocksCrossing() { return this.blocks; },
|
|
update(dt) {
|
|
if (this.pause > 0) {
|
|
this.pause -= dt;
|
|
} else {
|
|
this.s += this.dir * this.speed * dt;
|
|
if (this.s >= this.trackTo - 40) {
|
|
this.s = this.trackTo - 40;
|
|
this.dir = -1;
|
|
this.pause = 3;
|
|
this.train.rotation.y = Math.PI;
|
|
} else if (this.s <= this.trackFrom + 40) {
|
|
this.s = this.trackFrom + 40;
|
|
this.dir = 1;
|
|
this.pause = 3;
|
|
this.train.rotation.y = 0;
|
|
}
|
|
}
|
|
const noseX = this.s + this.dir * 4;
|
|
this.train.position.set(this.s, 0, this.trackZ ?? this.crossZ);
|
|
|
|
const distToCross = Math.abs(noseX - this.xMid);
|
|
this.blocks = distToCross < 45 && this.pause <= 0;
|
|
|
|
const blink = Math.sin(performance.now() * 0.012) > 0;
|
|
for (const b of this.barriers) {
|
|
const target = this.blocks ? 0 : -Math.PI / 2;
|
|
b.pivot.rotation.z += (target - b.pivot.rotation.z) * Math.min(1, dt * 4);
|
|
b.lamp.material.emissiveIntensity = this.blocks ? (blink ? 3 : 0.2) : 0.2;
|
|
}
|
|
},
|
|
};
|
|
trainSystem.crossZ = trackZ;
|
|
trainSystem.trackZ = trackZ;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// River + bridge
|
|
// ------------------------------------------------------------------
|
|
function initRiver(g) {
|
|
const riverX = g.minX - 30;
|
|
const riverW = 16;
|
|
const zMid = g.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.zs[0]);
|
|
const len = (g.maxZ - g.minZ) + 140;
|
|
|
|
const waterTex = canvasTexture(256, (ctx, s) => {
|
|
const grad = ctx.createLinearGradient(0, 0, s, s);
|
|
grad.addColorStop(0, '#1a5f8a');
|
|
grad.addColorStop(0.5, '#2a7fb0');
|
|
grad.addColorStop(1, '#1a5f8a');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, s, s);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
|
ctx.lineWidth = 3;
|
|
for (let i = 0; i < 14; i++) {
|
|
const y = Math.random() * s;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
for (let x = 0; x <= s; x += 16) {
|
|
ctx.lineTo(x, y + Math.sin(x * 0.08 + i) * 6);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
}, 3, 10);
|
|
|
|
const geo = new THREE.PlaneGeometry(riverW, len, 6, 40);
|
|
const water = new THREE.Mesh(
|
|
geo,
|
|
new THREE.MeshStandardMaterial({
|
|
map: waterTex, color: 0x88bbdd, roughness: 0.25, metalness: 0.35,
|
|
transparent: true, opacity: 0.92,
|
|
})
|
|
);
|
|
water.rotation.x = -Math.PI / 2;
|
|
water.position.set(riverX, 0.04, (g.minZ + g.maxZ) / 2);
|
|
water.receiveShadow = true;
|
|
worldGroup.add(water);
|
|
|
|
const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7f66, roughness: 1 });
|
|
for (const side of [-1, 1]) {
|
|
const bank = new THREE.Mesh(new THREE.BoxGeometry(4, 0.3, len), bankMat);
|
|
bank.position.set(riverX + side * (riverW / 2 + 1.5), 0.1, (g.minZ + g.maxZ) / 2);
|
|
worldGroup.add(bank);
|
|
}
|
|
|
|
const basePos = Float32Array.from(geo.attributes.position.array);
|
|
|
|
// Bridge where the mid horizontal road extension crosses the river.
|
|
const bridge = new THREE.Mesh(
|
|
new THREE.BoxGeometry(riverW + 8, 0.25, 9),
|
|
new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.9 })
|
|
);
|
|
bridge.position.set(riverX, 0.05, zMid);
|
|
bridge.receiveShadow = true;
|
|
worldGroup.add(bridge);
|
|
|
|
const railMat = new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.6, metalness: 0.4 });
|
|
for (const side of [-1, 1]) {
|
|
const rail = new THREE.Mesh(new THREE.BoxGeometry(riverW + 8, 0.7, 0.15), railMat);
|
|
rail.position.set(riverX, 0.5, zMid + side * 4.4);
|
|
worldGroup.add(rail);
|
|
const pier = new THREE.Mesh(
|
|
new THREE.BoxGeometry(1.2, 2.2, 1.2),
|
|
new THREE.MeshStandardMaterial({ color: 0x555560, roughness: 0.9 })
|
|
);
|
|
pier.position.set(riverX, -0.9, zMid + side * 3);
|
|
worldGroup.add(pier);
|
|
}
|
|
|
|
riverSystem = {
|
|
riverX, riverW, zMid, water, basePos,
|
|
update(dt, t) {
|
|
waterTex.offset.y = (waterTex.offset.y + dt * 0.04) % 1;
|
|
waterTex.offset.x = (waterTex.offset.x + dt * 0.012) % 1;
|
|
const pos = geo.attributes.position;
|
|
for (let i = 0; i < pos.count; i++) {
|
|
const bx = basePos[i * 3];
|
|
const by = basePos[i * 3 + 1];
|
|
pos.array[i * 3 + 2] =
|
|
Math.sin(bx * 0.6 + t * 2.2) * 0.09 +
|
|
Math.sin(by * 0.35 + t * 1.6) * 0.07;
|
|
}
|
|
pos.needsUpdate = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// City builder
|
|
// ------------------------------------------------------------------
|
|
const layoutExtras = { bridgeRoad: null, crossRoad: null };
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Citizen characters (all characters in this city) ----
|
|
if (Array.isArray(data.characters)) {
|
|
for (const c of data.characters) {
|
|
const [cx, , cz] = c.position;
|
|
const humanoid = buildHumanoid({
|
|
name: c.name,
|
|
type: c.is_me ? 'mayor' : (c.type === 'child' ? 'crowd' : 'pedestrian'),
|
|
status: c.status || 'active',
|
|
id: c.id,
|
|
}, !c.is_me);
|
|
humanoid.position.set(cx, 0, cz);
|
|
if (c.is_me) {
|
|
humanoid.userData.isMe = true;
|
|
meMesh = humanoid;
|
|
}
|
|
worldGroup.add(humanoid);
|
|
citizenMeshes.push(humanoid);
|
|
if (!c.is_me) {
|
|
crowdMeshes.push(humanoid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Simulation systems ----
|
|
initVehicles(grid);
|
|
if (!airplaneSystem) initAirplanes();
|
|
else for (const p of airplaneSystem.planes) scene.add(p.mesh);
|
|
initTrain(grid);
|
|
|
|
statsEl.textContent = buildStatsText();
|
|
}
|
|
|
|
function buildStatsText() {
|
|
const stores = cityData?.stores?.length || 0;
|
|
const agents = cityData?.agents?.length || 0;
|
|
const veh = vehicleSystem?.count() || 0;
|
|
const peds = pedestrianSystem?.count() || 0;
|
|
return [
|
|
`${stores} ${i18n.stores || ''}`,
|
|
`${agents} ${i18n.agents || ''}`,
|
|
`${veh} ${i18n.vehicles || ''}`,
|
|
`${peds} ${i18n.pedestrians || ''}`,
|
|
].join(' · ');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// CameraController — first person WASD + pointer lock + AABB collision
|
|
// ------------------------------------------------------------------
|
|
const player = {
|
|
pos: new THREE.Vector3(0, 1.7, 25),
|
|
vel: new THREE.Vector3(),
|
|
yaw: 0,
|
|
pitch: 0,
|
|
speed: 6,
|
|
radius: 0.35,
|
|
eyeHeight: 1.7,
|
|
onGround: true,
|
|
};
|
|
const keys = {};
|
|
let locked = false;
|
|
|
|
renderer.domElement.addEventListener('click', () => {
|
|
if (!locked) renderer.domElement.requestPointerLock();
|
|
});
|
|
startGate.addEventListener('click', () => {
|
|
renderer.domElement.requestPointerLock();
|
|
});
|
|
|
|
document.addEventListener('pointerlockchange', () => {
|
|
locked = document.pointerLockElement === renderer.domElement;
|
|
startGate.style.display = locked ? 'none' : 'grid';
|
|
posPanel.style.display = locked ? 'block' : 'none';
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!locked) return;
|
|
player.yaw -= e.movementX * 0.0022;
|
|
player.pitch -= e.movementY * 0.0022;
|
|
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
|
});
|
|
|
|
function isTypingTarget(el) {
|
|
if (!el) return false;
|
|
const tag = (el.tagName || '').toLowerCase();
|
|
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (isTypingTarget(e.target)) return;
|
|
keys[e.code] = true;
|
|
});
|
|
document.addEventListener('keyup', (e) => {
|
|
keys[e.code] = false;
|
|
});
|
|
|
|
function playerAABB(px, py, pz) {
|
|
const r = player.radius;
|
|
return {
|
|
minX: px - r, maxX: px + r,
|
|
minY: py - 1.5, maxY: py + 0.3,
|
|
minZ: pz - r, maxZ: pz + r,
|
|
};
|
|
}
|
|
|
|
function overlaps(a, b) {
|
|
return a.minX < b.maxX && a.maxX > b.minX &&
|
|
a.minZ < b.maxZ && a.maxZ > b.minZ &&
|
|
a.minY < b.maxY && a.maxY > b.minY;
|
|
}
|
|
|
|
function vehicleBoxes() {
|
|
if (!vehicleSystem) return [];
|
|
const boxes = [];
|
|
for (const v of vehicleSystem.vehicles) {
|
|
const p = new THREE.Vector3();
|
|
let rot = 0;
|
|
if (v.pts) {
|
|
posOnPath(v.pts, v.s, v.L, p);
|
|
const ahead = new THREE.Vector3();
|
|
posOnPath(v.pts, v.s + 0.6, v.L, ahead);
|
|
rot = Math.atan2(ahead.x - p.x, ahead.z - p.z);
|
|
} else {
|
|
p.lerpVectors(v.a, v.b, v.t);
|
|
rot = Math.atan2(v.b.x - v.a.x, v.b.z - v.a.z) + Math.PI / 2;
|
|
}
|
|
const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0);
|
|
const halfW = v.mesh?.userData?.halfW ?? 1.0;
|
|
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
|
|
const ex = halfL * c + halfW * s;
|
|
const ez = halfL * s + halfW * c;
|
|
boxes.push({
|
|
minX: p.x - ex, maxX: p.x + ex,
|
|
minZ: p.z - ez, maxZ: p.z + ez,
|
|
minY: 0, maxY: 2,
|
|
});
|
|
}
|
|
return boxes;
|
|
}
|
|
|
|
function resolveCollision(px, py, pz) {
|
|
const box = playerAABB(px, py, pz);
|
|
const cur = playerAABB(player.pos.x, player.pos.y, player.pos.z);
|
|
const inside = (c) => overlaps(cur, c);
|
|
|
|
// Static buildings: if the player already overlaps one (spawned inside
|
|
// or built a house on their feet), allow moving so they can walk out.
|
|
for (const c of colliders) {
|
|
if (overlaps(box, c) && !inside(c)) return true;
|
|
}
|
|
|
|
// Hitting a vehicle is an accident (only once the world is live).
|
|
if (window.__city10?.ready && spawnApplied && meCharacter && meCharacter.state !== 'hospital') {
|
|
for (const c of vehicleBoxes()) {
|
|
if (overlaps(box, c)) {
|
|
reportVehicleAccident();
|
|
if (!inside(c)) return true;
|
|
}
|
|
}
|
|
}
|
|
if (trainSystem) {
|
|
const tb = {
|
|
minX: trainSystem.s - 18, maxX: trainSystem.s + 18,
|
|
minZ: trainSystem.crossZ - 1.6, maxZ: trainSystem.crossZ + 1.6,
|
|
minY: 0, maxY: 4,
|
|
};
|
|
if (overlaps(box, tb) && !inside(tb)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect vehicles driving into a stationary player and report the accident.
|
|
* Runs only after the city is fully built and the player spawn is applied.
|
|
*/
|
|
function checkVehicleHitOnPlayer() {
|
|
if (!window.__city10?.ready) return;
|
|
if (!meCharacter || hospitalBusy) return;
|
|
if (meCharacter.state === 'hospital') return;
|
|
if (!spawnApplied) return;
|
|
const box = playerAABB(player.pos.x, player.pos.y, player.pos.z);
|
|
for (const c of vehicleBoxes()) {
|
|
if (overlaps(box, c)) {
|
|
reportVehicleAccident();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tell the server the character was hit by a vehicle (2-day hospital stay).
|
|
*/
|
|
function reportVehicleAccident() {
|
|
if (!meCharacter || hospitalBusy) return;
|
|
if (meCharacter.state === 'hospital') return;
|
|
hospitalBusy = true;
|
|
cityAudio.play('horn');
|
|
postJson('/city/actions/accident', {})
|
|
.then((data) => {
|
|
if (data?.character) updateCharacterHud(data.character);
|
|
if (data?.bed) {
|
|
player.pos.x = data.bed.pos_x;
|
|
player.pos.z = data.bed.pos_z;
|
|
player.vel.set(0, 0, 0);
|
|
}
|
|
flashCharMsg(data?.message || i18n.hospitalHit || 'hospital', true);
|
|
cityAudio.play('hospital');
|
|
showHospitalBanner(data?.hospitalized_until);
|
|
updateCameraFromPlayer();
|
|
})
|
|
.catch((e) => {
|
|
flashCharMsg(e.message, true);
|
|
cityAudio.play('error');
|
|
})
|
|
.finally(() => {
|
|
hospitalBusy = false;
|
|
});
|
|
}
|
|
|
|
function showHospitalBanner(untilIso) {
|
|
const el = document.getElementById('hospital-banner');
|
|
if (!el) return;
|
|
if (!untilIso) {
|
|
el.style.display = 'none';
|
|
return;
|
|
}
|
|
el.style.display = 'block';
|
|
const tick = () => {
|
|
const until = new Date(untilIso).getTime();
|
|
const left = until - Date.now();
|
|
if (left <= 0 || meCharacter?.state !== 'hospital') {
|
|
el.style.display = 'none';
|
|
return;
|
|
}
|
|
const totalSec = Math.ceil(left / 1000);
|
|
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
|
const ss = String(totalSec % 60).padStart(2, '0');
|
|
const label = i18n.hospitalLeft || 'Discharge in';
|
|
el.textContent = `🏥 ${i18n.hospitalTitle || 'Hospitalized'} · ${label} ${mm}:${ss}`;
|
|
setTimeout(tick, 1000);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
function isHospitalizedNow() {
|
|
if (meCharacter?.state !== 'hospital') return false;
|
|
if (!meCharacter.hospitalized_until) return true;
|
|
return new Date(meCharacter.hospitalized_until).getTime() > Date.now();
|
|
}
|
|
|
|
function updatePlayer(dt) {
|
|
// Hospitalized characters stay in bed until discharge.
|
|
if (isHospitalizedNow()) {
|
|
player.vel.set(0, 0, 0);
|
|
player.pos.y = player.eyeHeight;
|
|
return;
|
|
}
|
|
|
|
// Auto-release when the two-game-day stay elapses while online.
|
|
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
|
|
meCharacter.state = 'awake';
|
|
meCharacter.hospitalized_until = null;
|
|
const banner = document.getElementById('hospital-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
flashCharMsg(i18n.hospitalReleased || 'Released');
|
|
}
|
|
|
|
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
|
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
|
const wish = new THREE.Vector3();
|
|
|
|
// Allow walking with WASD/arrows even before pointer lock so the
|
|
// character is never completely stuck on the overlay screen.
|
|
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
|
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
|
if (keys['KeyA'] || keys['ArrowLeft']) wish.sub(right);
|
|
if (keys['KeyD'] || keys['ArrowRight']) wish.add(right);
|
|
|
|
if (wish.lengthSq() > 0) {
|
|
wish.normalize();
|
|
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
|
|
const step = wish.multiplyScalar(speed * dt);
|
|
|
|
const tryX = player.pos.x + step.x;
|
|
if (!resolveCollision(tryX, player.pos.y, player.pos.z)) player.pos.x = tryX;
|
|
|
|
const tryZ = player.pos.z + step.z;
|
|
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)) player.pos.z = tryZ;
|
|
}
|
|
|
|
player.vel.y -= 18 * dt;
|
|
player.pos.y += player.vel.y * dt;
|
|
if (player.pos.y <= player.eyeHeight) {
|
|
player.pos.y = player.eyeHeight;
|
|
player.vel.y = 0;
|
|
player.onGround = true;
|
|
}
|
|
if (keys['Space'] && player.onGround) {
|
|
player.vel.y = 6;
|
|
player.onGround = false;
|
|
}
|
|
|
|
player.pos.x = cityBounds
|
|
? Math.max(cityBounds.min_x, Math.min(cityBounds.max_x, player.pos.x))
|
|
: Math.max(-400, Math.min(400, player.pos.x));
|
|
player.pos.z = cityBounds
|
|
? Math.max(cityBounds.min_z, Math.min(cityBounds.max_z, player.pos.z))
|
|
: Math.max(-400, Math.min(400, player.pos.z));
|
|
}
|
|
|
|
function updateCameraFromPlayer() {
|
|
camera.position.copy(player.pos);
|
|
const dir = new THREE.Vector3(
|
|
-Math.sin(player.yaw) * Math.cos(player.pitch),
|
|
Math.sin(player.pitch),
|
|
-Math.cos(player.yaw) * Math.cos(player.pitch)
|
|
);
|
|
camera.lookAt(camera.position.clone().add(dir));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Click-to-interact
|
|
// ------------------------------------------------------------------
|
|
const ray = new THREE.Raycaster();
|
|
const ndc = new THREE.Vector2();
|
|
let pointerDown = null;
|
|
|
|
renderer.domElement.addEventListener('pointerdown', (e) => {
|
|
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerup', (e) => {
|
|
if (!pointerDown) return;
|
|
const dx = Math.abs(e.clientX - pointerDown.x);
|
|
const dy = Math.abs(e.clientY - pointerDown.y);
|
|
const dt = Date.now() - pointerDown.t;
|
|
pointerDown = null;
|
|
if (dx > 6 || dy > 6 || dt > 400) return;
|
|
|
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
ray.setFromCamera(ndc, camera);
|
|
|
|
const agentHits = ray.intersectObjects(agentMeshes, true);
|
|
if (agentHits.length) {
|
|
let obj = agentHits[0].object;
|
|
while (obj && !obj.userData?.agent) obj = obj.parent;
|
|
if (obj?.userData?.agent) {
|
|
openChat(obj.userData.agent);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const storeHits = ray.intersectObjects(storeMeshes, false);
|
|
if (storeHits.length) {
|
|
const s = storeHits[0].object.userData.store;
|
|
selectedEl.style.display = 'inline-block';
|
|
const open = shopOpenNow(s);
|
|
selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`;
|
|
} else {
|
|
selectedEl.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// ChatPanel
|
|
// ------------------------------------------------------------------
|
|
const chatPanel = document.getElementById('chat-panel');
|
|
const chatDot = document.getElementById('chat-dot');
|
|
const chatName = document.getElementById('chat-name');
|
|
const chatStatus = document.getElementById('chat-status');
|
|
const chatTime = document.getElementById('chat-time');
|
|
const chatClose = document.getElementById('chat-close');
|
|
const chatMessages = document.getElementById('chat-messages');
|
|
const chatForm = document.getElementById('chat-form');
|
|
const chatInput = document.getElementById('chat-input');
|
|
|
|
let currentAgent = null;
|
|
let eventSource = null;
|
|
|
|
function addBubble(text, who) {
|
|
const div = document.createElement('div');
|
|
div.className = `chat-bubble ${who}`;
|
|
div.textContent = text;
|
|
chatMessages.appendChild(div);
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
}
|
|
|
|
function setStatus(status) {
|
|
if (!currentAgent) return;
|
|
currentAgent.status = status;
|
|
const map = {
|
|
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
|
|
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
|
|
active: ['#22c55e', 'online'],
|
|
error: ['#ef4444', 'error'],
|
|
};
|
|
const [color, label] = map[status] || map.active;
|
|
chatDot.style.background = color;
|
|
chatStatus.textContent = label;
|
|
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentAgent.id);
|
|
if (mesh) refreshLabel(mesh, status);
|
|
}
|
|
|
|
function openChat(agent) {
|
|
currentAgent = agent;
|
|
chatPanel.style.display = 'flex';
|
|
chatName.textContent = agent.name;
|
|
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
chatMessages.innerHTML = '';
|
|
|
|
const history = agent.last_decision || [];
|
|
history.forEach(m => {
|
|
if (m.role === 'user') addBubble(m.content, 'user');
|
|
else if (m.role === 'assistant') addBubble(m.content, 'npc');
|
|
});
|
|
|
|
setStatus(agent.status || 'active');
|
|
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
eventSource = new EventSource(`/city/chat/${agent.id}/stream`);
|
|
eventSource.addEventListener('message', (e) => {
|
|
try {
|
|
const payload = JSON.parse(e.data);
|
|
if (payload.type === 'thinking') {
|
|
setStatus('thinking');
|
|
addBubble(i18n.chatThinking || 'thinking…', 'meta');
|
|
} else if (payload.type === 'reply') {
|
|
setStatus('active');
|
|
addBubble(payload.reply, 'npc');
|
|
chatTime.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} else if (payload.type === 'error') {
|
|
setStatus('error');
|
|
addBubble(payload.reply || i18n.chatError || 'error', 'meta');
|
|
}
|
|
} catch (_) { /* ignore */ }
|
|
});
|
|
eventSource.onerror = () => { /* auto-retry */ };
|
|
}
|
|
|
|
function closeChat() {
|
|
chatPanel.style.display = 'none';
|
|
currentAgent = null;
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
}
|
|
|
|
chatClose.addEventListener('click', closeChat);
|
|
|
|
chatForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const msg = chatInput.value.trim();
|
|
if (!msg || !currentAgent) return;
|
|
chatInput.value = '';
|
|
addBubble(msg, 'user');
|
|
setStatus('thinking');
|
|
try {
|
|
await fetch(`/city/chat/${currentAgent.id}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ message: msg }),
|
|
});
|
|
} catch (_) {
|
|
addBubble(i18n.chatError || 'error', 'meta');
|
|
}
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// HUD
|
|
// ------------------------------------------------------------------
|
|
function updateHUD() {
|
|
const g = player.pos.y <= player.eyeHeight + 0.01;
|
|
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
|
|
posGround.textContent = g
|
|
? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m`
|
|
: `airborne · ${player.pos.y.toFixed(1)}m`;
|
|
posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`;
|
|
|
|
const hh = String(Math.floor(sim.hour)).padStart(2, '0');
|
|
const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0');
|
|
if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`;
|
|
if (weatherEl) {
|
|
const label = i18n[`weather_${weatherState.state}`] || weatherState.state;
|
|
weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`;
|
|
}
|
|
|
|
if (meMesh) {
|
|
meMesh.position.x = player.pos.x;
|
|
meMesh.position.z = player.pos.z;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Character HUD + player actions (server-backed lifecycle)
|
|
// ------------------------------------------------------------------
|
|
const charNameEl = document.getElementById('char-name');
|
|
const charMsgEl = document.getElementById('char-msg');
|
|
const hungerEl = document.getElementById('hud-hunger');
|
|
const thirstEl = document.getElementById('hud-thirst');
|
|
const energyEl = document.getElementById('hud-energy');
|
|
const 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 (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();
|
|
updateCameraFromPlayer();
|
|
updateHUD();
|
|
syncPositionThrottled();
|
|
|
|
// Footsteps while the local player is actually walking.
|
|
const walking = locked && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
|
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight']);
|
|
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
|
|
|
|
statsTimer += dt;
|
|
if (statsTimer > 1 && cityData) {
|
|
statsTimer = 0;
|
|
statsEl.textContent = buildStatsText();
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
animate();
|
|
|
|
// ------------------------------------------------------------------
|
|
// Test/debug API
|
|
// ------------------------------------------------------------------
|
|
window.__city10 = {
|
|
ready: false,
|
|
get hour() { return sim.hour; },
|
|
get weather() { return weatherState.state; },
|
|
get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; },
|
|
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
|
|
setHour(h) {
|
|
sim.hour = ((h % 24) + 24) % 24;
|
|
updateDayNight();
|
|
updateHUD();
|
|
},
|
|
setWeather,
|
|
audio: {
|
|
state: () => cityAudio.state(),
|
|
setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); },
|
|
setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); },
|
|
unlock: () => cityAudio.unlock(),
|
|
play: (name) => { cityAudio.unlock(); cityAudio.play(name); },
|
|
},
|
|
teleport(x, z, yaw = null, pitch = 0, y = null) {
|
|
player.pos.x = x;
|
|
player.pos.z = z;
|
|
player.pos.y = y !== null ? y : player.eyeHeight;
|
|
player.vel.set(0, 0, 0);
|
|
if (yaw !== null) player.yaw = yaw;
|
|
player.pitch = pitch;
|
|
updateCameraFromPlayer();
|
|
},
|
|
stats() {
|
|
return {
|
|
ready: this.ready,
|
|
hour: +sim.hour.toFixed(2),
|
|
weather: weatherState.state,
|
|
trafficPhase: this.trafficPhase,
|
|
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
|
|
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
|
|
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
|
|
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
|
|
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
|
|
trains: trainSystem ? 1 : 0,
|
|
river: !!riverSystem,
|
|
stores: cityData ? cityData.stores.length : 0,
|
|
storesOpen: shopSystem ? shopSystem.openCount() : 0,
|
|
agents: cityData ? cityData.agents.length : 0,
|
|
colliders: colliders.length,
|
|
};
|
|
},
|
|
vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; },
|
|
pedestrianPositions() { return pedestrianSystem ? pedestrianSystem.positions() : []; },
|
|
get me() { return meCharacter; },
|
|
refreshMe: async function () {
|
|
try {
|
|
const res = await fetch(cfg.dataUrl, { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
updateCharacterHud(data.me);
|
|
return data.me;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
},
|
|
action: runAction,
|
|
agentPositions() {
|
|
return agentMeshes.map(m => ({
|
|
id: m.userData?.agent?.id ?? null,
|
|
name: m.userData?.agent?.name ?? '',
|
|
type: m.userData?.agent?.type ?? '',
|
|
x: +m.position.x.toFixed(2),
|
|
z: +m.position.z.toFixed(2),
|
|
}));
|
|
},
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Data loading
|
|
// ------------------------------------------------------------------
|
|
async function loadCity() {
|
|
loadingEl.style.display = 'grid';
|
|
errorEl.style.display = 'none';
|
|
window.__city10.ready = false;
|
|
try {
|
|
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
cityData = await res.json();
|
|
buildCity(cityData);
|
|
applyWeatherVisuals();
|
|
updateDayNight();
|
|
loadingEl.style.display = 'none';
|
|
window.__city10.ready = true;
|
|
} catch (err) {
|
|
console.error(err);
|
|
loadingEl.style.display = 'none';
|
|
errorEl.style.display = 'grid';
|
|
}
|
|
}
|
|
|
|
reloadBtn.addEventListener('click', () => {
|
|
cityAudio.unlock();
|
|
cityAudio.play('reload');
|
|
loadCity();
|
|
});
|
|
bindCharacterUI();
|
|
bindAudioUI();
|
|
|
|
// Unlock audio on the first gesture anywhere (autoplay policy).
|
|
const unlockAudio = () => {
|
|
cityAudio.unlock();
|
|
};
|
|
document.addEventListener('pointerdown', unlockAudio, { once: false });
|
|
document.addEventListener('keydown', unlockAudio, { once: false });
|
|
startGate?.addEventListener('click', () => {
|
|
cityAudio.unlock();
|
|
cityAudio.play('ok');
|
|
});
|
|
|
|
loadCity();
|
|
initWeather();
|
|
applyWeatherVisuals(); |