- Add preserveDrawingBuffer to enable automated test screenshots - Neutralize scene fog during drone render pass to prevent misted aerial view - Render drone feed every frame instead of alternating with ground view - Update visor styling with scanline effect and improved borders - Add debug getters for WebGL renderer and scene information
7892 lines
305 KiB
JavaScript
Executable File
7892 lines
305 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);
|
||
|
||
// preserveDrawingBuffer: lets automated tests/screenshots read real
|
||
// frame pixels (the PIP visor proof); cost is negligible here.
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: 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);
|
||
// Tight shadow window that follows the player (see updateDayNight): the
|
||
// city is far larger than the frustum now, and a follow box keeps the
|
||
// per-frame caster list small while doubling effective shadow resolution.
|
||
sun.shadow.camera.left = -120;
|
||
sun.shadow.camera.right = 120;
|
||
sun.shadow.camera.top = 120;
|
||
sun.shadow.camera.bottom = -120;
|
||
sun.shadow.camera.far = 500;
|
||
sun.shadow.bias = -0.0004;
|
||
scene.add(sun);
|
||
scene.add(sun.target);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Texture helpers
|
||
// ------------------------------------------------------------------
|
||
function canvasTexture(size, draw, repeatX = 1, repeatY = 1) {
|
||
const c = document.createElement('canvas');
|
||
c.width = size; c.height = size;
|
||
const ctx = c.getContext('2d');
|
||
draw(ctx, size);
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||
tex.repeat.set(repeatX, repeatY);
|
||
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||
return tex;
|
||
}
|
||
|
||
const asphaltTex = canvasTexture(512, (ctx, s) => {
|
||
ctx.fillStyle = '#1a1a1e';
|
||
ctx.fillRect(0, 0, s, s);
|
||
for (let i = 0; i < 9000; i++) {
|
||
const v = 20 + Math.random() * 30;
|
||
ctx.fillStyle = `rgb(${v},${v},${v + 4})`;
|
||
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
|
||
}
|
||
ctx.fillStyle = '#e8e8e8';
|
||
ctx.fillRect(s * 0.44, 0, 4, s);
|
||
ctx.fillRect(s * 0.54, 0, 4, s);
|
||
ctx.fillStyle = '#d0d0d0';
|
||
for (let y = 0; y < s; y += 48) {
|
||
ctx.fillRect(s * 0.08, y, 4, 26);
|
||
ctx.fillRect(s * 0.90, y, 4, 26);
|
||
}
|
||
});
|
||
|
||
const sidewalkTex = canvasTexture(256, (ctx, s) => {
|
||
ctx.fillStyle = '#b8bcc4';
|
||
ctx.fillRect(0, 0, s, s);
|
||
const tile = 64;
|
||
ctx.strokeStyle = '#9aa0a8';
|
||
ctx.lineWidth = 2;
|
||
for (let i = 0; i <= s; i += tile) {
|
||
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, s); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(s, i); ctx.stroke();
|
||
}
|
||
for (let i = 0; i < 40; i++) {
|
||
ctx.fillStyle = `rgba(120,125,130,${Math.random() * 0.15})`;
|
||
ctx.beginPath();
|
||
ctx.arc(Math.random() * s, Math.random() * s, Math.random() * 20, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}, 4, 1);
|
||
|
||
// Zebra crosswalk: white longitudinal bars parallel to vehicle travel
|
||
// (MUTCD "longitudinal bar" design), transparent gaps so the asphalt
|
||
// underneath shows through. Six bars (~0.6 unit each with ~0.6 gaps,
|
||
// NZTA proportions) across the 8-unit road width. `barsAcrossU` draws
|
||
// bars along V (for planes spanning roadW × band) — otherwise along U.
|
||
function zebraTexture(barsAcrossU) {
|
||
return canvasTexture(512, (ctx, s) => {
|
||
ctx.clearRect(0, 0, s, s);
|
||
ctx.fillStyle = '#f5f5f5';
|
||
const bars = 6, bar = 38, pitch = 76;
|
||
const start = (s - (bars * bar + (bars - 1) * (pitch - bar))) / 2;
|
||
for (let i = 0; i < bars; i++) {
|
||
const p = start + i * pitch;
|
||
if (barsAcrossU) {
|
||
ctx.fillRect(p, 0, bar, s);
|
||
} else {
|
||
ctx.fillRect(0, p, s, bar);
|
||
}
|
||
}
|
||
}, 1, 1);
|
||
}
|
||
|
||
const zebraTexOverZRoad = zebraTexture(true);
|
||
const zebraTexOverXRoad = zebraTexture(false);
|
||
|
||
function facadePair(baseColor = '#c9a84c', floorCount = 6, windowsPerFloor = 4, size = 512) {
|
||
const w = size, h = size;
|
||
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 = [];
|
||
// Luminous signs (letreiros): materials get a day/night glow ramp in
|
||
// updateVehicleHeadlights; counts feed stats().signs.
|
||
const signMaterials = [];
|
||
let signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 };
|
||
let grid = null;
|
||
let crosswalkCount = 0;
|
||
let sidewalkSegmentCount = 0;
|
||
// Per-frame cost split (update JS vs render submit) for perf monitoring.
|
||
const perfFrame = { update: 0, render: 0 };
|
||
let trafficSystem = null;
|
||
let vehicleSystem = null;
|
||
let pedestrianSystem = null;
|
||
let airplaneSystem = null;
|
||
let trainSystem = null;
|
||
let riverSystem = null;
|
||
let shopSystem = null;
|
||
let mayorSystem = null;
|
||
let meMesh = null;
|
||
let hospitalBusy = false;
|
||
let spawnApplied = false;
|
||
let plazas = [];
|
||
let gymEquipment = [];
|
||
let exercise = {
|
||
active: false,
|
||
equipmentId: null,
|
||
repTimer: 0,
|
||
animPhase: 0,
|
||
nearId: null,
|
||
};
|
||
// Beach leisure (east shore): geometry from the payload + relax session.
|
||
let relax = { active: false, timer: 0, animPhase: 0 };
|
||
let beach = null;
|
||
let beachSystem = null;
|
||
// Airports payload {visit, sites} — geometry, walk zone and flight paths.
|
||
let airportsCfg = null;
|
||
// Port payload {visit, water, quay, yard, terminal, cranes, berths, ships…}.
|
||
let portCfg = null;
|
||
// Port ops runtime (ships, boats, trucks, crane, AI clearances).
|
||
let portSystem = null;
|
||
let remoteCitizens = new Map(); // characterId -> mesh
|
||
let presenceTimer = null;
|
||
let vehicleHeadlights = [];
|
||
let vehicleTailLights = [];
|
||
let vehicleBeams = [];
|
||
let mobileInput = { x: 0, y: 0, active: false };
|
||
let minimapCtx = null;
|
||
let lastPresenceOk = 0;
|
||
|
||
function clearGroup(g) {
|
||
while (g.children.length) {
|
||
const c = g.children.pop();
|
||
c.traverse?.(o => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
|
||
}
|
||
citizenMeshes = [];
|
||
remoteCitizens = new Map();
|
||
vehicleHeadlights = [];
|
||
vehicleTailLights = [];
|
||
vehicleBeams = [];
|
||
}
|
||
|
||
function addCollider(cx, cy, cz, w, h, d) {
|
||
colliders.push({
|
||
minX: cx - w / 2, maxX: cx + w / 2,
|
||
minY: cy, maxY: cy + h,
|
||
minZ: cz - d / 2, maxZ: cz + d / 2,
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Simulation clock (day/night)
|
||
// ------------------------------------------------------------------
|
||
const DAY_LENGTH = 240; // real seconds per simulated day
|
||
const sim = {
|
||
hour: 9,
|
||
nightFactor: 0,
|
||
};
|
||
|
||
function updateClock(dt) {
|
||
sim.hour = (sim.hour + dt * (24 / DAY_LENGTH)) % 24;
|
||
}
|
||
|
||
function skyColorForHour(hour) {
|
||
const stops = [
|
||
[0, 0x0a1226], [4.5, 0x101a36], [6, 0x4a3a6a], [7, 0xff9a5c],
|
||
[8.5, 0x87ceeb], [16.5, 0x87ceeb], [18.2, 0xff7a45],
|
||
[19.5, 0x2a2550], [21, 0x0c1428], [24, 0x0a1226],
|
||
];
|
||
let a = stops[0], b = stops[stops.length - 1];
|
||
for (let i = 0; i < stops.length - 1; i++) {
|
||
if (hour >= stops[i][0] && hour <= stops[i + 1][0]) {
|
||
a = stops[i]; b = stops[i + 1]; break;
|
||
}
|
||
}
|
||
const t = b[0] === a[0] ? 0 : (hour - a[0]) / (b[0] - a[0]);
|
||
const ca = new THREE.Color(a[1]);
|
||
const cb = new THREE.Color(b[1]);
|
||
return ca.lerp(cb, t);
|
||
}
|
||
|
||
function updateDayNight() {
|
||
const hour = sim.hour;
|
||
const dayT = (hour - 6) / 12;
|
||
const elevation = Math.sin(Math.max(0, Math.min(1, dayT)) * Math.PI);
|
||
const isDay = hour >= 6 && hour <= 18;
|
||
// Smooth dusk (17–19) / dawn (5–7) so headlights ramp in gradually.
|
||
let night = 1;
|
||
if (hour >= 19 || hour < 5) night = 1;
|
||
else if (hour >= 17) night = (hour - 17) / 2;
|
||
else if (hour < 7) night = (7 - hour) / 2;
|
||
else night = 0;
|
||
sim.nightFactor = night;
|
||
|
||
const weatherDim = weatherSunDim();
|
||
const sky = skyColorForHour(hour);
|
||
const gray = new THREE.Color(0x4a5560);
|
||
const dim = weatherState.state === 'clear' ? 0
|
||
: weatherState.state === 'cloudy' ? 0.35
|
||
: weatherState.state === 'rain' ? 0.55 : 0.7;
|
||
sky.lerp(gray, dim * (isDay ? 1 : 0.4));
|
||
|
||
if (lightningFlash > 0) {
|
||
sky.lerp(new THREE.Color(0xdfe8ff), Math.min(1, lightningFlash * 2));
|
||
}
|
||
|
||
scene.background = sky;
|
||
scene.fog.color.copy(sky);
|
||
|
||
const ang = ((hour - 6) / 12) * Math.PI;
|
||
// Anchor sun + shadow frustum on the player so the shadow pass only
|
||
// sees casters around them, wherever they are in the expanded city.
|
||
const focus = player.pos;
|
||
sun.position.set(
|
||
focus.x + Math.cos(ang) * 120,
|
||
Math.max(8, Math.sin(ang) * 150),
|
||
focus.z + 60
|
||
);
|
||
sun.target.position.set(focus.x, 0, focus.z);
|
||
sun.target.updateMatrixWorld();
|
||
sun.intensity = (isDay ? elevation : 0) * 1.25 * weatherDim + lightningFlash * 2;
|
||
sun.color.setHSL(0.09, 0.55, isDay ? 0.55 + elevation * 0.4 : 0.5);
|
||
|
||
hemiLight.intensity = 0.12 + (isDay ? elevation * 0.5 : 0) * weatherDim;
|
||
ambientLight.intensity = 0.14 + (isDay ? elevation * 0.28 : 0) * weatherDim + lightningFlash;
|
||
|
||
const fogNear = weatherState.state === 'rain' ? 50 : weatherState.state === 'storm' ? 35 : (night ? 70 : 110);
|
||
const fogFar = weatherState.state === 'rain' ? 340 : weatherState.state === 'storm' ? 220 : (night ? 380 : 600);
|
||
scene.fog.near = fogNear;
|
||
scene.fog.far = fogFar;
|
||
|
||
const lampGlow = 0.05 + night * 1.5;
|
||
for (const m of lampHeads) m.emissiveIntensity = lampGlow;
|
||
for (const m of facadeMaterials) m.emissiveIntensity = night * 0.85;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Weather system
|
||
// ------------------------------------------------------------------
|
||
const WEATHER_TYPES = ['clear', 'cloudy', 'rain', 'storm'];
|
||
const WEATHER_ICONS = { clear: '☀️', cloudy: '☁️', rain: '🌧️', storm: '⛈️' };
|
||
const weatherState = {
|
||
state: 'clear',
|
||
timer: 0,
|
||
duration: 50,
|
||
sequence: ['clear', 'cloudy', 'rain', 'storm', 'cloudy', 'clear'],
|
||
index: 0,
|
||
};
|
||
let lightningFlash = 0;
|
||
let lightningTimer = 3;
|
||
let rainPoints = null;
|
||
let cloudSprites = [];
|
||
|
||
function weatherSunDim() {
|
||
switch (weatherState.state) {
|
||
case 'cloudy': return 0.55;
|
||
case 'rain': return 0.3;
|
||
case 'storm': return 0.15;
|
||
default: return 1;
|
||
}
|
||
}
|
||
|
||
function makeCloudTexture() {
|
||
const c = document.createElement('canvas');
|
||
c.width = 128; c.height = 128;
|
||
const ctx = c.getContext('2d');
|
||
const g = ctx.createRadialGradient(64, 64, 8, 64, 64, 60);
|
||
g.addColorStop(0, 'rgba(255,255,255,0.95)');
|
||
g.addColorStop(0.6, 'rgba(230,235,240,0.55)');
|
||
g.addColorStop(1, 'rgba(220,225,230,0)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, 128, 128);
|
||
return new THREE.CanvasTexture(c);
|
||
}
|
||
|
||
function initWeather() {
|
||
const rainGeo = new THREE.BufferGeometry();
|
||
const N = 2500;
|
||
const pos = new Float32Array(N * 3);
|
||
for (let i = 0; i < N; i++) {
|
||
pos[i * 3] = (Math.random() - 0.5) * 70;
|
||
pos[i * 3 + 1] = Math.random() * 40;
|
||
pos[i * 3 + 2] = (Math.random() - 0.5) * 70;
|
||
}
|
||
rainGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||
const rainMat = new THREE.PointsMaterial({
|
||
color: 0x9ab8cc, size: 0.14, transparent: true, opacity: 0.65, depthWrite: false,
|
||
});
|
||
rainPoints = new THREE.Points(rainGeo, rainMat);
|
||
rainPoints.visible = false;
|
||
scene.add(rainPoints);
|
||
|
||
const cloudTex = makeCloudTexture();
|
||
cloudSprites = [];
|
||
for (let i = 0; i < 14; i++) {
|
||
const mat = new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.3, depthWrite: false });
|
||
const s = new THREE.Sprite(mat);
|
||
s.position.set((Math.random() - 0.5) * 500, 55 + Math.random() * 35, (Math.random() - 0.5) * 500);
|
||
const sc = 45 + Math.random() * 55;
|
||
s.scale.set(sc, sc * 0.45, 1);
|
||
scene.add(s);
|
||
cloudSprites.push(s);
|
||
}
|
||
}
|
||
|
||
function applyWeatherVisuals() {
|
||
const w = weatherState.state;
|
||
const raining = w === 'rain' || w === 'storm';
|
||
if (rainPoints) {
|
||
rainPoints.visible = raining;
|
||
rainPoints.material.opacity = w === 'storm' ? 0.85 : 0.6;
|
||
}
|
||
const cloudOpacity = { clear: 0.12, cloudy: 0.65, rain: 0.85, storm: 0.95 }[w] ?? 0.3;
|
||
const cloudTint = w === 'storm' ? 0x6a7280 : w === 'rain' ? 0x9aa3ad : 0xffffff;
|
||
for (const s of cloudSprites) {
|
||
s.material.opacity = cloudOpacity;
|
||
s.material.color.setHex(cloudTint);
|
||
}
|
||
cityAudio.setWeather(w);
|
||
}
|
||
|
||
function setWeather(type) {
|
||
if (!WEATHER_TYPES.includes(type)) return;
|
||
weatherState.state = type;
|
||
weatherState.timer = 0;
|
||
applyWeatherVisuals();
|
||
}
|
||
|
||
function updateWeather(dt) {
|
||
weatherState.timer += dt;
|
||
if (weatherState.timer >= weatherState.duration) {
|
||
weatherState.timer = 0;
|
||
weatherState.index = (weatherState.index + 1) % weatherState.sequence.length;
|
||
weatherState.state = weatherState.sequence[weatherState.index];
|
||
applyWeatherVisuals();
|
||
}
|
||
|
||
for (const s of cloudSprites) {
|
||
s.position.x += dt * (weatherState.state === 'storm' ? 3.5 : 1.2);
|
||
if (s.position.x > 320) s.position.x = -320;
|
||
}
|
||
|
||
if (rainPoints && rainPoints.visible) {
|
||
rainPoints.position.x = camera.position.x;
|
||
rainPoints.position.z = camera.position.z;
|
||
const arr = rainPoints.geometry.attributes.position.array;
|
||
const fall = weatherState.state === 'storm' ? 38 : 26;
|
||
for (let i = 1; i < arr.length; i += 3) {
|
||
arr[i] -= fall * dt;
|
||
if (arr[i] < 0) arr[i] = 40;
|
||
}
|
||
rainPoints.geometry.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
if (lightningFlash > 0) lightningFlash = Math.max(0, lightningFlash - dt * 4);
|
||
if (weatherState.state === 'storm') {
|
||
lightningTimer -= dt;
|
||
if (lightningTimer <= 0) {
|
||
lightningTimer = 2.5 + Math.random() * 5;
|
||
lightningFlash = 1;
|
||
cityAudio.play('thunder');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// City grid helpers
|
||
// ------------------------------------------------------------------
|
||
function buildGrid(layout) {
|
||
const xs = [...new Set(layout.roads.filter(r => r.axis === 'z').map(r => r.from[0]))]
|
||
.sort((a, b) => a - b);
|
||
const zs = [...new Set(layout.roads.filter(r => r.axis === 'x').map(r => r.from[2]))]
|
||
.sort((a, b) => a - b);
|
||
const roadW = layout.roads[0]?.width ?? 8;
|
||
const squares = [];
|
||
for (let i = 0; i < xs.length - 1; i++) {
|
||
for (let j = 0; j < zs.length - 1; j++) {
|
||
squares.push({ x0: xs[i], x1: xs[i + 1], z0: zs[j], z1: zs[j + 1] });
|
||
}
|
||
}
|
||
return { xs, zs, roadW, squares, minX: xs[0], maxX: xs[xs.length - 1], minZ: zs[0], maxZ: zs[zs.length - 1] };
|
||
}
|
||
|
||
function onRoad(x, z, g) {
|
||
const lim = g.roadW / 2 - 0.4;
|
||
for (const vx of g.xs) if (Math.abs(x - vx) < lim) return true;
|
||
for (const vz of g.zs) if (Math.abs(z - vz) < lim) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Split the interval [a, b] of a road's travel axis into segments that
|
||
* exclude ±half bands around every perpendicular crossing line. Sidewalks
|
||
* and curbs use this so they stop at each crosswalk instead of running
|
||
* across the roadway like a plain walkway.
|
||
*
|
||
* @param {number[]} lines grid lines of the perpendicular roads
|
||
* @return {Array<[number, number]>}
|
||
*/
|
||
function stripSegments(lines, a, b, half) {
|
||
if (a > b) [a, b] = [b, a];
|
||
const gaps = [];
|
||
for (const line of lines) {
|
||
const g0 = line - half;
|
||
const g1 = line + half;
|
||
if (g1 > a && g0 < b) gaps.push([Math.max(g0, a), Math.min(g1, b)]);
|
||
}
|
||
gaps.sort((p, q) => p[0] - q[0]);
|
||
|
||
const merged = [];
|
||
for (const gap of gaps) {
|
||
const last = merged[merged.length - 1];
|
||
if (last && gap[0] <= last[1] + 1e-6) {
|
||
last[1] = Math.max(last[1], gap[1]);
|
||
} else {
|
||
merged.push([gap[0], gap[1]]);
|
||
}
|
||
}
|
||
|
||
const segs = [];
|
||
let cursor = a;
|
||
for (const [g0, g1] of merged) {
|
||
if (g0 - cursor > 0.05) segs.push([cursor, g0]);
|
||
cursor = Math.max(cursor, g1);
|
||
}
|
||
if (b - cursor > 0.05) segs.push([cursor, b]);
|
||
return segs;
|
||
}
|
||
|
||
/**
|
||
* Batches static same-material geometries into single merged meshes so a
|
||
* large city costs a handful of draw calls instead of thousands. Entries
|
||
* are baked into world space at flush(); only truly static, non-
|
||
* interactive meshes belong here (roadsides, poles, housings, roofs).
|
||
*
|
||
* @returns {{add: Function, flush: Function}}
|
||
*/
|
||
function makeStaticBatcher() {
|
||
/** @type {Map<THREE.Material, {entries: Array<{geometry: THREE.BufferGeometry, matrix: THREE.Matrix4}>, cast: boolean, receive: boolean}>} */
|
||
const buckets = new Map();
|
||
|
||
return {
|
||
add(geometry, material, matrix, cast = false, receive = false) {
|
||
let bucket = buckets.get(material);
|
||
if (!bucket) {
|
||
bucket = { entries: [], cast: false, receive: false };
|
||
buckets.set(material, bucket);
|
||
}
|
||
bucket.entries.push({ geometry, matrix });
|
||
bucket.cast = bucket.cast || cast;
|
||
bucket.receive = bucket.receive || receive;
|
||
},
|
||
|
||
flush(parent) {
|
||
for (const [material, bucket] of buckets) {
|
||
if (!bucket.entries.length) continue;
|
||
|
||
const parts = bucket.entries.map(({ geometry, matrix }) => {
|
||
const g = geometry.index ? geometry.toNonIndexed() : geometry.clone();
|
||
if (matrix) g.applyMatrix4(matrix);
|
||
return g;
|
||
});
|
||
|
||
let total = 0;
|
||
for (const g of parts) total += g.getAttribute('position').count;
|
||
|
||
const pos = new Float32Array(total * 3);
|
||
const nor = new Float32Array(total * 3);
|
||
const uv = new Float32Array(total * 2);
|
||
let o3 = 0;
|
||
let o2 = 0;
|
||
for (const g of parts) {
|
||
const count = g.getAttribute('position').count;
|
||
pos.set(g.getAttribute('position').array, o3);
|
||
const n = g.getAttribute('normal');
|
||
if (n) nor.set(n.array, o3);
|
||
const u = g.getAttribute('uv');
|
||
if (u) uv.set(u.array, o2);
|
||
o3 += count * 3;
|
||
o2 += count * 2;
|
||
g.dispose();
|
||
}
|
||
|
||
const merged = new THREE.BufferGeometry();
|
||
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||
merged.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
|
||
merged.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||
merged.computeBoundingSphere();
|
||
|
||
const mesh = new THREE.Mesh(merged, material);
|
||
mesh.castShadow = bucket.cast;
|
||
mesh.receiveShadow = bucket.receive;
|
||
parent.add(mesh);
|
||
}
|
||
buckets.clear();
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Traffic light system
|
||
// ------------------------------------------------------------------
|
||
const PHASES = [
|
||
{ name: 'NS_GREEN', dur: 10, z: 'green', x: 'red' },
|
||
{ name: 'NS_YELLOW', dur: 2, z: 'yellow', x: 'red' },
|
||
{ name: 'ALL_RED1', dur: 1, z: 'red', x: 'red' },
|
||
{ name: 'EW_GREEN', dur: 10, z: 'red', x: 'green' },
|
||
{ name: 'EW_YELLOW', dur: 2, z: 'red', x: 'yellow' },
|
||
{ name: 'ALL_RED2', dur: 1, z: 'red', x: 'red' },
|
||
];
|
||
|
||
/**
|
||
* Pedestrian signal for a road axis.
|
||
*
|
||
* Pedestrians may cross a road when vehicles on that road are stopped (red).
|
||
* Yellow is treated as "do not start crossing" for safety.
|
||
*
|
||
* @param {string} axis
|
||
* @returns {'green'|'red'}
|
||
*/
|
||
function pedStateForAxis(axis) {
|
||
if (!trafficSystem) return 'green';
|
||
const vehicle = trafficSystem.axisState(axis);
|
||
return vehicle === 'red' ? 'green' : 'red';
|
||
}
|
||
|
||
// Shared housings: hundreds of signal bodies merge into one draw call
|
||
// while the lamp spheres stay individual (animated, distance-culled).
|
||
const pedHousingMat = new THREE.MeshStandardMaterial({ color: 0x141820, roughness: 0.75 });
|
||
const signalHousingMat = new THREE.MeshStandardMaterial({ color: 0x1c1c20, roughness: 0.8 });
|
||
|
||
function makePedSignal() {
|
||
const g = new THREE.Group();
|
||
const box = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.28, 0.7, 0.22),
|
||
pedHousingMat
|
||
);
|
||
box.userData.batchStatic = true;
|
||
g.add(box);
|
||
// Top = red "don't walk", bottom = green "walk".
|
||
const red = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.09, 10, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 0.08 })
|
||
);
|
||
red.position.set(0, 0.16, 0.13);
|
||
g.add(red);
|
||
const green = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.09, 10, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0x22dd66, emissive: 0x22dd66, emissiveIntensity: 0.08 })
|
||
);
|
||
green.position.set(0, -0.16, 0.13);
|
||
g.add(green);
|
||
g.userData.lamps = { red, green };
|
||
return g;
|
||
}
|
||
|
||
function makeSignalHead() {
|
||
const g = new THREE.Group();
|
||
const box = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.32, 1.0, 0.26),
|
||
signalHousingMat
|
||
);
|
||
box.userData.batchStatic = true;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Move a signal head's static housing into the batcher, leaving the
|
||
* animated lamp spheres in the group (they are distance-culled instead).
|
||
*
|
||
* @param {THREE.Group} head
|
||
* @param {ReturnType<typeof makeStaticBatcher>} batcher
|
||
*/
|
||
function batchHeadStatic(head, batcher) {
|
||
head.updateMatrixWorld(true);
|
||
for (const child of [...head.children]) {
|
||
if (!child.userData.batchStatic) continue;
|
||
batcher.add(child.geometry, child.material, child.matrixWorld);
|
||
head.remove(child);
|
||
}
|
||
}
|
||
|
||
function initTrafficLights(g, batcher) {
|
||
const group = new THREE.Group();
|
||
const heads = [];
|
||
const pedHeads = [];
|
||
const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a3a40, roughness: 0.6, metalness: 0.4 });
|
||
const pedPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.65, metalness: 0.4 });
|
||
|
||
// Sidewalk band is [half, half+2] from the road centre (half=4 → 4..6).
|
||
const half = g.roadW / 2;
|
||
const sw = half + 1; // sidewalk centre
|
||
|
||
for (const x of g.xs) {
|
||
for (const z of g.zs) {
|
||
// SE-corner pole, planted on the sidewalk (group/world are at
|
||
// identity, so local matrices are world matrices here).
|
||
const poleGeo = new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6);
|
||
const pole = new THREE.Mesh(poleGeo, poleMat);
|
||
pole.position.set(x + sw, 2.1, z + sw);
|
||
pole.updateMatrix();
|
||
batcher.add(poleGeo, poleMat, pole.matrix, true);
|
||
|
||
// Mast arm over the vertical road (northbound lane), facing
|
||
// south so approaching traffic sees the signal. Northbound
|
||
// drives on the east half (right-hand traffic); the SE-corner
|
||
// pole sits on that same (right) side.
|
||
const armNSGeo = new THREE.BoxGeometry(0.12, 0.1, 2.2);
|
||
const armNS = new THREE.Mesh(armNSGeo, poleMat);
|
||
armNS.position.set(x + sw, 4.0, z + sw - 1.1);
|
||
armNS.updateMatrix();
|
||
batcher.add(armNSGeo, poleMat, armNS.matrix);
|
||
const headNS = makeSignalHead();
|
||
headNS.position.set(x + sw, 3.7, z + sw - 2.2);
|
||
headNS.rotation.y = 0; // face +Z (south — toward northbound traffic)
|
||
group.add(headNS);
|
||
batchHeadStatic(headNS, batcher);
|
||
heads.push({ head: headNS, axis: 'z' });
|
||
|
||
// Mast arm over the horizontal (eastbound lane), facing west.
|
||
const armEWGeo = new THREE.BoxGeometry(2.2, 0.1, 0.12);
|
||
const armEW = new THREE.Mesh(armEWGeo, poleMat);
|
||
armEW.position.set(x + sw - 1.1, 4.0, z + sw);
|
||
armEW.updateMatrix();
|
||
batcher.add(armEWGeo, poleMat, armEW.matrix);
|
||
const headEW = makeSignalHead();
|
||
headEW.position.set(x + sw - 2.2, 3.7, z + sw);
|
||
headEW.rotation.y = -Math.PI / 2; // face -X (west)
|
||
group.add(headEW);
|
||
batchHeadStatic(headEW, batcher);
|
||
heads.push({ head: headEW, axis: 'x' });
|
||
|
||
// Pedestrian signals: short posts on the same corner, well
|
||
// inside the sidewalk, facing the crosswalks they control.
|
||
//
|
||
// The east crosswalk crosses the horizontal (x) road, so its
|
||
// signal follows the x-axis vehicle state. It sits on the east
|
||
// sidewalk facing north, for walkers arriving from the north.
|
||
const pedEast = makePedSignal();
|
||
const pedEastPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
|
||
const pedEastPole = new THREE.Mesh(pedEastPoleGeo, pedPoleMat);
|
||
pedEastPole.position.set(x + sw + 0.6, 1.2, z + sw - 0.6);
|
||
pedEastPole.updateMatrix();
|
||
batcher.add(pedEastPoleGeo, pedPoleMat, pedEastPole.matrix);
|
||
pedEast.position.set(x + sw + 0.6, 2.5, z + sw - 0.6);
|
||
pedEast.rotation.y = Math.PI; // faces north (approach from the north)
|
||
group.add(pedEast);
|
||
batchHeadStatic(pedEast, batcher);
|
||
pedHeads.push({ head: pedEast, axis: 'x' });
|
||
|
||
// The south crosswalk crosses the vertical (z) road, so its
|
||
// signal follows the z-axis vehicle state. It sits on the south
|
||
// sidewalk facing west, for walkers arriving from the east.
|
||
const pedSouth = makePedSignal();
|
||
const pedSouthPoleGeo = new THREE.CylinderGeometry(0.05, 0.06, 2.4, 6);
|
||
const pedSouthPole = new THREE.Mesh(pedSouthPoleGeo, pedPoleMat);
|
||
pedSouthPole.position.set(x + sw - 0.6, 1.2, z + sw + 0.6);
|
||
pedSouthPole.updateMatrix();
|
||
batcher.add(pedSouthPoleGeo, pedPoleMat, pedSouthPole.matrix);
|
||
pedSouth.position.set(x + sw - 0.6, 2.5, z + sw + 0.6);
|
||
pedSouth.rotation.y = -Math.PI / 2; // faces west (approach from the east)
|
||
group.add(pedSouth);
|
||
batchHeadStatic(pedSouth, batcher);
|
||
pedHeads.push({ head: pedSouth, axis: 'z' });
|
||
}
|
||
}
|
||
|
||
worldGroup.add(group);
|
||
|
||
trafficSystem = {
|
||
phaseIndex: 0,
|
||
phaseTimer: 0,
|
||
heads,
|
||
pedHeads,
|
||
axisState(axis) {
|
||
return PHASES[this.phaseIndex][axis];
|
||
},
|
||
pedState(axis) {
|
||
return pedStateForAxis(axis);
|
||
},
|
||
get phaseName() { return PHASES[this.phaseIndex].name; },
|
||
/** Jump to a named phase (used by tests / debug). */
|
||
setPhase(name) {
|
||
const idx = PHASES.findIndex(p => p.name === name);
|
||
if (idx >= 0) {
|
||
this.phaseIndex = idx;
|
||
this.phaseTimer = 0;
|
||
}
|
||
},
|
||
update(dt) {
|
||
this.phaseTimer += dt;
|
||
while (this.phaseTimer >= PHASES[this.phaseIndex].dur) {
|
||
this.phaseTimer -= PHASES[this.phaseIndex].dur;
|
||
this.phaseIndex = (this.phaseIndex + 1) % PHASES.length;
|
||
}
|
||
for (const { head, axis } of this.heads) {
|
||
const state = PHASES[this.phaseIndex][axis];
|
||
const lamps = head.userData.lamps;
|
||
lamps[0].material.emissiveIntensity = state === 'red' ? 2.2 : 0.06;
|
||
lamps[1].material.emissiveIntensity = state === 'yellow' ? 2.2 : 0.06;
|
||
lamps[2].material.emissiveIntensity = state === 'green' ? 2.2 : 0.06;
|
||
}
|
||
for (const { head, axis } of this.pedHeads) {
|
||
const ped = pedStateForAxis(axis);
|
||
const lamps = head.userData.lamps;
|
||
lamps.red.material.emissiveIntensity = ped === 'red' ? 2.4 : 0.06;
|
||
lamps.green.material.emissiveIntensity = ped === 'green' ? 2.4 : 0.06;
|
||
}
|
||
// Lamp dots are sub-pixel at distance — hide them far away.
|
||
// Housings are batched separately and always stay visible.
|
||
const camX = camera.position.x;
|
||
const camZ = camera.position.z;
|
||
const hideD2 = 60 * 60;
|
||
for (const { head } of this.heads) {
|
||
const dx = head.position.x - camX;
|
||
const dz = head.position.z - camZ;
|
||
head.visible = dx * dx + dz * dz < hideD2;
|
||
}
|
||
for (const { head } of this.pedHeads) {
|
||
const dx = head.position.x - camX;
|
||
const dz = head.position.z - camZ;
|
||
head.visible = dx * dx + dz * dz < hideD2;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Vehicle system
|
||
// ------------------------------------------------------------------
|
||
const CAR_COLORS = [0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400];
|
||
const MOTO_COLORS = [0x111827, 0xb91c1c, 0x1d4ed8, 0x166534, 0x7c2d12, 0x4b5563];
|
||
const BIKE_COLORS = [0xef4444, 0x3b82f6, 0x22c55e, 0xf59e0b, 0xa855f7, 0x111827];
|
||
|
||
function buildCarMesh(colorHex, isBus = false) {
|
||
const g = new THREE.Group();
|
||
const bodyLen = isBus ? 7.5 : 4.2;
|
||
const bodyW = isBus ? 2.2 : 1.85;
|
||
const bodyH = isBus ? 1.6 : 0.55;
|
||
const paint = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.32, metalness: 0.55 });
|
||
const dark = new THREE.MeshStandardMaterial({ color: 0x14161a, roughness: 0.5, metalness: 0.3 });
|
||
const glass = new THREE.MeshStandardMaterial({
|
||
color: 0x9fd4ff, roughness: 0.12, metalness: 0.2, transparent: true, opacity: 0.55,
|
||
});
|
||
const chrome = new THREE.MeshStandardMaterial({ color: 0xd8dee6, roughness: 0.2, metalness: 0.9 });
|
||
const rubber = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, roughness: 0.92 });
|
||
|
||
const bodyY = isBus ? 1.15 : 0.52;
|
||
const chassis = new THREE.Mesh(new THREE.BoxGeometry(bodyLen, bodyH, bodyW), paint);
|
||
chassis.position.y = bodyY;
|
||
chassis.castShadow = true;
|
||
g.add(chassis);
|
||
|
||
// Rounded hood / trunk edges.
|
||
const hood = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.38, 0.18, bodyW * 0.92), paint);
|
||
hood.position.set(bodyLen * 0.28, bodyY + bodyH * 0.5 + 0.05, 0);
|
||
g.add(hood);
|
||
|
||
if (isBus) {
|
||
const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.92, 1.35, bodyW * 0.96), paint);
|
||
cabin.position.set(-0.1, bodyY + 1.15, 0);
|
||
cabin.castShadow = true;
|
||
g.add(cabin);
|
||
for (const side of [-1, 1]) {
|
||
const strip = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.8, 0.5, 0.04), glass);
|
||
strip.position.set(-0.1, bodyY + 1.35, side * (bodyW / 2 + 0.01));
|
||
g.add(strip);
|
||
}
|
||
const front = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.55, bodyW * 0.7), glass);
|
||
front.position.set(bodyLen / 2 - 0.02, bodyY + 1.3, 0);
|
||
g.add(front);
|
||
const roof = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.9, 0.12, bodyW * 0.9), chrome);
|
||
roof.position.set(-0.1, bodyY + 1.9, 0);
|
||
g.add(roof);
|
||
} else {
|
||
const cabin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.48, 0.52, bodyW * 0.86), paint);
|
||
cabin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.28, 0);
|
||
cabin.castShadow = true;
|
||
g.add(cabin);
|
||
const windshield = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.22, 0.38, bodyW * 0.78), glass);
|
||
windshield.position.set(bodyLen * 0.08, bodyY + bodyH * 0.5 + 0.3, 0);
|
||
g.add(windshield);
|
||
for (const side of [-1, 1]) {
|
||
const sideWin = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.3, 0.32, 0.04), glass);
|
||
sideWin.position.set(-0.2, bodyY + bodyH * 0.5 + 0.3, side * (bodyW * 0.44));
|
||
g.add(sideWin);
|
||
}
|
||
// Bumpers + grille.
|
||
const bumperF = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome);
|
||
bumperF.position.set(bodyLen / 2 + 0.02, 0.38, 0);
|
||
g.add(bumperF);
|
||
const bumperR = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.22, bodyW * 0.95), chrome);
|
||
bumperR.position.set(-bodyLen / 2 - 0.02, 0.38, 0);
|
||
g.add(bumperR);
|
||
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, bodyW * 0.55), dark);
|
||
grille.position.set(bodyLen / 2 + 0.08, 0.52, 0);
|
||
g.add(grille);
|
||
// License plates.
|
||
const plateMat = new THREE.MeshStandardMaterial({ color: 0xf5f5f0, roughness: 0.8 });
|
||
const plateF = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat);
|
||
plateF.position.set(bodyLen / 2 + 0.1, 0.42, 0);
|
||
g.add(plateF);
|
||
const plateR = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.16, 0.5), plateMat);
|
||
plateR.position.set(-bodyLen / 2 - 0.1, 0.42, 0);
|
||
g.add(plateR);
|
||
// Side mirrors.
|
||
const mirrorMat = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.4, metalness: 0.5 });
|
||
for (const side of [-1, 1]) {
|
||
const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.1), mirrorMat);
|
||
mirror.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.48 + 0.08));
|
||
g.add(mirror);
|
||
const armM = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.04, 0.12), mirrorMat);
|
||
armM.position.set(bodyLen * 0.18, bodyY + bodyH * 0.5 + 0.35, side * (bodyW * 0.44));
|
||
g.add(armM);
|
||
}
|
||
// Roof detail.
|
||
const roofTrim = new THREE.Mesh(new THREE.BoxGeometry(bodyLen * 0.4, 0.06, bodyW * 0.7), chrome);
|
||
roofTrim.position.set(-0.2, bodyY + bodyH * 0.5 + 0.56, 0);
|
||
g.add(roofTrim);
|
||
// Headlights / taillights (materials stored for night ramp).
|
||
g.userData.headMats = [];
|
||
g.userData.tailMats = [];
|
||
for (const side of [-1, 1]) {
|
||
const headMat = new THREE.MeshStandardMaterial({
|
||
color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2,
|
||
});
|
||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.14, 0.28), headMat);
|
||
head.position.set(bodyLen / 2 + 0.05, 0.58, side * (bodyW * 0.32));
|
||
g.add(head);
|
||
g.userData.headMats.push(headMat);
|
||
const tailMat = new THREE.MeshStandardMaterial({
|
||
color: 0xff3344, emissive: 0xcc1122, emissiveIntensity: 0.4,
|
||
});
|
||
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.12, 0.3), tailMat);
|
||
tail.position.set(-bodyLen / 2 - 0.04, 0.55, side * (bodyW * 0.32));
|
||
g.add(tail);
|
||
g.userData.tailMats.push(tailMat);
|
||
}
|
||
// Visible beam cones (hidden by day).
|
||
g.userData.beams = [];
|
||
for (const side of [-1, 1]) {
|
||
const beam = new THREE.Mesh(
|
||
new THREE.ConeGeometry(0.55, 6, 10, 1, true),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0xfff2b0, transparent: true, opacity: 0,
|
||
side: THREE.DoubleSide, depthWrite: false,
|
||
})
|
||
);
|
||
// Cone default points +Y; rotate to point forward (+X).
|
||
beam.rotation.z = -Math.PI / 2;
|
||
beam.position.set(bodyLen / 2 + 3.1, 0.5, side * (bodyW * 0.28));
|
||
g.add(beam);
|
||
g.userData.beams.push(beam.material);
|
||
}
|
||
}
|
||
|
||
const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.26, 14);
|
||
wheelGeo.rotateX(Math.PI / 2);
|
||
const rimGeo = new THREE.CylinderGeometry(0.16, 0.16, 0.28, 10);
|
||
rimGeo.rotateX(Math.PI / 2);
|
||
const wheels = [];
|
||
const wx = bodyLen * 0.3;
|
||
const wz = bodyW / 2 + 0.04;
|
||
[[wx, wz], [wx, -wz], [-wx, wz], [-wx, -wz]].forEach(([px, pz]) => {
|
||
const w = new THREE.Mesh(wheelGeo, rubber);
|
||
w.position.set(px, 0.34, pz);
|
||
w.castShadow = false; w.userData.lodDetail = true;
|
||
g.add(w);
|
||
const rim = new THREE.Mesh(rimGeo, chrome);
|
||
rim.position.set(px, 0.34, pz);
|
||
g.add(rim);
|
||
wheels.push(w);
|
||
});
|
||
|
||
g.userData.wheels = wheels;
|
||
g.userData.kind = isBus ? 'bus' : 'car';
|
||
g.userData.halfL = bodyLen * 0.46;
|
||
g.userData.halfW = bodyW * 0.52;
|
||
return g;
|
||
}
|
||
|
||
function buildMotoMesh(colorHex) {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.3, metalness: 0.6 });
|
||
const chrome = new THREE.MeshStandardMaterial({ color: 0xc0c6cc, roughness: 0.22, metalness: 0.9 });
|
||
const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: 0.95 });
|
||
const dark = new THREE.MeshStandardMaterial({ color: 0x1a1c20, roughness: 0.7 });
|
||
|
||
const tank = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.38, 0.42), bodyMat);
|
||
tank.position.set(0.1, 0.74, 0);
|
||
tank.castShadow = true;
|
||
g.add(tank);
|
||
const tankStripe = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.08, 0.44), chrome);
|
||
tankStripe.position.set(0.1, 0.88, 0);
|
||
g.add(tankStripe);
|
||
|
||
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.14, 0.36), dark);
|
||
seat.position.set(-0.38, 0.84, 0);
|
||
g.add(seat);
|
||
|
||
const engine = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.32, 0.38), chrome);
|
||
engine.position.set(0.15, 0.42, 0);
|
||
g.add(engine);
|
||
|
||
const fork = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.58, 8), chrome);
|
||
fork.rotation.z = 0.35;
|
||
fork.position.set(0.7, 0.55, 0);
|
||
g.add(fork);
|
||
|
||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.09, 0.58), chrome);
|
||
bar.position.set(0.62, 0.86, 0);
|
||
g.add(bar);
|
||
for (const side of [-1, 1]) {
|
||
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.14, 6), dark);
|
||
grip.rotation.x = Math.PI / 2;
|
||
grip.position.set(0.62, 0.86, side * 0.3);
|
||
g.add(grip);
|
||
}
|
||
|
||
const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.14, 14);
|
||
wheelGeo.rotateX(Math.PI / 2);
|
||
const rimGeo = new THREE.CylinderGeometry(0.12, 0.12, 0.16, 8);
|
||
rimGeo.rotateX(Math.PI / 2);
|
||
const wheels = [];
|
||
[[0.74, 0], [-0.56, 0]].forEach(([px]) => {
|
||
const w = new THREE.Mesh(wheelGeo, rubber);
|
||
w.position.set(px, 0.34, 0);
|
||
w.castShadow = false; w.userData.lodDetail = true;
|
||
g.add(w);
|
||
const rim = new THREE.Mesh(rimGeo, chrome);
|
||
rim.position.set(px, 0.34, 0);
|
||
g.add(rim);
|
||
wheels.push(w);
|
||
});
|
||
|
||
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.95, 8), chrome);
|
||
exhaust.rotation.z = Math.PI / 2;
|
||
exhaust.position.set(-0.2, 0.36, 0.2);
|
||
g.add(exhaust);
|
||
|
||
// Headlight.
|
||
const head = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.14, 10, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.3 })
|
||
);
|
||
head.position.set(0.9, 0.72, 0);
|
||
g.add(head);
|
||
g.userData.headMats = [head.material];
|
||
g.userData.tailMats = [];
|
||
g.userData.beams = [];
|
||
|
||
const rider = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.19, 0.48, 4, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.8 })
|
||
);
|
||
rider.position.set(-0.14, 1.18, 0);
|
||
rider.rotation.z = -0.28;
|
||
rider.castShadow = true;
|
||
g.add(rider);
|
||
const helmet = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.17, 12, 12),
|
||
new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.25, metalness: 0.5 })
|
||
);
|
||
helmet.position.set(0.06, 1.54, 0);
|
||
g.add(helmet);
|
||
const visor = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.12, 0.08, 0.22),
|
||
new THREE.MeshStandardMaterial({ color: 0x111827, roughness: 0.2, metalness: 0.4 })
|
||
);
|
||
visor.position.set(0.2, 1.52, 0);
|
||
g.add(visor);
|
||
|
||
g.userData.wheels = wheels;
|
||
g.userData.kind = 'moto';
|
||
g.userData.halfL = 0.98;
|
||
g.userData.halfW = 0.36;
|
||
return g;
|
||
}
|
||
|
||
function buildBikeMesh(colorHex) {
|
||
const g = new THREE.Group();
|
||
const frameMat = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.4, metalness: 0.55 });
|
||
const rubber = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.95 });
|
||
const chrome = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.25, metalness: 0.85 });
|
||
const dark = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.85 });
|
||
|
||
const tube = (len, x, y, rz = 0) => {
|
||
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.038, 0.038, len, 8), frameMat);
|
||
m.position.set(x, y, 0);
|
||
m.rotation.z = rz;
|
||
g.add(m);
|
||
return m;
|
||
};
|
||
|
||
tube(1.1, 0, 0.58, Math.PI / 2.4);
|
||
tube(0.58, -0.15, 0.78, 0);
|
||
tube(0.48, 0.35, 0.88, Math.PI / 2);
|
||
// Chainstay.
|
||
tube(0.7, -0.3, 0.38, Math.PI / 2.1);
|
||
|
||
const chainring = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.02, 6, 16), chrome);
|
||
chainring.position.set(-0.05, 0.38, 0.12);
|
||
g.add(chainring);
|
||
|
||
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.07, 0.18), dark);
|
||
seat.position.set(-0.2, 1.02, 0);
|
||
g.add(seat);
|
||
const seatPost = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.22, 6), chrome);
|
||
seatPost.position.set(-0.18, 0.9, 0);
|
||
g.add(seatPost);
|
||
|
||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.52), chrome);
|
||
bar.position.set(0.52, 1.02, 0);
|
||
g.add(bar);
|
||
for (const side of [-1, 1]) {
|
||
const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.12, 6), dark);
|
||
grip.rotation.x = Math.PI / 2;
|
||
grip.position.set(0.52, 1.02, side * 0.24);
|
||
g.add(grip);
|
||
}
|
||
|
||
const wheelGeo = new THREE.TorusGeometry(0.34, 0.05, 8, 20);
|
||
const wheels = [];
|
||
[[0.58], [-0.58]].forEach(([px]) => {
|
||
const w = new THREE.Mesh(wheelGeo, rubber);
|
||
w.position.set(px, 0.34, 0);
|
||
w.castShadow = false; w.userData.lodDetail = true;
|
||
g.add(w);
|
||
// Spokes.
|
||
for (let i = 0; i < 6; i++) {
|
||
const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.64, 0.015, 0.015), chrome);
|
||
spoke.position.set(px, 0.34, 0);
|
||
spoke.rotation.z = (i / 6) * Math.PI;
|
||
g.add(spoke);
|
||
}
|
||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.09, 8), chrome);
|
||
hub.rotation.x = Math.PI / 2;
|
||
hub.position.set(px, 0.34, 0);
|
||
g.add(hub);
|
||
wheels.push(w);
|
||
});
|
||
|
||
// Rider pedalling.
|
||
const torso = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.17, 0.42, 4, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0x374151, roughness: 0.8 })
|
||
);
|
||
torso.position.set(-0.05, 1.28, 0);
|
||
torso.rotation.z = -0.38;
|
||
torso.castShadow = true;
|
||
g.add(torso);
|
||
const head = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.15, 12, 12),
|
||
new THREE.MeshStandardMaterial({ color: 0xf1c27d, roughness: 0.7 })
|
||
);
|
||
head.position.set(0.14, 1.58, 0);
|
||
g.add(head);
|
||
// Helmet.
|
||
const helmet = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.17, 10, 8, 0, Math.PI * 2, 0, Math.PI * 0.55),
|
||
new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.35, metalness: 0.4 })
|
||
);
|
||
helmet.position.set(0.14, 1.6, 0);
|
||
g.add(helmet);
|
||
|
||
const legGeo = new THREE.CapsuleGeometry(0.065, 0.4, 4, 6);
|
||
[-1, 1].forEach(side => {
|
||
const leg = new THREE.Mesh(legGeo, new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85 }));
|
||
leg.position.set(0.05, 0.78, side * 0.13);
|
||
leg.rotation.z = side * 0.42;
|
||
g.add(leg);
|
||
// Legs animate with "wheel" spin for a pedalling look.
|
||
wheels.push(leg);
|
||
});
|
||
|
||
// Front reflector (night headlight).
|
||
const reflMat = new THREE.MeshStandardMaterial({
|
||
color: 0xfff6c8, emissive: 0xfff2b0, emissiveIntensity: 0.2,
|
||
});
|
||
const refl = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.08, 0.1), reflMat);
|
||
refl.position.set(0.72, 0.9, 0);
|
||
g.add(refl);
|
||
g.userData.headMats = [reflMat];
|
||
g.userData.tailMats = [];
|
||
g.userData.beams = [];
|
||
|
||
g.userData.wheels = wheels;
|
||
g.userData.kind = 'bike';
|
||
g.userData.halfL = 0.9;
|
||
g.userData.halfW = 0.32;
|
||
return g;
|
||
}
|
||
|
||
function squarePath(sq, inset) {
|
||
const o = inset;
|
||
return [
|
||
new THREE.Vector3(sq.x0 + o, 0, sq.z0 + o),
|
||
new THREE.Vector3(sq.x1 - o, 0, sq.z0 + o),
|
||
new THREE.Vector3(sq.x1 - o, 0, sq.z1 - o),
|
||
new THREE.Vector3(sq.x0 + o, 0, sq.z1 - o),
|
||
];
|
||
}
|
||
|
||
function squarePathOuter(sq, outset) {
|
||
const o = outset;
|
||
return [
|
||
new THREE.Vector3(sq.x0 - o, 0, sq.z0 - o),
|
||
new THREE.Vector3(sq.x0 - o, 0, sq.z1 + o),
|
||
new THREE.Vector3(sq.x1 + o, 0, sq.z1 + o),
|
||
new THREE.Vector3(sq.x1 + o, 0, sq.z0 - o),
|
||
];
|
||
}
|
||
|
||
function pathLength(pts) {
|
||
let L = 0;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[i], b = pts[(i + 1) % pts.length];
|
||
L += a.distanceTo(b);
|
||
}
|
||
return L;
|
||
}
|
||
|
||
function posOnPath(pts, s, L, out) {
|
||
const n = pts.length;
|
||
let rem = ((s % L) + L) % L;
|
||
for (let i = 0; i < n; i++) {
|
||
const a = pts[i], b = pts[(i + 1) % n];
|
||
const seg = a.distanceTo(b);
|
||
if (rem <= seg || i === n - 1) {
|
||
const t = seg === 0 ? 0 : rem / seg;
|
||
out.lerpVectors(a, b, Math.min(1, t));
|
||
return i;
|
||
}
|
||
rem -= seg;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function initVehicles(g) {
|
||
const vehicles = [];
|
||
const loops = [];
|
||
|
||
// Prefetch the layout so stop-line math can see real intersection coords.
|
||
initVehicles.g = g;
|
||
|
||
for (const sq of g.squares) {
|
||
const dx = sq.x1 - sq.x0;
|
||
const dz = sq.z1 - sq.z0;
|
||
if (dx < 30 || dz < 30) continue;
|
||
// Right-hand traffic only: every block circulates clockwise (from
|
||
// above). Adjacent blocks then occupy the two opposite lanes of a
|
||
// shared road without ever driving contra-mão. Outer paths are
|
||
// never used — they coincide with the neighbour's inner path.
|
||
const cornerR = Math.min(dx, dz) * 0.22;
|
||
const corners = squarePath(sq, g.roadW / 4);
|
||
const pts = roundedRectPath(corners, cornerR);
|
||
loops.push({ pts, L: pathLength(pts) });
|
||
}
|
||
|
||
const spawnLoop = (loop, offsetS) => {
|
||
const roll = Math.random();
|
||
let kind = 'car';
|
||
if (roll < 0.12) kind = 'moto';
|
||
else if (roll < 0.28) kind = 'bike';
|
||
else if (roll < 0.44) kind = 'bus';
|
||
|
||
const color = kind === 'moto'
|
||
? MOTO_COLORS[Math.floor(Math.random() * MOTO_COLORS.length)]
|
||
: kind === 'bike'
|
||
? BIKE_COLORS[Math.floor(Math.random() * BIKE_COLORS.length)]
|
||
: CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)];
|
||
|
||
let mesh;
|
||
if (kind === 'moto') mesh = buildMotoMesh(color);
|
||
else if (kind === 'bike') mesh = buildBikeMesh(color);
|
||
else mesh = buildCarMesh(color, kind === 'bus');
|
||
|
||
worldGroup.add(mesh);
|
||
const lodDetail = [];
|
||
mesh.traverse(o => { if (o.userData.lodDetail) lodDetail.push(o); });
|
||
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,
|
||
lodDetail, lodFar: false,
|
||
};
|
||
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);
|
||
sh.lodDetail = [];
|
||
sh.mesh.traverse(o => { if (o.userData.lodDetail) sh.lodDetail.push(o); });
|
||
sh.lodFar = false;
|
||
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) {
|
||
const camX = camera.position.x;
|
||
const camZ = camera.position.z;
|
||
const lodD2 = 45 * 45;
|
||
for (const v of vehicles) {
|
||
if (v.pts) {
|
||
updateLoopVehicle(v, dt);
|
||
} else {
|
||
updateShuttleVehicle(v, dt);
|
||
}
|
||
// Wheels past the near band are a handful of pixels — skip
|
||
// their draw calls (body/cabin stay; state flips only on change).
|
||
if (v.lodDetail && v.lodDetail.length) {
|
||
const dx = v.mesh.position.x - camX;
|
||
const dz = v.mesh.position.z - camZ;
|
||
const far = dx * dx + dz * dz > lodD2;
|
||
if (far !== v.lodFar) {
|
||
v.lodFar = far;
|
||
for (const part of v.lodDetail) part.visible = !far;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
function vehicleAheadGap(v) {
|
||
let minGap = Infinity;
|
||
for (const other of vehicleSystem.vehicles) {
|
||
if (other === v || !other.pts || other.loopKey !== v.loopKey) continue;
|
||
const gap = ((other.s - v.s) % v.L + v.L) % v.L;
|
||
if (gap > 0.01 && gap < minGap) minGap = gap;
|
||
}
|
||
return minGap;
|
||
}
|
||
|
||
/**
|
||
* World-space pose of a vehicle: position, unit forward, body half-sizes.
|
||
*/
|
||
function getVehiclePose(v) {
|
||
const pos = new THREE.Vector3();
|
||
const ahead = new THREE.Vector3();
|
||
if (v.pts) {
|
||
posOnPath(v.pts, v.s, v.L, pos);
|
||
posOnPath(v.pts, v.s + 0.6, v.L, ahead);
|
||
} else {
|
||
pos.lerpVectors(v.a, v.b, v.t);
|
||
const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.06));
|
||
ahead.lerpVectors(v.a, v.b, lookT);
|
||
if (ahead.distanceToSquared(pos) < 1e-6) {
|
||
ahead.x = pos.x + (v.b.x - v.a.x) * v.dir;
|
||
ahead.z = pos.z + (v.b.z - v.a.z) * v.dir;
|
||
}
|
||
}
|
||
const fx = ahead.x - pos.x;
|
||
const fz = ahead.z - pos.z;
|
||
const len = Math.hypot(fx, fz) || 1;
|
||
return {
|
||
x: pos.x,
|
||
z: pos.z,
|
||
fx: fx / len,
|
||
fz: fz / len,
|
||
halfL: v.mesh?.userData?.halfL ?? 2.0,
|
||
halfW: v.mesh?.userData?.halfW ?? 0.95,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Do the two oriented boxes (sampled along each long axis) intersect?
|
||
*/
|
||
function bodiesOverlap(a, b) {
|
||
const lat = (a.halfW + b.halfW) * 0.88;
|
||
for (let ta = -1; ta <= 1.001; ta += 0.35) {
|
||
const ax = a.x + a.fx * a.halfL * ta;
|
||
const az = a.z + a.fz * a.halfL * ta;
|
||
for (let tb = -1; tb <= 1.001; tb += 0.35) {
|
||
const bx = b.x + b.fx * b.halfL * tb;
|
||
const bz = b.z + b.fz * b.halfL * tb;
|
||
const dx = ax - bx;
|
||
const dz = az - bz;
|
||
if (dx * dx + dz * dz < lat * lat) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Hard body-overlap check against every other vehicle.
|
||
*
|
||
* Returns null when clear, otherwise the pose of the nearest overlapping
|
||
* vehicle plus the along-axis offset (positive = other is ahead of us).
|
||
* Deliberately does NOT block on "vehicle ahead in lane" — that is what
|
||
* same-loop following and traffic lights are for. World-space blocking
|
||
* caused intersection deadlocks (two cars freezing each other).
|
||
*/
|
||
function vehicleSeparation(v) {
|
||
const self = getVehiclePose(v);
|
||
const list = vehicleSystem.vehicles;
|
||
|
||
for (const other of list) {
|
||
if (other === v) continue;
|
||
const o = getVehiclePose(other);
|
||
|
||
const dx = o.x - self.x;
|
||
const dz = o.z - self.z;
|
||
const reach = self.halfL + o.halfL + 0.5;
|
||
if (dx * dx + dz * dz > reach * reach) continue;
|
||
|
||
if (bodiesOverlap(self, o)) {
|
||
return {
|
||
type: 'overlap',
|
||
along: dx * self.fx + dz * self.fz,
|
||
other,
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function probeAhead(pts, s, L) {
|
||
let rem = ((s % L) + L) % L;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[i];
|
||
const b = pts[(i + 1) % pts.length];
|
||
const seg = a.distanceTo(b);
|
||
if (rem <= seg + 1e-6) {
|
||
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
|
||
return { distToCorner: seg - rem, axis, corner: b, segStart: a, segEnd: b, segIndex: i };
|
||
}
|
||
rem -= seg;
|
||
}
|
||
return { distToCorner: 999, axis: 'x', corner: pts[0], segStart: pts[0], segEnd: pts[1] || pts[0], segIndex: 0 };
|
||
}
|
||
|
||
/**
|
||
* Distance along travel to the real intersection stop line.
|
||
*
|
||
* Uses the road grid, not path waypoints: finds the next grid line ahead
|
||
* in the direction of travel and returns how far the vehicle still is
|
||
* from the stop line (edge of the crossing box).
|
||
*
|
||
* Returns:
|
||
* >= 0 — metres still to go before the stop line
|
||
* -1 — stop line already passed (committed; must clear the box)
|
||
*/
|
||
function distanceToStopLine(probe, vehiclePos) {
|
||
const g = initVehicles.g;
|
||
if (!g) return probe.distToCorner;
|
||
|
||
// Travel direction from the current path segment (dominant axis).
|
||
const dx = probe.segEnd.x - probe.segStart.x;
|
||
const dz = probe.segEnd.z - probe.segStart.z;
|
||
const axis = Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z';
|
||
const sign = (axis === 'x' ? dx : dz) >= 0 ? 1 : -1;
|
||
|
||
const coord = axis === 'x' ? vehiclePos.x : vehiclePos.z;
|
||
const lines = axis === 'x' ? g.xs : g.zs;
|
||
|
||
// Next grid line strictly ahead of the vehicle.
|
||
let next = null;
|
||
let bestAhead = Infinity;
|
||
for (const line of lines) {
|
||
const ahead = (line - coord) * sign;
|
||
if (ahead > 0.6 && ahead < bestAhead) {
|
||
bestAhead = ahead;
|
||
next = line;
|
||
}
|
||
}
|
||
if (next === null) return 999;
|
||
|
||
// Stop line sits just before the crossing box (half road + nose + margin).
|
||
const boxEdge = g.roadW * 0.5 + 2.0 + 0.8;
|
||
const stopCoord = next - sign * boxEdge;
|
||
const dist = (stopCoord - coord) * sign;
|
||
|
||
if (dist <= 0) return -1; // already at/past the line — clear the box
|
||
return dist;
|
||
}
|
||
|
||
/**
|
||
* Yaw so a mesh whose nose is local +X faces the world direction (dx, dz).
|
||
*
|
||
* Three.js: local +X after rotation.y=θ becomes (cos θ, 0, -sin θ).
|
||
*/
|
||
function yawFromDelta(dx, dz) {
|
||
return Math.atan2(-dz, dx);
|
||
}
|
||
|
||
/**
|
||
* Rounded rectangle path for smoother 90° turns.
|
||
*
|
||
* Each corner is replaced by a quarter-arc of radius r so vehicles
|
||
* steers through curves instead of snapping headings.
|
||
*/
|
||
function roundedRectPath(corners, radius) {
|
||
const pts = [];
|
||
const n = corners.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const prev = corners[(i - 1 + n) % n];
|
||
const curr = corners[i];
|
||
const next = corners[(i + 1) % n];
|
||
|
||
const toPrev = new THREE.Vector3().subVectors(prev, curr);
|
||
const toNext = new THREE.Vector3().subVectors(next, curr);
|
||
const lenP = toPrev.length() || 1;
|
||
const lenN = toNext.length() || 1;
|
||
toPrev.divideScalar(lenP);
|
||
toNext.divideScalar(lenN);
|
||
|
||
const r = Math.min(radius, lenP * 0.45, lenN * 0.45);
|
||
const enter = curr.clone().addScaledVector(toPrev, r);
|
||
const exit = curr.clone().addScaledVector(toNext, r);
|
||
|
||
// Circular arc tangent to both straights. Centre lies on the
|
||
// angle bisector at distance r / sin(θ/2) from the corner.
|
||
// half-angle θ/2 from cosθ = toPrev·toNext:
|
||
// cos(θ/2) = √((1 + cosθ) / 2)
|
||
pts.push(enter);
|
||
const bis = toPrev.clone().add(toNext).normalize();
|
||
const cosTheta = Math.max(-1, Math.min(1, toPrev.dot(toNext)));
|
||
const halfAngle = Math.acos(Math.sqrt(Math.max(0, (1 + cosTheta) / 2)));
|
||
const sinH = Math.sin(halfAngle) || Math.SQRT1_2;
|
||
const centre = curr.clone().addScaledVector(bis, r / sinH);
|
||
const v0 = enter.clone().sub(centre);
|
||
const v1 = exit.clone().sub(centre);
|
||
const a0 = Math.atan2(v0.z, v0.x);
|
||
let a1 = Math.atan2(v1.z, v1.x);
|
||
// Sweep the short way (≤ 180°).
|
||
while (a1 - a0 > Math.PI) a1 -= Math.PI * 2;
|
||
while (a1 - a0 < -Math.PI) a1 += Math.PI * 2;
|
||
const steps = 10;
|
||
for (let k = 1; k < steps; k++) {
|
||
const ang = a0 + (a1 - a0) * (k / steps);
|
||
pts.push(new THREE.Vector3(
|
||
centre.x + Math.cos(ang) * r,
|
||
0,
|
||
centre.z + Math.sin(ang) * r
|
||
));
|
||
}
|
||
pts.push(exit);
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
/**
|
||
* Speed cap approaching a corner so the turn angle looks natural.
|
||
*/
|
||
function cornerSpeedCap(kind, distToCorner, maxSpeed) {
|
||
if (distToCorner > 10) return maxSpeed;
|
||
const kindScale = kind === 'bike' ? 0.55 : kind === 'moto' ? 0.75 : kind === 'bus' ? 0.55 : 0.7;
|
||
const base = Math.max(1.6, maxSpeed * kindScale);
|
||
// Ease in: full cap when very close, blend out by 10 units.
|
||
const t = Math.min(1, Math.max(0, (10 - distToCorner) / 10));
|
||
return maxSpeed - (maxSpeed - base) * t;
|
||
}
|
||
|
||
function updateLoopVehicle(v, dt) {
|
||
const probe = probeAhead(v.pts, v.s, v.L);
|
||
const cur = new THREE.Vector3();
|
||
posOnPath(v.pts, v.s, v.L, cur);
|
||
const distToStop = distanceToStopLine(probe, cur);
|
||
const committed = distToStop < 0; // already past the stop line
|
||
const distForLogic = committed ? 999 : distToStop;
|
||
let wantStop = false;
|
||
|
||
// Stop line: keep well before the intersection so the nose never
|
||
// enters the crossing box while the light is red/yellow.
|
||
const stopLine = 9.5;
|
||
const stopAt = 4.5; // never brake once closer than this — commit to crossing
|
||
|
||
const travelAxis = (() => {
|
||
const dx = probe.segEnd.x - probe.segStart.x;
|
||
const dz = probe.segEnd.z - probe.segStart.z;
|
||
return Math.abs(dx) >= Math.abs(dz) ? 'x' : 'z';
|
||
})();
|
||
|
||
if (trafficSystem && !committed && distForLogic < stopLine) {
|
||
const state = trafficSystem.axisState(travelAxis);
|
||
if (state === 'red') {
|
||
// Always stop before the line — never enter the box on red,
|
||
// regardless of how close we already are.
|
||
wantStop = true;
|
||
} else if (state === 'yellow') {
|
||
// Brake for yellow unless already about to clear the box.
|
||
wantStop = distForLogic > 6;
|
||
}
|
||
}
|
||
|
||
const gap = vehicleAheadGap(v);
|
||
// Same-loop following only (path distance) — keep a comfortable buffer.
|
||
if (gap < 6.5 && !committed) wantStop = true;
|
||
if (gap < 4.5 && committed) wantStop = v.speed < 2;
|
||
|
||
// Hard overlap: brake into the vehicle ahead of us AND nudge the
|
||
// pair apart so perpendicular clips dissolve in a frame or two.
|
||
const sep = vehicleSeparation(v);
|
||
if (sep && sep.type === 'overlap') {
|
||
// Brake into the vehicle ahead; if the overlap is severe also
|
||
// nudge ourselves back along the path so bodies separate.
|
||
if (sep.along > 0.5) {
|
||
v.speed = Math.max(0, v.speed - 28 * dt);
|
||
if (v.speed < 0.3) v.speed = 0;
|
||
} else if (sep.along < -0.5) {
|
||
// Their nose is behind ours — keep going to pull away.
|
||
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
||
} else {
|
||
v.speed = Math.max(0, v.speed - 18 * dt);
|
||
}
|
||
const o = getVehiclePose(sep.other);
|
||
const dx = o.x - v.mesh.position.x;
|
||
const dz = o.z - v.mesh.position.z;
|
||
if (dx * dx + dz * dz < 2.2) {
|
||
v.s = (v.s - 0.5 + v.L) % v.L;
|
||
}
|
||
}
|
||
|
||
if (wantStop) {
|
||
v.speed = Math.max(0, v.speed - v.decel * dt);
|
||
// Clamp exactly at the stop line — never roll into the junction.
|
||
if (distForLogic <= 0.35 && v.speed < 0.8) v.speed = 0;
|
||
if (distForLogic <= 0 && v.speed > 0) v.speed = 0;
|
||
} else if (!committed) {
|
||
const cap = cornerSpeedCap(v.kind, distForLogic, v.maxSpeed);
|
||
v.speed = Math.min(cap, v.speed + v.accel * dt);
|
||
} else {
|
||
// Inside the box: keep momentum to clear it.
|
||
v.speed = Math.min(v.maxSpeed, v.speed + v.accel * dt);
|
||
}
|
||
|
||
// Extra safety: if somehow still inside the stop zone on red, freeze.
|
||
if (trafficSystem && !committed && distForLogic >= 0
|
||
&& trafficSystem.axisState(travelAxis) === 'red'
|
||
&& distForLogic < 1.2) {
|
||
v.speed = 0;
|
||
}
|
||
|
||
v.s = (v.s + v.speed * dt) % v.L;
|
||
const pos = new THREE.Vector3();
|
||
const tmp = new THREE.Vector3();
|
||
posOnPath(v.pts, v.s, v.L, pos);
|
||
// Constant look-ahead so the heading never oscillates with speed.
|
||
const lookAhead = 1.1;
|
||
posOnPath(v.pts, v.s + lookAhead, v.L, tmp);
|
||
v.mesh.position.copy(pos);
|
||
if (tmp.distanceToSquared(pos) > 0.0001) {
|
||
const targetYaw = yawFromDelta(tmp.x - pos.x, tmp.z - pos.z);
|
||
const curYaw = v.mesh.rotation.y;
|
||
let delta = targetYaw - curYaw;
|
||
while (delta > Math.PI) delta -= Math.PI * 2;
|
||
while (delta < -Math.PI) delta += Math.PI * 2;
|
||
// Exponential damp toward the target; snap when close so the
|
||
// body settles instead of hunting (which caused the zig-zag).
|
||
const turnRate = v.kind === 'bike' ? 9 : v.kind === 'moto' ? 7 : 6;
|
||
const t = Math.min(1, turnRate * dt);
|
||
const yawStep = Math.abs(delta) < 0.01 ? delta : delta * t;
|
||
v.mesh.rotation.y = curYaw + yawStep;
|
||
if (v.kind === 'bike' || v.kind === 'moto') {
|
||
v.mesh.rotation.z = -yawStep * 0.85;
|
||
}
|
||
const wheels = v.mesh.userData.wheels || [];
|
||
// Front-axle steering follows the yaw rate, damped so it settles.
|
||
const steer = Math.max(-0.45, Math.min(0.45, yawStep * 4));
|
||
if (wheels[0]) wheels[0].rotation.y += (steer - wheels[0].rotation.y) * Math.min(1, 10 * dt);
|
||
if (wheels[1]) wheels[1].rotation.y += (steer - wheels[1].rotation.y) * Math.min(1, 10 * dt);
|
||
}
|
||
const wheelSpin = v.speed * dt * 3;
|
||
for (const w of v.mesh.userData.wheels || []) w.rotation.z += wheelSpin;
|
||
}
|
||
|
||
function updateShuttleVehicle(v, dt) {
|
||
const trainNear = trainSystem && trainSystem.blocksCrossing();
|
||
const distToEnd = v.dir > 0 ? (1 - v.t) : v.t;
|
||
let wantStop = false;
|
||
|
||
if (v.axis === 'z' && trainNear) {
|
||
const total = v.a.distanceTo(v.b);
|
||
const stopT = (trainSystem.crossZ - v.a.z) / (v.b.z - v.a.z);
|
||
const stopPos = v.a.z + (stopT - 0.12) * (v.b.z - v.a.z);
|
||
const curZ = v.a.z + v.t * (v.b.z - v.a.z);
|
||
if ((v.dir > 0 && curZ < stopPos && stopPos - curZ < 14) ||
|
||
(v.dir < 0 && curZ > stopPos && curZ - stopPos < 14)) {
|
||
wantStop = true;
|
||
}
|
||
void total;
|
||
}
|
||
|
||
// Avoid driving through other vehicles on bridges / crossing.
|
||
const sep = vehicleSeparation(v);
|
||
if (sep && sep.type === 'overlap' && sep.along > 0.8) {
|
||
v.speed = Math.max(0, v.speed - 22 * dt);
|
||
} else if (wantStop) {
|
||
v.speed = Math.max(0, v.speed - 10 * dt);
|
||
} else {
|
||
v.speed = Math.min(v.maxSpeed, v.speed + 5 * dt);
|
||
}
|
||
|
||
if (distToEnd < 0.03) {
|
||
v.dir *= -1;
|
||
v.speed = 0;
|
||
}
|
||
|
||
v.t += v.dir * (v.speed * dt) / v.a.distanceTo(v.b);
|
||
v.t = Math.max(0, Math.min(1, v.t));
|
||
|
||
const pos = new THREE.Vector3().lerpVectors(v.a, v.b, v.t);
|
||
v.mesh.position.copy(pos);
|
||
const look = new THREE.Vector3().lerpVectors(v.a, v.b, Math.max(0, Math.min(1, v.t + v.dir * 0.05)));
|
||
if (look.distanceToSquared(pos) > 0.0001) {
|
||
v.mesh.rotation.y = yawFromDelta(look.x - pos.x, look.z - pos.z);
|
||
}
|
||
for (const w of v.mesh.userData.wheels || []) w.rotation.z += v.speed * dt * 3;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Humanoid builder
|
||
// ------------------------------------------------------------------
|
||
function buildHumanoid(agent, showLabel = true) {
|
||
const g = new THREE.Group();
|
||
const type = agent.type || 'pedestrian';
|
||
const configs = {
|
||
pedestrian: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x556b8a, pants: 0x2c2c2c, scale: 1 },
|
||
shopkeeper: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xf5e6d3, pants: 0x4a3728, scale: 1 },
|
||
mayor: { skin: 0xe0ac69, hair: 0x555555, shirt: 0x1a1a2e, pants: 0x111111, scale: 1.05 },
|
||
traffic_officer: { skin: 0xd2956b, hair: 0x1a1a1a, shirt: 0x2a4a2a, pants: 0x1a1a1a, scale: 1 },
|
||
crowd: { skin: 0xf1c27d, hair: 0x2a1a0a, shirt: [0x8a4a4a, 0x4a6a8a, 0x5a7a4a, 0x7a5a8a, 0x8a7a4a][Math.floor(Math.random() * 5)], pants: 0x333333, scale: 1 },
|
||
// Beach crowd: swimwear palette; lifeguards wear classic red/yellow.
|
||
bather: { skin: 0xf1c27d, hair: [0x2a1a0a, 0x553311, 0x111111, 0x886644][Math.floor(Math.random() * 4)], shirt: [0xd9534f, 0x5bc0de, 0xf0ad4e, 0x8e44ad, 0x2ecc71][Math.floor(Math.random() * 5)], pants: [0x2c5f8a, 0xc0392b, 0x111111][Math.floor(Math.random() * 3)], scale: 1 },
|
||
lifeguard: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0xd92b2b, pants: 0xf2e14c, scale: 1 },
|
||
surfer: { skin: 0xf1c27d, hair: 0x3b2314, shirt: 0x3fb6c9, pants: 0x1c1c1c, scale: 1 },
|
||
basket: { skin: 0xd2956b, hair: 0x111111, shirt: 0xe8802a, pants: 0xf2f2f2, scale: 1 },
|
||
atc: { skin: 0xe0ac69, hair: 0x1a1a1a, shirt: 0x223366, pants: 0x0f1420, scale: 1.02 },
|
||
// Humanoid worker robots: brushed metal body + safety-orange vest.
|
||
worker_robot: { skin: 0xc9d1d9, hair: 0x9aa4b2, shirt: 0xd97706, pants: 0x47515e, scale: 1, metalness: 0.5 },
|
||
};
|
||
const cfgc = configs[type] || configs.pedestrian;
|
||
const s = cfgc.scale;
|
||
const metalness = cfgc.metalness ?? 0;
|
||
|
||
const skinMat = new THREE.MeshStandardMaterial({ color: cfgc.skin, roughness: 0.7 - metalness * 0.4, metalness });
|
||
const hairMat = new THREE.MeshStandardMaterial({ color: cfgc.hair, roughness: 0.6, metalness });
|
||
const shirtMat = new THREE.MeshStandardMaterial({ color: cfgc.shirt, roughness: 0.8 - metalness * 0.3, metalness });
|
||
const pantsMat = new THREE.MeshStandardMaterial({ color: cfgc.pants, roughness: 0.85 - metalness * 0.3, metalness });
|
||
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);
|
||
|
||
if (type === 'worker_robot') {
|
||
// Glowing visor across the face.
|
||
const visor = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.34 * s, 0.1 * s, 0.06 * s),
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0x0e7490, emissive: 0x22d3ee, emissiveIntensity: 1.5, roughness: 0.3,
|
||
})
|
||
);
|
||
visor.position.set(0, 1.63 * s, 0.255 * s);
|
||
g.add(visor);
|
||
|
||
// Antenna with a blinking beacon (ramps/pulses with the sign system).
|
||
const antennaRod = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.014, 0.02, 0.24, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0x6b7280, roughness: 0.5, metalness: 0.7 })
|
||
);
|
||
antennaRod.position.set(0, 1.97 * s, 0);
|
||
g.add(antennaRod);
|
||
const beacon = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.045, 8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
|
||
);
|
||
beacon.position.set(0, 2.1 * s, 0);
|
||
g.add(beacon);
|
||
signMaterials.push({
|
||
mat: beacon.material, base: 1.2, nightBoost: 1.0, pulse: 0.9,
|
||
phase: Math.random() * Math.PI * 2,
|
||
});
|
||
|
||
// Chest status light (amber, softer pulse).
|
||
const chest = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.15, 0.11, 0.05),
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0x78350f, emissive: 0xf59e0b, emissiveIntensity: 1.1, roughness: 0.4,
|
||
})
|
||
);
|
||
chest.position.set(0, 1.2 * s, 0.21 * s);
|
||
g.add(chest);
|
||
signMaterials.push({
|
||
mat: chest.material, base: 0.8, nightBoost: 0.7, pulse: 0.35,
|
||
phase: Math.random() * Math.PI * 2,
|
||
});
|
||
}
|
||
|
||
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' || a.type === 'worker_robot');
|
||
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,
|
||
robot: a.type === 'worker_robot',
|
||
};
|
||
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;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Luminous signs (letreiros): glowing boards for plazas, the beach,
|
||
// airports, buildings and stores. Emissive glow ramps up at night via
|
||
// updateVehicleHeadlights.
|
||
// ------------------------------------------------------------------
|
||
|
||
/**
|
||
* Shrink a font until the text fits the given canvas width.
|
||
*
|
||
* @returns {number} the final font size in px
|
||
*/
|
||
function fitSignText(ctx, text, maxWidth, startSize, weight = 'bold') {
|
||
let size = startSize;
|
||
ctx.font = `${weight} ${size}px sans-serif`;
|
||
while (ctx.measureText(text).width > maxWidth && size > 12) {
|
||
size -= 2;
|
||
ctx.font = `${weight} ${size}px sans-serif`;
|
||
}
|
||
return size;
|
||
}
|
||
|
||
/**
|
||
* Board rotation.y so a front facing local +Z points toward (dx, dz).
|
||
*/
|
||
function rotationToward(dx, dz) {
|
||
if (Math.abs(dx) > Math.abs(dz)) {
|
||
return dx > 0 ? Math.PI / 2 : -Math.PI / 2;
|
||
}
|
||
return dz > 0 ? 0 : Math.PI;
|
||
}
|
||
|
||
/**
|
||
* Rotation that faces the nearest road grid line (board front +Z).
|
||
*/
|
||
function faceNearestRoad(x, z, g) {
|
||
if (!g) return 0;
|
||
let bestD = Infinity;
|
||
let dir = [0, 1];
|
||
for (const vx of g.xs) {
|
||
const d = Math.abs(x - vx);
|
||
if (d < bestD) {
|
||
bestD = d;
|
||
dir = [vx - x, 0];
|
||
}
|
||
}
|
||
for (const vz of g.zs) {
|
||
const d = Math.abs(z - vz);
|
||
if (d < bestD) {
|
||
bestD = d;
|
||
dir = [0, vz - z];
|
||
}
|
||
}
|
||
return rotationToward(dir[0], dir[1]);
|
||
}
|
||
|
||
/**
|
||
* Wall offset pushing a roof/facade sign out of the building volume
|
||
* for a given facing rotation.
|
||
*
|
||
* @return {[number, number]} [ox, oz]
|
||
*/
|
||
function facingOffset(rot, halfW, halfD) {
|
||
if (Math.abs(rot - 0) < 0.01 || Math.abs(Math.abs(rot) - Math.PI) < 0.01) {
|
||
return [0, rot === 0 ? halfD : -halfD];
|
||
}
|
||
return [rot > 0 ? halfW : -halfW, 0];
|
||
}
|
||
|
||
/**
|
||
* Build a luminous text sign (or a decorative neon strip when
|
||
* opts.decor is set — no text, no i18n needed).
|
||
*
|
||
* opts: {w, h, vertical, accent, bg, posts: 'double'|'arch', postH,
|
||
* decor, y} — returns the display node (plane or group);
|
||
* callers set .position / .rotation.
|
||
*/
|
||
function buildLitSign(text, opts = {}) {
|
||
const vertical = !!opts.vertical;
|
||
const decor = !!opts.decor;
|
||
const w = opts.w ?? (vertical ? 1.4 : 6);
|
||
const h = opts.h ?? (vertical ? 4.6 : 1.6);
|
||
const accent = opts.accent ?? '#38bdf8';
|
||
const bg = opts.bg ?? '#0c1220';
|
||
|
||
const c = document.createElement('canvas');
|
||
c.width = vertical ? 160 : 512;
|
||
c.height = vertical ? 640 : 128;
|
||
const ctx = c.getContext('2d');
|
||
|
||
// Backplate + accent border.
|
||
const grad = ctx.createLinearGradient(0, 0, c.width, c.height);
|
||
grad.addColorStop(0, bg);
|
||
grad.addColorStop(1, '#151c2c');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, c.width, c.height);
|
||
ctx.strokeStyle = accent;
|
||
ctx.lineWidth = 8;
|
||
ctx.strokeRect(6, 6, c.width - 12, c.height - 12);
|
||
|
||
if (decor) {
|
||
// Abstract glowing tubes — no letters, works in every locale.
|
||
ctx.strokeStyle = accent;
|
||
ctx.lineWidth = 14;
|
||
ctx.lineCap = 'round';
|
||
ctx.shadowColor = accent;
|
||
ctx.shadowBlur = 22;
|
||
ctx.beginPath();
|
||
const steps = 5;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const px = 40 + (i * (c.width - 80)) / steps;
|
||
const py = c.height / 2 + ((i % 2 ? -1 : 1) * (c.height - 60)) / 3;
|
||
if (i === 0) ctx.moveTo(px, py);
|
||
else ctx.lineTo(px, py);
|
||
}
|
||
ctx.stroke();
|
||
for (const px of [34, c.width - 34]) {
|
||
ctx.fillStyle = accent;
|
||
ctx.beginPath();
|
||
ctx.arc(px, c.height / 2, 12, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
} else {
|
||
ctx.fillStyle = '#f8fafc';
|
||
ctx.textAlign = 'center';
|
||
ctx.shadowColor = accent;
|
||
ctx.shadowBlur = 18;
|
||
if (vertical) {
|
||
// Stacked upright letters (classic blade sign).
|
||
const chars = [...text];
|
||
const size = Math.min(84, Math.floor((c.height - 40) / Math.max(1, chars.length)) + 6);
|
||
ctx.font = `bold ${size}px sans-serif`;
|
||
ctx.textBaseline = 'middle';
|
||
const step = (c.height - 30) / Math.max(1, chars.length);
|
||
chars.forEach((ch, i) => {
|
||
ctx.fillText(ch, c.width / 2, 15 + step * (i + 0.5));
|
||
});
|
||
} else {
|
||
fitSignText(ctx, text, c.width - 48, 56);
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, c.width / 2, c.height / 2 + 2, c.width - 40);
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
}
|
||
|
||
const tex = new THREE.CanvasTexture(c);
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
map: tex,
|
||
emissiveMap: tex,
|
||
emissive: 0xffffff,
|
||
emissiveIntensity: 0.6,
|
||
roughness: 0.55,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
signMaterials.push({
|
||
mat,
|
||
base: 0.5,
|
||
nightBoost: decor ? 1.5 : 1.2,
|
||
pulse: decor ? 0.16 : 0.08,
|
||
phase: Math.random() * Math.PI * 2,
|
||
});
|
||
|
||
const board = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||
|
||
if (!opts.posts) {
|
||
return board;
|
||
}
|
||
|
||
const group = new THREE.Group();
|
||
const postMat = new THREE.MeshStandardMaterial({ color: 0x3a4050, roughness: 0.6, metalness: 0.4 });
|
||
const postH = opts.postH ?? 2.4;
|
||
const postXs = opts.posts === 'arch'
|
||
? [-w / 2, w / 2]
|
||
: [-w * 0.32, w * 0.32];
|
||
for (const px of postXs) {
|
||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.13, postH, 8), postMat);
|
||
post.position.set(px, postH / 2, 0);
|
||
post.castShadow = true;
|
||
group.add(post);
|
||
}
|
||
board.position.y = postH + h / 2 - 0.05;
|
||
board.castShadow = true;
|
||
group.add(board);
|
||
return group;
|
||
}
|
||
|
||
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
|
||
// ------------------------------------------------------------------
|
||
// ------------------------------------------------------------------
|
||
// Airports: airline liveries, aircraft models, ground build, operations
|
||
// ------------------------------------------------------------------
|
||
const AIRLINE_LIVERIES = [
|
||
{ name: 'City10 Air', accent: 0x1f6fb2 },
|
||
{ name: 'AzulCéu', accent: 0x2fb6c9 },
|
||
{ name: 'Bandeirante', accent: 0x3f9d4f },
|
||
{ name: 'Sol Nascente', accent: 0xe08a1e },
|
||
{ name: 'Pampa Air', accent: 0xc0392b },
|
||
{ name: 'NorteLine', accent: 0x2c3e6b },
|
||
{ name: 'City10 Heli', accent: 0x7a5aa8 },
|
||
];
|
||
|
||
/** Runway marking overlay: centerline, threshold keys, designators. */
|
||
function runwayMarkings(designators) {
|
||
return canvasTexture(1024, (ctx, s) => {
|
||
ctx.clearRect(0, 0, s, s);
|
||
ctx.fillStyle = '#f2f2f2';
|
||
// Centerline dashes along the length (u axis).
|
||
for (let x = 110; x < s - 110; x += 64) {
|
||
ctx.fillRect(x, s / 2 - 5, 36, 10);
|
||
}
|
||
// Threshold "piano keys": bars parallel to travel at both ends.
|
||
const slot = (s - 96) / 6;
|
||
for (const end of [0, 1]) {
|
||
const baseX = end ? s - 56 : 14;
|
||
for (let i = 0; i < 6; i++) {
|
||
ctx.fillRect(baseX, 48 + i * slot, 42, slot - 16);
|
||
}
|
||
// Designator near the threshold, reading along travel.
|
||
ctx.save();
|
||
ctx.translate(end ? s - 130 : 130, s / 2);
|
||
ctx.rotate(-Math.PI / 2);
|
||
ctx.font = 'bold 72px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(end ? designators[1] : designators[0], 0, 0);
|
||
ctx.restore();
|
||
}
|
||
}, 1, 1);
|
||
}
|
||
|
||
/**
|
||
* Fixed-wing aircraft: airliner, regional jet or turboprop with the
|
||
* airline livery (accent tail/cone/engine inlets). Nose points +X.
|
||
*
|
||
* @param {'airliner'|'regional'|'prop'} kind
|
||
* @param {{name: string, accent: number}} livery
|
||
*/
|
||
function buildAircraft(kind, livery) {
|
||
const g = new THREE.Group();
|
||
const dims = {
|
||
// clearance: fuselage centreline height — the wheels (bottom at
|
||
// y≈0) carry the airframe above the pavement instead of dragging.
|
||
airliner: { L: 16, r: 0.95, span: 15, chord: 2.6, tail: 3.6, fin: 2.8, engines: true, clearance: 1.75, wheel: 0.34 },
|
||
regional: { L: 12, r: 0.75, span: 11.6, chord: 2.1, tail: 2.8, fin: 2.2, engines: true, clearance: 1.4, wheel: 0.3 },
|
||
prop: { L: 9.5, r: 0.65, span: 12, chord: 1.9, tail: 2.4, fin: 1.9, engines: false, prop: true, blade: 2.6, clearance: 1.5, wheel: 0.28 },
|
||
};
|
||
const d = dims[kind] || dims.airliner;
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf3f5f8, roughness: 0.35, metalness: 0.45 });
|
||
const accentMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 });
|
||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.55 });
|
||
|
||
const fus = new THREE.Mesh(new THREE.CylinderGeometry(d.r, d.r, d.L, 14), bodyMat);
|
||
fus.rotation.z = Math.PI / 2;
|
||
g.add(fus);
|
||
|
||
const noseLen = d.L * 0.14;
|
||
const nose = new THREE.Mesh(new THREE.ConeGeometry(d.r, noseLen, 14), bodyMat);
|
||
nose.rotation.z = -Math.PI / 2;
|
||
nose.position.x = d.L / 2 + noseLen / 2 - 0.05;
|
||
g.add(nose);
|
||
|
||
const tailLen = d.L * 0.13;
|
||
const tailCone = new THREE.Mesh(new THREE.ConeGeometry(d.r * 0.92, tailLen, 14), accentMat);
|
||
tailCone.rotation.z = Math.PI / 2;
|
||
tailCone.position.x = -d.L / 2 - tailLen / 2 + 0.05;
|
||
g.add(tailCone);
|
||
|
||
const wing = new THREE.Mesh(new THREE.BoxGeometry(d.chord, 0.18, d.span), bodyMat);
|
||
wing.position.set(d.L * 0.04, -d.r * 0.3, 0);
|
||
g.add(wing);
|
||
|
||
const tailWing = new THREE.Mesh(new THREE.BoxGeometry(d.chord * 0.6, 0.14, d.span * 0.34), bodyMat);
|
||
tailWing.position.set(-d.L / 2 + d.tail * 0.45, d.r * 0.3, 0);
|
||
g.add(tailWing);
|
||
|
||
const fin = new THREE.Mesh(new THREE.BoxGeometry(d.tail, d.fin, 0.16), accentMat);
|
||
fin.position.set(-d.L / 2 + d.tail * 0.5, d.fin / 2 + d.r * 0.5, 0);
|
||
g.add(fin);
|
||
|
||
for (const s of [1, -1]) {
|
||
const win = new THREE.Mesh(new THREE.BoxGeometry(d.L * 0.5, 0.16, 0.05), darkMat);
|
||
win.position.set(d.L * 0.06, d.r * 0.35, s * (d.r - 0.02));
|
||
g.add(win);
|
||
}
|
||
|
||
if (d.engines) {
|
||
for (const s of [1, -1]) {
|
||
const eng = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.55, 2.6, 12), darkMat);
|
||
eng.rotation.z = Math.PI / 2;
|
||
eng.position.set(d.L * 0.02, -d.r * 0.3 - 0.5, s * d.span * 0.3);
|
||
g.add(eng);
|
||
const inlet = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.42, 0.25, 12), accentMat);
|
||
inlet.rotation.z = Math.PI / 2;
|
||
inlet.position.set(d.L * 0.02 + 1.35, -d.r * 0.3 - 0.5, s * d.span * 0.3);
|
||
g.add(inlet);
|
||
}
|
||
}
|
||
|
||
if (d.prop) {
|
||
const prop = new THREE.Group();
|
||
const bladeLen = d.blade ?? 3.4;
|
||
for (let i = 0; i < 3; i++) {
|
||
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.12, bladeLen, 0.16), darkMat);
|
||
blade.rotation.x = (i * Math.PI * 2) / 3;
|
||
prop.add(blade);
|
||
}
|
||
prop.position.x = d.L / 2 + noseLen;
|
||
g.add(prop);
|
||
g.userData.prop = prop;
|
||
}
|
||
|
||
// Cockpit glass at the nose (rider sits above the nose gear).
|
||
const glassMat = new THREE.MeshStandardMaterial({ color: 0x0e1620, roughness: 0.2, metalness: 0.8 });
|
||
const cockpit = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.4, 0.62), glassMat);
|
||
cockpit.position.set(d.L * 0.44, d.r * 0.5, 0);
|
||
g.add(cockpit);
|
||
|
||
// Winglets at the tips (airline livery accent).
|
||
for (const side of [1, -1]) {
|
||
const winglet = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.55, 0.12), accentMat);
|
||
winglet.position.set(d.L * 0.04, 0.1, side * (d.span / 2));
|
||
g.add(winglet);
|
||
}
|
||
|
||
const beacon = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.2, 8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
|
||
);
|
||
beacon.position.set(d.L * 0.08, d.r + 0.3, 0);
|
||
g.add(beacon);
|
||
g.userData.beacon = beacon;
|
||
|
||
// Ride on the landing gear: lift the whole airframe so the wheels
|
||
// (added next, ground-based) carry it above the pavement.
|
||
for (const child of g.children) {
|
||
child.position.y += d.clearance;
|
||
}
|
||
|
||
// ---- Landing gear: nose leg + two mains; wheel bottoms touch y=0 ----
|
||
const gearMat = new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.45, metalness: 0.6 });
|
||
const addGear = (gx, gz, wr, strutR) => {
|
||
const strutBottom = wr * 0.5;
|
||
const top = d.clearance - d.r * 0.35;
|
||
const strutH = top - strutBottom;
|
||
const strut = new THREE.Mesh(new THREE.CylinderGeometry(strutR, strutR * 1.2, strutH, 8), gearMat);
|
||
strut.position.set(gx, strutBottom + strutH / 2, gz);
|
||
g.add(strut);
|
||
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(wr, wr, wr * 0.75, 12), gearMat);
|
||
wheel.rotation.x = Math.PI / 2; // axle along Z — rolls along the runway
|
||
// Centre at half the tyre thickness → the bottom sits exactly on y=0.
|
||
wheel.position.set(gx, wr * 0.375, gz);
|
||
g.add(wheel);
|
||
};
|
||
addGear(d.L * 0.4, 0, d.wheel * 0.85, 0.06); // nose gear
|
||
addGear(d.L * 0.06, d.span * 0.2, d.wheel, 0.085); // main right
|
||
addGear(d.L * 0.06, -d.span * 0.2, d.wheel, 0.085); // main left
|
||
|
||
g.userData.kind = kind;
|
||
g.userData.gear = { kind: 'wheels', clearance: d.clearance };
|
||
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
return g;
|
||
}
|
||
|
||
/**
|
||
* Helicopter with spinning main/tail rotors and skids (livery accent).
|
||
*
|
||
* @param {{name: string, accent: number}} livery
|
||
*/
|
||
function buildHelicopter(livery) {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: livery.accent, roughness: 0.4, metalness: 0.4 });
|
||
const trimMat = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.4, metalness: 0.3 });
|
||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1c2026, roughness: 0.5 });
|
||
|
||
const cabin = new THREE.Mesh(new THREE.CapsuleGeometry(0.95, 2.4, 6, 12), bodyMat);
|
||
cabin.rotation.z = Math.PI / 2;
|
||
g.add(cabin);
|
||
|
||
const bubble = new THREE.Mesh(new THREE.SphereGeometry(0.85, 14, 12), trimMat);
|
||
bubble.position.set(1.9, 0.05, 0);
|
||
g.add(bubble);
|
||
|
||
const boom = new THREE.Mesh(new THREE.BoxGeometry(4.6, 0.34, 0.34), bodyMat);
|
||
boom.position.set(-3.4, 0.35, 0);
|
||
g.add(boom);
|
||
|
||
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.9, 1.2, 0.14), bodyMat);
|
||
fin.position.set(-5.5, 0.95, 0);
|
||
g.add(fin);
|
||
|
||
const tailRotor = new THREE.Group();
|
||
for (let i = 0; i < 2; i++) {
|
||
const blade = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.14, 0.08), darkMat);
|
||
blade.rotation.z = (i * Math.PI) / 2;
|
||
tailRotor.add(blade);
|
||
}
|
||
tailRotor.position.set(-5.7, 0.95, 0.22);
|
||
g.add(tailRotor);
|
||
g.userData.tailRotor = tailRotor;
|
||
|
||
for (const s of [1, -1]) {
|
||
const skid = new THREE.Mesh(new THREE.BoxGeometry(3.8, 0.1, 0.12), darkMat);
|
||
skid.position.set(0.2, -1.35, s * 0.85);
|
||
g.add(skid);
|
||
for (const bx of [-1, 1.2]) {
|
||
const strut = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.75, 0.1), darkMat);
|
||
strut.position.set(bx, -0.95, s * 0.85);
|
||
g.add(strut);
|
||
}
|
||
}
|
||
|
||
const mastCol = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.8, 8), darkMat);
|
||
mastCol.position.y = 1.0;
|
||
g.add(mastCol);
|
||
|
||
const rotor = new THREE.Group();
|
||
for (let i = 0; i < 4; i++) {
|
||
const blade = new THREE.Mesh(new THREE.BoxGeometry(9, 0.08, 0.5), darkMat);
|
||
blade.rotation.y = (i * Math.PI) / 2;
|
||
rotor.add(blade);
|
||
}
|
||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.4, 10), trimMat);
|
||
rotor.add(hub);
|
||
rotor.position.y = 1.35;
|
||
g.add(rotor);
|
||
g.userData.rotor = rotor;
|
||
|
||
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, 2.0, 0);
|
||
g.add(beacon);
|
||
g.userData.beacon = beacon;
|
||
|
||
// Ride on the skids: lift the fuselage so the skid tubes rest on y=0.
|
||
for (const child of g.children) {
|
||
child.position.y += 1.4;
|
||
}
|
||
g.userData.gear = { kind: 'skids', clearance: 1.4 };
|
||
g.userData.kind = 'helicopter';
|
||
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
return g;
|
||
}
|
||
|
||
/** Helipad marking (white circle + H) on transparent ground. */
|
||
function helipadTexture() {
|
||
return canvasTexture(256, (ctx, s) => {
|
||
ctx.clearRect(0, 0, s, s);
|
||
ctx.strokeStyle = '#f2f2f2';
|
||
ctx.lineWidth = 14;
|
||
ctx.beginPath();
|
||
ctx.arc(s / 2, s / 2, s / 2 - 20, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#f2f2f2';
|
||
ctx.font = 'bold 110px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('H', s / 2, s / 2 + 6);
|
||
}, 1, 1);
|
||
}
|
||
|
||
/**
|
||
* Build both airports (pavement, markings, terminal, hangars, tower,
|
||
* stands, helipads) from the payload geometry. Adds colliders for the
|
||
* buildings so the player cannot walk through them.
|
||
*
|
||
* @param {{visit: object, sites: Array<object>}} cfg
|
||
*/
|
||
function buildAirports(cfg) {
|
||
if (!cfg?.sites?.length) return;
|
||
|
||
const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x2b2d31, roughness: 0.92 });
|
||
const taxiMat = new THREE.MeshStandardMaterial({ color: 0x34373c, roughness: 0.92 });
|
||
const apronMat = new THREE.MeshStandardMaterial({ color: 0x50555c, roughness: 0.95 });
|
||
const centerLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 });
|
||
const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 });
|
||
const hangarBodyMat = new THREE.MeshStandardMaterial({ color: 0xd8dade, roughness: 0.7, metalness: 0.3 });
|
||
const hangarDoorMat = new THREE.MeshStandardMaterial({ color: 0x4a5058, roughness: 0.6, metalness: 0.4 });
|
||
const hangarRoofMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.6, metalness: 0.3 });
|
||
const towerBaseMat = new THREE.MeshStandardMaterial({ color: 0xcfd3d8, roughness: 0.7 });
|
||
const towerGlassMat = new THREE.MeshStandardMaterial({ color: 0x1d2a38, roughness: 0.25, metalness: 0.6 });
|
||
const bridgeMat = new THREE.MeshStandardMaterial({ color: 0xb9bec6, roughness: 0.7 });
|
||
const helipadMat = new THREE.MeshStandardMaterial({
|
||
map: helipadTexture(), transparent: true, alphaTest: 0.3, roughness: 0.9,
|
||
});
|
||
|
||
const addFlat = (cx, cz, w, h, mat, y) => {
|
||
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
|
||
m.rotation.x = -Math.PI / 2;
|
||
m.position.set(cx, y, cz);
|
||
m.receiveShadow = true;
|
||
worldGroup.add(m);
|
||
return m;
|
||
};
|
||
|
||
for (const site of cfg.sites) {
|
||
// Runway + markings.
|
||
const rw = site.runway;
|
||
const rwAlongX = rw.axis === 'x';
|
||
addFlat(rw.center[0], rw.center[1],
|
||
rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length, asphaltMat, 0.03);
|
||
const marks = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(rwAlongX ? rw.length : rw.width, rwAlongX ? rw.width : rw.length),
|
||
new THREE.MeshStandardMaterial({
|
||
map: runwayMarkings(rw.designators), transparent: true, alphaTest: 0.3, roughness: 0.85,
|
||
})
|
||
);
|
||
marks.rotation.x = -Math.PI / 2;
|
||
marks.position.set(rw.center[0], 0.045, rw.center[1]);
|
||
worldGroup.add(marks);
|
||
|
||
// Taxiways + yellow centerlines.
|
||
for (const tw of site.taxiways) {
|
||
const alongX = tw.axis === 'x';
|
||
addFlat(tw.center[0], tw.center[1],
|
||
alongX ? tw.length : tw.width, alongX ? tw.width : tw.length, taxiMat, 0.03);
|
||
const line = new THREE.Mesh(
|
||
alongX ? new THREE.BoxGeometry(tw.length, 0.02, 0.16) : new THREE.BoxGeometry(0.16, 0.02, tw.length),
|
||
centerLineMat
|
||
);
|
||
line.position.set(tw.center[0], 0.05, tw.center[1]);
|
||
worldGroup.add(line);
|
||
}
|
||
|
||
// Apron + stand markings (aircraft parking positions).
|
||
addFlat(site.apron.center[0], site.apron.center[1], site.apron.size[0], site.apron.size[1], apronMat, 0.03);
|
||
for (const [sx, sz] of site.stands) {
|
||
const lead = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.02, 7), whiteLineMat);
|
||
lead.position.set(sx, 0.05, sz - 5);
|
||
worldGroup.add(lead);
|
||
const stopBar = new THREE.Mesh(new THREE.BoxGeometry(4.5, 0.02, 0.25), whiteLineMat);
|
||
stopBar.position.set(sx, 0.05, sz - 8.2);
|
||
worldGroup.add(stopBar);
|
||
}
|
||
|
||
// Helipad.
|
||
const pad = site.helipad;
|
||
const padMesh = new THREE.Mesh(new THREE.PlaneGeometry(pad.radius * 2, pad.radius * 2), helipadMat);
|
||
padMesh.rotation.x = -Math.PI / 2;
|
||
padMesh.position.set(pad.center[0], 0.05, pad.center[1]);
|
||
worldGroup.add(padMesh);
|
||
|
||
// Terminal building (multi-window facade + roof slab).
|
||
const term = site.terminal;
|
||
const termFloors = 3;
|
||
const termPair = facadePair('#a4553f', termFloors, Math.max(3, Math.round(term.size[0] / 7)));
|
||
const termMat = new THREE.MeshStandardMaterial({
|
||
map: termPair.map, emissiveMap: termPair.emissiveMap,
|
||
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
|
||
});
|
||
facadeMaterials.push(termMat);
|
||
const termH = 9;
|
||
const termMesh = new THREE.Mesh(new THREE.BoxGeometry(term.size[0], termH, term.size[1]), termMat);
|
||
termMesh.position.set(term.center[0], termH / 2, term.center[1]);
|
||
termMesh.castShadow = true;
|
||
termMesh.receiveShadow = true;
|
||
worldGroup.add(termMesh);
|
||
addCollider(term.center[0], 0, term.center[1], term.size[0], termH, term.size[1]);
|
||
const termRoof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(term.size[0] + 0.6, 0.5, term.size[1] + 0.6),
|
||
new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 })
|
||
);
|
||
termRoof.position.set(term.center[0], termH + 0.15, term.center[1]);
|
||
worldGroup.add(termRoof);
|
||
|
||
// Jet bridges reaching from the terminal toward the apron.
|
||
const bridgeZ = term.center[1] + term.size[1] / 2 + 3.2;
|
||
for (let i = -1; i <= 1; i++) {
|
||
const bridge = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.5, 6.4), bridgeMat);
|
||
bridge.position.set(term.center[0] + i * 18, 3.0, bridgeZ);
|
||
bridge.castShadow = true;
|
||
worldGroup.add(bridge);
|
||
}
|
||
|
||
// Hangars: tall box, wide door toward the apron, roof slab.
|
||
for (const hg of site.hangars) {
|
||
const [sx, sz] = hg.size;
|
||
const body = new THREE.Mesh(new THREE.BoxGeometry(sx, 7, sz), hangarBodyMat);
|
||
body.position.set(hg.center[0], 3.5, hg.center[1]);
|
||
body.castShadow = true;
|
||
body.receiveShadow = true;
|
||
worldGroup.add(body);
|
||
const door = new THREE.Mesh(new THREE.BoxGeometry(sx * 0.7, 5.2, 0.4), hangarDoorMat);
|
||
door.position.set(hg.center[0], 2.7, hg.center[1] + sz / 2 + 0.15);
|
||
worldGroup.add(door);
|
||
const roof = new THREE.Mesh(new THREE.BoxGeometry(sx + 0.6, 0.6, sz + 0.6), hangarRoofMat);
|
||
roof.position.set(hg.center[0], 7.2, hg.center[1]);
|
||
worldGroup.add(roof);
|
||
addCollider(hg.center[0], 0, hg.center[1], sx, 7, sz);
|
||
}
|
||
|
||
// Control tower.
|
||
const tw2 = site.tower;
|
||
const base = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 2.1, 10, 12), towerBaseMat);
|
||
base.position.set(tw2.center[0], 5, tw2.center[1]);
|
||
base.castShadow = true;
|
||
worldGroup.add(base);
|
||
const cabin = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.6, 4.6), towerGlassMat);
|
||
cabin.position.set(tw2.center[0], 11.3, tw2.center[1]);
|
||
cabin.castShadow = true;
|
||
worldGroup.add(cabin);
|
||
const cap = new THREE.Mesh(new THREE.BoxGeometry(5.2, 0.35, 5.2), towerBaseMat);
|
||
cap.position.set(tw2.center[0], 12.8, tw2.center[1]);
|
||
worldGroup.add(cap);
|
||
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.6, 6), towerBaseMat);
|
||
antenna.position.set(tw2.center[0], 14.2, tw2.center[1]);
|
||
worldGroup.add(antenna);
|
||
const towerBeacon = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.22, 8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xff3333, emissive: 0xff3333, emissiveIntensity: 2 })
|
||
);
|
||
towerBeacon.position.set(tw2.center[0], 15.6, tw2.center[1]);
|
||
worldGroup.add(towerBeacon);
|
||
addCollider(tw2.center[0], 0, tw2.center[1], 4.6, 12, 4.6);
|
||
|
||
// Luminous letreiros: airport name on the terminal roofline
|
||
// (facing the apron) + entrance pylon on the city side.
|
||
const toApron = [
|
||
site.apron.center[0] - term.center[0],
|
||
site.apron.center[1] - term.center[1],
|
||
];
|
||
const termFace = rotationToward(toApron[0], toApron[1]);
|
||
const [tOffX, tOffZ] = facingOffset(termFace, term.size[0] / 2 + 0.3, term.size[1] / 2 + 0.3);
|
||
const termSign = buildLitSign(site.name, {
|
||
w: Math.min(15, term.size[0] * 0.8),
|
||
h: 1.8,
|
||
accent: '#7dd3fc',
|
||
});
|
||
termSign.position.set(term.center[0] + tOffX, termH + 1.4, term.center[1] + tOffZ);
|
||
termSign.rotation.y = termFace;
|
||
worldGroup.add(termSign);
|
||
signCounts.airports++;
|
||
|
||
const cityDir = Math.sign(site.apron.center[1]) >= 0 ? -1 : 1;
|
||
const apronEdgeZ = site.apron.center[1] + cityDir * site.apron.size[1] / 2;
|
||
const entry = buildLitSign(site.name, {
|
||
w: 8.5,
|
||
h: 1.9,
|
||
posts: 'double',
|
||
postH: 3.0,
|
||
accent: '#a78bfa',
|
||
});
|
||
entry.position.set(tw2.center[0] + 6, 0, apronEdgeZ + cityDir * 8);
|
||
entry.rotation.y = rotationToward(0, cityDir);
|
||
worldGroup.add(entry);
|
||
signCounts.airports++;
|
||
}
|
||
}
|
||
|
||
/** Linear interpolation between 3D points. */
|
||
function lerp3(a, b, t) {
|
||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||
}
|
||
|
||
/** 2D waypoint interpolation for ground/water paths [x, z]. */
|
||
function lerp2(a, b, t) {
|
||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||
}
|
||
|
||
/** Distance between two 3D points. */
|
||
function dist3(a, b) {
|
||
return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
||
}
|
||
|
||
/**
|
||
* Flight operations: taxi, takeoff roll, climb, cruise circuit, approach,
|
||
* landing and taxi-in for both airports, plus vertical helipad cycles.
|
||
*
|
||
* Aircraft states: park → taxi_out → roll → climb → cruise → approach →
|
||
* land → taxi_in → park (helicopters: park → takeoff → cruise → land).
|
||
*/
|
||
function initAirplanes(cfg) {
|
||
const aircraft = [];
|
||
const flights = { takeoffs: 0, landings: 0 };
|
||
const sites = cfg?.sites || [];
|
||
const emptyFlights = () => ({
|
||
total: 0, parked: 0, taxiing: 0, airborne: 0, takeoffs: 0, landings: 0,
|
||
});
|
||
|
||
if (!sites.length) {
|
||
airplaneSystem = {
|
||
planes: [],
|
||
count: () => 0,
|
||
flights: emptyFlights,
|
||
update() {},
|
||
};
|
||
return;
|
||
}
|
||
|
||
// Flight paths bind to the runway orientation, not to the labels:
|
||
// the east-west runway site uses the east-west ops, the other uses
|
||
// the north-south ops (both derive every coordinate from the site).
|
||
const ewSite = sites.find(s => s.runway?.axis === 'x') || sites[0];
|
||
const nsSite = sites.find(s => s.runway?.axis === 'z') || sites[1] || sites[0];
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const V = (x, y, z) => [x, y, z];
|
||
|
||
// ---- Per-airport flight paths (shared, stand-specific starts) ----
|
||
function ewOps(stand) {
|
||
const rz = ewSite.runway.center[1];
|
||
const tz = ewSite.taxiways[0].center[1];
|
||
const j = clamp(stand[0] - 40, -80, 80);
|
||
return {
|
||
heli: false,
|
||
taxiOut: [V(stand[0], 0, stand[1]), V(j, 0, 310), V(-80, 0, tz), V(-80, 0, rz)],
|
||
roll: { from: V(-80, 0, rz), to: V(85, 0, rz) },
|
||
climb: { from: V(85, 0, rz), to: V(175, 40, rz) },
|
||
cruise: [
|
||
V(300, 85, 60), V(40, 85, -150), V(-280, 80, -90), V(-300, 80, 240),
|
||
V(-140, 75, 400), V(60, 70, 400), V(240, 65, rz),
|
||
],
|
||
approach: [V(140, 15, rz), V(85, 0, rz)],
|
||
landRoll: { from: V(85, 0, rz), to: V(-80, 0, rz) },
|
||
taxiIn: [V(-80, 0, tz), V(j, 0, 310), V(stand[0], 0, stand[1])],
|
||
};
|
||
}
|
||
|
||
function nsOps(stand) {
|
||
const rx = nsSite.runway.center[0];
|
||
const tx = nsSite.taxiways[0].center[0];
|
||
return {
|
||
heli: false,
|
||
taxiOut: [V(stand[0], 0, stand[1]), V(tx, 0, stand[1]), V(tx, 0, -240), V(rx, 0, -240)],
|
||
roll: { from: V(rx, 0, -240), to: V(rx, 0, -370) },
|
||
climb: { from: V(rx, 0, -370), to: V(rx, 40, -460) },
|
||
cruise: [
|
||
V(-280, 85, -420), V(-340, 85, -40), V(-60, 85, 240), V(240, 80, 200),
|
||
V(320, 80, -160), V(200, 75, -360), V(rx, 65, -60),
|
||
],
|
||
approach: [V(rx, 20, -140), V(rx, 0, -220)],
|
||
landRoll: { from: V(rx, 0, -220), to: V(rx, 0, -370) },
|
||
taxiIn: [V(-40, 0, -370), V(tx, 0, -370), V(tx, 0, clamp(stand[1] + 40, -365, -235)), V(stand[0], 0, stand[1])],
|
||
};
|
||
}
|
||
|
||
function heliOps(site) {
|
||
const pad = site.helipad.center;
|
||
const far = Math.sign(site.runway.center[1] || 1) >= 0 ? [40, 430] : [-320, -430];
|
||
return {
|
||
heli: true,
|
||
pad: [pad[0], 0, pad[1]],
|
||
cruise: [
|
||
V(far[0], 60, far[1]),
|
||
V(-far[0] * 0.8, 58, far[1] * 0.6),
|
||
V(-far[0], 60, -far[1] * 0.4),
|
||
V(pad[0], 60, pad[1] - 40),
|
||
V(pad[0], 60, pad[1]),
|
||
],
|
||
};
|
||
}
|
||
|
||
// ---- State machine helpers ----
|
||
const poseForLegs = (a, legIdx, t) => {
|
||
const start = legIdx === 0 ? a.stateFrom : a.legs[legIdx - 1];
|
||
return lerp3(start, a.legs[legIdx], t);
|
||
};
|
||
|
||
function legsFor(a, state) {
|
||
const ops = a.ops;
|
||
if (a.ops.heli) {
|
||
return state === 'cruise' ? ops.cruise : null;
|
||
}
|
||
return {
|
||
taxi_out: ops.taxiOut,
|
||
cruise: ops.cruise,
|
||
approach: ops.approach,
|
||
taxi_in: ops.taxiIn,
|
||
}[state] || null;
|
||
}
|
||
|
||
function enterState(a, state) {
|
||
a.state = state;
|
||
a.stateFrom = [a.p[0], a.p[1], a.p[2]];
|
||
a.legs = legsFor(a, state);
|
||
a.leg = 0;
|
||
a.t = 0;
|
||
a.done = false;
|
||
a.speed = state === 'taxi_out' || state === 'taxi_in' ? 6
|
||
: state === 'cruise' ? 55
|
||
: state === 'approach' ? 24
|
||
: state === 'roll' ? 3 : a.speed;
|
||
a.dist = 0;
|
||
|
||
if (state === 'roll' || state === 'takeoff') {
|
||
flights.takeoffs++;
|
||
// Ask the AI control tower for a clearance (fire and forget).
|
||
postJson('/city/airport/dispatch', {}).catch(() => { /* throttled */ });
|
||
} else if (state === 'land') {
|
||
flights.landings++;
|
||
} else if (state === 'park') {
|
||
a.wait = 8 + Math.random() * 6;
|
||
}
|
||
|
||
// Ground taxi and fixed-heading states get an immediate yaw.
|
||
if (state === 'takeoff') {
|
||
a.yaw = a.yaw ?? 0;
|
||
} else if (!a.heli && (state === 'roll' || state === 'land')) {
|
||
const seg = state === 'roll' ? a.ops.roll : a.ops.landRoll;
|
||
a.yaw = Math.atan2(-(seg.to[2] - seg.from[2]), seg.to[0] - seg.from[0]);
|
||
}
|
||
}
|
||
|
||
/** Place an aircraft exactly at a mid-flight spawn state. */
|
||
function poseSpawn(a, state, leg, t) {
|
||
const ops = a.ops;
|
||
a.state = state;
|
||
a.legs = legsFor(a, state);
|
||
a.leg = leg || 0;
|
||
a.t = t || 0;
|
||
a.dist = 0;
|
||
a.speed = 6;
|
||
|
||
if (state === 'park') {
|
||
a.p = ops.heli ? [...ops.pad] : [...a.stand3];
|
||
a.wait = a.spawnWait;
|
||
a.yaw = 0;
|
||
} else if (state === 'taxi_out') {
|
||
a.stateFrom = [...a.stand3];
|
||
a.p = poseForLegs(a, a.leg, a.t);
|
||
a.yaw = Math.atan2(-(a.legs[0][2] - a.stand3[2]), a.legs[0][0] - a.stand3[0]);
|
||
} else if (state === 'roll') {
|
||
a.stateFrom = [...ops.roll.from];
|
||
a.p = lerp3(ops.roll.from, ops.roll.to, a.t);
|
||
a.yaw = Math.atan2(-(ops.roll.to[2] - ops.roll.from[2]), ops.roll.to[0] - ops.roll.from[0]);
|
||
a.speed = 12;
|
||
} else if (state === 'climb') {
|
||
a.stateFrom = [...ops.climb.from];
|
||
a.p = lerp3(ops.climb.from, ops.climb.to, a.t);
|
||
a.yaw = Math.atan2(-(ops.climb.to[2] - ops.climb.from[2]), ops.climb.to[0] - ops.climb.from[0]);
|
||
} else if (state === 'cruise') {
|
||
a.stateFrom = ops.heli ? [ops.pad[0], 45, ops.pad[2]] : [...ops.climb.to];
|
||
a.p = poseForLegs(a, a.leg, a.t);
|
||
const from = a.leg === 0 ? a.stateFrom : a.legs[a.leg - 1];
|
||
a.yaw = Math.atan2(-(a.legs[a.leg][2] - from[2]), a.legs[a.leg][0] - from[0]);
|
||
a.speed = ops.heli ? 32 : 55;
|
||
} else if (state === 'approach') {
|
||
a.stateFrom = [...ops.cruise[ops.cruise.length - 1]];
|
||
a.p = poseForLegs(a, a.leg, a.t);
|
||
a.yaw = Math.atan2(-(ops.approach[0][2] - a.stateFrom[2]), ops.approach[0][0] - a.stateFrom[0]);
|
||
a.speed = 24;
|
||
} else if (state === 'land') {
|
||
a.stateFrom = [...ops.approach[ops.approach.length - 1]];
|
||
a.p = lerp3(ops.landRoll.from, ops.landRoll.to, a.t);
|
||
a.yaw = Math.atan2(-(ops.landRoll.to[2] - ops.landRoll.from[2]), ops.landRoll.to[0] - ops.landRoll.from[0]);
|
||
a.speed = 14;
|
||
} else if (state === 'taxi_in') {
|
||
a.stateFrom = [...ops.landRoll.to];
|
||
a.p = poseForLegs(a, a.leg, a.t);
|
||
a.yaw = Math.atan2(-(a.legs[0][2] - a.stateFrom[2]), a.legs[0][0] - a.stateFrom[0]);
|
||
}
|
||
}
|
||
|
||
/** Advance along the current leg targets; returns true when finished. */
|
||
function stepLegs(a, speed, dt) {
|
||
if (!a.legs || a.leg >= a.legs.length) return true;
|
||
let guard = 0;
|
||
while (guard++ < 4) {
|
||
const target = a.legs[a.leg];
|
||
const dx = target[0] - a.p[0];
|
||
const dy = target[1] - a.p[1];
|
||
const dz = target[2] - a.p[2];
|
||
const len = Math.hypot(dx, dy, dz);
|
||
if (len < 1e-3) {
|
||
a.leg++;
|
||
if (a.leg >= a.legs.length) return true;
|
||
continue;
|
||
}
|
||
const step = speed * dt;
|
||
if (step >= len) {
|
||
a.p = [...target];
|
||
a.leg++;
|
||
if (a.leg >= a.legs.length) return true;
|
||
continue;
|
||
}
|
||
a.p = [a.p[0] + (dx / len) * step, a.p[1] + (dy / len) * step, a.p[2] + (dz / len) * step];
|
||
a.yaw = Math.atan2(-dz, dx);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ---- Fleet ----
|
||
function spawnFixed(site, opsFn, stand, kind, airlineIdx, state, leg, t, wait) {
|
||
const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length];
|
||
const mesh = kind === 'helicopter' ? buildHelicopter(livery) : buildAircraft(kind, livery);
|
||
worldGroup.add(mesh);
|
||
const a = {
|
||
mesh,
|
||
kind,
|
||
livery,
|
||
callsign: 'C10-'.concat(101 + aircraft.length),
|
||
ops: opsFn(stand),
|
||
stand3: [stand[0], 0, stand[1]],
|
||
p: [stand[0], 0, stand[1]],
|
||
yaw: 0,
|
||
state: 'park',
|
||
legs: null,
|
||
leg: 0,
|
||
t: 0,
|
||
speed: 6,
|
||
dist: 0,
|
||
wait: wait ?? 6,
|
||
spawnWait: wait ?? 6,
|
||
heli: false,
|
||
};
|
||
poseSpawn(a, state, leg, t);
|
||
aircraft.push(a);
|
||
return a;
|
||
}
|
||
|
||
function spawnHeli(site, airlineIdx, state, wait) {
|
||
const ops = heliOps(site);
|
||
const livery = AIRLINE_LIVERIES[airlineIdx % AIRLINE_LIVERIES.length];
|
||
const mesh = buildHelicopter(livery);
|
||
worldGroup.add(mesh);
|
||
const a = {
|
||
mesh,
|
||
kind: 'helicopter',
|
||
livery,
|
||
callsign: 'C10-'.concat(101 + aircraft.length),
|
||
ops,
|
||
stand3: [...ops.pad],
|
||
p: [...ops.pad],
|
||
yaw: 0,
|
||
state: 'park',
|
||
legs: null,
|
||
leg: 0,
|
||
t: 0,
|
||
speed: 6,
|
||
dist: 0,
|
||
wait: wait ?? 10,
|
||
spawnWait: wait ?? 10,
|
||
heli: true,
|
||
};
|
||
poseSpawn(a, state, 0, 0);
|
||
aircraft.push(a);
|
||
return a;
|
||
}
|
||
|
||
// Staggered initial states so operations are already in motion.
|
||
spawnFixed(ewSite, ewOps, ewSite.stands[0], 'airliner', 0, 'park', 0, 0, 7);
|
||
spawnFixed(ewSite, ewOps, ewSite.stands[1], 'airliner', 1, 'taxi_out', 1, 0.25, 0);
|
||
spawnFixed(ewSite, ewOps, ewSite.stands[2], 'regional', 2, 'cruise', 2, 0.4, 0);
|
||
spawnHeli(ewSite, 3, 'park', 14);
|
||
spawnFixed(nsSite, nsOps, nsSite.stands[0], 'regional', 4, 'approach', 0, 0.5, 0);
|
||
spawnFixed(nsSite, nsOps, nsSite.stands[1], 'prop', 5, 'park', 0, 0, 20);
|
||
spawnHeli(nsSite, 6, 'park', 12);
|
||
|
||
let atcPoll = 6;
|
||
let clearanceShown = null;
|
||
|
||
airplaneSystem = {
|
||
planes: aircraft,
|
||
count: () => aircraft.length,
|
||
flights: () => {
|
||
let parked = 0;
|
||
let taxiing = 0;
|
||
let airborne = 0;
|
||
for (const a of aircraft) {
|
||
if (a.state === 'park') parked++;
|
||
else if (a.state === 'taxi_out' || a.state === 'taxi_in') taxiing++;
|
||
else if (a.state === 'climb' || a.state === 'cruise' || a.state === 'approach') airborne++;
|
||
}
|
||
return {
|
||
total: aircraft.length,
|
||
parked,
|
||
taxiing,
|
||
airborne,
|
||
takeoffs: flights.takeoffs,
|
||
landings: flights.landings,
|
||
states: aircraft.map(a => a.callsign + ':' + a.state),
|
||
gear: aircraft.map(a => ({
|
||
kind: a.mesh.userData.kind,
|
||
gear: a.mesh.userData.gear?.kind || 'none',
|
||
state: a.state,
|
||
x: +a.p[0].toFixed(1),
|
||
y: +a.p[1].toFixed(2),
|
||
z: +a.p[2].toFixed(1),
|
||
})),
|
||
};
|
||
},
|
||
update(dt) {
|
||
const time = performance.now() / 1000;
|
||
|
||
for (const a of aircraft) {
|
||
switch (a.state) {
|
||
case 'park': {
|
||
a.wait -= dt;
|
||
if (a.wait <= 0) enterState(a, a.heli ? 'takeoff' : 'taxi_out');
|
||
break;
|
||
}
|
||
case 'taxi_out':
|
||
if (stepLegs(a, 6, dt)) enterState(a, 'roll');
|
||
break;
|
||
case 'roll': {
|
||
a.speed = Math.min(18, a.speed + 5.5 * dt);
|
||
const len = dist3(a.ops.roll.from, a.ops.roll.to);
|
||
a.dist += a.speed * dt;
|
||
const s = Math.min(1, a.dist / len);
|
||
a.p = lerp3(a.ops.roll.from, a.ops.roll.to, s);
|
||
if (s >= 1) enterState(a, 'climb');
|
||
break;
|
||
}
|
||
case 'climb': {
|
||
const len = dist3(a.ops.climb.from, a.ops.climb.to);
|
||
a.t += (16 * dt) / len;
|
||
if (a.t >= 1) {
|
||
a.p = [...a.ops.climb.to];
|
||
enterState(a, 'cruise');
|
||
} else {
|
||
a.p = lerp3(a.ops.climb.from, a.ops.climb.to, a.t);
|
||
}
|
||
break;
|
||
}
|
||
case 'cruise':
|
||
if (stepLegs(a, a.ops.heli ? 32 : 55, dt)) {
|
||
enterState(a, a.ops.heli ? 'land' : 'approach');
|
||
}
|
||
break;
|
||
case 'approach':
|
||
if (stepLegs(a, 24, dt)) enterState(a, 'land');
|
||
break;
|
||
case 'land': {
|
||
// Helicopter landing (vertical descent) is handled below.
|
||
if (a.heli) break;
|
||
a.speed = Math.max(4, a.speed - 3.2 * dt);
|
||
const len = dist3(a.ops.landRoll.from, a.ops.landRoll.to);
|
||
a.dist += a.speed * dt;
|
||
const s = Math.min(1, a.dist / len);
|
||
a.p = lerp3(a.ops.landRoll.from, a.ops.landRoll.to, s);
|
||
if (s >= 1) enterState(a, 'taxi_in');
|
||
break;
|
||
}
|
||
case 'taxi_in':
|
||
if (stepLegs(a, 6, dt)) {
|
||
enterState(a, 'park');
|
||
a.wait = 8 + Math.random() * 6;
|
||
}
|
||
break;
|
||
case 'takeoff': {
|
||
// Vertical departure from the helipad.
|
||
a.t += dt / 5;
|
||
if (a.t >= 1) {
|
||
a.p = [a.ops.pad[0], 45, a.ops.pad[2]];
|
||
enterState(a, 'cruise');
|
||
} else {
|
||
a.p = [a.ops.pad[0], 45 * a.t, a.ops.pad[2]];
|
||
}
|
||
break;
|
||
}
|
||
default: break;
|
||
}
|
||
|
||
// Helicopter landing: descend back onto the pad.
|
||
if (a.state === 'land' && a.heli) {
|
||
a.p = [a.ops.pad[0], Math.max(0, a.p[1] - 9 * dt), a.ops.pad[2]];
|
||
if (a.p[1] <= 0.01) {
|
||
a.p = [...a.ops.pad];
|
||
enterState(a, 'park');
|
||
a.wait = 8 + Math.random() * 6;
|
||
}
|
||
}
|
||
|
||
// Pose the mesh.
|
||
a.mesh.position.set(a.p[0], a.p[1], a.p[2]);
|
||
a.mesh.rotation.y = a.yaw || 0;
|
||
let pitch = 0;
|
||
if (a.state === 'climb') pitch = 0.16;
|
||
else if (a.state === 'approach') pitch = -0.1;
|
||
else if (a.state === 'land' && !a.heli) pitch = 0.06;
|
||
a.mesh.rotation.z = a.heli ? 0 : pitch;
|
||
|
||
if (a.mesh.userData.beacon) {
|
||
a.mesh.userData.beacon.material.emissiveIntensity =
|
||
(Math.sin(time * 6) > 0) ? 2.5 : 0.2;
|
||
}
|
||
const spinning = a.state !== 'park';
|
||
if (a.mesh.userData.rotor) {
|
||
a.mesh.userData.rotor.rotation.y += (spinning ? 26 : 5) * dt;
|
||
}
|
||
if (a.mesh.userData.tailRotor) {
|
||
a.mesh.userData.tailRotor.rotation.z += (spinning ? 30 : 4) * dt;
|
||
}
|
||
if (a.mesh.userData.prop) {
|
||
a.mesh.userData.prop.rotation.x += (spinning ? 28 : 0) * dt;
|
||
}
|
||
}
|
||
|
||
// Poll the AI tower clearance and flash new authorizations.
|
||
atcPoll -= dt;
|
||
if (atcPoll <= 0) {
|
||
atcPoll = 10;
|
||
fetch('/city/airport', {
|
||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
cache: 'no-store',
|
||
})
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then((data) => {
|
||
const c = data?.clearance;
|
||
if (c?.at && c.at !== clearanceShown) {
|
||
clearanceShown = c.at;
|
||
flashCharMsg('✈ '.concat(c.text));
|
||
}
|
||
})
|
||
.catch(() => { /* offline-safe */ });
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Port — far half of the north-east coast (z −430..−290; the near half
|
||
// stays free for city expansion): quay, yard, terminal, gantry cranes,
|
||
// container ships, liner, speedboats, trucks — with ship maneuvers
|
||
// authorised by the Capitania AI agent (/city/port/dispatch).
|
||
// ------------------------------------------------------------------
|
||
|
||
/** Ship heading: hull nose points local −Z. */
|
||
function shipYaw(dx, dz) {
|
||
return Math.atan2(-dx, -dz);
|
||
}
|
||
|
||
/** Clamped keyframe interpolation: kf(t, [[time, value], …]). */
|
||
function kf(t, frames) {
|
||
if (t <= frames[0][0]) return frames[0][1];
|
||
for (let i = 1; i < frames.length; i++) {
|
||
if (t <= frames[i][0]) {
|
||
const [t0, v0] = frames[i - 1];
|
||
const [t1, v1] = frames[i];
|
||
const k = (t - t0) / Math.max(1e-6, t1 - t0);
|
||
return v0 + (v1 - v0) * k;
|
||
}
|
||
}
|
||
return frames[frames.length - 1][1];
|
||
}
|
||
|
||
/**
|
||
* Big vessels: hull with stepped bow, decks, bridge/funnel and (for
|
||
* container ships) a stacked container block on deck. Nose points −Z.
|
||
*
|
||
* @param {'container'|'liner'} kind
|
||
* @param {{name: string, company: string, accent?: number}} spec
|
||
*/
|
||
function buildShip(kind, spec) {
|
||
const g = new THREE.Group();
|
||
const white = new THREE.MeshStandardMaterial({ color: 0xe8ecf1, roughness: 0.55, metalness: 0.2 });
|
||
const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.4, metalness: 0.5 });
|
||
const accent = new THREE.MeshStandardMaterial({ color: spec.accent ?? 0x1f6fb2, roughness: 0.45, metalness: 0.3 });
|
||
const box = (w, h, d, mat, x, y, z) => {
|
||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
|
||
m.position.set(x, y, z);
|
||
g.add(m);
|
||
return m;
|
||
};
|
||
|
||
if (kind === 'liner') {
|
||
const L = 88, W = 20;
|
||
box(W, 6.5, L, white, 0, 1.2, 0); // hull
|
||
box(W + 0.4, 1.3, L + 0.4, accent, 0, 2.4, 0); // company band
|
||
box(14, 5, 8, white, 0, 1.8, -L / 2 - 2); // stepped bow
|
||
box(17, 4, 60, white, 0, 6.2, 4); // deck tier 1
|
||
box(17.3, 1, 60.3, dark, 0, 7.0, 4); // window band
|
||
box(14, 3.4, 44, white, 0, 10.0, 6); // tier 2
|
||
box(14.3, 1, 44.3, dark, 0, 10.6, 6); // windows
|
||
box(11, 2.6, 14, white, 0, 13.4, -14); // bridge
|
||
box(11.3, 1, 14.3, dark, 0, 13.8, -14); // bridge glass
|
||
for (const fz of [14, 22]) {
|
||
const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.9, 2.3, 6, 12), accent);
|
||
funnel.position.set(0, 14.0, fz);
|
||
g.add(funnel);
|
||
}
|
||
} else {
|
||
const L = 60, W = 16;
|
||
box(W, 5.5, L - 2, dark, 0, 1.0, 0); // hull
|
||
box(W + 0.3, 1.1, L - 1.6, accent, 0, 3.2, 0); // livery band
|
||
box(W - 0.6, 1.2, L - 3, white, 0, 4.0, 0); // deck rim
|
||
box(11, 4.5, 6, dark, 0, 1.6, -L / 2 - 1); // stepped bow
|
||
box(11, 3.6, 9, white, 0, 5.9, 21); // deckhouse t1
|
||
box(9, 2.4, 8, white, 0, 8.6, 21.5); // deckhouse t2
|
||
box(9.2, 0.9, 8.2, dark, 0, 8.9, 21.5); // bridge glass
|
||
const funnel = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.8, 4.4, 10), accent);
|
||
funnel.position.set(0, 11.4, 17);
|
||
g.add(funnel);
|
||
// Container stacks on deck (3 lanes × 6 rows, some double-stacked).
|
||
const containerColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910];
|
||
let ci = 0;
|
||
for (const lx of [-5, 0, 5]) {
|
||
for (let r = 0; r < 6; r++) {
|
||
const cz = -22 + r * 6.4;
|
||
const color = containerColors[ci++ % containerColors.length];
|
||
box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color, roughness: 0.7 }), lx, 5.9, cz);
|
||
if ((ci + r) % 3 === 0) {
|
||
box(4.6, 2.6, 3, new THREE.MeshStandardMaterial({ color: containerColors[ci % containerColors.length], roughness: 0.7 }), lx, 8.5, cz);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
|
||
g.userData.shipName = spec.name;
|
||
return g;
|
||
}
|
||
|
||
/** Small fast boat (speedboat) hull + cabin. */
|
||
function buildPortBoat(accentColor) {
|
||
const g = new THREE.Group();
|
||
const white = new THREE.MeshStandardMaterial({ color: 0xf2f4f7, roughness: 0.5 });
|
||
const accent = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.45 });
|
||
const dark = new THREE.MeshStandardMaterial({ color: 0x14202c, roughness: 0.6 });
|
||
const hull = new THREE.Mesh(new THREE.BoxGeometry(3.2, 1.4, 9), white);
|
||
hull.position.y = 0.55;
|
||
g.add(hull);
|
||
const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.3, 0.4, 8.6), accent);
|
||
stripe.position.y = 0.95;
|
||
g.add(stripe);
|
||
const nose = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.0, 2.4), white);
|
||
nose.position.set(0, 0.6, -5.2);
|
||
g.add(nose);
|
||
const cabin = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.4, 3), white);
|
||
cabin.position.set(0, 1.9, 0.6);
|
||
g.add(cabin);
|
||
const screen = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.6, 0.5), dark);
|
||
screen.position.set(0, 2.2, -0.9);
|
||
g.add(screen);
|
||
const motor = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.9, 0.8), dark);
|
||
motor.position.set(0, 0.9, 4.8);
|
||
g.add(motor);
|
||
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
return g;
|
||
}
|
||
|
||
/** Yard truck: cab + chassis, optional container on the back (nose +X). */
|
||
function buildPortTruck(accentColor, loaded) {
|
||
const g = new THREE.Group();
|
||
const steel = new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.6, metalness: 0.4 });
|
||
const cabMat = new THREE.MeshStandardMaterial({ color: accentColor, roughness: 0.5, metalness: 0.2 });
|
||
const chassis = new THREE.Mesh(new THREE.BoxGeometry(7, 0.5, 2.6), steel);
|
||
chassis.position.y = 1.15;
|
||
g.add(chassis);
|
||
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.3, 2.5), cabMat);
|
||
cab.position.set(2.4, 2.3, 0);
|
||
g.add(cab);
|
||
if (loaded) {
|
||
const container = new THREE.Mesh(
|
||
new THREE.BoxGeometry(4.2, 2.3, 2.4),
|
||
new THREE.MeshStandardMaterial({ color: 0x2980b9, roughness: 0.7 })
|
||
);
|
||
container.position.set(-1.2, 2.6, 0);
|
||
g.add(container);
|
||
}
|
||
for (const [wx, wz] of [[2.4, 1.35], [2.4, -1.35], [-2.4, 1.35], [-2.4, -1.35]]) {
|
||
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 0.4, 10), steel);
|
||
wheel.rotation.x = Math.PI / 2;
|
||
wheel.position.set(wx, 0.5, wz);
|
||
g.add(wheel);
|
||
}
|
||
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
return g;
|
||
}
|
||
|
||
/**
|
||
* Build the port and its moving fleet from the payload geometry.
|
||
*
|
||
* Fixed structures (quay, yard, terminal, bollards, container stacks,
|
||
* crane frames) merge through a static batcher; ships, boats, trucks
|
||
* and crane trolleys stay individual and animated. Container-ship
|
||
* maneuvers wait for a Capitania clearance (AI text or deterministic
|
||
* fallback) before departing or berthing.
|
||
*
|
||
* @param {object} cfg port payload {visit, water, quay, yard, terminal, cranes, berths, ships, boats}
|
||
*/
|
||
function initPort(cfg) {
|
||
const quay = cfg.quay;
|
||
const yard = cfg.yard;
|
||
const terminal = cfg.terminal;
|
||
const berths = cfg.berths;
|
||
const cranes = cfg.cranes;
|
||
const ships = cfg.ships;
|
||
const batch = makeStaticBatcher();
|
||
|
||
const concreteMat = new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.95 });
|
||
const asphaltMat = new THREE.MeshStandardMaterial({ color: 0x4a4f56, roughness: 0.95 });
|
||
const steelMat = new THREE.MeshStandardMaterial({ color: 0x8f969e, roughness: 0.5, metalness: 0.6 });
|
||
const whiteLineMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8, roughness: 0.85 });
|
||
const yellowLineMat = new THREE.MeshStandardMaterial({ color: 0xd8b12a, roughness: 0.8 });
|
||
|
||
const addFlat = (cx, cz, w, d, mat, y) => {
|
||
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat);
|
||
m.rotation.x = -Math.PI / 2;
|
||
m.position.set(cx, y, cz);
|
||
m.receiveShadow = true;
|
||
worldGroup.add(m);
|
||
};
|
||
|
||
// ---- Quay + yard pavement ----
|
||
addFlat(quay.center[0], quay.center[1], quay.size[0], quay.size[1], concreteMat, 0.06);
|
||
const edgeLine = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.3, 0.02, quay.size[1] - 4), yellowLineMat
|
||
);
|
||
edgeLine.position.set(quay.center[0] + quay.size[0] / 2 - 0.6, 0.08, quay.center[1]);
|
||
worldGroup.add(edgeLine);
|
||
addFlat(yard.center[0], yard.center[1], yard.size[0], yard.size[1], asphaltMat, 0.05);
|
||
|
||
// Bollards along the quay edge (merged).
|
||
const bollardGeo = new THREE.CylinderGeometry(0.17, 0.2, 0.7, 8);
|
||
for (let z = quay.center[1] - quay.size[1] / 2 + 8; z <= quay.center[1] + quay.size[1] / 2 - 8; z += 13) {
|
||
const b = new THREE.Mesh(bollardGeo, steelMat);
|
||
b.position.set(quay.center[0] + quay.size[0] / 2 - 1.6, 0.4, z);
|
||
b.updateMatrix();
|
||
batch.add(bollardGeo, steelMat, b.matrix, true);
|
||
}
|
||
|
||
// ---- Terminal (multi-window facade) ----
|
||
const termPair = facadePair('#6d7f93', 3, Math.max(4, Math.round(terminal.size[0] / 7)));
|
||
const termMat = new THREE.MeshStandardMaterial({
|
||
map: termPair.map, emissiveMap: termPair.emissiveMap,
|
||
emissive: 0xffd9a0, emissiveIntensity: 0, roughness: 0.85,
|
||
});
|
||
facadeMaterials.push(termMat);
|
||
const termH = 9;
|
||
const termMesh = new THREE.Mesh(new THREE.BoxGeometry(terminal.size[0], termH, terminal.size[1]), termMat);
|
||
termMesh.position.set(terminal.center[0], termH / 2, terminal.center[1]);
|
||
termMesh.castShadow = true;
|
||
termMesh.receiveShadow = true;
|
||
worldGroup.add(termMesh);
|
||
addCollider(terminal.center[0], 0, terminal.center[1], terminal.size[0], termH, terminal.size[1]);
|
||
const termRoof = new THREE.Mesh(
|
||
new THREE.BoxGeometry(terminal.size[0] + 0.6, 0.5, terminal.size[1] + 0.6),
|
||
new THREE.MeshStandardMaterial({ color: 0x33373d, roughness: 0.9 })
|
||
);
|
||
termRoof.position.set(terminal.center[0], termH + 0.15, terminal.center[1]);
|
||
worldGroup.add(termRoof);
|
||
|
||
// ---- Container stacks in the yard (merged per colour) ----
|
||
const stackColors = [0xc0392b, 0x2980b9, 0x27ae60, 0xd68910];
|
||
const stackMats = stackColors.map(
|
||
color => new THREE.MeshStandardMaterial({ color, roughness: 0.7 })
|
||
);
|
||
let stackIdx = 0;
|
||
for (const ox of [-16, -4, 8]) {
|
||
for (let z = yard.center[1] - yard.size[1] / 2 + 8;
|
||
z <= yard.center[1] + yard.size[1] / 2 - 8;
|
||
z += 16) {
|
||
const mat = stackMats[stackIdx % stackMats.length];
|
||
const sx = yard.center[0] + ox;
|
||
const baseGeo = new THREE.BoxGeometry(5, 2.6, 2.8);
|
||
const base = new THREE.Mesh(baseGeo, mat);
|
||
base.position.set(sx, 1.35, z);
|
||
base.updateMatrix();
|
||
batch.add(baseGeo, mat, base.matrix, true);
|
||
if (stackIdx % 3 === 0) {
|
||
const topGeo = new THREE.BoxGeometry(5, 2.6, 2.8);
|
||
const top = new THREE.Mesh(topGeo, mat);
|
||
top.position.set(sx, 3.95, z);
|
||
top.updateMatrix();
|
||
batch.add(topGeo, mat, top.matrix, true);
|
||
}
|
||
stackIdx++;
|
||
}
|
||
}
|
||
|
||
// ---- Gantry crane frames (merged) + animated trolleys ----
|
||
const craneRuntime = [];
|
||
const deckYs = berths.map((b) => {
|
||
const ship = ships.find(s => s.berth === b.id);
|
||
return ship && ship.kind === 'liner' ? 6.2 : 4.6;
|
||
});
|
||
cranes.forEach((crane, i) => {
|
||
const [cx, cz] = crane.center;
|
||
for (const lx of [cx - 2.5, cx + 2.5]) {
|
||
for (const lz of [cz - 6.5, cz + 6.5]) {
|
||
const legGeo = new THREE.BoxGeometry(1.1, 15, 1.1);
|
||
const leg = new THREE.Mesh(legGeo, steelMat);
|
||
leg.position.set(lx, 7.5, lz);
|
||
leg.updateMatrix();
|
||
batch.add(legGeo, steelMat, leg.matrix, true);
|
||
}
|
||
const tieGeo = new THREE.BoxGeometry(1, 1, 14);
|
||
const tie = new THREE.Mesh(tieGeo, steelMat);
|
||
tie.position.set(lx, 15.5, cz);
|
||
tie.updateMatrix();
|
||
batch.add(tieGeo, steelMat, tie.matrix);
|
||
}
|
||
const beamGeo = new THREE.BoxGeometry(6, 1, 1.2);
|
||
for (const bz of [cz - 6.5, cz + 6.5]) {
|
||
const beam = new THREE.Mesh(beamGeo, steelMat);
|
||
beam.position.set(cx, 15.5, bz);
|
||
beam.updateMatrix();
|
||
batch.add(beamGeo, steelMat, beam.matrix);
|
||
}
|
||
// Boom rails reaching out over the water.
|
||
for (const rz of [cz - 0.9, cz + 0.9]) {
|
||
const railGeo = new THREE.BoxGeometry(36, 0.6, 0.45);
|
||
const rail = new THREE.Mesh(railGeo, steelMat);
|
||
rail.position.set(cx + 18, 15.6, rz);
|
||
rail.updateMatrix();
|
||
batch.add(railGeo, steelMat, rail.matrix);
|
||
}
|
||
const deckGeo = new THREE.BoxGeometry(36, 0.25, 2.6);
|
||
const deck = new THREE.Mesh(deckGeo, steelMat);
|
||
deck.position.set(cx + 18, 16.1, cz);
|
||
deck.updateMatrix();
|
||
batch.add(deckGeo, steelMat, deck.matrix);
|
||
|
||
// Animated trolley + cable + spreader + carried container.
|
||
const trolley = new THREE.Group();
|
||
const carriage = new THREE.Mesh(new THREE.BoxGeometry(3, 1.5, 3.1), steelMat);
|
||
carriage.position.y = 14.75;
|
||
trolley.add(carriage);
|
||
const cable = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.14, 1, 0.14),
|
||
new THREE.MeshStandardMaterial({ color: 0x2a2e36, roughness: 0.5 })
|
||
);
|
||
trolley.add(cable);
|
||
const spreader = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.4, 2.5), yellowLineMat);
|
||
trolley.add(spreader);
|
||
const load = new THREE.Mesh(
|
||
new THREE.BoxGeometry(4.6, 2.6, 2.9),
|
||
new THREE.MeshStandardMaterial({ color: stackColors[i % stackColors.length], roughness: 0.7 })
|
||
);
|
||
trolley.add(load);
|
||
worldGroup.add(trolley);
|
||
craneRuntime.push({
|
||
cx, cz, trolley, cable, spreader, load,
|
||
deckY: deckYs[i], t: i * 0.45,
|
||
berthId: berths[i] ? berths[i].id : null,
|
||
});
|
||
});
|
||
|
||
batch.flush(worldGroup);
|
||
|
||
// ---- Fleet: container ships, liner, speedboats, trucks ----
|
||
const laneX = quay.center[0] + 52; // clear of the berthed hulls (W/2 sums ~18)
|
||
const zoneStart = berths[0].center[1] + 20;
|
||
const zoneEnd = berths[1].center[1] - 20;
|
||
const oval = [
|
||
[laneX, zoneStart],
|
||
[laneX + 8, zoneStart - 25],
|
||
[laneX + 8, zoneEnd + 25],
|
||
[laneX, zoneEnd],
|
||
[laneX - 8, zoneEnd + 25],
|
||
[laneX - 8, zoneStart - 25],
|
||
];
|
||
const departLegsFor = berthCenter => [
|
||
[berthCenter[0] + 16, berthCenter[1]],
|
||
[laneX, zoneStart],
|
||
];
|
||
const returnLegsFor = berthCenter => [
|
||
[berthCenter[0] + 16, berthCenter[1]],
|
||
[berthCenter[0], berthCenter[1]],
|
||
];
|
||
|
||
const fleet = [];
|
||
ships.forEach((spec) => {
|
||
const mesh = buildShip(spec.kind, spec);
|
||
worldGroup.add(mesh);
|
||
const berth = spec.berth ? berths.find(b => b.id === spec.berth) : null;
|
||
const entity = {
|
||
spec,
|
||
mesh,
|
||
berth: berth ? [...berth.center] : null,
|
||
cycle: spec.kind === 'container',
|
||
state: berth ? 'berthed' : 'sailing',
|
||
legs: null,
|
||
leg: 0,
|
||
t: 0,
|
||
p: [0, 0, 0],
|
||
yaw: 0,
|
||
timer: berth ? 11 + Math.random() * 6 : 22 + Math.random() * 14,
|
||
purpose: berth ? 'depart' : 'berth',
|
||
awaitedAt: 0,
|
||
retry: 0,
|
||
phase: Math.random() * Math.PI * 2,
|
||
};
|
||
if (berth) {
|
||
entity.p = [berth.center[0], 0, berth.center[1]];
|
||
} else {
|
||
entity.legs = oval;
|
||
entity.leg = 1;
|
||
entity.t = 0.4;
|
||
const [sx, sz] = lerp2(oval[0], oval[1], 0.4);
|
||
entity.p = [sx, 0, sz];
|
||
entity.yaw = shipYaw(oval[1][0] - oval[0][0], oval[1][1] - oval[0][1]);
|
||
entity.purpose = 'berth';
|
||
}
|
||
fleet.push(entity);
|
||
});
|
||
|
||
const boats = [];
|
||
const boatOval = [
|
||
[laneX - 18, zoneStart],
|
||
[laneX - 10, zoneEnd],
|
||
[laneX + 18, zoneEnd],
|
||
[laneX + 10, zoneStart],
|
||
];
|
||
[0x2fb6c9, 0xe08a1e, 0xd92b2b].forEach((color, i) => {
|
||
const mesh = buildPortBoat(color);
|
||
worldGroup.add(mesh);
|
||
const start = i % boatOval.length;
|
||
const t0 = (i * 0.37) % 1;
|
||
boats.push({
|
||
mesh,
|
||
legs: boatOval,
|
||
leg: start,
|
||
t: t0,
|
||
p: (() => {
|
||
const [bx, bz] = lerp2(boatOval[start], boatOval[(start + 1) % boatOval.length], t0);
|
||
return [bx, 0, bz];
|
||
})(),
|
||
yaw: 0,
|
||
speed: 8 + i,
|
||
phase: i * 2.1,
|
||
});
|
||
});
|
||
|
||
const trucks = [];
|
||
const yardLoop = [
|
||
[yard.center[0] - 16, yard.center[1] - 50],
|
||
[yard.center[0] + 16, yard.center[1] - 50],
|
||
[yard.center[0] + 16, yard.center[1] + 50],
|
||
[yard.center[0] - 16, yard.center[1] + 50],
|
||
];
|
||
[true, false].forEach((loaded, i) => {
|
||
const mesh = buildPortTruck(i === 0 ? 0xd97706 : 0x3f9d4f, loaded);
|
||
worldGroup.add(mesh);
|
||
const leg = (i * 2) % yardLoop.length;
|
||
const t0 = i * 0.5;
|
||
trucks.push({
|
||
mesh,
|
||
legs: yardLoop,
|
||
leg,
|
||
t: t0,
|
||
p: (() => {
|
||
const [tx, tz] = lerp2(yardLoop[leg], yardLoop[(leg + 1) % yardLoop.length], t0);
|
||
return [tx, 0, tz];
|
||
})(),
|
||
yaw: 0,
|
||
speed: loaded ? 4 : 4.6,
|
||
truck: true,
|
||
});
|
||
});
|
||
|
||
/** Advance one entity along its waypoint legs; true when finished. */
|
||
const stepAlong = (e, speed, dt) => {
|
||
if (!e.legs || !e.legs.length) return true;
|
||
let guard = 0;
|
||
while (guard++ < 4) {
|
||
// Perpetual loops wrap; one-shot paths reset via their caller.
|
||
if (e.leg >= e.legs.length) e.leg = 0;
|
||
const target = e.legs[e.leg]; // [x, z] waypoint pair
|
||
const dx = target[0] - e.p[0];
|
||
const dz = target[1] - e.p[2];
|
||
const len = Math.hypot(dx, dz);
|
||
if (len < 1e-3) {
|
||
e.leg++;
|
||
if (e.leg >= e.legs.length) return true;
|
||
continue;
|
||
}
|
||
const step = speed * dt;
|
||
if (step >= len) {
|
||
e.p = [target[0], 0, target[1]];
|
||
e.leg++;
|
||
if (e.leg >= e.legs.length) return true;
|
||
continue;
|
||
}
|
||
e.p = [e.p[0] + (dx / len) * step, 0, e.p[2] + (dz / len) * step];
|
||
e.yaw = e.truck ? Math.atan2(-dz, dx) : shipYaw(dx, dz);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const postDispatch = () => {
|
||
postJson('/city/port/dispatch', {}).catch(() => { /* throttled server-side */ });
|
||
};
|
||
|
||
const system = {
|
||
clearance: null,
|
||
shownAt: null,
|
||
poll: 4,
|
||
|
||
counts() {
|
||
return {
|
||
ships: fleet.length,
|
||
boats: boats.length,
|
||
trucks: trucks.length,
|
||
cranes: craneRuntime.length,
|
||
berthed: fleet.filter(f => f.state === 'berthed').length,
|
||
underway: fleet.filter(f => ['departing', 'sailing', 'returning', 'turning'].includes(f.state)).length,
|
||
states: fleet.map(f => `${f.spec.id}:${f.state}`),
|
||
shipPositions: fleet.map(f => ({
|
||
id: f.spec.id,
|
||
x: +f.p[0].toFixed(1),
|
||
z: +f.p[2].toFixed(1),
|
||
})),
|
||
// Motion sample for tests: positions that must change
|
||
// between two readings.
|
||
movingSample: {
|
||
boat: boats[0] ? [+boats[0].p[0].toFixed(1), +boats[0].p[2].toFixed(1)] : null,
|
||
truck: trucks[0] ? [+trucks[0].p[0].toFixed(1), +trucks[0].p[2].toFixed(1)] : null,
|
||
},
|
||
};
|
||
},
|
||
|
||
update(dt, time) {
|
||
// ---- Capitania clearance poll (flash + maneuver gating) ----
|
||
this.poll -= dt;
|
||
if (this.poll <= 0) {
|
||
this.poll = 8;
|
||
fetch('/city/port', {
|
||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
cache: 'no-store',
|
||
})
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then((data) => {
|
||
const c = data && data.clearance;
|
||
if (c && c.at && c.at !== this.shownAt) {
|
||
this.shownAt = c.at;
|
||
this.clearance = c;
|
||
flashCharMsg('💬 '.concat(c.text));
|
||
}
|
||
})
|
||
.catch(() => { /* offline-safe */ });
|
||
}
|
||
|
||
// ---- Container ships: AI-gated maneuver cycle ----
|
||
for (const f of fleet) {
|
||
if (!f.cycle) continue;
|
||
if (f.state === 'berthed') {
|
||
f.timer -= dt;
|
||
if (f.timer <= 0) {
|
||
f.state = 'awaiting';
|
||
f.purpose = 'depart';
|
||
f.awaitedAt = Date.now();
|
||
f.retry = 1;
|
||
postDispatch();
|
||
}
|
||
} else if (f.state === 'awaiting') {
|
||
f.retry -= dt;
|
||
if (f.retry <= 0) {
|
||
f.retry = 7;
|
||
postDispatch();
|
||
}
|
||
const c = this.clearance;
|
||
if (c && Date.parse(c.at) >= f.awaitedAt - 1500
|
||
&& c.action === f.purpose) {
|
||
if (f.purpose === 'depart') {
|
||
if (!f.berth) continue; // nothing to leave
|
||
f.legs = departLegsFor(f.berth);
|
||
} else {
|
||
// Claim the first FREE berth: occupied means
|
||
// berthed, or still moored waiting to depart
|
||
// (c1 sits on its berth until cleared to leave).
|
||
const free = berths.find(b => !fleet.some(o => {
|
||
if (o === f || !o.berth) return false;
|
||
const sameSlot = o.berth[0] === b.center[0]
|
||
&& o.berth[1] === b.center[1];
|
||
const occupying = o.state === 'berthed'
|
||
|| o.state === 'returning'
|
||
|| o.state === 'turning'
|
||
|| (o.state === 'awaiting' && o.purpose === 'depart');
|
||
return sameSlot && occupying;
|
||
}));
|
||
if (!free) {
|
||
f.retry = 6; // all busy: ask again shortly
|
||
continue;
|
||
}
|
||
f.berth = [...free.center];
|
||
f.legs = returnLegsFor(f.berth);
|
||
}
|
||
f.leg = 0;
|
||
f.state = f.purpose === 'depart' ? 'departing' : 'returning';
|
||
}
|
||
} else if (f.state === 'departing') {
|
||
if (stepAlong(f, 4.5, dt)) {
|
||
f.legs = oval;
|
||
f.leg = 0;
|
||
f.timer = 20 + Math.random() * 14;
|
||
f.state = 'sailing';
|
||
}
|
||
} else if (f.state === 'sailing') {
|
||
f.timer -= dt;
|
||
stepAlong(f, 5.5, dt);
|
||
if (f.timer <= 0) {
|
||
f.state = 'awaiting';
|
||
f.purpose = 'berth';
|
||
f.awaitedAt = Date.now();
|
||
f.retry = 1;
|
||
postDispatch();
|
||
}
|
||
} else if (f.state === 'returning') {
|
||
if (stepAlong(f, 4.5, dt)) {
|
||
f.state = 'turning';
|
||
f.turnT = 0;
|
||
f.fromYaw = f.yaw;
|
||
}
|
||
} else if (f.state === 'turning') {
|
||
f.turnT += dt / 2.2;
|
||
const k = Math.min(1, f.turnT);
|
||
f.yaw = f.fromYaw + (0 - f.fromYaw) * k;
|
||
if (k >= 1) {
|
||
f.state = 'berthed';
|
||
f.timer = 10 + Math.random() * 6;
|
||
f.purpose = 'depart';
|
||
}
|
||
}
|
||
|
||
// Pose: position + swell bob + gentle roll.
|
||
f.mesh.position.set(
|
||
f.p[0],
|
||
0.1 + beachWaveY(f.p[0], f.p[2], time) * 0.5,
|
||
f.p[2]
|
||
);
|
||
f.mesh.rotation.y = f.yaw;
|
||
f.mesh.rotation.z = Math.sin(time * 0.7 + f.phase) * 0.03;
|
||
}
|
||
|
||
// ---- Speedboats: perpetual loops ----
|
||
for (const b of boats) {
|
||
if (stepAlong(b, b.speed, dt)) b.leg = 0;
|
||
b.mesh.position.set(
|
||
b.p[0],
|
||
0.15 + beachWaveY(b.p[0], b.p[2], time) * 0.7,
|
||
b.p[2]
|
||
);
|
||
b.mesh.rotation.y = b.yaw;
|
||
b.mesh.rotation.z = Math.sin(time * 1.1 + b.phase) * 0.07;
|
||
}
|
||
|
||
// ---- Yard trucks on their loop ----
|
||
for (const tr of trucks) {
|
||
if (stepAlong(tr, tr.speed, dt)) tr.leg = 0;
|
||
tr.mesh.position.set(tr.p[0], 0, tr.p[2]);
|
||
tr.mesh.rotation.y = tr.yaw;
|
||
}
|
||
|
||
// ---- Gantry cranes: load cycle while their berth is occupied ----
|
||
for (const cr of craneRuntime) {
|
||
const busy = fleet.some(
|
||
f => f.spec.berth === cr.berthId && f.state === 'berthed'
|
||
);
|
||
cr.busy = busy;
|
||
if (busy) cr.t = (cr.t + dt / 13) % 1;
|
||
const t = busy ? cr.t : 0;
|
||
cr.trolley.position.x = kf(t, [
|
||
[0, cr.cx], [0.18, cr.cx + 32],
|
||
[0.60, cr.cx + 32], [0.72, cr.cx], [1, cr.cx],
|
||
]);
|
||
const hookY = kf(t, [
|
||
[0, 13], [0.18, 13], [0.30, cr.deckY],
|
||
[0.40, 12.5], [0.60, 12.5],
|
||
[0.72, 3.2], [0.82, 13], [1, 13],
|
||
]);
|
||
cr.cable.scale.y = Math.max(0.1, 14.0 - hookY);
|
||
cr.cable.position.y = (14.0 + hookY) / 2;
|
||
cr.spreader.position.y = hookY;
|
||
const carrying = busy && t >= 0.28 && t < 0.70;
|
||
cr.load.visible = carrying;
|
||
cr.load.position.y = hookY - 1.6;
|
||
}
|
||
},
|
||
};
|
||
|
||
return system;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Personal drone (per logged-in session): first-person quad with a
|
||
// command panel whose "video" hole shows the drone camera feed — the
|
||
// renderer draws that exact scissored region of the main canvas, so no
|
||
// second WebGL context is needed.
|
||
// ------------------------------------------------------------------
|
||
let drone = {
|
||
active: false,
|
||
created: false,
|
||
pos: new THREE.Vector3(0, 30, 0),
|
||
yaw: 0,
|
||
pitch: -0.55,
|
||
vel: new THREE.Vector3(),
|
||
mesh: null,
|
||
cam: null,
|
||
qPrev: false,
|
||
frame: 0,
|
||
teleTimer: 0,
|
||
view: null,
|
||
};
|
||
|
||
/** Quadcopter: nose toward −Z (matches drone.yaw like ships' convention). */
|
||
function buildDroneMesh() {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x232a35, roughness: 0.5, metalness: 0.5 });
|
||
const darkMat = new THREE.MeshStandardMaterial({ color: 0x141920, roughness: 0.6 });
|
||
|
||
const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.24, 1.1), bodyMat);
|
||
g.add(body);
|
||
const nose = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.18, 0.45), bodyMat);
|
||
nose.position.set(0, 0.04, -0.72);
|
||
g.add(nose);
|
||
|
||
// X-arms.
|
||
for (const rot of [Math.PI / 4, -Math.PI / 4]) {
|
||
const arm = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.07, 0.14), darkMat);
|
||
arm.rotation.y = rot;
|
||
g.add(arm);
|
||
}
|
||
|
||
// Motors, spinning rotors and skids at the four tips.
|
||
const rotors = [];
|
||
for (const [ox, oz] of [[0.7, 0.7], [0.7, -0.7], [-0.7, 0.7], [-0.7, -0.7]]) {
|
||
const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.12, 0.15, 8), darkMat);
|
||
motor.position.set(ox, 0.1, oz);
|
||
g.add(motor);
|
||
const rotor = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.45, 0.45, 0.03, 12),
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0x9aa4b2, roughness: 0.3, metalness: 0.6, transparent: true, opacity: 0.7,
|
||
})
|
||
);
|
||
rotor.position.set(ox, 0.21, oz);
|
||
g.add(rotor);
|
||
rotors.push(rotor);
|
||
const skid = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.35, 0.07), darkMat);
|
||
skid.position.set(ox, -0.25, oz);
|
||
g.add(skid);
|
||
}
|
||
|
||
// Camera gimbal + lens pointing forward (−Z) and a status LED.
|
||
const gimbal = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.14, 10, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0x0e1620, roughness: 0.2, metalness: 0.8 })
|
||
);
|
||
gimbal.position.set(0, -0.14, -0.55);
|
||
g.add(gimbal);
|
||
const lens = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.06, 0.07, 0.1, 10),
|
||
new THREE.MeshStandardMaterial({ color: 0x164e63, roughness: 0.2, metalness: 0.7 })
|
||
);
|
||
lens.rotation.x = -Math.PI / 2;
|
||
lens.position.set(0, -0.14, -0.7);
|
||
g.add(lens);
|
||
const led = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.05, 8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xff3344, emissive: 0xff3344, emissiveIntensity: 2 })
|
||
);
|
||
led.position.set(0, 0.18, -0.45);
|
||
g.add(led);
|
||
|
||
g.userData = { rotors, led };
|
||
g.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
return g;
|
||
}
|
||
|
||
/** Activate/deactivate the personal drone (spawn above the player). */
|
||
function toggleDrone() {
|
||
if (!drone.mesh) {
|
||
drone.pos.set(
|
||
player.pos.x,
|
||
Math.max(30, (player.pos.y || 0) + 28),
|
||
player.pos.z
|
||
);
|
||
drone.yaw = player.yaw;
|
||
drone.pitch = -0.55;
|
||
drone.vel.set(0, 0, 0);
|
||
drone.mesh = buildDroneMesh();
|
||
drone.mesh.position.copy(drone.pos);
|
||
worldGroup.add(drone.mesh);
|
||
drone.cam = new THREE.PerspectiveCamera(68, 1.6, 0.1, 800);
|
||
drone.created = true;
|
||
}
|
||
drone.active = !drone.active;
|
||
|
||
const panel = document.getElementById('drone-panel');
|
||
if (panel) panel.style.display = drone.active ? 'block' : 'none';
|
||
|
||
if (drone.mesh?.userData.led) {
|
||
const hex = drone.active ? 0x22ff66 : 0xff3344;
|
||
drone.mesh.userData.led.material.color.setHex(hex);
|
||
drone.mesh.userData.led.material.emissive.setHex(hex);
|
||
}
|
||
}
|
||
|
||
/** Drone flight, rotor spin, telemetry and camera update (per frame). */
|
||
function updateDrone(dt) {
|
||
if (!drone.mesh) return;
|
||
|
||
// Q toggles (edge-detected through the shared key map).
|
||
const q = !!keys['KeyQ'];
|
||
if (q && !drone.qPrev) toggleDrone();
|
||
drone.qPrev = q;
|
||
|
||
const time = performance.now() / 1000;
|
||
const spin = drone.active ? 46 : 9;
|
||
for (const r of drone.mesh.userData.rotors) r.rotation.y += spin * dt;
|
||
|
||
let inFwd = 0;
|
||
let inRight = 0;
|
||
let inAsc = 0;
|
||
if (drone.active) {
|
||
if (keys['KeyW']) inFwd += 1;
|
||
if (keys['KeyS']) inFwd -= 1;
|
||
if (keys['KeyD']) inRight += 1;
|
||
if (keys['KeyA']) inRight -= 1;
|
||
if (keys['Space']) inAsc += 1;
|
||
if (keys['KeyC']) inAsc -= 1;
|
||
if (keys['ArrowLeft']) drone.yaw += 2.2 * dt;
|
||
if (keys['ArrowRight']) drone.yaw -= 2.2 * dt;
|
||
if (keys['ArrowUp']) drone.pitch = Math.min(1.2, drone.pitch + 1.6 * dt);
|
||
if (keys['ArrowDown']) drone.pitch = Math.max(-1.4, drone.pitch - 1.6 * dt);
|
||
|
||
const fwd = new THREE.Vector3(-Math.sin(drone.yaw), 0, -Math.cos(drone.yaw));
|
||
const right = new THREE.Vector3(Math.cos(drone.yaw), 0, -Math.sin(drone.yaw));
|
||
const wish = new THREE.Vector3()
|
||
.addScaledVector(fwd, inFwd * 20)
|
||
.addScaledVector(right, inRight * 20);
|
||
wish.y = inAsc * 9;
|
||
// Smooth acceleration (inertial feel).
|
||
drone.vel.lerp(wish, 1 - Math.exp(-4 * dt));
|
||
drone.pos.addScaledVector(drone.vel, dt);
|
||
|
||
// Flight envelope: generous box over city, beach and port.
|
||
drone.pos.x = Math.max(-500, Math.min(500, drone.pos.x));
|
||
drone.pos.z = Math.max(-500, Math.min(500, drone.pos.z));
|
||
drone.pos.y = Math.max(4, Math.min(150, drone.pos.y));
|
||
} else {
|
||
drone.vel.multiplyScalar(Math.exp(-3 * dt)); // rotors spool down
|
||
}
|
||
|
||
// Pose: heading, forward dip, strafe bank, idle hover bob.
|
||
drone.mesh.position.copy(drone.pos);
|
||
if (!drone.active) drone.mesh.position.y += Math.sin(time * 2) * 0.05;
|
||
drone.mesh.rotation.y = drone.yaw;
|
||
const targetPitch = drone.active ? -inFwd * 0.12 : 0;
|
||
const targetBank = drone.active ? -inRight * 0.22 : 0;
|
||
drone.mesh.rotation.x += (targetPitch - drone.mesh.rotation.x) * Math.min(1, dt * 6);
|
||
drone.mesh.rotation.z += (targetBank - drone.mesh.rotation.z) * Math.min(1, dt * 6);
|
||
|
||
// Camera: gimbal just below the nose, following yaw/pitch.
|
||
drone.cam.position.set(drone.pos.x, drone.pos.y - 0.12, drone.pos.z);
|
||
const cp = Math.cos(drone.pitch);
|
||
drone.cam.lookAt(
|
||
drone.pos.x - Math.sin(drone.yaw) * cp,
|
||
drone.pos.y + Math.sin(drone.pitch),
|
||
drone.pos.z - Math.cos(drone.yaw) * cp
|
||
);
|
||
|
||
// Telemetry (5 Hz).
|
||
drone.teleTimer -= dt;
|
||
if (drone.active && drone.teleTimer <= 0) {
|
||
drone.teleTimer = 0.2;
|
||
const tele = document.getElementById('drone-tele');
|
||
if (tele) {
|
||
const spd = Math.round(drone.vel.length());
|
||
const deg = Math.round(((drone.yaw * 180) / Math.PI % 360 + 360) % 360);
|
||
tele.textContent = `↑${Math.round(drone.pos.y)} m · ${spd} m/s · ${deg}°`;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Draw the drone camera feed into the panel's transparent region using a
|
||
* scissored viewport (30 fps alternate frames to halve the extra cost).
|
||
*/
|
||
function renderDroneView() {
|
||
const video = document.getElementById('drone-video');
|
||
if (!video || !drone.cam) return;
|
||
const vr = video.getBoundingClientRect();
|
||
if (vr.width < 8 || vr.height < 8) return;
|
||
const canvas = renderer.domElement;
|
||
const cr = canvas.getBoundingClientRect();
|
||
|
||
const x = Math.round(vr.left - cr.left);
|
||
const yTop = Math.round(vr.top - cr.top);
|
||
const w = Math.round(vr.width);
|
||
const h = Math.round(vr.height);
|
||
// GL viewports are bottom-up.
|
||
const y = Math.round(cr.height - yTop - h);
|
||
|
||
drone.cam.aspect = w / h;
|
||
drone.cam.updateProjectionMatrix();
|
||
|
||
// Neutralise the city fog for the drone feed so the aerial view is
|
||
// never misted ("embassado"). Only the fog uniform values change for
|
||
// this pass — three refreshes them per render, so no material is
|
||
// recompiled and the ground view keeps its weather haze.
|
||
const fog = scene.fog;
|
||
const fogNear = fog ? fog.near : 0;
|
||
const fogFar = fog ? fog.far : 0;
|
||
if (fog) {
|
||
fog.near = 1e7;
|
||
fog.far = 2e7; // smoothstep → factor 0 at any real distance
|
||
}
|
||
|
||
renderer.setScissorTest(true);
|
||
renderer.setScissor(x, y, w, h);
|
||
renderer.setViewport(x, y, w, h);
|
||
renderer.render(scene, drone.cam); // clears only the scissored hole
|
||
renderer.setScissorTest(false);
|
||
renderer.setViewport(0, 0, cr.width, cr.height);
|
||
|
||
if (fog) {
|
||
fog.near = fogNear;
|
||
fog.far = fogFar;
|
||
}
|
||
|
||
drone.fogCleared = !!fog;
|
||
drone.view = { x, y, w, h };
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Train system + level crossing
|
||
// ------------------------------------------------------------------
|
||
function initTrain(g) {
|
||
const trackZ = g.maxZ + 14;
|
||
const xMid = g.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.xs[0]);
|
||
const trackFrom = g.minX - 70;
|
||
const trackTo = g.maxX + 70;
|
||
|
||
const railMat = new THREE.MeshStandardMaterial({ color: 0x6a6a72, roughness: 0.4, metalness: 0.8 });
|
||
const bedMat = new THREE.MeshStandardMaterial({ color: 0x5a5248, roughness: 1 });
|
||
const bed = new THREE.Mesh(
|
||
new THREE.BoxGeometry(trackTo - trackFrom, 0.18, 3.6),
|
||
bedMat
|
||
);
|
||
bed.position.set((trackFrom + trackTo) / 2, 0.09, trackZ);
|
||
worldGroup.add(bed);
|
||
|
||
for (const off of [-0.75, 0.75]) {
|
||
const rail = new THREE.Mesh(new THREE.BoxGeometry(trackTo - trackFrom, 0.14, 0.12), railMat);
|
||
rail.position.set((trackFrom + trackTo) / 2, 0.24, trackZ + off);
|
||
worldGroup.add(rail);
|
||
}
|
||
|
||
const sleeperGeo = new THREE.BoxGeometry(0.35, 0.1, 2.6);
|
||
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3d2f22, roughness: 0.9 });
|
||
const count = Math.floor((trackTo - trackFrom) / 2);
|
||
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
|
||
const m4 = new THREE.Matrix4();
|
||
for (let i = 0; i < count; i++) {
|
||
m4.setPosition(trackFrom + i * 2 + 1, 0.16, trackZ);
|
||
sleepers.setMatrixAt(i, m4);
|
||
}
|
||
worldGroup.add(sleepers);
|
||
|
||
const train = new THREE.Group();
|
||
const locoMat = new THREE.MeshStandardMaterial({ color: 0xb03030, roughness: 0.5, metalness: 0.3 });
|
||
const loco = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.4), locoMat);
|
||
loco.position.y = 1.5;
|
||
loco.castShadow = true;
|
||
train.add(loco);
|
||
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 1.4, 2.2), new THREE.MeshStandardMaterial({ color: 0x882222 }));
|
||
cab.position.set(2.4, 3.2, 0);
|
||
train.add(cab);
|
||
|
||
const carColors = [0x2a5a8a, 0x2a5a8a, 0x3a7a4a, 0x2a5a8a];
|
||
carColors.forEach((col, i) => {
|
||
const car = new THREE.Mesh(
|
||
new THREE.BoxGeometry(6.5, 2.2, 2.3),
|
||
new THREE.MeshStandardMaterial({ color: col, roughness: 0.6 })
|
||
);
|
||
car.position.set(-6.5 - i * 7.2, 1.4, 0);
|
||
car.castShadow = true;
|
||
train.add(car);
|
||
});
|
||
worldGroup.add(train);
|
||
|
||
// Level crossing barriers on the vertical road that crosses the track.
|
||
const barriers = [];
|
||
for (const side of [-1, 1]) {
|
||
const pivot = new THREE.Group();
|
||
pivot.position.set(xMid + side * (g.roadW / 2 + 2.2), 1.1, trackZ + side * 2.6);
|
||
|
||
const armGeo = new THREE.BoxGeometry(7, 0.18, 0.25);
|
||
// Shift geometry so the pivot sits at the post end of the arm.
|
||
armGeo.translate(side > 0 ? -3.5 : 3.5, 0, 0);
|
||
const armMesh = new THREE.Mesh(
|
||
armGeo,
|
||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 })
|
||
);
|
||
pivot.add(armMesh);
|
||
|
||
const lamp = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.16, 8, 8),
|
||
new THREE.MeshStandardMaterial({ color: 0xff2222, emissive: 0xff2222, emissiveIntensity: 0.3 })
|
||
);
|
||
lamp.position.y = 1.1;
|
||
pivot.add(lamp);
|
||
|
||
const post = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.1, 0.12, 2.2, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0x888888 })
|
||
);
|
||
pivot.add(post);
|
||
|
||
// Start raised (open).
|
||
pivot.rotation.z = -Math.PI / 2;
|
||
worldGroup.add(pivot);
|
||
barriers.push({ pivot, lamp });
|
||
}
|
||
|
||
const state = {
|
||
s: trackFrom + 30,
|
||
dir: 1,
|
||
speed: 14,
|
||
pause: 0,
|
||
blocks: false,
|
||
crossZ: trackZ,
|
||
xMid,
|
||
trackFrom,
|
||
trackTo,
|
||
train,
|
||
barriers,
|
||
};
|
||
|
||
trainSystem = {
|
||
...state,
|
||
blocksCrossing() { return this.blocks; },
|
||
update(dt) {
|
||
if (this.pause > 0) {
|
||
this.pause -= dt;
|
||
} else {
|
||
this.s += this.dir * this.speed * dt;
|
||
if (this.s >= this.trackTo - 40) {
|
||
this.s = this.trackTo - 40;
|
||
this.dir = -1;
|
||
this.pause = 3;
|
||
this.train.rotation.y = Math.PI;
|
||
} else if (this.s <= this.trackFrom + 40) {
|
||
this.s = this.trackFrom + 40;
|
||
this.dir = 1;
|
||
this.pause = 3;
|
||
this.train.rotation.y = 0;
|
||
}
|
||
}
|
||
const noseX = this.s + this.dir * 4;
|
||
this.train.position.set(this.s, 0, this.trackZ ?? this.crossZ);
|
||
|
||
const distToCross = Math.abs(noseX - this.xMid);
|
||
this.blocks = distToCross < 45 && this.pause <= 0;
|
||
|
||
const blink = Math.sin(performance.now() * 0.012) > 0;
|
||
for (const b of this.barriers) {
|
||
const target = this.blocks ? 0 : -Math.PI / 2;
|
||
b.pivot.rotation.z += (target - b.pivot.rotation.z) * Math.min(1, dt * 4);
|
||
b.lamp.material.emissiveIntensity = this.blocks ? (blink ? 3 : 0.2) : 0.2;
|
||
}
|
||
},
|
||
};
|
||
trainSystem.crossZ = trackZ;
|
||
trainSystem.trackZ = trackZ;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// River + bridge
|
||
// ------------------------------------------------------------------
|
||
function initRiver(g) {
|
||
const riverX = g.minX - 30;
|
||
const riverW = 16;
|
||
const zMid = g.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, g.zs[0]);
|
||
const len = (g.maxZ - g.minZ) + 140;
|
||
|
||
const waterTex = canvasTexture(256, (ctx, s) => {
|
||
const grad = ctx.createLinearGradient(0, 0, s, s);
|
||
grad.addColorStop(0, '#1a5f8a');
|
||
grad.addColorStop(0.5, '#2a7fb0');
|
||
grad.addColorStop(1, '#1a5f8a');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, s, s);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
||
ctx.lineWidth = 3;
|
||
for (let i = 0; i < 14; i++) {
|
||
const y = Math.random() * s;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, y);
|
||
for (let x = 0; x <= s; x += 16) {
|
||
ctx.lineTo(x, y + Math.sin(x * 0.08 + i) * 6);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
}, 3, 10);
|
||
|
||
const geo = new THREE.PlaneGeometry(riverW, len, 6, 40);
|
||
const water = new THREE.Mesh(
|
||
geo,
|
||
new THREE.MeshStandardMaterial({
|
||
map: waterTex, color: 0x88bbdd, roughness: 0.25, metalness: 0.35,
|
||
transparent: true, opacity: 0.92,
|
||
})
|
||
);
|
||
water.rotation.x = -Math.PI / 2;
|
||
water.position.set(riverX, 0.04, (g.minZ + g.maxZ) / 2);
|
||
water.receiveShadow = true;
|
||
worldGroup.add(water);
|
||
|
||
const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7f66, roughness: 1 });
|
||
for (const side of [-1, 1]) {
|
||
const bank = new THREE.Mesh(new THREE.BoxGeometry(4, 0.3, len), bankMat);
|
||
bank.position.set(riverX + side * (riverW / 2 + 1.5), 0.1, (g.minZ + g.maxZ) / 2);
|
||
worldGroup.add(bank);
|
||
}
|
||
|
||
const basePos = Float32Array.from(geo.attributes.position.array);
|
||
|
||
// Bridge where the mid horizontal road extension crosses the river.
|
||
const bridge = new THREE.Mesh(
|
||
new THREE.BoxGeometry(riverW + 8, 0.25, 9),
|
||
new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.9 })
|
||
);
|
||
bridge.position.set(riverX, 0.05, zMid);
|
||
bridge.receiveShadow = true;
|
||
worldGroup.add(bridge);
|
||
|
||
const railMat = new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.6, metalness: 0.4 });
|
||
for (const side of [-1, 1]) {
|
||
const rail = new THREE.Mesh(new THREE.BoxGeometry(riverW + 8, 0.7, 0.15), railMat);
|
||
rail.position.set(riverX, 0.5, zMid + side * 4.4);
|
||
worldGroup.add(rail);
|
||
const pier = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1.2, 2.2, 1.2),
|
||
new THREE.MeshStandardMaterial({ color: 0x555560, roughness: 0.9 })
|
||
);
|
||
pier.position.set(riverX, -0.9, zMid + side * 3);
|
||
worldGroup.add(pier);
|
||
}
|
||
|
||
riverSystem = {
|
||
riverX, riverW, zMid, water, basePos,
|
||
update(dt, t) {
|
||
waterTex.offset.y = (waterTex.offset.y + dt * 0.04) % 1;
|
||
waterTex.offset.x = (waterTex.offset.x + dt * 0.012) % 1;
|
||
const pos = geo.attributes.position;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const bx = basePos[i * 3];
|
||
const by = basePos[i * 3 + 1];
|
||
pos.array[i * 3 + 2] =
|
||
Math.sin(bx * 0.6 + t * 2.2) * 0.09 +
|
||
Math.sin(by * 0.35 + t * 1.6) * 0.07;
|
||
}
|
||
pos.needsUpdate = true;
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// City builder
|
||
// ------------------------------------------------------------------
|
||
const layoutExtras = { bridgeRoad: null, crossRoad: null };
|
||
|
||
// ------------------------------------------------------------------
|
||
// Public plazas + outdoor gym equipment
|
||
// ------------------------------------------------------------------
|
||
function buildGymEquipment(kind, x, z) {
|
||
const g = new THREE.Group();
|
||
const metal = new THREE.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.45, metalness: 0.6 });
|
||
const accent = new THREE.MeshStandardMaterial({ color: 0x2563eb, roughness: 0.5, metalness: 0.3 });
|
||
const pad = new THREE.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.9 });
|
||
|
||
if (kind === 'bench') {
|
||
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.12, 0.5), pad);
|
||
seat.position.y = 0.55;
|
||
g.add(seat);
|
||
[-0.6, 0.6].forEach(px => {
|
||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.55, 0.4), metal);
|
||
leg.position.set(px, 0.28, 0);
|
||
g.add(leg);
|
||
});
|
||
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
|
||
bar.rotation.z = Math.PI / 2;
|
||
bar.position.set(0, 0.95, 0);
|
||
g.add(bar);
|
||
[-0.7, 0.7].forEach(px => {
|
||
const plate = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 0.1, 10), accent);
|
||
plate.rotation.z = Math.PI / 2;
|
||
plate.position.set(px, 0.95, 0);
|
||
g.add(plate);
|
||
});
|
||
} else if (kind === 'bars') {
|
||
[-0.8, 0.8].forEach(px => {
|
||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 2.2, 8), metal);
|
||
post.position.set(px, 1.1, 0);
|
||
g.add(post);
|
||
});
|
||
const top = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1.8, 8), metal);
|
||
top.rotation.z = Math.PI / 2;
|
||
top.position.set(0, 2.15, 0);
|
||
g.add(top);
|
||
const mid = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.8, 8), accent);
|
||
mid.rotation.z = Math.PI / 2;
|
||
mid.position.set(0, 1.5, 0);
|
||
g.add(mid);
|
||
} else if (kind === 'bike') {
|
||
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.0, 0.12), metal);
|
||
frame.position.set(0, 0.55, 0);
|
||
g.add(frame);
|
||
const seatB = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.1, 0.25), pad);
|
||
seatB.position.set(-0.15, 1.1, 0);
|
||
g.add(seatB);
|
||
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.5), accent);
|
||
handle.position.set(0.3, 1.15, 0);
|
||
g.add(handle);
|
||
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.35, 0.04, 8, 16), metal);
|
||
wheel.position.set(0.1, 0.4, 0);
|
||
g.add(wheel);
|
||
g.userData.spin = wheel;
|
||
} else if (kind === 'treadmill') {
|
||
const deck = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.12, 0.9), pad);
|
||
deck.position.set(0, 0.18, 0);
|
||
g.add(deck);
|
||
const railL = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.08, 0.08), metal);
|
||
railL.position.set(0, 0.7, 0.4);
|
||
g.add(railL);
|
||
const railR = railL.clone();
|
||
railR.position.z = -0.4;
|
||
g.add(railR);
|
||
const consoleBox = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.55, 0.7), accent);
|
||
consoleBox.position.set(1.1, 0.55, 0);
|
||
g.add(consoleBox);
|
||
}
|
||
|
||
const ring = new THREE.Mesh(
|
||
new THREE.RingGeometry(1.1, 1.45, 24),
|
||
new THREE.MeshBasicMaterial({ color: 0x22c55e, side: THREE.DoubleSide, transparent: true, opacity: 0.45 })
|
||
);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.position.y = 0.08;
|
||
g.add(ring);
|
||
g.userData.glowRing = ring;
|
||
|
||
g.position.set(x, 0, z);
|
||
g.userData.kind = kind;
|
||
g.userData.baseY = 0;
|
||
worldGroup.add(g);
|
||
|
||
// Soft collider so the player stands next to equipment without walking through the frame.
|
||
addCollider(x, 0, z, 1.4, 1.2, 1.4);
|
||
|
||
const item = { id: `${kind}-${x.toFixed(1)}-${z.toFixed(1)}`, kind, x, z, mesh: g };
|
||
gymEquipment.push(item);
|
||
return item;
|
||
}
|
||
|
||
function initPlazas(list) {
|
||
plazas = Array.isArray(list) ? list : [];
|
||
gymEquipment = [];
|
||
|
||
const grassMat = new THREE.MeshStandardMaterial({ color: 0x3f7d3a, roughness: 1 });
|
||
const pathMat = new THREE.MeshStandardMaterial({ color: 0xc4b8a0, roughness: 0.95 });
|
||
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.9 });
|
||
const leafMat = new THREE.MeshStandardMaterial({ color: 0x2f6b2a, roughness: 0.85 });
|
||
|
||
for (const p of plazas) {
|
||
const [px, pz] = p.position;
|
||
const [pw, pd] = p.size || [22, 22];
|
||
|
||
const lawn = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.12, pd), grassMat);
|
||
lawn.position.set(px, 0.06, pz);
|
||
lawn.receiveShadow = true;
|
||
worldGroup.add(lawn);
|
||
|
||
// Cross paths through the plaza.
|
||
const pathA = new THREE.Mesh(new THREE.BoxGeometry(pw, 0.04, 2.2), pathMat);
|
||
pathA.position.set(px, 0.13, pz);
|
||
worldGroup.add(pathA);
|
||
const pathB = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.04, pd), pathMat);
|
||
pathB.position.set(px, 0.13, pz);
|
||
worldGroup.add(pathB);
|
||
|
||
// Trees on plaza corners.
|
||
const corners = [
|
||
[px - pw / 2 + 2.5, pz - pd / 2 + 2.5],
|
||
[px + pw / 2 - 2.5, pz - pd / 2 + 2.5],
|
||
[px - pw / 2 + 2.5, pz + pd / 2 - 2.5],
|
||
[px + pw / 2 - 2.5, pz + pd / 2 - 2.5],
|
||
];
|
||
corners.forEach(([tx, tz], i) => {
|
||
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.28, 1.6, 6), trunkMat);
|
||
trunk.position.set(tx, 0.9, tz);
|
||
trunk.castShadow = true;
|
||
worldGroup.add(trunk);
|
||
const crown = new THREE.Mesh(new THREE.SphereGeometry(1.1 + (i % 2) * 0.2, 10, 10), leafMat);
|
||
crown.position.set(tx, 2.4, tz);
|
||
crown.castShadow = true;
|
||
worldGroup.add(crown);
|
||
});
|
||
|
||
// Central fountain basin.
|
||
const basin = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(2.2, 2.4, 0.45, 16),
|
||
new THREE.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.7 })
|
||
);
|
||
basin.position.set(px, 0.25, pz);
|
||
basin.castShadow = true;
|
||
worldGroup.add(basin);
|
||
const water = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(1.9, 1.9, 0.1, 16),
|
||
new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.2, metalness: 0.3, transparent: true, opacity: 0.85 })
|
||
);
|
||
water.position.set(px, 0.42, pz);
|
||
worldGroup.add(water);
|
||
|
||
// Luminous plaza letreiro on the perimeter, facing the nearest road.
|
||
const plazaFace = faceNearestRoad(px, pz, grid);
|
||
const [pOffX, pOffZ] = facingOffset(plazaFace, pw / 2 + 1.4, pd / 2 + 1.4);
|
||
const plazaNum = (p.id || '').split('-')[1] || '';
|
||
const plazaLabel = plazaNum
|
||
? `${i18n.signPlaza || 'Praça'} ${plazaNum}`
|
||
: (i18n.signPlaza || 'Praça');
|
||
const plazaSign = buildLitSign(plazaLabel, {
|
||
w: 5.4,
|
||
h: 1.5,
|
||
posts: 'double',
|
||
postH: 2.2,
|
||
accent: '#facc15',
|
||
});
|
||
plazaSign.position.set(px + pOffX, 0, pz + pOffZ);
|
||
plazaSign.rotation.y = plazaFace;
|
||
worldGroup.add(plazaSign);
|
||
signCounts.plazas++;
|
||
|
||
for (const eq of p.equipment || []) {
|
||
buildGymEquipment(eq.kind, eq.position[0], eq.position[1]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Beach (east shore): sand, animated sea, food stalls, chairs, court,
|
||
// lifeguards, bathers, surfers and basket players.
|
||
// ------------------------------------------------------------------
|
||
|
||
/** Shared wave height — vertex animation and swimmers/surfers use it. */
|
||
function beachWaveY(x, z, t) {
|
||
return Math.sin(x * 0.16 + t * 1.5) * 0.3 + Math.sin(z * 0.11 + t * 0.9) * 0.2;
|
||
}
|
||
|
||
/**
|
||
* Build the whole east-beach scene from the payload geometry.
|
||
*
|
||
* @param {object} cfg beach geometry (sand_from/sand_to/sea_to/z_min/z_max)
|
||
* @returns {{update: Function, counts: Function}}
|
||
*/
|
||
function initBeach(cfg, portCfg = null) {
|
||
const sandFrom = cfg.sand_from;
|
||
const sandTo = cfg.sand_to;
|
||
const seaTo = cfg.sea_to;
|
||
const zMin = cfg.z_min;
|
||
const zMax = cfg.z_max;
|
||
const midZ = (zMin + zMax) / 2;
|
||
const zDepth = zMax - zMin;
|
||
// The sea extends north over the port basin (and the free half of
|
||
// that coast, kept open for future city expansion).
|
||
const seaZMin = Math.min(zMin, portCfg?.water?.z_min ?? zMin);
|
||
const seaSpan = zMax - seaZMin;
|
||
const seaCenterZ = (zMax + seaZMin) / 2;
|
||
|
||
const group = new THREE.Group();
|
||
const counts = {
|
||
bathers: 0, swimmers: 0, surfers: 0, basket: 0,
|
||
lifeguards: 0, chairs: 0, umbrellas: 0, stalls: 0, towers: 0,
|
||
};
|
||
|
||
// ---- Sand + wet shoreline strip ----
|
||
const sandMat = new THREE.MeshStandardMaterial({ color: 0xe7d6a3, roughness: 1 });
|
||
const sand = new THREE.Mesh(new THREE.PlaneGeometry(sandTo - sandFrom, zDepth), sandMat);
|
||
sand.rotation.x = -Math.PI / 2;
|
||
sand.position.set((sandFrom + sandTo) / 2, 0.05, midZ);
|
||
sand.receiveShadow = true;
|
||
group.add(sand);
|
||
|
||
const wetMat = new THREE.MeshStandardMaterial({ color: 0xcbb982, roughness: 0.85, metalness: 0.08 });
|
||
const wet = new THREE.Mesh(new THREE.PlaneGeometry(6, zDepth), wetMat);
|
||
wet.rotation.x = -Math.PI / 2;
|
||
wet.position.set(sandTo - 3, 0.055, midZ);
|
||
group.add(wet);
|
||
|
||
// ---- Sea with animated waves (one mesh, vertex displacement) ----
|
||
const seaW = seaTo - sandTo;
|
||
const seaGeo = new THREE.PlaneGeometry(
|
||
seaW, seaSpan, 40,
|
||
Math.max(16, Math.round((56 * seaSpan) / zDepth))
|
||
);
|
||
const seaMat = new THREE.MeshStandardMaterial({
|
||
color: 0x2f7fb5, roughness: 0.35, metalness: 0.15,
|
||
transparent: true, opacity: 0.94,
|
||
});
|
||
const seaMesh = new THREE.Mesh(seaGeo, seaMat);
|
||
seaMesh.rotation.x = -Math.PI / 2;
|
||
seaMesh.position.set(sandTo + seaW / 2, 0.09, seaCenterZ);
|
||
seaMesh.frustumCulled = false;
|
||
group.add(seaMesh);
|
||
const seaBase = Float32Array.from(seaGeo.attributes.position.array);
|
||
const seaWorldX = sandTo + seaW / 2;
|
||
|
||
// Foam line where the waves break (opacity pulses with the swell).
|
||
const foamMat = new THREE.MeshStandardMaterial({
|
||
color: 0xeef6fa, roughness: 0.9, transparent: true, opacity: 0.7,
|
||
});
|
||
const foam = new THREE.Mesh(new THREE.PlaneGeometry(2.6, zDepth), foamMat);
|
||
foam.rotation.x = -Math.PI / 2;
|
||
foam.position.set(sandTo + 1.2, 0.1, midZ);
|
||
group.add(foam);
|
||
|
||
// Luminous letreiros: entrance arch over the road end (faces the
|
||
// city/west) and a food board between the stalls.
|
||
const entrance = buildLitSign(i18n.signBeach || 'Praia', {
|
||
w: 9,
|
||
h: 1.8,
|
||
posts: 'arch',
|
||
postH: 3.6,
|
||
accent: '#22d3ee',
|
||
});
|
||
entrance.position.set(sandFrom - 2, 0, 0);
|
||
entrance.rotation.y = -Math.PI / 2; // face the city (west)
|
||
group.add(entrance);
|
||
|
||
const foodSign = buildLitSign(i18n.signFood || 'Comidas', {
|
||
w: 4.4,
|
||
h: 1.2,
|
||
posts: 'double',
|
||
postH: 2.0,
|
||
accent: '#fb923c',
|
||
});
|
||
foodSign.position.set(sandFrom + 4, 0, 59);
|
||
foodSign.rotation.y = -Math.PI / 2;
|
||
group.add(foodSign);
|
||
signCounts.beach += 2;
|
||
|
||
// ---- Basketball court: painted slab + two hoops ----
|
||
const courtTex = canvasTexture(512, (ctx, s) => {
|
||
ctx.fillStyle = '#c9a06a';
|
||
ctx.fillRect(0, 0, s, s);
|
||
ctx.strokeStyle = '#f4efe3';
|
||
ctx.lineWidth = 6;
|
||
ctx.strokeRect(26, 26, s - 52, s - 52);
|
||
ctx.beginPath();
|
||
ctx.moveTo(26, s / 2);
|
||
ctx.lineTo(s - 26, s / 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(s / 2, s / 2, 58, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
// Keys at both ends.
|
||
const keyW = 110, keyH = 150;
|
||
ctx.strokeRect(s / 2 - keyW / 2, 26, keyW, keyH);
|
||
ctx.strokeRect(s / 2 - keyW / 2, s - 26 - keyH, keyW, keyH);
|
||
ctx.beginPath();
|
||
ctx.arc(s / 2, 26 + keyH, 58, 0, Math.PI);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.arc(s / 2, s - 26 - keyH, 58, Math.PI, Math.PI * 2);
|
||
ctx.stroke();
|
||
}, 1, 1);
|
||
|
||
const courtX = sandFrom + 24;
|
||
const courtZ = -95;
|
||
const court = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(16, 26),
|
||
new THREE.MeshStandardMaterial({ map: courtTex, roughness: 0.95 })
|
||
);
|
||
court.rotation.x = -Math.PI / 2;
|
||
court.position.set(courtX, 0.065, courtZ);
|
||
court.receiveShadow = true;
|
||
group.add(court);
|
||
|
||
const poleMatBeach = new THREE.MeshStandardMaterial({ color: 0x5a5f66, roughness: 0.6, metalness: 0.4 });
|
||
const boardMat = new THREE.MeshStandardMaterial({ color: 0xf2e9d8, roughness: 0.7 });
|
||
const ringMat = new THREE.MeshStandardMaterial({ color: 0xe86a17, roughness: 0.5, metalness: 0.3 });
|
||
const addHoop = (zEnd, dir) => {
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 3.2, 8), poleMatBeach);
|
||
pole.position.set(courtX, 1.6, zEnd);
|
||
pole.castShadow = true;
|
||
group.add(pole);
|
||
|
||
const board = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.05, 0.1), boardMat);
|
||
board.position.set(courtX, 3.05, zEnd - dir * 0.3);
|
||
board.castShadow = true;
|
||
group.add(board);
|
||
|
||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.24, 0.035, 8, 20), ringMat);
|
||
ring.rotation.x = Math.PI / 2;
|
||
ring.position.set(courtX, 2.9, zEnd - dir * 0.75);
|
||
group.add(ring);
|
||
};
|
||
addHoop(courtZ - 13.5, 1);
|
||
addHoop(courtZ + 13.5, -1);
|
||
|
||
// ---- Food stalls (striped awnings) ----
|
||
const awningTex = canvasTexture(128, (ctx, s) => {
|
||
for (let i = 0; i < 8; i++) {
|
||
ctx.fillStyle = i % 2 ? '#d94b3a' : '#f7f2e6';
|
||
ctx.fillRect((i * s) / 8, 0, s / 8, s);
|
||
}
|
||
}, 2, 1);
|
||
const woodMat = new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.9 });
|
||
const woodTopMat = new THREE.MeshStandardMaterial({ color: 0x6e4526, roughness: 0.85 });
|
||
const awningMat = new THREE.MeshStandardMaterial({ map: awningTex, roughness: 0.85, side: THREE.DoubleSide });
|
||
const postMat = new THREE.MeshStandardMaterial({ color: 0xd8c9a8, roughness: 0.9 });
|
||
|
||
const addStall = (z) => {
|
||
const x = sandFrom + 5;
|
||
const counter = new THREE.Mesh(new THREE.BoxGeometry(3.6, 1.05, 2.2), woodMat);
|
||
counter.position.set(x, 0.53, z);
|
||
counter.castShadow = true;
|
||
counter.receiveShadow = true;
|
||
group.add(counter);
|
||
|
||
const top = new THREE.Mesh(new THREE.BoxGeometry(3.9, 0.08, 2.5), woodTopMat);
|
||
top.position.set(x, 1.1, z);
|
||
group.add(top);
|
||
|
||
for (const [ox, oz] of [[-1.6, -1], [1.6, -1], [-1.6, 1], [1.6, 1]]) {
|
||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.3, 6), postMat);
|
||
post.position.set(x + ox, 1.2, z + oz);
|
||
post.castShadow = true;
|
||
group.add(post);
|
||
}
|
||
|
||
const awning = new THREE.Mesh(new THREE.BoxGeometry(4.3, 0.1, 3.1), awningMat);
|
||
awning.position.set(x, 2.4, z);
|
||
awning.rotation.z = 0.06;
|
||
awning.castShadow = true;
|
||
group.add(awning);
|
||
|
||
// Crates of fruit on the counter.
|
||
const crate1 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
|
||
new THREE.MeshStandardMaterial({ color: 0xd93b3b, roughness: 0.8 }));
|
||
crate1.position.set(x - 0.8, 1.28, z + 0.3);
|
||
group.add(crate1);
|
||
const crate2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.28, 0.5),
|
||
new THREE.MeshStandardMaterial({ color: 0xe8b21e, roughness: 0.8 }));
|
||
crate2.position.set(x + 0.7, 1.28, z - 0.2);
|
||
group.add(crate2);
|
||
|
||
counts.stalls++;
|
||
};
|
||
addStall(40);
|
||
addStall(78);
|
||
|
||
// ---- Beach chairs + umbrellas facing the sea ----
|
||
const chairMat = new THREE.MeshStandardMaterial({ color: 0xf3efe4, roughness: 0.9 });
|
||
const chairBackMat = new THREE.MeshStandardMaterial({ color: 0x4a90c2, roughness: 0.9 });
|
||
const addChair = (x, z) => {
|
||
const chair = new THREE.Group();
|
||
const seat = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.08, 1.7), chairMat);
|
||
seat.position.y = 0.32;
|
||
chair.add(seat);
|
||
const back = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.62, 0.07), chairBackMat);
|
||
back.position.set(0, 0.58, -0.85);
|
||
back.rotation.x = -0.35;
|
||
chair.add(back);
|
||
for (const [ox, oz] of [[-0.3, -0.7], [0.3, -0.7], [-0.3, 0.7], [0.3, 0.7]]) {
|
||
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.3, 0.06), chairMat);
|
||
leg.position.set(ox, 0.15, oz);
|
||
chair.add(leg);
|
||
}
|
||
chair.position.set(x, 0.05, z);
|
||
chair.rotation.y = Math.PI / 2; // face the sea (+X)
|
||
chair.traverse(o => { if (o.isMesh) o.castShadow = true; });
|
||
group.add(chair);
|
||
counts.chairs++;
|
||
};
|
||
|
||
const umbrellaPoleMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8 });
|
||
const addUmbrella = (x, z) => {
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 2.5, 6), umbrellaPoleMat);
|
||
pole.position.set(x, 1.25, z);
|
||
pole.castShadow = true;
|
||
group.add(pole);
|
||
const canopy = new THREE.Mesh(new THREE.ConeGeometry(1.6, 0.6, 10), awningMat);
|
||
canopy.position.set(x, 2.7, z);
|
||
canopy.castShadow = true;
|
||
group.add(canopy);
|
||
counts.umbrellas++;
|
||
};
|
||
|
||
const chairRowA = sandFrom + 34;
|
||
const chairRowB = sandFrom + 41;
|
||
[-30, -14, 2, 18].forEach(z => addChair(chairRowA, z));
|
||
[-22, 6, 34].forEach(z => addChair(chairRowB, z));
|
||
[-22, 10].forEach(z => addUmbrella(sandFrom + 37.5, z));
|
||
|
||
// ---- Lifeguard tower + guards ----
|
||
const towerX = sandTo - 7;
|
||
const towerZ = 16;
|
||
for (const [ox, oz] of [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]]) {
|
||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.08, 2.5, 6), poleMatBeach);
|
||
leg.position.set(towerX + ox, 1.25, towerZ + oz);
|
||
leg.castShadow = true;
|
||
group.add(leg);
|
||
}
|
||
const platform = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.12, 1.7), woodMat);
|
||
platform.position.set(towerX, 2.56, towerZ);
|
||
platform.castShadow = true;
|
||
group.add(platform);
|
||
const railMat = new THREE.MeshStandardMaterial({ color: 0xd94f3a, roughness: 0.8 });
|
||
for (const [w, d, ox, oz] of [[1.7, 0.06, 0, -0.82], [0.06, 1.7, -0.82, 0], [0.06, 1.7, 0.82, 0]]) {
|
||
const rail = new THREE.Mesh(new THREE.BoxGeometry(w, 0.5, d), railMat);
|
||
rail.position.set(towerX + ox, 2.95, towerZ + oz);
|
||
group.add(rail);
|
||
}
|
||
const roof = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2), awningMat);
|
||
roof.position.set(towerX, 4.05, towerZ);
|
||
roof.castShadow = true;
|
||
group.add(roof);
|
||
for (const [ox, oz] of [[-0.8, -0.8], [0.8, -0.8], [-0.8, 0.8], [0.8, 0.8]]) {
|
||
const p = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1.35, 6), postMat);
|
||
p.position.set(towerX + ox, 3.4, towerZ + oz);
|
||
group.add(p);
|
||
}
|
||
counts.towers = 1;
|
||
|
||
// ---- Crowd: bathers, swimmers, surfers, basket players, lifeguards ----
|
||
const bathers = [];
|
||
[-124, -70, -20, 34, 92, 132].forEach((z, i) => {
|
||
const b = buildHumanoid({ type: 'bather', name: '' }, false);
|
||
const x = sandTo - (i % 3) * 1.4;
|
||
b.position.set(x, -0.28, z);
|
||
b.rotation.y = i % 2 === 0 ? Math.PI / 2 : Math.PI; // face sea / along shore
|
||
group.add(b);
|
||
bathers.push({ mesh: b, baseX: x, baseZ: z, phase: i * 1.7 });
|
||
counts.bathers++;
|
||
});
|
||
|
||
const swimmers = [];
|
||
[-58, 52].forEach((z, i) => {
|
||
const s = buildHumanoid({ type: 'bather', name: '' }, false);
|
||
s.position.set(sandTo + 12 + i * 6, -0.55, z);
|
||
s.rotation.y = Math.PI / 2;
|
||
group.add(s);
|
||
swimmers.push({ mesh: s, baseX: sandTo + 12 + i * 6, baseZ: z, phase: i * 2.4 });
|
||
counts.swimmers++;
|
||
});
|
||
|
||
const surfers = [];
|
||
[{ z: -44, phase: 0 }, { z: 74, phase: Math.PI }].forEach(sCfg => {
|
||
const rig = new THREE.Group();
|
||
const board = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.1, 2.3),
|
||
new THREE.MeshStandardMaterial({ color: 0xf5f2ea, roughness: 0.6 }));
|
||
board.position.y = 0.06;
|
||
rig.add(board);
|
||
const rider = buildHumanoid({ type: 'surfer', name: '' }, false);
|
||
rider.position.y = 0.1;
|
||
rig.add(rider);
|
||
rig.position.set(sandTo + 30, 0.2, sCfg.z);
|
||
group.add(rig);
|
||
surfers.push({ mesh: rig, baseX: sandTo + 30, baseZ: sCfg.z, phase: sCfg.phase, span: 26 });
|
||
counts.surfers++;
|
||
});
|
||
|
||
const basket = [];
|
||
const lanes = [courtX - 2.4, courtX, courtX + 2.4];
|
||
lanes.forEach((laneX, i) => {
|
||
const p = buildHumanoid({ type: 'basket', name: '' }, false);
|
||
const startZ = courtZ - 8 + i * 8;
|
||
p.position.set(laneX, 0.05, startZ);
|
||
group.add(p);
|
||
basket.push({
|
||
mesh: p, laneX,
|
||
z: startZ, dir: i % 2 ? 1 : -1,
|
||
speed: 3.4 + i * 0.5,
|
||
endA: courtZ - 11.5, endB: courtZ + 11.5,
|
||
});
|
||
counts.basket++;
|
||
});
|
||
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12),
|
||
new THREE.MeshStandardMaterial({ color: 0xe07b20, roughness: 0.75 }));
|
||
ball.castShadow = true;
|
||
group.add(ball);
|
||
const shot = { timer: 4, phase: 'follow', t: 0, from: new THREE.Vector3(), to: new THREE.Vector3(), target: 0 };
|
||
|
||
const towerGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
|
||
towerGuard.position.set(towerX, 2.62, towerZ);
|
||
group.add(towerGuard);
|
||
counts.lifeguards++;
|
||
|
||
const patrolGuard = buildHumanoid({ type: 'lifeguard', name: '' }, false);
|
||
patrolGuard.position.set(sandTo - 9, 0.05, -60);
|
||
group.add(patrolGuard);
|
||
counts.lifeguards++;
|
||
|
||
worldGroup.add(group);
|
||
|
||
return {
|
||
counts: () => counts,
|
||
update(dt, time) {
|
||
// Waves: displace the sea plane along its normal (local Z).
|
||
const pos = seaGeo.attributes.position;
|
||
const arr = pos.array;
|
||
for (let i = 0; i < pos.count; i++) {
|
||
const lx = seaBase[i * 3];
|
||
const ly = seaBase[i * 3 + 1];
|
||
arr[i * 3 + 2] = beachWaveY(seaWorldX + lx, seaCenterZ - ly, time);
|
||
}
|
||
pos.needsUpdate = true;
|
||
foamMat.opacity = 0.55 + Math.sin(time * 1.7) * 0.2;
|
||
|
||
for (const b of bathers) {
|
||
b.mesh.position.y = -0.28 + Math.sin(time * 1.6 + b.phase) * 0.07;
|
||
b.mesh.position.x = b.baseX + Math.sin(time * 0.5 + b.phase) * 0.7;
|
||
b.mesh.rotation.y = Math.PI / 2 + Math.sin(time * 0.4 + b.phase) * 0.5;
|
||
}
|
||
for (const s of swimmers) {
|
||
s.mesh.position.y = -0.55 + Math.sin(time * 2 + s.phase) * 0.1;
|
||
s.mesh.position.z = s.baseZ + Math.sin(time * 0.35 + s.phase) * 5;
|
||
}
|
||
for (const s of surfers) {
|
||
const w = time * 0.14 + s.phase;
|
||
s.mesh.position.z = s.baseZ + Math.sin(w) * s.span;
|
||
s.mesh.position.x = s.baseX + Math.cos(w) * 5;
|
||
s.mesh.position.y = 0.14 + beachWaveY(s.mesh.position.x, s.mesh.position.z, time);
|
||
// Face the direction of travel (derivative of the path).
|
||
s.mesh.rotation.y = Math.atan2(
|
||
-Math.sin(w) * 5 * 0.14,
|
||
Math.cos(w) * s.span * 0.14
|
||
);
|
||
}
|
||
|
||
// Basket players patrol the court; the ball follows the lead
|
||
// player and takes an arc shot whenever they reach a hoop.
|
||
for (const p of basket) {
|
||
p.z += p.dir * p.speed * dt;
|
||
if (p.z >= p.endB) { p.z = p.endB; p.dir = -1; }
|
||
if (p.z <= p.endA) { p.z = p.endA; p.dir = 1; }
|
||
p.mesh.position.z = p.z;
|
||
p.mesh.rotation.y = p.dir > 0 ? 0 : Math.PI;
|
||
}
|
||
const lead = basket[shot.target % basket.length];
|
||
if (shot.phase === 'follow') {
|
||
ball.position.set(
|
||
lead.mesh.position.x + 0.45,
|
||
0.95 + Math.abs(Math.sin(time * 5)) * 0.5,
|
||
lead.mesh.position.z + 0.2
|
||
);
|
||
shot.timer -= dt;
|
||
const nearHoop = Math.abs(lead.z - lead.endA) < 1.6 || Math.abs(lead.z - lead.endB) < 1.6;
|
||
if (shot.timer <= 0 && nearHoop) {
|
||
const hoopZ = Math.abs(lead.z - lead.endA) < 1.6 ? lead.endA : lead.endB;
|
||
shot.phase = 'shoot';
|
||
shot.t = 0;
|
||
shot.from.copy(ball.position);
|
||
shot.to.set(courtX, 2.9, hoopZ + (hoopZ < courtZ ? 0.75 : -0.75));
|
||
shot.timer = 6 + Math.random() * 3;
|
||
} else if (shot.timer <= 0) {
|
||
shot.timer = 2; // retry soon when they reach an end
|
||
}
|
||
} else if (shot.phase === 'shoot') {
|
||
shot.t += dt / 1.1;
|
||
const t = Math.min(1, shot.t);
|
||
ball.position.lerpVectors(shot.from, shot.to, t);
|
||
ball.position.y += Math.sin(Math.PI * t) * 1.4;
|
||
if (t >= 1) {
|
||
shot.phase = 'return';
|
||
shot.t = 0;
|
||
shot.from.copy(ball.position);
|
||
shot.target = (shot.target + 1) % basket.length;
|
||
const next = basket[shot.target];
|
||
shot.to.set(next.mesh.position.x + 0.45, 1.1, next.mesh.position.z);
|
||
}
|
||
} else { // return/pass
|
||
shot.t += dt / 0.9;
|
||
const t = Math.min(1, shot.t);
|
||
ball.position.lerpVectors(shot.from, shot.to, t);
|
||
ball.position.y += Math.sin(Math.PI * t) * 0.8;
|
||
if (t >= 1) shot.phase = 'follow';
|
||
}
|
||
|
||
// Lifeguards: the tower guard scans the water, the other
|
||
// patrols the shoreline facing the sea.
|
||
towerGuard.rotation.y = Math.sin(time * 0.5) * 0.7;
|
||
patrolGuard.position.z = Math.sin(time * 0.16) * 70;
|
||
patrolGuard.position.x = sandTo - 9 + Math.sin(time * 0.3) * 1.5;
|
||
patrolGuard.rotation.y = Math.PI / 2 + Math.sin(time * 0.16) * 0.15;
|
||
},
|
||
};
|
||
}
|
||
|
||
function nearestGymEquipment(maxDist = 2.8) {
|
||
let best = null;
|
||
for (const eq of gymEquipment) {
|
||
const d = Math.hypot(player.pos.x - eq.x, player.pos.z - eq.z);
|
||
if (d <= maxDist && (!best || d < best.dist)) best = { eq, dist: d };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function setExercisePrompt(text) {
|
||
const el = document.getElementById('exercise-prompt');
|
||
if (!el) return;
|
||
if (!text) {
|
||
el.style.display = 'none';
|
||
return;
|
||
}
|
||
el.style.display = 'block';
|
||
el.textContent = text;
|
||
}
|
||
|
||
function animateExercise(dt) {
|
||
if (!exercise.active || !meMesh) return;
|
||
exercise.animPhase += dt * 6;
|
||
const u = meMesh.userData;
|
||
if (!u?.legs || !u?.arms) return;
|
||
const swing = Math.sin(exercise.animPhase) * 0.9;
|
||
const lift = Math.abs(Math.sin(exercise.animPhase));
|
||
u.legs[0].rotation.x = swing;
|
||
u.legs[1].rotation.x = -swing;
|
||
u.arms[0].rotation.x = -Math.PI / 2 + lift * 0.4;
|
||
u.arms[1].rotation.x = -Math.PI / 2 + lift * 0.4;
|
||
u.arms[0].rotation.z = -0.6 - lift * 0.5;
|
||
u.arms[1].rotation.z = 0.6 + lift * 0.5;
|
||
meMesh.position.y = lift * 0.08;
|
||
}
|
||
|
||
async function startExerciseSession(eq) {
|
||
if (!meCharacter || exercise.active) return;
|
||
exercise.active = true;
|
||
exercise.equipmentId = eq.id;
|
||
exercise.repTimer = 0;
|
||
meCharacter.state = 'exercising';
|
||
if (stateEl) stateEl.textContent = 'exercising';
|
||
flashCharMsg(i18n.exerciseStart || 'Exercising…');
|
||
cityAudio.play('work');
|
||
|
||
try {
|
||
const data = await postJson('/city/actions/exercise', {
|
||
pos_x: player.pos.x,
|
||
pos_z: player.pos.z,
|
||
});
|
||
if (data.character) {
|
||
updateCharacterHud(data.character);
|
||
if ((data.character.health ?? 100) >= 100) {
|
||
flashCharMsg(data.message || i18n.exerciseMaxHealth || 'Max health');
|
||
await stopExerciseSession(false);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
exercise.active = false;
|
||
exercise.equipmentId = null;
|
||
flashCharMsg(e.message, true);
|
||
if (meCharacter) meCharacter.state = 'awake';
|
||
}
|
||
}
|
||
|
||
async function stopExerciseSession(showMsg = true) {
|
||
if (!exercise.active) return;
|
||
exercise.active = false;
|
||
exercise.equipmentId = null;
|
||
if (meMesh) meMesh.position.y = 0;
|
||
if (meMesh?.userData?.arms) {
|
||
meMesh.userData.arms.forEach(a => { a.rotation.z = 0; a.rotation.x = 0; });
|
||
}
|
||
try {
|
||
const data = await postJson('/city/actions/stop-exercise', {});
|
||
if (data.character) updateCharacterHud(data.character);
|
||
if (showMsg) flashCharMsg(i18n.exerciseStop || 'Stopped exercising');
|
||
} catch (_) { /* still leave local state */ }
|
||
if (meCharacter && meCharacter.state === 'exercising') meCharacter.state = 'awake';
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Beach relax session: sit on the sand and recover a bit per server tick.
|
||
// ------------------------------------------------------------------
|
||
function animateRelax(dt) {
|
||
relax.animPhase += dt;
|
||
if (!meMesh) return;
|
||
const u = meMesh.userData;
|
||
if (!u?.legs || !u?.arms) return;
|
||
// Sitting with legs stretched forward and gentle breathing.
|
||
u.legs[0].rotation.x = -1.35;
|
||
u.legs[1].rotation.x = -1.35;
|
||
u.arms[0].rotation.x = 0.25;
|
||
u.arms[1].rotation.x = 0.25;
|
||
u.arms[0].rotation.z = -0.18;
|
||
u.arms[1].rotation.z = 0.18;
|
||
meMesh.position.y = 0.42 + Math.sin(relax.animPhase * 1.6) * 0.02;
|
||
}
|
||
|
||
function stopRelaxSession() {
|
||
if (!relax.active) return;
|
||
relax.active = false;
|
||
if (meMesh) {
|
||
meMesh.position.y = 0;
|
||
if (meMesh.userData?.legs) meMesh.userData.legs.forEach(l => { l.rotation.x = 0; });
|
||
if (meMesh.userData?.arms) meMesh.userData.arms.forEach(ar => {
|
||
ar.rotation.x = 0;
|
||
ar.rotation.z = 0;
|
||
});
|
||
}
|
||
}
|
||
|
||
function startRelaxSession() {
|
||
if (!meCharacter || relax.active || exercise.active) return;
|
||
relax.active = true;
|
||
relax.timer = 0;
|
||
relax.animPhase = 0;
|
||
flashCharMsg(i18n.beachStart || 'Resting on the sand…');
|
||
cityAudio.play('ok');
|
||
}
|
||
|
||
function updateExercise(dt) {
|
||
if (isHospitalizedNow()) {
|
||
if (exercise.active) stopExerciseSession(false);
|
||
if (relax.active) stopRelaxSession();
|
||
setExercisePrompt(null);
|
||
return;
|
||
}
|
||
|
||
// Drone mode owns the controls: no ground sessions or prompts.
|
||
if (drone.active) {
|
||
if (exercise.active) stopExerciseSession(false);
|
||
if (relax.active) stopRelaxSession();
|
||
setExercisePrompt(null);
|
||
return;
|
||
}
|
||
|
||
// ---- Beach leisure (east shore) ----
|
||
const onBeach = !!beach
|
||
&& player.pos.x >= beach.sand_from - 1
|
||
&& player.pos.x <= beach.sand_to + 2
|
||
&& player.pos.z >= beach.z_min
|
||
&& player.pos.z <= beach.z_max;
|
||
const isMoving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
|
||
|
||
if (relax.active) {
|
||
if (isMoving || !onBeach) {
|
||
stopRelaxSession();
|
||
setExercisePrompt(null);
|
||
} else {
|
||
setExercisePrompt(`🏖️ ${i18n.beachActive || 'Relaxing at the beach'} · E ${i18n.exerciseStopHint || 'stop'}`);
|
||
animateRelax(dt);
|
||
relax.timer += dt;
|
||
if (relax.timer >= 1.5) {
|
||
relax.timer = 0;
|
||
postJson('/city/actions/beach', {
|
||
pos_x: player.pos.x,
|
||
pos_z: player.pos.z,
|
||
})
|
||
.then((data) => {
|
||
if (data.character) updateCharacterHud(data.character);
|
||
if (data.full) {
|
||
flashCharMsg(data.message);
|
||
stopRelaxSession();
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
flashCharMsg(e.message, true);
|
||
stopRelaxSession();
|
||
});
|
||
}
|
||
if (keys['KeyE']) {
|
||
keys['KeyE'] = false;
|
||
stopRelaxSession();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
const near = spawnApplied ? nearestGymEquipment(2.8) : null;
|
||
|
||
if (exercise.active) {
|
||
const still = near && near.eq.id === exercise.equipmentId
|
||
|| (gymEquipment.some(e => e.id === exercise.equipmentId)
|
||
&& Math.hypot(player.pos.x - (gymEquipment.find(e => e.id === exercise.equipmentId)?.x ?? 999),
|
||
player.pos.z - (gymEquipment.find(e => e.id === exercise.equipmentId)?.z ?? 999)) < 4);
|
||
|
||
// Moving with WASD cancels the session.
|
||
const moving = keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||
|| keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'];
|
||
if (!still || moving) {
|
||
stopExerciseSession();
|
||
setExercisePrompt(null);
|
||
return;
|
||
}
|
||
|
||
setExercisePrompt(`🏃 ${i18n.exerciseActive || 'Exercising…'} · E ${i18n.exerciseStopHint || 'stop'}`);
|
||
animateExercise(dt);
|
||
|
||
exercise.repTimer += dt;
|
||
if (exercise.repTimer >= 1.0) {
|
||
exercise.repTimer = 0;
|
||
postJson('/city/actions/exercise', {
|
||
pos_x: player.pos.x,
|
||
pos_z: player.pos.z,
|
||
})
|
||
.then((data) => {
|
||
if (data.character) {
|
||
updateCharacterHud(data.character);
|
||
if (data.character.health >= 100) {
|
||
flashCharMsg(i18n.exerciseMaxHealth || 'Max health');
|
||
stopExerciseSession(false);
|
||
}
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
flashCharMsg(e.message, true);
|
||
stopExerciseSession(false);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (near && meCharacter && meCharacter.state !== 'sleeping') {
|
||
setExercisePrompt(`🏋️ ${i18n.exerciseUse || 'Press E to exercise'} · ${near.eq.kind}`);
|
||
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
|
||
setExercisePrompt(`🏖️ ${i18n.beachPrompt || 'Relax at the beach'} · E`);
|
||
} else {
|
||
setExercisePrompt(null);
|
||
}
|
||
|
||
// Toggle with E.
|
||
if (keys['KeyE']) {
|
||
keys['KeyE'] = false;
|
||
if (exercise.active) {
|
||
stopExerciseSession();
|
||
} else if (near) {
|
||
startExerciseSession(near.eq);
|
||
} else if (onBeach && meCharacter && meCharacter.state !== 'sleeping') {
|
||
startRelaxSession();
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildCity(data) {
|
||
clearGroup(worldGroup);
|
||
colliders.length = 0;
|
||
storeMeshes.length = 0;
|
||
agentMeshes.length = 0;
|
||
crowdMeshes.length = 0;
|
||
facadeMaterials.length = 0;
|
||
lampHeads.length = 0;
|
||
signMaterials.length = 0;
|
||
signCounts = { stores: 0, plazas: 0, beach: 0, airports: 0, buildings: 0, filler: 0 };
|
||
trafficSystem = null;
|
||
vehicleSystem = null;
|
||
pedestrianSystem = null;
|
||
airplaneSystem = null;
|
||
trainSystem = null;
|
||
riverSystem = null;
|
||
shopSystem = null;
|
||
mayorSystem = null;
|
||
meMesh = null;
|
||
spawnApplied = false;
|
||
crosswalkCount = 0;
|
||
sidewalkSegmentCount = 0;
|
||
plazas = [];
|
||
gymEquipment = [];
|
||
exercise = { active: false, equipmentId: null, repTimer: 0, animPhase: 0, nearId: null };
|
||
relax = { active: false, timer: 0, animPhase: 0 };
|
||
beach = null;
|
||
beachSystem = null;
|
||
airportsCfg = null;
|
||
portCfg = null;
|
||
portSystem = null;
|
||
|
||
const layout = data.layout || { roads: [], blocks: [] };
|
||
const stores = data.stores || [];
|
||
const agents = data.agents || [];
|
||
meCharacter = data.me || null;
|
||
cityBounds = data.city?.bounds || null;
|
||
|
||
if (meCharacter) {
|
||
player.pos.set(meCharacter.position[0], player.eyeHeight, meCharacter.position[2]);
|
||
// If the persisted spot is inside a building (e.g. a house built on
|
||
// the spawn), push the player out onto the nearest open ground.
|
||
if (colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
|
||
const push = 14;
|
||
const candidates = [
|
||
[meCharacter.position[0], meCharacter.position[2] + push],
|
||
[meCharacter.position[0], meCharacter.position[2] - push],
|
||
[meCharacter.position[0] + push, meCharacter.position[2]],
|
||
[meCharacter.position[0] - push, meCharacter.position[2]],
|
||
[0, 12],
|
||
[8, 4],
|
||
];
|
||
for (const [x, z] of candidates) {
|
||
player.pos.x = x;
|
||
player.pos.z = z;
|
||
if (!colliders.some(c => overlaps(playerAABB(player.pos.x, player.pos.y, player.pos.z), c))) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
updateCharacterHud(meCharacter);
|
||
populateJobSelect(data.jobs || []);
|
||
populateCitizenSelect(data.characters || [], meCharacter);
|
||
spawnApplied = true;
|
||
}
|
||
|
||
if (!layout.roads.length) {
|
||
statsEl.textContent = '';
|
||
return;
|
||
}
|
||
|
||
grid = buildGrid(layout);
|
||
|
||
// Synthetic roads: bridge approach (west) + level-crossing extension (north).
|
||
const zMid = grid.zs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.zs[0]);
|
||
const xMid = grid.xs.reduce((best, v) => Math.abs(v) < Math.abs(best) ? v : best, grid.xs[0]);
|
||
layoutExtras.bridgeRoad = {
|
||
from: [grid.minX, 0, zMid], to: [grid.minX - 55, 0, zMid], width: 8, axis: 'x',
|
||
};
|
||
layoutExtras.crossRoad = {
|
||
from: [xMid, 0, grid.maxZ], to: [xMid, 0, grid.maxZ + 40], width: 8, axis: 'z',
|
||
};
|
||
layout.roads.push(layoutExtras.bridgeRoad, layoutExtras.crossRoad);
|
||
|
||
// ---- Ground ----
|
||
const ground = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(2000, 2000),
|
||
new THREE.MeshStandardMaterial({ color: 0x555f4a, roughness: 1 })
|
||
);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -0.05;
|
||
ground.receiveShadow = true;
|
||
worldGroup.add(ground);
|
||
|
||
// ---- River (under the bridge road) ----
|
||
initRiver(grid);
|
||
|
||
// ---- Roads ----
|
||
const roadMat = new THREE.MeshStandardMaterial({ map: asphaltTex, roughness: 0.9, metalness: 0.05 });
|
||
const walkMat = new THREE.MeshStandardMaterial({ map: sidewalkTex.clone(), roughness: 0.95 });
|
||
walkMat.map.repeat.set(4, 1);
|
||
// Static same-material geometry is merged into single draw calls.
|
||
const staticBatch = makeStaticBatcher();
|
||
|
||
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;
|
||
mesh.updateMatrix();
|
||
staticBatch.add(geo, roadMat, mesh.matrix, false, true);
|
||
|
||
if (r === layoutExtras.bridgeRoad) return; // bridge has its own deck
|
||
|
||
const swWidth = 2;
|
||
const offset = w / 2 + swWidth / 2;
|
||
// Sidewalks stop at each perpendicular roadway: the crossing band
|
||
// itself is painted as a zebra crosswalk below.
|
||
const crossingLines = r.axis === 'z' ? grid.zs : grid.xs;
|
||
const travel1 = r.axis === 'z' ? z1 : x1;
|
||
const travel2 = r.axis === 'z' ? z2 : x2;
|
||
const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2);
|
||
[-1, 1].forEach(side => {
|
||
for (const [a, b] of spans) {
|
||
const segLen = b - a;
|
||
const cmid = (a + b) / 2;
|
||
const sg = r.axis === 'z'
|
||
? new THREE.PlaneGeometry(swWidth, segLen)
|
||
: new THREE.PlaneGeometry(segLen, swWidth);
|
||
const sm = new THREE.Mesh(sg, walkMat);
|
||
sm.rotation.x = -Math.PI / 2;
|
||
if (r.axis === 'z') {
|
||
sm.position.set(mx + side * offset, 0.12, cmid);
|
||
} else {
|
||
sm.position.set(cmid, 0.12, mz + side * offset);
|
||
}
|
||
sm.receiveShadow = true;
|
||
sm.updateMatrix();
|
||
staticBatch.add(sg, walkMat, sm.matrix, false, true);
|
||
sidewalkSegmentCount++;
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---- Curbs (interrupted at crosswalks — dropped-curb look) ----
|
||
const curbMat = new THREE.MeshStandardMaterial({ color: 0x888c94, roughness: 0.9 });
|
||
layout.roads.forEach(r => {
|
||
if (r === layoutExtras.bridgeRoad) return;
|
||
const [x1,, z1] = r.from;
|
||
const [x2,, z2] = r.to;
|
||
const w = r.width;
|
||
const mx = (x1 + x2) / 2, mz = (z1 + z2) / 2;
|
||
const offset = w / 2 + 0.1;
|
||
const crossingLines = r.axis === 'z' ? grid.zs : grid.xs;
|
||
const travel1 = r.axis === 'z' ? z1 : x1;
|
||
const travel2 = r.axis === 'z' ? z2 : x2;
|
||
const spans = stripSegments(crossingLines, travel1, travel2, grid.roadW / 2);
|
||
[-1, 1].forEach(side => {
|
||
for (const [a, b] of spans) {
|
||
const segLen = b - a;
|
||
const cmid = (a + b) / 2;
|
||
const curbGeo = r.axis === 'z'
|
||
? new THREE.BoxGeometry(0.2, 0.14, segLen)
|
||
: new THREE.BoxGeometry(segLen, 0.14, 0.2);
|
||
const curb = new THREE.Mesh(curbGeo, curbMat);
|
||
if (r.axis === 'z') {
|
||
curb.position.set(mx + side * offset, 0.07, cmid);
|
||
} else {
|
||
curb.position.set(cmid, 0.07, mz + side * offset);
|
||
}
|
||
curb.updateMatrix();
|
||
staticBatch.add(curbGeo, curbMat, curb.matrix);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---- Zebra crosswalks (real markings, not sidewalk-coloured bands) ----
|
||
//
|
||
// Four approaches per intersection; each band sits exactly where the
|
||
// perpendicular sidewalk used to cross the roadway (2 units deep =
|
||
// the sidewalk width), directly over the asphalt. Bars run parallel
|
||
// to vehicle travel (MUTCD longitudinal-bar zebra design).
|
||
const zebraMatOverZ = new THREE.MeshStandardMaterial({
|
||
map: zebraTexOverZRoad, transparent: true, alphaTest: 0.4, roughness: 0.9,
|
||
});
|
||
const zebraMatOverX = new THREE.MeshStandardMaterial({
|
||
map: zebraTexOverXRoad, transparent: true, alphaTest: 0.4, roughness: 0.9,
|
||
});
|
||
const crossHalf = grid.roadW / 2;
|
||
const crossBand = 2;
|
||
const eps = 0.01;
|
||
const crossRoadX = layoutExtras.crossRoad ? layoutExtras.crossRoad.from[0] : null;
|
||
|
||
for (const vx of grid.xs) {
|
||
for (const vz of grid.zs) {
|
||
// Crossings over the vertical (z) road — south & north bands.
|
||
const zSpans = [
|
||
{ side: 1, valid: vz + crossHalf + crossBand <= grid.maxZ + eps || vx === crossRoadX },
|
||
{ side: -1, valid: vz - crossHalf - crossBand >= grid.minZ - eps },
|
||
];
|
||
for (const { side, valid } of zSpans) {
|
||
if (!valid) continue;
|
||
const zebra = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(grid.roadW, crossBand), zebraMatOverZ
|
||
);
|
||
zebra.rotation.x = -Math.PI / 2;
|
||
zebra.position.set(vx, 0.02, vz + side * (crossHalf + crossBand / 2));
|
||
zebra.receiveShadow = true;
|
||
zebra.updateMatrix();
|
||
staticBatch.add(zebra.geometry, zebraMatOverZ, zebra.matrix, false, true);
|
||
crosswalkCount++;
|
||
}
|
||
|
||
// Crossings over the horizontal (x) road — east & west bands.
|
||
// At the bridge corner the west approach rides on the deck.
|
||
const xSpans = [
|
||
{ side: 1, valid: vx + crossHalf + crossBand <= grid.maxX + eps },
|
||
{ side: -1, valid: vx - crossHalf - crossBand >= grid.minX - eps },
|
||
];
|
||
for (const { side, valid } of xSpans) {
|
||
if (!valid) continue;
|
||
const zebra = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(crossBand, grid.roadW), zebraMatOverX
|
||
);
|
||
zebra.rotation.x = -Math.PI / 2;
|
||
zebra.position.set(vx + side * (crossHalf + crossBand / 2), 0.02, vz);
|
||
zebra.receiveShadow = true;
|
||
zebra.updateMatrix();
|
||
staticBatch.add(zebra.geometry, zebraMatOverX, zebra.matrix, false, true);
|
||
crosswalkCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Street lamps (on the sidewalk, never in the roadway) ----
|
||
const lampPoleMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.7, metalness: 0.5 });
|
||
// One shared material for every cobra head: all fade together at night.
|
||
const streetHeadMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc, emissive: 0xffe9a0, emissiveIntensity: 0.4 });
|
||
lampHeads.push(streetHeadMat);
|
||
const lampSwOffset = grid.roadW / 2 + 1; // sidewalk centre = 5 from road centre
|
||
const nearIntersection = (lx, lz) => {
|
||
// Skip spots within 7 units of any crossing so lamps never sit
|
||
// in the intersection or on the crosswalk.
|
||
return grid.xs.some(vx => Math.abs(lx - vx) < 7)
|
||
&& grid.zs.some(vz => Math.abs(lz - vz) < 7);
|
||
};
|
||
layout.roads.forEach(r => {
|
||
if (r === layoutExtras.bridgeRoad || r === layoutExtras.crossRoad) return;
|
||
const [x1,, z1] = r.from;
|
||
const [x2,, z2] = r.to;
|
||
const len = Math.hypot(x2 - x1, z2 - z1);
|
||
const spacing = 28;
|
||
const count = Math.max(1, Math.floor(len / spacing));
|
||
for (let i = 0; i <= count; i++) {
|
||
const t = i / count;
|
||
const lx = x1 + (x2 - x1) * t;
|
||
const lz = z1 + (z2 - z1) * t;
|
||
const side = (i % 2 === 0) ? 1 : -1;
|
||
// Plant the pole on the sidewalk centre-line.
|
||
const px = r.axis === 'z' ? lx + lampSwOffset * side : lx;
|
||
const pz = r.axis === 'x' ? lz + lampSwOffset * side : lz;
|
||
if (nearIntersection(px, pz)) continue;
|
||
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.5, 6), lampPoleMat);
|
||
pole.position.set(px, 2.25, pz);
|
||
pole.updateMatrix();
|
||
staticBatch.add(pole.geometry, lampPoleMat, pole.matrix, true);
|
||
|
||
// Curved arm reaching over the curb toward the road.
|
||
const dirX = r.axis === 'z' ? -side : 0;
|
||
const dirZ = r.axis === 'x' ? -side : 0;
|
||
const armGeo = new THREE.BoxGeometry(r.axis === 'z' ? 1.3 : 0.1, 0.08, r.axis === 'x' ? 1.3 : 0.1);
|
||
const arm = new THREE.Mesh(armGeo, lampPoleMat);
|
||
arm.position.set(px + dirX * 0.6, 4.55, pz + dirZ * 0.6);
|
||
arm.updateMatrix();
|
||
staticBatch.add(armGeo, lampPoleMat, arm.matrix);
|
||
|
||
const headGeo = new THREE.BoxGeometry(0.55, 0.18, 0.28);
|
||
// Compact cobra-style head sitting at the arm tip, hanging slightly down.
|
||
const head = new THREE.Mesh(headGeo, streetHeadMat);
|
||
head.position.set(px + dirX * 1.2, 4.45, pz + dirZ * 1.2);
|
||
head.updateMatrix();
|
||
staticBatch.add(headGeo, streetHeadMat, head.matrix);
|
||
}
|
||
});
|
||
|
||
// ---- Traffic lights ----
|
||
initTrafficLights(grid, staticBatch);
|
||
// Merge every static bucket collected so far (roadsides, poles,
|
||
// housings, lamp heads) into one mesh per material.
|
||
staticBatch.flush(worldGroup);
|
||
|
||
// ---- 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);
|
||
|
||
// Vertical neon blade letreiro over the roofline, facing the
|
||
// nearest street (the front OPEN/CLOSED board stays as is).
|
||
const bladeFace = faceNearestRoad(x, z, grid);
|
||
const [bOffX, bOffZ] = facingOffset(bladeFace, w / 2 + 0.25, d / 2 + 0.25);
|
||
const bladeH = Math.min(4.6, h * 0.7 + 1.4);
|
||
const blade = buildLitSign(s.name, {
|
||
vertical: true,
|
||
w: 1.5,
|
||
h: bladeH,
|
||
accent: ['#38bdf8', '#f472b6', '#facc15', '#4ade80'][idx % 4],
|
||
});
|
||
blade.position.set(x + bOffX, y + h + bladeH / 2 - 0.6, z + bOffZ);
|
||
blade.rotation.y = bladeFace;
|
||
worldGroup.add(blade);
|
||
signCounts.stores++;
|
||
|
||
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);
|
||
});
|
||
|
||
// ---- Beach (east shore, sea also covers the port basin) ----
|
||
beach = data.beach || null;
|
||
portCfg = data.port || null;
|
||
beachSystem = beach ? initBeach(beach, portCfg) : null;
|
||
|
||
// ---- Airports (north/south) + flight operations ----
|
||
airportsCfg = data.airports || null;
|
||
buildAirports(airportsCfg);
|
||
|
||
// ---- Port (far half of the north-east coast) ----
|
||
portSystem = portCfg ? initPort(portCfg) : null;
|
||
|
||
// ---- Filler buildings ----
|
||
// Fillers only exist on empty ground: skip cells near stores AND near
|
||
// any placed building (hospital, school, city hall, player houses) so
|
||
// nothing ever spawns on top of a real structure.
|
||
const occupied = [
|
||
...stores.map(s => ({ x: s.position[0], z: s.position[2] })),
|
||
...(data.buildings || []).map(b => ({ x: b.position[0], z: b.position[2] })),
|
||
];
|
||
const fillerRoofMat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.9 });
|
||
const roofBatch = makeStaticBatcher();
|
||
(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));
|
||
// Filler blocks repeat across the whole city — half-resolution
|
||
// facades keep VRAM flat while the city grows (stores keep 512).
|
||
const pair = facadePair(color, floors, Math.max(2, Math.round(bw / 3)), 256);
|
||
|
||
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);
|
||
// One material → one draw call per building (the roof slab covers
|
||
// the top face; multi-material boxes cost up to six calls each).
|
||
|
||
const m = new THREE.Mesh(geo, sideMat);
|
||
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),
|
||
fillerRoofMat
|
||
);
|
||
roof.position.set(cx, bh + 0.15, cz);
|
||
roof.updateMatrix();
|
||
roofBatch.add(roof.geometry, fillerRoofMat, roof.matrix);
|
||
|
||
// Decorative neon letreiro on every third block (abstract glow,
|
||
// no text — needs no translation).
|
||
if (bi % 3 === 0) {
|
||
const neonAccent = ['#22d3ee', '#f472b6', '#a3e635', '#fb923c'][(Math.floor(bi / 3)) % 4];
|
||
const neonFace = faceNearestRoad(cx, cz, grid);
|
||
const [nOffX, nOffZ] = facingOffset(neonFace, bw / 2 + 0.3, bd / 2 + 0.3);
|
||
const neon = buildLitSign('', {
|
||
w: 4.8,
|
||
h: 1.1,
|
||
decor: true,
|
||
accent: neonAccent,
|
||
});
|
||
neon.position.set(cx + nOffX, bh * 0.72, cz + nOffZ);
|
||
neon.rotation.y = neonFace;
|
||
worldGroup.add(neon);
|
||
signCounts.filler++;
|
||
}
|
||
});
|
||
roofBatch.flush(worldGroup);
|
||
|
||
// ---- Agents (mayor, shopkeepers handled in their systems; pedestrians walk) ----
|
||
const walkingTypes = new Set(['pedestrian', 'mayor', 'shopkeeper', 'worker_robot']);
|
||
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 landmarkBodyColors = { university: 0x7b87c9, fire_station: 0xc0442e };
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color: isHospital ? 0xe8eef5
|
||
: (landmarkBodyColors[b.type] ?? (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);
|
||
}
|
||
|
||
// Luminous roofline letreiro for the landmark buildings.
|
||
const landmarkStyle = {
|
||
hospital: { label: i18n.signHospital || 'Hospital', accent: '#f87171' },
|
||
school: { label: i18n.signSchool || 'Escola', accent: '#4ade80' },
|
||
city_hall: { label: i18n.signCityhall || 'Prefeitura', accent: '#7dd3fc' },
|
||
university: { label: i18n.signUniversity || 'Universidade', accent: '#a5b4fc' },
|
||
fire_station: { label: i18n.signFirestation || 'Corpo de Bombeiros', accent: '#fb923c' },
|
||
};
|
||
const landmark = landmarkStyle[b.type];
|
||
if (b.is_complete && landmark) {
|
||
const lFace = faceNearestRoad(bx, bz, grid);
|
||
const [lOffX, lOffZ] = facingOffset(lFace, bw / 2 + 0.35, bd / 2 + 0.35);
|
||
const landmarkSign = buildLitSign(landmark.label, {
|
||
w: Math.min(11, Math.max(bw, bd) * 0.8),
|
||
h: 1.6,
|
||
accent: landmark.accent,
|
||
});
|
||
landmarkSign.position.set(bx + lOffX, bh + 1.35, bz + lOffZ);
|
||
landmarkSign.rotation.y = lFace;
|
||
worldGroup.add(landmarkSign);
|
||
signCounts.buildings++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Public plazas with outdoor gym equipment ----
|
||
initPlazas(data.plazas || []);
|
||
|
||
// ---- Citizen characters (all characters in this city) ----
|
||
if (Array.isArray(data.characters)) {
|
||
for (const c of data.characters) {
|
||
const [cx, , cz] = c.position;
|
||
const humanoid = buildHumanoid({
|
||
name: c.name,
|
||
type: c.is_me ? 'mayor' : (c.type === 'child' ? 'crowd' : 'pedestrian'),
|
||
status: c.status || 'active',
|
||
id: c.id,
|
||
}, !c.is_me);
|
||
humanoid.position.set(cx, 0, cz);
|
||
if (c.is_me) {
|
||
humanoid.userData.isMe = true;
|
||
meMesh = humanoid;
|
||
}
|
||
worldGroup.add(humanoid);
|
||
citizenMeshes.push(humanoid);
|
||
if (!c.is_me) {
|
||
crowdMeshes.push(humanoid);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Simulation systems ----
|
||
initVehicles(grid);
|
||
initAirplanes(airportsCfg);
|
||
initTrain(grid);
|
||
|
||
statsEl.textContent = buildStatsText();
|
||
}
|
||
|
||
function buildStatsText() {
|
||
const stores = cityData?.stores?.length || 0;
|
||
const agents = cityData?.agents?.length || 0;
|
||
const veh = vehicleSystem?.count() || 0;
|
||
const peds = pedestrianSystem?.count() || 0;
|
||
return [
|
||
`${stores} ${i18n.stores || ''}`,
|
||
`${agents} ${i18n.agents || ''}`,
|
||
`${veh} ${i18n.vehicles || ''}`,
|
||
`${peds} ${i18n.pedestrians || ''}`,
|
||
].join(' · ');
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// CameraController — first person WASD + pointer lock + AABB collision
|
||
// ------------------------------------------------------------------
|
||
const player = {
|
||
pos: new THREE.Vector3(0, 1.7, 25),
|
||
vel: new THREE.Vector3(),
|
||
yaw: 0,
|
||
pitch: 0,
|
||
speed: 6,
|
||
radius: 0.35,
|
||
eyeHeight: 1.7,
|
||
onGround: true,
|
||
};
|
||
const keys = {};
|
||
let locked = false;
|
||
// Gate dismissed without pointer lock (touch devices, or when the browser
|
||
// denies pointer lock). The camera is then steered with drag-to-look.
|
||
let touchMode = false;
|
||
let lookDrag = null;
|
||
|
||
function applyLook(dx, dy, sens) {
|
||
player.yaw -= dx * sens;
|
||
player.pitch -= dy * sens;
|
||
player.pitch = Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, player.pitch));
|
||
}
|
||
|
||
function dismissGate() {
|
||
touchMode = true;
|
||
startGate.style.display = 'none';
|
||
posPanel.style.display = 'block';
|
||
}
|
||
|
||
function tryRequestLock() {
|
||
try {
|
||
const p = renderer.domElement.requestPointerLock?.();
|
||
if (p && typeof p.catch === 'function') p.catch(() => dismissGate());
|
||
} catch (_) {
|
||
dismissGate();
|
||
}
|
||
}
|
||
|
||
renderer.domElement.addEventListener('click', () => {
|
||
if (locked || touchMode) return;
|
||
tryRequestLock();
|
||
});
|
||
startGate.addEventListener('click', () => {
|
||
// Touch devices have no pointer lock: start immediately and steer the
|
||
// camera by dragging on the scene (the virtual joystick drives walking).
|
||
if (window.matchMedia?.('(pointer: coarse)')?.matches) {
|
||
dismissGate();
|
||
return;
|
||
}
|
||
tryRequestLock();
|
||
});
|
||
|
||
document.addEventListener('pointerlockchange', () => {
|
||
locked = document.pointerLockElement === renderer.domElement;
|
||
startGate.style.display = (locked || touchMode) ? 'none' : 'grid';
|
||
posPanel.style.display = (locked || touchMode) ? 'block' : 'none';
|
||
});
|
||
|
||
document.addEventListener('pointerlockerror', () => {
|
||
// Lock denied (embedded context, automation, permissions): fall back to
|
||
// drag-to-look so the player is never stuck behind the gate.
|
||
dismissGate();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!locked) return;
|
||
applyLook(e.movementX, e.movementY, 0.0022);
|
||
});
|
||
|
||
// Drag-to-look fallback when pointer lock is not in effect: drag anywhere
|
||
// on the scene to turn (standard mobile first-person controls).
|
||
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||
if (locked) return;
|
||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||
lookDrag = { id: e.pointerId, x: e.clientX, y: e.clientY };
|
||
try { renderer.domElement.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||
});
|
||
renderer.domElement.addEventListener('pointermove', (e) => {
|
||
if (locked || !lookDrag || lookDrag.id !== e.pointerId) return;
|
||
applyLook(e.clientX - lookDrag.x, e.clientY - lookDrag.y, 0.005);
|
||
lookDrag.x = e.clientX;
|
||
lookDrag.y = e.clientY;
|
||
});
|
||
const endLookDrag = (e) => {
|
||
if (lookDrag && lookDrag.id === e.pointerId) lookDrag = null;
|
||
};
|
||
renderer.domElement.addEventListener('pointerup', endLookDrag);
|
||
renderer.domElement.addEventListener('pointercancel', endLookDrag);
|
||
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
|
||
function isTypingTarget(el) {
|
||
if (!el) return false;
|
||
const tag = (el.tagName || '').toLowerCase();
|
||
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
|
||
}
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (isTypingTarget(e.target)) return;
|
||
// Keep arrows/space from scrolling the page while the scene controls
|
||
// are active (arrows turn the camera, space jumps).
|
||
if ((locked || touchMode)
|
||
&& (e.code === 'ArrowLeft' || e.code === 'ArrowRight'
|
||
|| e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'Space')) {
|
||
e.preventDefault();
|
||
}
|
||
keys[e.code] = true;
|
||
});
|
||
document.addEventListener('keyup', (e) => {
|
||
keys[e.code] = false;
|
||
});
|
||
|
||
function playerAABB(px, py, pz) {
|
||
const r = player.radius;
|
||
return {
|
||
minX: px - r, maxX: px + r,
|
||
minY: py - 1.5, maxY: py + 0.3,
|
||
minZ: pz - r, maxZ: pz + r,
|
||
};
|
||
}
|
||
|
||
function overlaps(a, b) {
|
||
return a.minX < b.maxX && a.maxX > b.minX &&
|
||
a.minZ < b.maxZ && a.maxZ > b.minZ &&
|
||
a.minY < b.maxY && a.maxY > b.minY;
|
||
}
|
||
|
||
function vehicleBoxes() {
|
||
if (!vehicleSystem) return [];
|
||
const boxes = [];
|
||
for (const v of vehicleSystem.vehicles) {
|
||
const p = new THREE.Vector3();
|
||
let rot = 0;
|
||
if (v.pts) {
|
||
posOnPath(v.pts, v.s, v.L, p);
|
||
const ahead = new THREE.Vector3();
|
||
posOnPath(v.pts, v.s + 0.6, v.L, ahead);
|
||
rot = yawFromDelta(ahead.x - p.x, ahead.z - p.z);
|
||
} else {
|
||
p.lerpVectors(v.a, v.b, v.t);
|
||
const dirSign = v.dir >= 0 ? 1 : -1;
|
||
rot = yawFromDelta((v.b.x - v.a.x) * dirSign, (v.b.z - v.a.z) * dirSign);
|
||
}
|
||
const halfL = v.mesh?.userData?.halfL ?? (v.pts ? 2.1 : 2.0);
|
||
const halfW = v.mesh?.userData?.halfW ?? 1.0;
|
||
const c = Math.abs(Math.cos(rot)), s = Math.abs(Math.sin(rot));
|
||
const ex = halfL * c + halfW * s;
|
||
const ez = halfL * s + halfW * c;
|
||
boxes.push({
|
||
minX: p.x - ex, maxX: p.x + ex,
|
||
minZ: p.z - ez, maxZ: p.z + ez,
|
||
minY: 0, maxY: 2,
|
||
});
|
||
}
|
||
return boxes;
|
||
}
|
||
|
||
function resolveCollision(px, py, pz) {
|
||
const box = playerAABB(px, py, pz);
|
||
const cur = playerAABB(player.pos.x, player.pos.y, player.pos.z);
|
||
const inside = (c) => overlaps(cur, c);
|
||
|
||
// Static buildings: if the player already overlaps one (spawned inside
|
||
// or built a house on their feet), allow moving so they can walk out.
|
||
for (const c of colliders) {
|
||
if (overlaps(box, c) && !inside(c)) return true;
|
||
}
|
||
|
||
// Hitting a vehicle is an accident (only once the world is live).
|
||
if (window.__city10?.ready && spawnApplied && meCharacter && meCharacter.state !== 'hospital') {
|
||
for (const c of vehicleBoxes()) {
|
||
if (overlaps(box, c)) {
|
||
reportVehicleAccident();
|
||
if (!inside(c)) return true;
|
||
}
|
||
}
|
||
}
|
||
if (trainSystem) {
|
||
const tb = {
|
||
minX: trainSystem.s - 18, maxX: trainSystem.s + 18,
|
||
minZ: trainSystem.crossZ - 1.6, maxZ: trainSystem.crossZ + 1.6,
|
||
minY: 0, maxY: 4,
|
||
};
|
||
if (overlaps(box, tb) && !inside(tb)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Detect vehicles driving into a stationary player and report the accident.
|
||
* Runs only after the city is fully built and the player spawn is applied.
|
||
* Players on public plazas (gym parks) are safe from traffic.
|
||
*/
|
||
function checkVehicleHitOnPlayer() {
|
||
if (!window.__city10?.ready) return;
|
||
if (!meCharacter || hospitalBusy) return;
|
||
if (meCharacter.state === 'hospital') return;
|
||
if (!spawnApplied) return;
|
||
if (exercise.active) return;
|
||
// Soft zone: plazas are fenced parks off the roadway.
|
||
if (plazas.some(p => {
|
||
const [px, pz] = p.position;
|
||
const [pw, pd] = p.size || [22, 22];
|
||
return Math.abs(player.pos.x - px) <= pw / 2 && Math.abs(player.pos.z - pz) <= pd / 2;
|
||
})) {
|
||
return;
|
||
}
|
||
const box = playerAABB(player.pos.x, player.pos.y, player.pos.z);
|
||
for (const c of vehicleBoxes()) {
|
||
if (overlaps(box, c)) {
|
||
reportVehicleAccident();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Tell the server the character was hit by a vehicle (2-day hospital stay).
|
||
*/
|
||
function reportVehicleAccident() {
|
||
if (!meCharacter || hospitalBusy) return;
|
||
if (meCharacter.state === 'hospital') return;
|
||
hospitalBusy = true;
|
||
cityAudio.play('horn');
|
||
postJson('/city/actions/accident', {})
|
||
.then((data) => {
|
||
if (data?.character) updateCharacterHud(data.character);
|
||
if (data?.bed) {
|
||
player.pos.x = data.bed.pos_x;
|
||
player.pos.z = data.bed.pos_z;
|
||
player.vel.set(0, 0, 0);
|
||
}
|
||
flashCharMsg(data?.message || i18n.hospitalHit || 'hospital', true);
|
||
cityAudio.play('hospital');
|
||
showHospitalBanner(data?.hospitalized_until);
|
||
updateCameraFromPlayer();
|
||
})
|
||
.catch((e) => {
|
||
flashCharMsg(e.message, true);
|
||
cityAudio.play('error');
|
||
})
|
||
.finally(() => {
|
||
hospitalBusy = false;
|
||
});
|
||
}
|
||
|
||
function showHospitalBanner(untilIso) {
|
||
const el = document.getElementById('hospital-banner');
|
||
if (!el) return;
|
||
if (!untilIso) {
|
||
el.style.display = 'none';
|
||
return;
|
||
}
|
||
el.style.display = 'block';
|
||
const tick = () => {
|
||
const until = new Date(untilIso).getTime();
|
||
const left = until - Date.now();
|
||
if (left <= 0 || meCharacter?.state !== 'hospital') {
|
||
el.style.display = 'none';
|
||
return;
|
||
}
|
||
const totalSec = Math.ceil(left / 1000);
|
||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||
const ss = String(totalSec % 60).padStart(2, '0');
|
||
const label = i18n.hospitalLeft || 'Discharge in';
|
||
el.textContent = `🏥 ${i18n.hospitalTitle || 'Hospitalized'} · ${label} ${mm}:${ss}`;
|
||
setTimeout(tick, 1000);
|
||
};
|
||
tick();
|
||
}
|
||
|
||
function isHospitalizedNow() {
|
||
if (meCharacter?.state !== 'hospital') return false;
|
||
if (!meCharacter.hospitalized_until) return true;
|
||
return new Date(meCharacter.hospitalized_until).getTime() > Date.now();
|
||
}
|
||
|
||
let lastPedBlockAt = 0;
|
||
|
||
/**
|
||
* Check whether the player may step from their current position to (toX, toZ).
|
||
*
|
||
* Crossing onto a road is gated by the pedestrian signal for that road's
|
||
* axis: vehicles green ⇒ pedestrians red. Players already mid-road may
|
||
* finish the crossing so they never get stuck in the asphalt.
|
||
*/
|
||
function playerCrossingAllowed(toX, toZ) {
|
||
if (!grid || !trafficSystem) return true;
|
||
|
||
const fromOnRoad = onRoad(player.pos.x, player.pos.z, grid);
|
||
const toOnRoad = onRoad(toX, toZ, grid);
|
||
if (!toOnRoad) return true;
|
||
if (fromOnRoad) return true; // already committed to the road
|
||
|
||
const half = grid.roadW / 2;
|
||
const enteringZRoad = grid.xs.some(vx => Math.abs(toX - vx) <= half);
|
||
const enteringXRoad = grid.zs.some(vz => Math.abs(toZ - vz) <= half);
|
||
|
||
// Blocked when the matching pedestrian signal is red.
|
||
if (enteringZRoad && trafficSystem.pedState('z') === 'red') return false;
|
||
if (enteringXRoad && trafficSystem.pedState('x') === 'red') return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
function flashPedRed() {
|
||
const now = performance.now();
|
||
if (now - lastPedBlockAt < 1200) return;
|
||
lastPedBlockAt = now;
|
||
const el = document.getElementById('ped-wait');
|
||
if (el) {
|
||
el.style.display = 'block';
|
||
el.textContent = `🚶 ${i18n.pedRed || 'Red pedestrian light — wait.'}`;
|
||
clearTimeout(flashPedRed._t);
|
||
flashPedRed._t = setTimeout(() => { el.style.display = 'none'; }, 1600);
|
||
}
|
||
}
|
||
|
||
function updatePlayer(dt) {
|
||
// Hospitalized characters stay in bed until discharge.
|
||
if (isHospitalizedNow()) {
|
||
player.vel.set(0, 0, 0);
|
||
player.pos.y = player.eyeHeight;
|
||
return;
|
||
}
|
||
|
||
// Auto-release when the hospital stay elapses while online.
|
||
if (meCharacter?.state === 'hospital' && !isHospitalizedNow()) {
|
||
meCharacter.state = 'awake';
|
||
meCharacter.hospitalized_until = null;
|
||
const banner = document.getElementById('hospital-banner');
|
||
if (banner) banner.style.display = 'none';
|
||
flashCharMsg(i18n.hospitalReleased || 'Released');
|
||
}
|
||
|
||
const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
|
||
const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
|
||
const wish = new THREE.Vector3();
|
||
|
||
// While the drone is airborne the keys pilot IT (updateDrone) — the
|
||
// grounded character stays put but keeps gravity and collisions.
|
||
if (!drone.active) {
|
||
// Allow walking with WASD even before pointer lock so the character is
|
||
// never completely stuck on the overlay screen. A/D strafe; the arrow
|
||
// keys turn the first-person camera instead of strafing, so every
|
||
// forward step always follows the direction the camera is facing.
|
||
if (keys['KeyW'] || keys['ArrowUp']) wish.add(forward);
|
||
if (keys['KeyS'] || keys['ArrowDown']) wish.sub(forward);
|
||
if (keys['KeyA']) wish.sub(right);
|
||
if (keys['KeyD']) wish.add(right);
|
||
const turn = (keys['ArrowLeft'] ? 1 : 0) - (keys['ArrowRight'] ? 1 : 0);
|
||
if (turn) player.yaw += turn * 2.4 * dt;
|
||
if (mobileInput.active) {
|
||
// Joystick: vertical walks along the camera facing, horizontal
|
||
// turns the camera (mobile first-person convention).
|
||
wish.addScaledVector(forward, -mobileInput.y);
|
||
if (Math.abs(mobileInput.x) > 0.15) {
|
||
player.yaw -= mobileInput.x * 2.4 * dt;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (wish.lengthSq() > 0) {
|
||
wish.normalize();
|
||
const speed = keys['ShiftLeft'] ? player.speed * 1.6 : player.speed;
|
||
const step = wish.multiplyScalar(speed * dt);
|
||
|
||
const tryX = player.pos.x + step.x;
|
||
if (!resolveCollision(tryX, player.pos.y, player.pos.z)
|
||
&& playerCrossingAllowed(tryX, player.pos.z)) {
|
||
player.pos.x = tryX;
|
||
} else if (playerCrossingAllowed(tryX, player.pos.z) === false) {
|
||
flashPedRed();
|
||
}
|
||
|
||
const tryZ = player.pos.z + step.z;
|
||
if (!resolveCollision(player.pos.x, player.pos.y, tryZ)
|
||
&& playerCrossingAllowed(player.pos.x, tryZ)) {
|
||
player.pos.z = tryZ;
|
||
} else if (playerCrossingAllowed(player.pos.x, tryZ) === false) {
|
||
flashPedRed();
|
||
}
|
||
}
|
||
|
||
player.vel.y -= 18 * dt;
|
||
player.pos.y += player.vel.y * dt;
|
||
if (player.pos.y <= player.eyeHeight) {
|
||
player.pos.y = player.eyeHeight;
|
||
player.vel.y = 0;
|
||
player.onGround = true;
|
||
}
|
||
if (!drone.active && keys['Space'] && player.onGround) {
|
||
player.vel.y = 6;
|
||
player.onGround = false;
|
||
}
|
||
|
||
// City bounds, extended over the east beach: players may walk onto
|
||
// the sand and wade at the shoreline, but never into the deep sea.
|
||
let maxX = cityBounds ? cityBounds.max_x : 400;
|
||
if (beach && player.pos.z >= beach.z_min && player.pos.z <= beach.z_max) {
|
||
maxX = Math.max(maxX, beach.sand_to + 1);
|
||
}
|
||
if (portCfg?.visit && player.pos.z <= portCfg.visit.max_z && player.pos.z >= portCfg.visit.min_z) {
|
||
maxX = Math.max(maxX, portCfg.visit.max_x);
|
||
}
|
||
player.pos.x = cityBounds
|
||
? Math.max(cityBounds.min_x, Math.min(maxX, player.pos.x))
|
||
: Math.max(-400, Math.min(maxX, player.pos.x));
|
||
// Z bounds: the airport strips (north/south) are walkable beyond the
|
||
// city while the player stays inside their X band.
|
||
let minZ = cityBounds ? cityBounds.min_z : -400;
|
||
let maxZ = cityBounds ? cityBounds.max_z : 400;
|
||
if (airportsCfg?.visit && Math.abs(player.pos.x) <= airportsCfg.visit.half_width) {
|
||
minZ = airportsCfg.visit.min_z;
|
||
maxZ = airportsCfg.visit.max_z;
|
||
}
|
||
if (portCfg?.visit && player.pos.x >= portCfg.visit.min_x) {
|
||
minZ = Math.min(minZ, portCfg.visit.min_z);
|
||
}
|
||
player.pos.z = Math.max(minZ, Math.min(maxZ, player.pos.z));
|
||
}
|
||
|
||
function updateCameraFromPlayer() {
|
||
camera.position.copy(player.pos);
|
||
const dir = new THREE.Vector3(
|
||
-Math.sin(player.yaw) * Math.cos(player.pitch),
|
||
Math.sin(player.pitch),
|
||
-Math.cos(player.yaw) * Math.cos(player.pitch)
|
||
);
|
||
camera.lookAt(camera.position.clone().add(dir));
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Click-to-interact
|
||
// ------------------------------------------------------------------
|
||
const ray = new THREE.Raycaster();
|
||
const ndc = new THREE.Vector2();
|
||
let pointerDown = null;
|
||
|
||
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||
pointerDown = { x: e.clientX, y: e.clientY, t: Date.now() };
|
||
});
|
||
|
||
renderer.domElement.addEventListener('pointerup', (e) => {
|
||
if (!pointerDown) return;
|
||
const dx = Math.abs(e.clientX - pointerDown.x);
|
||
const dy = Math.abs(e.clientY - pointerDown.y);
|
||
const dt = Date.now() - pointerDown.t;
|
||
pointerDown = null;
|
||
if (dx > 6 || dy > 6 || dt > 400) return;
|
||
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
ray.setFromCamera(ndc, camera);
|
||
|
||
const agentHits = ray.intersectObjects(agentMeshes, true);
|
||
if (agentHits.length) {
|
||
let obj = agentHits[0].object;
|
||
while (obj && !obj.userData?.agent) obj = obj.parent;
|
||
if (obj?.userData?.agent) {
|
||
const agent = obj.userData.agent;
|
||
openTalkChat({
|
||
kind: 'agent',
|
||
id: agent.id,
|
||
name: agent.name,
|
||
status: agent.status || 'active',
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
const storeHits = ray.intersectObjects(storeMeshes, false);
|
||
if (storeHits.length) {
|
||
const s = storeHits[0].object.userData.store;
|
||
selectedEl.style.display = 'inline-block';
|
||
const open = shopOpenNow(s);
|
||
selectedEl.textContent = `${i18n.selected || 'Selected'}: ${s.name} · ${open ? (i18n.open || 'Open') : (i18n.closed || 'Closed')}`;
|
||
} else {
|
||
selectedEl.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// ChatPanel — street conversations via /talk/person
|
||
//
|
||
// Opens automatically when the player bumps into another citizen
|
||
// (human peer) or agentic NPC, polls for replies while open, closes
|
||
// when the pair moves apart, and renders the daily history that backs
|
||
// the persistent context of future conversations.
|
||
// ------------------------------------------------------------------
|
||
const chatPanel = document.getElementById('chat-panel');
|
||
const chatDot = document.getElementById('chat-dot');
|
||
const chatName = document.getElementById('chat-name');
|
||
const chatStatus = document.getElementById('chat-status');
|
||
const chatTime = document.getElementById('chat-time');
|
||
const chatClose = document.getElementById('chat-close');
|
||
const chatMessages = document.getElementById('chat-messages');
|
||
const chatForm = document.getElementById('chat-form');
|
||
const chatInput = document.getElementById('chat-input');
|
||
|
||
const TALK_OPEN_RADIUS = 2.6;
|
||
const TALK_CLOSE_RADIUS = 3.6;
|
||
const TALK_POLL_MS = 2500;
|
||
|
||
let currentPeer = null; // { kind, id, name, status, key }
|
||
let talkPollTimer = null;
|
||
let talkLastDay = null;
|
||
let talkErrorShown = false;
|
||
let dismissedPeerKey = null;
|
||
const talkSeenIds = new Set();
|
||
|
||
function peerKey(kind, id) {
|
||
return `${kind}:${id}`;
|
||
}
|
||
|
||
function clockNow() {
|
||
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function addBubble(text, who) {
|
||
const div = document.createElement('div');
|
||
div.className = `chat-bubble ${who}`;
|
||
div.textContent = text;
|
||
chatMessages.appendChild(div);
|
||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||
}
|
||
|
||
function addDaySeparator(day) {
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-bubble meta';
|
||
const parsed = new Date(`${day}T12:00:00`);
|
||
div.textContent = Number.isNaN(parsed.getTime()) ? day : parsed.toLocaleDateString();
|
||
chatMessages.appendChild(div);
|
||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||
}
|
||
|
||
function setStatus(status) {
|
||
if (!currentPeer) return;
|
||
currentPeer.status = status;
|
||
const map = {
|
||
thinking: ['#f59e0b', i18n.chatThinking || 'thinking…'],
|
||
responding: ['#f59e0b', i18n.chatResponding || 'responding…'],
|
||
active: ['#22c55e', 'online'],
|
||
error: ['#ef4444', 'error'],
|
||
};
|
||
const [color, label] = map[status] || map.active;
|
||
chatDot.style.background = color;
|
||
chatStatus.textContent = label;
|
||
if (currentPeer.kind === 'agent') {
|
||
const mesh = agentMeshes.find(m => m.userData?.agent?.id === currentPeer.id);
|
||
if (mesh) refreshLabel(mesh, status);
|
||
}
|
||
}
|
||
|
||
/** Poll the daily conversation and append anything not seen yet. */
|
||
async function pullTalk() {
|
||
if (!currentPeer) return;
|
||
const peer = currentPeer;
|
||
try {
|
||
const res = await fetch(`/talk/person?kind=${encodeURIComponent(peer.kind)}&peer=${encodeURIComponent(peer.id)}`, {
|
||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
cache: 'no-store',
|
||
});
|
||
if (!res.ok || !currentPeer || currentPeer.key !== peer.key) return;
|
||
const data = await res.json();
|
||
const messages = data.messages || [];
|
||
let arrived = false;
|
||
for (const m of messages) {
|
||
if (talkSeenIds.has(m.id)) continue;
|
||
if (m.talked_on && m.talked_on !== talkLastDay) {
|
||
talkLastDay = m.talked_on;
|
||
addDaySeparator(m.talked_on);
|
||
}
|
||
talkSeenIds.add(m.id);
|
||
addBubble(m.body, m.fallback ? 'meta' : (m.from === 'me' ? 'user' : 'npc'));
|
||
if (m.at) chatTime.textContent = new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
arrived = true;
|
||
}
|
||
if (data.thinking) {
|
||
setStatus('thinking');
|
||
} else if (peer.kind === 'agent') {
|
||
setStatus(data.peer?.status || 'active');
|
||
const last = messages[messages.length - 1];
|
||
if (data.peer?.status === 'error' && last?.from === 'me' && !talkErrorShown) {
|
||
talkErrorShown = true;
|
||
addBubble(i18n.chatError || 'error', 'meta');
|
||
}
|
||
} else if (currentPeer.status === 'thinking') {
|
||
setStatus('active');
|
||
}
|
||
if (arrived) talkErrorShown = false;
|
||
} catch (_) { /* offline-safe */ }
|
||
}
|
||
|
||
async function openTalkChat(peer) {
|
||
if (currentPeer && currentPeer.key === peerKey(peer.kind, peer.id)) return;
|
||
currentPeer = { ...peer, key: peerKey(peer.kind, peer.id) };
|
||
talkSeenIds.clear();
|
||
talkLastDay = null;
|
||
talkErrorShown = false;
|
||
chatMessages.innerHTML = '';
|
||
chatPanel.style.display = 'flex';
|
||
chatName.textContent = peer.name;
|
||
chatTime.textContent = clockNow();
|
||
setStatus(peer.status || 'active');
|
||
if (talkPollTimer) clearInterval(talkPollTimer);
|
||
talkPollTimer = setInterval(pullTalk, TALK_POLL_MS);
|
||
await pullTalk();
|
||
}
|
||
|
||
function closeChat(manual = false) {
|
||
chatPanel.style.display = 'none';
|
||
if (manual && currentPeer) dismissedPeerKey = currentPeer.key;
|
||
currentPeer = null;
|
||
if (talkPollTimer) {
|
||
clearInterval(talkPollTimer);
|
||
talkPollTimer = null;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Bump detection: auto-open near a citizen, auto-close when apart
|
||
// ------------------------------------------------------------------
|
||
const _talkPos = new THREE.Vector3();
|
||
let talkProximityTimer = 0;
|
||
|
||
function talkCandidates() {
|
||
const out = [];
|
||
for (const mesh of agentMeshes) {
|
||
const agent = mesh.userData?.agent;
|
||
if (!agent || typeof agent.id !== 'number') continue;
|
||
mesh.getWorldPosition(_talkPos);
|
||
out.push({
|
||
kind: 'agent',
|
||
id: agent.id,
|
||
name: agent.name,
|
||
status: agent.status || 'active',
|
||
d: Math.hypot(_talkPos.x - player.pos.x, _talkPos.z - player.pos.z),
|
||
});
|
||
}
|
||
for (const mesh of remoteCitizens.values()) {
|
||
const citizen = mesh.userData?.citizen;
|
||
if (!citizen) continue;
|
||
out.push({
|
||
kind: 'character',
|
||
id: citizen.id,
|
||
name: citizen.name,
|
||
status: 'active',
|
||
d: Math.hypot(mesh.position.x - player.pos.x, mesh.position.z - player.pos.z),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function updateTalkProximity(dt) {
|
||
talkProximityTimer -= dt;
|
||
if (talkProximityTimer > 0) return;
|
||
talkProximityTimer = 0.25;
|
||
|
||
const candidates = talkCandidates();
|
||
|
||
// A manually dismissed chat only reopens after a fresh bump.
|
||
if (dismissedPeerKey) {
|
||
const dis = candidates.find(c => peerKey(c.kind, c.id) === dismissedPeerKey);
|
||
if (!dis || dis.d > TALK_CLOSE_RADIUS) dismissedPeerKey = null;
|
||
}
|
||
|
||
if (currentPeer) {
|
||
const cur = candidates.find(c => peerKey(c.kind, c.id) === currentPeer.key);
|
||
if (!cur || cur.d > TALK_CLOSE_RADIUS) closeChat(false);
|
||
return;
|
||
}
|
||
|
||
let best = null;
|
||
for (const c of candidates) {
|
||
if (!best || c.d < best.d) best = c;
|
||
}
|
||
if (best && best.d <= TALK_OPEN_RADIUS && peerKey(best.kind, best.id) !== dismissedPeerKey) {
|
||
openTalkChat(best);
|
||
}
|
||
}
|
||
|
||
chatClose.addEventListener('click', () => closeChat(true));
|
||
|
||
chatForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const msg = chatInput.value.trim();
|
||
if (!msg || !currentPeer) return;
|
||
chatInput.value = '';
|
||
if (currentPeer.kind === 'agent') setStatus('thinking');
|
||
talkErrorShown = false;
|
||
try {
|
||
const res = await fetch('/talk/person', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||
body: JSON.stringify({ kind: currentPeer.kind, peer: currentPeer.id, message: msg }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
// Day separator first so live and polled rows read in order.
|
||
if (data.talked_on && data.talked_on !== talkLastDay) {
|
||
talkLastDay = data.talked_on;
|
||
addDaySeparator(data.talked_on);
|
||
}
|
||
addBubble(msg, 'user');
|
||
if (data.id) talkSeenIds.add(data.id);
|
||
if (currentPeer.kind !== 'agent') chatTime.textContent = clockNow();
|
||
} else {
|
||
// 429 has its own cause — do not reuse the AI fallback text.
|
||
addBubble(res.status === 429
|
||
? (i18n.talkRateLimited || 'rate limited')
|
||
: (i18n.chatError || 'error'), 'meta');
|
||
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
|
||
}
|
||
} catch (_) {
|
||
addBubble(i18n.chatError || 'error', 'meta');
|
||
if (currentPeer.kind === 'agent') setStatus(currentPeer.status || 'active');
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------
|
||
// HUD
|
||
// ------------------------------------------------------------------
|
||
function updateHUD() {
|
||
const g = player.pos.y <= player.eyeHeight + 0.01;
|
||
posXYZ.textContent = `x:${player.pos.x.toFixed(1)} y:${player.pos.y.toFixed(1)} z:${player.pos.z.toFixed(1)}`;
|
||
posGround.textContent = g
|
||
? `${i18n.onGround || 'on ground'} · ${player.pos.y.toFixed(1)}m`
|
||
: `airborne · ${player.pos.y.toFixed(1)}m`;
|
||
posTris.textContent = `${colliders.length} ${i18n.tris || 'tris'}`;
|
||
|
||
const hh = String(Math.floor(sim.hour)).padStart(2, '0');
|
||
const mm = String(Math.floor((sim.hour % 1) * 60)).padStart(2, '0');
|
||
if (clockEl) clockEl.textContent = `🕒 ${hh}:${mm}`;
|
||
if (weatherEl) {
|
||
const label = i18n[`weather_${weatherState.state}`] || weatherState.state;
|
||
weatherEl.textContent = `${WEATHER_ICONS[weatherState.state] || ''} ${label}`;
|
||
}
|
||
|
||
if (meMesh) {
|
||
meMesh.position.x = player.pos.x;
|
||
meMesh.position.z = player.pos.z;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Character HUD + player actions (server-backed lifecycle)
|
||
// ------------------------------------------------------------------
|
||
const charNameEl = document.getElementById('char-name');
|
||
const charMsgEl = document.getElementById('char-msg');
|
||
const hungerEl = document.getElementById('hud-hunger');
|
||
const thirstEl = document.getElementById('hud-thirst');
|
||
const energyEl = document.getElementById('hud-energy');
|
||
const healthEl = document.getElementById('hud-health');
|
||
const exerciseCountEl = document.getElementById('hud-exercise-count');
|
||
const moneyEl = document.getElementById('hud-money');
|
||
const stateEl = document.getElementById('hud-char-state');
|
||
const educationEl = document.getElementById('hud-education');
|
||
const invEl = document.getElementById('hud-inv');
|
||
|
||
let lastPositionSync = 0;
|
||
|
||
function updateCharacterHud(me) {
|
||
meCharacter = me;
|
||
if (!me) return;
|
||
if (charNameEl) charNameEl.textContent = `👤 ${me.name}`;
|
||
if (hungerEl) hungerEl.textContent = `${me.hunger}%`;
|
||
if (thirstEl) thirstEl.textContent = `${me.thirst}%`;
|
||
if (energyEl) energyEl.textContent = `${me.energy}%`;
|
||
if (healthEl) healthEl.textContent = `${me.health ?? 100}%`;
|
||
if (exerciseCountEl) exerciseCountEl.textContent = `${me.exercise_count ?? 0}`;
|
||
if (moneyEl) moneyEl.textContent = `$${Number(me.money).toFixed(2)}`;
|
||
if (stateEl) stateEl.textContent = me.state || '—';
|
||
if (educationEl) educationEl.textContent = `${me.education ?? 0}%`;
|
||
if (invEl) {
|
||
const inv = me.inventory || {};
|
||
invEl.textContent = Object.keys(inv).length
|
||
? Object.entries(inv).map(([k, v]) => `${k}:${v}`).join(' · ')
|
||
: '—';
|
||
}
|
||
|
||
if (me.state === 'hospital' && me.hospitalized_until) {
|
||
showHospitalBanner(me.hospitalized_until);
|
||
const bed = me.position;
|
||
if (bed) {
|
||
player.pos.x = bed[0];
|
||
player.pos.z = bed[2];
|
||
player.vel.set(0, 0, 0);
|
||
}
|
||
} else if (me.state !== 'hospital') {
|
||
const banner = document.getElementById('hospital-banner');
|
||
if (banner) banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function flashCharMsg(text, isError = false) {
|
||
if (!charMsgEl) return;
|
||
charMsgEl.textContent = text;
|
||
charMsgEl.style.color = isError ? '#fca5a5' : '#93c5fd';
|
||
// Errors always beep; success chimes are played by the action itself.
|
||
if (isError) cityAudio.play('error');
|
||
clearTimeout(flashCharMsg._t);
|
||
flashCharMsg._t = setTimeout(() => { charMsgEl.textContent = ''; }, 4000);
|
||
}
|
||
|
||
async function postJson(url, body = {}) {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-CSRF-TOKEN': csrf,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const err = new Error(data.message || i18n.notFound || 'error');
|
||
err.data = data;
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
const ACTION_SFX = {
|
||
eat: 'eat',
|
||
drink: 'drink',
|
||
sleep: 'sleep',
|
||
wake: 'wake',
|
||
work: 'work',
|
||
'stop-work': 'ui',
|
||
study: 'work',
|
||
buy: 'money',
|
||
sell: 'money',
|
||
build: 'build',
|
||
'apply-job': 'ok',
|
||
};
|
||
|
||
async function runAction(act) {
|
||
if (!meCharacter) return null;
|
||
cityAudio.unlock();
|
||
try {
|
||
let body = {};
|
||
if (act === 'buy') {
|
||
await postJson('/city/actions/buy', { item: 'food', qty: 1, price: 4 });
|
||
await postJson('/city/actions/buy', { item: 'water', qty: 1, price: 3 });
|
||
const data = await postJson('/city/actions/buy', { item: 'materials', qty: 10, price: 1 });
|
||
if (data.character) updateCharacterHud(data.character);
|
||
flashCharMsg(data.message || i18n.actionOk);
|
||
return data;
|
||
}
|
||
if (act === 'sell') body = { item: 'product', qty: 1, price: 8 };
|
||
if (act === 'build') {
|
||
// Place the house a few metres ahead so it never sits on the player.
|
||
const ahead = 12;
|
||
body = {
|
||
pos_x: Math.round(player.pos.x - Math.sin(player.yaw) * ahead),
|
||
pos_z: Math.round(player.pos.z - Math.cos(player.yaw) * ahead),
|
||
name: `${meCharacter.name}`,
|
||
};
|
||
}
|
||
const data = await postJson(`/city/actions/${act}`, body);
|
||
if (data.character) updateCharacterHud(data.character);
|
||
if (ACTION_SFX[act]) cityAudio.play(ACTION_SFX[act]);
|
||
flashCharMsg(data.message || i18n.actionOk);
|
||
if (act === 'build') {
|
||
loadCity();
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
return { error: e.message };
|
||
}
|
||
}
|
||
|
||
function populateJobSelect(jobs) {
|
||
const sel = document.getElementById('job-select');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">—</option>';
|
||
for (const j of jobs) {
|
||
const opt = document.createElement('option');
|
||
opt.value = j.id;
|
||
opt.textContent = `${j.title} ($${j.salary})`;
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
function populateCitizenSelect(characters, me) {
|
||
const sel = document.getElementById('citizen-select');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">—</option>';
|
||
for (const c of characters) {
|
||
if (me && c.id === me.id) continue;
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name + (c.is_online ? ' ●' : '');
|
||
sel.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
function bindCharacterUI() {
|
||
document.querySelectorAll('.char-act').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('ui');
|
||
runAction(btn.dataset.act);
|
||
});
|
||
});
|
||
|
||
document.getElementById('job-apply')?.addEventListener('click', async () => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('ui');
|
||
const sel = document.getElementById('job-select');
|
||
if (!sel?.value) return;
|
||
try {
|
||
const data = await postJson('/city/actions/apply-job', { job_id: Number(sel.value) });
|
||
if (data.character) updateCharacterHud(data.character);
|
||
flashCharMsg(data.message || i18n.actionOk);
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
|
||
const social = async (verb, extra = {}) => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('ui');
|
||
const sel = document.getElementById('citizen-select');
|
||
if (!sel?.value) return;
|
||
try {
|
||
const data = await postJson(`/city/social/${verb}`, {
|
||
character_id: Number(sel.value),
|
||
...extra,
|
||
});
|
||
flashCharMsg(data.message || i18n.actionOk);
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
};
|
||
document.getElementById('soc-friend')?.addEventListener('click', () => social('friend'));
|
||
document.getElementById('soc-marry')?.addEventListener('click', () => social('marry'));
|
||
document.getElementById('soc-child')?.addEventListener('click', () => social('child'));
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Audio panel (master / music / SFX + mute)
|
||
// ------------------------------------------------------------------
|
||
function bindAudioUI() {
|
||
const panel = document.getElementById('audio-panel');
|
||
if (!panel) return;
|
||
|
||
const master = document.getElementById('vol-master');
|
||
const music = document.getElementById('vol-music');
|
||
const sfx = document.getElementById('vol-sfx');
|
||
const muteBtn = document.getElementById('vol-mute');
|
||
const masterOut = document.getElementById('vol-master-out');
|
||
const musicOut = document.getElementById('vol-music-out');
|
||
const sfxOut = document.getElementById('vol-sfx-out');
|
||
|
||
const pct = (v) => `${Math.round(v * 100)}%`;
|
||
const syncLabels = () => {
|
||
if (masterOut) masterOut.textContent = pct(cityAudio.getVolume('master'));
|
||
if (musicOut) musicOut.textContent = pct(cityAudio.getVolume('music'));
|
||
if (sfxOut) sfxOut.textContent = pct(cityAudio.getVolume('sfx'));
|
||
if (muteBtn) {
|
||
muteBtn.textContent = cityAudio.muted ? '🔇' : '🔊';
|
||
muteBtn.setAttribute('aria-pressed', String(cityAudio.muted));
|
||
}
|
||
if (master) master.value = String(cityAudio.getVolume('master'));
|
||
if (music) music.value = String(cityAudio.getVolume('music'));
|
||
if (sfx) sfx.value = String(cityAudio.getVolume('sfx'));
|
||
};
|
||
|
||
const onSlide = (channel) => (e) => {
|
||
cityAudio.unlock();
|
||
cityAudio.setVolume(channel, e.target.value);
|
||
syncLabels();
|
||
};
|
||
|
||
master?.addEventListener('input', onSlide('master'));
|
||
music?.addEventListener('input', onSlide('music'));
|
||
sfx?.addEventListener('input', onSlide('sfx'));
|
||
|
||
muteBtn?.addEventListener('click', () => {
|
||
cityAudio.unlock();
|
||
cityAudio.toggleMute();
|
||
if (!cityAudio.muted) cityAudio.play('ui');
|
||
syncLabels();
|
||
});
|
||
|
||
document.getElementById('audio-toggle')?.addEventListener('click', () => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('ui');
|
||
const hidden = panel.style.display === 'none' || panel.style.display === '';
|
||
panel.style.display = hidden ? 'block' : 'none';
|
||
});
|
||
|
||
syncLabels();
|
||
panel.style.display = 'none';
|
||
}
|
||
|
||
function syncPositionThrottled() {
|
||
if (!meCharacter) return;
|
||
const now = performance.now();
|
||
if (now - lastPositionSync < 2000) return;
|
||
lastPositionSync = now;
|
||
postJson('/city/actions/position', {
|
||
pos_x: player.pos.x,
|
||
pos_z: player.pos.z,
|
||
}).then((data) => {
|
||
if (data && meCharacter) {
|
||
meCharacter.position = [data.pos_x, 0, data.pos_z];
|
||
}
|
||
}).catch(() => { /* offline-safe */ });
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Resize + animation loop
|
||
// ------------------------------------------------------------------
|
||
function resize() {
|
||
const w = host.clientWidth, h = host.clientHeight;
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(w, h, false);
|
||
}
|
||
new ResizeObserver(resize).observe(host);
|
||
resize();
|
||
|
||
let lastT = performance.now();
|
||
let simTimeAcc = 0;
|
||
let statsTimer = 0;
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
const now = performance.now();
|
||
const dt = Math.min(0.05, (now - lastT) / 1000);
|
||
lastT = now;
|
||
simTimeAcc += dt;
|
||
const tUpdate = performance.now();
|
||
|
||
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);
|
||
if (beachSystem) {
|
||
try { beachSystem.update(dt, simTimeAcc); } catch (err) { console.error('beach update', err); }
|
||
}
|
||
if (portSystem) {
|
||
// A port glitch must never abort the frame (renderer.render below).
|
||
try { portSystem.update(dt, simTimeAcc); } catch (err) { console.error('port update', err); }
|
||
}
|
||
|
||
updatePlayer(dt);
|
||
updateDrone(dt);
|
||
checkVehicleHitOnPlayer();
|
||
updateExercise(dt);
|
||
updateCameraFromPlayer();
|
||
updateHUD();
|
||
updateVehicleHeadlights();
|
||
syncPositionThrottled();
|
||
updateTalkProximity(dt);
|
||
drawMinimap();
|
||
|
||
// Footsteps while the local player is actually walking (not just turning).
|
||
const walking = (locked || touchMode) && (keys['KeyW'] || keys['KeyS'] || keys['KeyA'] || keys['KeyD']
|
||
|| keys['ArrowUp'] || keys['ArrowDown']
|
||
|| (mobileInput.active && Math.abs(mobileInput.y) > 0.15));
|
||
cityAudio.footsteps(dt, walking && !isHospitalizedNow());
|
||
|
||
statsTimer += dt;
|
||
if (statsTimer > 1 && cityData) {
|
||
statsTimer = 0;
|
||
statsEl.textContent = buildStatsText();
|
||
}
|
||
|
||
perfFrame.update = performance.now() - tUpdate;
|
||
const tRender = performance.now();
|
||
renderer.render(scene, camera);
|
||
perfFrame.render = performance.now() - tRender;
|
||
|
||
// Drone command-panel feed: scissored pass every frame so the visor
|
||
// never alternates between the drone view and the ground view.
|
||
if (drone.active && drone.mesh) {
|
||
renderDroneView();
|
||
}
|
||
}
|
||
animate();
|
||
|
||
// ------------------------------------------------------------------
|
||
// Multiplayer presence (other citizens in near-real-time)
|
||
// ------------------------------------------------------------------
|
||
function upsertRemoteCitizen(c) {
|
||
if (c.is_me) return;
|
||
let mesh = remoteCitizens.get(c.id);
|
||
if (!mesh) {
|
||
mesh = buildHumanoid({
|
||
name: c.name,
|
||
type: c.type === 'child' ? 'crowd' : 'pedestrian',
|
||
status: c.status || 'active',
|
||
id: c.id,
|
||
}, true);
|
||
// Human peer marker used by the bump-to-talk detection.
|
||
mesh.userData.citizen = { kind: 'character', id: c.id, name: c.name };
|
||
const [cx, , cz] = c.position;
|
||
mesh.position.set(cx, 0, cz);
|
||
worldGroup.add(mesh);
|
||
crowdMeshes.push(mesh);
|
||
remoteCitizens.set(c.id, mesh);
|
||
} else {
|
||
const [cx, , cz] = c.position;
|
||
const dx = cx - mesh.position.x;
|
||
const dz = cz - mesh.position.z;
|
||
mesh.position.x = cx;
|
||
mesh.position.z = cz;
|
||
if (Math.hypot(dx, dz) > 0.05) {
|
||
mesh.rotation.y = Math.atan2(dx, dz);
|
||
animateHumanoid(mesh, 0.016, Math.min(2, Math.hypot(dx, dz) * 4));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pollPresence() {
|
||
if (!window.__city10?.ready || hospitalBusy) return;
|
||
try {
|
||
const qs = cfg.citySlug ? `?city=${encodeURIComponent(cfg.citySlug)}` : '';
|
||
const res = await fetch(`/city/presence${qs}`, {
|
||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||
cache: 'no-store',
|
||
});
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const seen = new Set();
|
||
for (const c of data.citizens || []) {
|
||
if (c.is_me) continue;
|
||
seen.add(c.id);
|
||
upsertRemoteCitizen(c);
|
||
}
|
||
for (const [id, mesh] of remoteCitizens) {
|
||
if (!seen.has(id)) {
|
||
worldGroup.remove(mesh);
|
||
remoteCitizens.delete(id);
|
||
}
|
||
}
|
||
lastPresenceOk = performance.now();
|
||
} catch (_) { /* offline-safe */ }
|
||
}
|
||
|
||
function startPresenceLoop() {
|
||
if (presenceTimer) clearInterval(presenceTimer);
|
||
presenceTimer = setInterval(pollPresence, 2500);
|
||
pollPresence();
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Minimap (canvas overlay)
|
||
// ------------------------------------------------------------------
|
||
function initMinimap() {
|
||
const canvas = document.getElementById('minimap');
|
||
if (!canvas) return;
|
||
minimapCtx = canvas.getContext('2d');
|
||
}
|
||
|
||
function drawMinimap() {
|
||
if (!minimapCtx || !cityBounds) return;
|
||
const canvas = minimapCtx.canvas;
|
||
const w = canvas.width;
|
||
const h = canvas.height;
|
||
const minX = cityBounds.min_x;
|
||
const maxX = cityBounds.max_x;
|
||
const minZ = cityBounds.min_z;
|
||
const maxZ = cityBounds.max_z;
|
||
const spanX = Math.max(1, maxX - minX);
|
||
const spanZ = Math.max(1, maxZ - minZ);
|
||
const toMap = (x, z) => [
|
||
((x - minX) / spanX) * (w - 8) + 4,
|
||
((z - minZ) / spanZ) * (h - 8) + 4,
|
||
];
|
||
|
||
minimapCtx.clearRect(0, 0, w, h);
|
||
minimapCtx.fillStyle = 'rgba(11,18,32,.85)';
|
||
minimapCtx.fillRect(0, 0, w, h);
|
||
|
||
// Roads
|
||
if (grid) {
|
||
minimapCtx.strokeStyle = 'rgba(148,163,184,.45)';
|
||
minimapCtx.lineWidth = 2;
|
||
for (const vx of grid.xs) {
|
||
const [mx] = toMap(vx, minZ);
|
||
minimapCtx.beginPath();
|
||
minimapCtx.moveTo(mx, 4);
|
||
minimapCtx.lineTo(mx, h - 4);
|
||
minimapCtx.stroke();
|
||
}
|
||
for (const vz of grid.zs) {
|
||
const [, mz] = toMap(minX, vz);
|
||
minimapCtx.beginPath();
|
||
minimapCtx.moveTo(4, mz);
|
||
minimapCtx.lineTo(w - 4, mz);
|
||
minimapCtx.stroke();
|
||
}
|
||
}
|
||
|
||
// Plazas (static parks — green)
|
||
for (const p of plazas) {
|
||
const [mx, mz] = toMap(p.position[0], p.position[1]);
|
||
minimapCtx.fillStyle = 'rgba(34,197,94,.55)';
|
||
minimapCtx.beginPath();
|
||
minimapCtx.arc(mx, mz, 5, 0, Math.PI * 2);
|
||
minimapCtx.fill();
|
||
}
|
||
|
||
// Stores (static shops — orange)
|
||
for (const s of cityData?.stores || []) {
|
||
const [mx, mz] = toMap(s.position[0], s.position[2]);
|
||
minimapCtx.fillStyle = '#f59e0b';
|
||
minimapCtx.fillRect(mx - 2.5, mz - 2.5, 5, 5);
|
||
}
|
||
|
||
// NPC pedestrians (local crowd — yellow, they walk)
|
||
if (pedestrianSystem) {
|
||
minimapCtx.fillStyle = '#facc15';
|
||
for (const p of pedestrianSystem.positions()) {
|
||
const [mx, mz] = toMap(p.x, p.z);
|
||
minimapCtx.beginPath();
|
||
minimapCtx.arc(mx, mz, 1.8, 0, Math.PI * 2);
|
||
minimapCtx.fill();
|
||
}
|
||
}
|
||
|
||
// Vehicles (moving — colored by kind)
|
||
if (vehicleSystem?.vehicles) {
|
||
for (const v of vehicleSystem.vehicles) {
|
||
const p = { x: v.mesh?.position?.x, z: v.mesh?.position?.z };
|
||
if (p.x == null || p.z == null) continue;
|
||
const kind = v.kind || v.mesh?.userData?.kind || 'car';
|
||
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
|
||
: kind === 'bike' ? '#4ade80'
|
||
: kind === 'bus' ? '#60a5fa'
|
||
: '#f472b6';
|
||
// Player is the bright pink; other cars use a softer white-blue.
|
||
if (Math.hypot(p.x - player.pos.x, p.z - player.pos.z) < 0.5) continue;
|
||
minimapCtx.fillStyle = kind === 'moto' ? '#a78bfa'
|
||
: kind === 'bike' ? '#4ade80'
|
||
: kind === 'bus' ? '#60a5fa'
|
||
: '#94a3b8';
|
||
const [mx, mz] = toMap(p.x, p.z);
|
||
minimapCtx.fillRect(mx - 1.2, mz - 1.2, 2.4, 2.4);
|
||
}
|
||
}
|
||
|
||
// Remote citizens (online players — cyan)
|
||
minimapCtx.fillStyle = '#38bdf8';
|
||
for (const mesh of remoteCitizens.values()) {
|
||
const [mx, mz] = toMap(mesh.position.x, mesh.position.z);
|
||
minimapCtx.beginPath();
|
||
minimapCtx.arc(mx, mz, 2.8, 0, Math.PI * 2);
|
||
minimapCtx.fill();
|
||
}
|
||
|
||
// Player (you — pink with white ring)
|
||
const [px, pz] = toMap(player.pos.x, player.pos.z);
|
||
minimapCtx.fillStyle = '#f472b6';
|
||
minimapCtx.beginPath();
|
||
minimapCtx.arc(px, pz, 4, 0, Math.PI * 2);
|
||
minimapCtx.fill();
|
||
minimapCtx.strokeStyle = '#fff';
|
||
minimapCtx.lineWidth = 1.5;
|
||
minimapCtx.stroke();
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Vehicle headlights at night
|
||
// ------------------------------------------------------------------
|
||
function initVehicleHeadlights() {
|
||
// Collect materials tagged on each mesh (car/bus/moto/bike).
|
||
for (const v of vehicleSystem?.vehicles || []) {
|
||
const mesh = v.mesh;
|
||
if (!mesh || mesh.userData.lightsCollected) continue;
|
||
if (mesh.userData.headMats) vehicleHeadlights.push(...mesh.userData.headMats);
|
||
if (mesh.userData.tailMats) vehicleTailLights.push(...mesh.userData.tailMats);
|
||
if (mesh.userData.beams) vehicleBeams.push(...mesh.userData.beams);
|
||
mesh.userData.lightsCollected = true;
|
||
}
|
||
}
|
||
|
||
function updateVehicleHeadlights() {
|
||
const night = sim.nightFactor;
|
||
// Heads fade in from dusk; beams only once fully dark.
|
||
const head = night > 0 ? 0.25 + night * 3.0 : 0.2;
|
||
const tail = night > 0 ? 0.5 + night * 1.6 : 0.4;
|
||
const beam = night > 0.55 ? (night - 0.55) / 0.45 * 0.22 : 0;
|
||
for (const m of vehicleHeadlights) m.emissiveIntensity = head;
|
||
for (const m of vehicleTailLights) m.emissiveIntensity = tail;
|
||
for (const m of vehicleBeams) m.opacity = beam;
|
||
// Luminous letreiros: visible glow by day, brighter with a subtle
|
||
// shimmer once night falls.
|
||
const t = performance.now() * 0.0025;
|
||
for (const s of signMaterials) {
|
||
s.mat.emissiveIntensity = s.base + night * s.nightBoost + Math.sin(t + s.phase) * s.pulse;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Mobile virtual joystick
|
||
// ------------------------------------------------------------------
|
||
function bindMobileJoystick() {
|
||
const base = document.getElementById('joystick-base');
|
||
const knob = document.getElementById('joystick-knob');
|
||
if (!base || !knob) return;
|
||
|
||
let activeId = null;
|
||
const radius = 42;
|
||
|
||
const setKnob = (dx, dy) => {
|
||
const len = Math.hypot(dx, dy) || 1;
|
||
const clamped = Math.min(len, radius);
|
||
const nx = (dx / len) * clamped;
|
||
const ny = (dy / len) * clamped;
|
||
knob.style.transform = `translate(${nx}px, ${ny}px)`;
|
||
mobileInput.x = nx / radius;
|
||
mobileInput.y = ny / radius;
|
||
mobileInput.active = Math.hypot(mobileInput.x, mobileInput.y) > 0.15;
|
||
};
|
||
|
||
const center = () => {
|
||
activeId = null;
|
||
mobileInput = { x: 0, y: 0, active: false };
|
||
knob.style.transform = 'translate(0px, 0px)';
|
||
};
|
||
|
||
base.addEventListener('pointerdown', (e) => {
|
||
activeId = e.pointerId;
|
||
try { base.setPointerCapture(e.pointerId); } catch (_) { /* synthetic or released pointer */ }
|
||
const rect = base.getBoundingClientRect();
|
||
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
|
||
e.preventDefault();
|
||
});
|
||
base.addEventListener('pointermove', (e) => {
|
||
if (activeId !== e.pointerId) return;
|
||
const rect = base.getBoundingClientRect();
|
||
setKnob(e.clientX - (rect.left + rect.width / 2), e.clientY - (rect.top + rect.height / 2));
|
||
});
|
||
base.addEventListener('pointerup', center);
|
||
base.addEventListener('pointercancel', center);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Market / missions / customize / ride UI
|
||
// ------------------------------------------------------------------
|
||
async function refreshMarketPanel() {
|
||
const listEl = document.getElementById('market-list');
|
||
const missionEl = document.getElementById('mission-list');
|
||
if (!listEl && !missionEl) return;
|
||
try {
|
||
const res = await fetch('/city/market', { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (listEl) {
|
||
listEl.innerHTML = (data.listings || []).length
|
||
? data.listings.map(l => `
|
||
<div style="display:flex; justify-content:space-between; gap:.4rem; align-items:center; margin:.25rem 0;">
|
||
<span>${l.qty}× ${l.item} · $${Number(l.unit_price).toFixed(2)} <span style="opacity:.7">(${l.seller_name})</span></span>
|
||
${l.is_mine ? '' : `<button type="button" class="btn market-buy" data-id="${l.id}" style="padding:.15rem .4rem; font-size:.7rem;">${i18n.marketBuy || 'Buy'}</button>`}
|
||
</div>`).join('')
|
||
: `<div style="opacity:.6;">—</div>`;
|
||
listEl.querySelectorAll('.market-buy').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
try {
|
||
const out = await postJson('/city/market/buy', { listing_id: Number(btn.dataset.id) });
|
||
flashCharMsg(out.message || i18n.actionOk);
|
||
cityAudio.play('money');
|
||
if (out.inventory && meCharacter) {
|
||
meCharacter.money = out.money;
|
||
meCharacter.inventory = out.inventory;
|
||
updateCharacterHud(meCharacter);
|
||
}
|
||
refreshMarketPanel();
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
if (missionEl) {
|
||
missionEl.innerHTML = (data.missions || []).map(m => `
|
||
<div style="margin:.3rem 0; ${m.is_completed ? 'opacity:.55;' : ''}">
|
||
<strong style="color:#fff;">${m.title}</strong>
|
||
<div style="font-size:.75rem;">${m.description}</div>
|
||
<div style="font-size:.75rem;">${m.progress}/${m.target} · $${Number(m.reward).toFixed(2)}
|
||
${m.is_completed ? '✓' : ''}</div>
|
||
</div>`).join('') || '<div style="opacity:.6;">—</div>';
|
||
}
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
function bindWorldUI() {
|
||
document.getElementById('btn-drone')?.addEventListener('click', () => toggleDrone());
|
||
document.getElementById('drone-close')?.addEventListener('click', () => {
|
||
if (drone.active) toggleDrone();
|
||
});
|
||
document.getElementById('btn-market')?.addEventListener('click', () => {
|
||
const panel = document.getElementById('market-panel');
|
||
if (!panel) return;
|
||
const show = panel.style.display === 'none' || panel.style.display === '';
|
||
panel.style.display = show ? 'block' : 'none';
|
||
if (show) refreshMarketPanel();
|
||
});
|
||
|
||
document.getElementById('market-sell')?.addEventListener('click', async () => {
|
||
const item = document.getElementById('market-item')?.value || 'product';
|
||
const qty = Number(document.getElementById('market-qty')?.value || 1);
|
||
const price = Number(document.getElementById('market-price')?.value || 5);
|
||
try {
|
||
const out = await postJson('/city/market/sell', { item, qty, unit_price: price });
|
||
flashCharMsg(out.message || i18n.actionOk);
|
||
cityAudio.play('money');
|
||
refreshMarketPanel();
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-customize')?.addEventListener('click', async () => {
|
||
const panel = document.getElementById('customize-panel');
|
||
if (!panel) return;
|
||
const show = panel.style.display === 'none' || panel.style.display === '';
|
||
panel.style.display = show ? 'block' : 'none';
|
||
if (!show) return;
|
||
try {
|
||
const res = await fetch('/city/customize', { headers: { Accept: 'application/json' } });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
['skin', 'hair', 'shirt', 'face', 'beard', 'accessory'].forEach(k => {
|
||
const el = document.getElementById(`cz-${k}`);
|
||
if (el) el.value = String(data[k]);
|
||
});
|
||
} catch (_) { /* ignore */ }
|
||
});
|
||
|
||
document.getElementById('customize-save')?.addEventListener('click', async () => {
|
||
const body = {};
|
||
for (const k of ['skin', 'hair', 'shirt', 'face', 'beard', 'accessory']) {
|
||
body[k] = Number(document.getElementById(`cz-${k}`)?.value || 1);
|
||
}
|
||
try {
|
||
const res = await fetch('/city/customize', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-CSRF-TOKEN': csrf,
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
const out = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(out.message || 'error');
|
||
flashCharMsg(out.message || i18n.actionOk);
|
||
cityAudio.play('ok');
|
||
loadCity();
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-ride-bus')?.addEventListener('click', async () => {
|
||
try {
|
||
const out = await postJson('/city/actions/ride', {
|
||
kind: 'bus',
|
||
pos_x: player.pos.x,
|
||
pos_z: player.pos.z,
|
||
});
|
||
if (out.character) updateCharacterHud(out.character);
|
||
flashCharMsg(out.message || i18n.actionOk);
|
||
cityAudio.play('ok');
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-ride-train')?.addEventListener('click', async () => {
|
||
try {
|
||
const out = await postJson('/city/actions/ride', {
|
||
kind: 'train',
|
||
pos_x: player.pos.x,
|
||
pos_z: cityBounds ? cityBounds.max_z + 14 : player.pos.z,
|
||
});
|
||
if (out.character) updateCharacterHud(out.character);
|
||
flashCharMsg(out.message || i18n.actionOk);
|
||
cityAudio.play('ok');
|
||
} catch (e) {
|
||
flashCharMsg(e.message, true);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Test/debug API
|
||
// ------------------------------------------------------------------
|
||
window.__city10 = {
|
||
ready: false,
|
||
get hour() { return sim.hour; },
|
||
get weather() { return weatherState.state; },
|
||
get trafficPhase() { return trafficSystem ? trafficSystem.phaseName : null; },
|
||
get pedStateX() { return trafficSystem ? trafficSystem.pedState('x') : null; },
|
||
get pedStateZ() { return trafficSystem ? trafficSystem.pedState('z') : null; },
|
||
setTrafficPhase(name) {
|
||
if (trafficSystem) trafficSystem.setPhase(name);
|
||
return this.trafficPhase;
|
||
},
|
||
/**
|
||
* Snapshot of every signal head (position, facing, axis and the lamp
|
||
* currently lit) so automated tests can assert placement and states.
|
||
*/
|
||
trafficLightHeads() {
|
||
if (!trafficSystem) return { vehicle: [], pedestrian: [] };
|
||
const vehicle = trafficSystem.heads.map(({ head, axis }) => {
|
||
const intensities = head.userData.lamps.map(
|
||
l => +l.material.emissiveIntensity.toFixed(2)
|
||
);
|
||
const active = ['red', 'yellow', 'green'][intensities.indexOf(Math.max(...intensities))];
|
||
return {
|
||
axis,
|
||
x: +head.position.x.toFixed(2),
|
||
y: +head.position.y.toFixed(2),
|
||
z: +head.position.z.toFixed(2),
|
||
rotationY: +head.rotation.y.toFixed(3),
|
||
active,
|
||
intensities,
|
||
};
|
||
});
|
||
const pedestrian = trafficSystem.pedHeads.map(({ head, axis }) => {
|
||
const red = +head.userData.lamps.red.material.emissiveIntensity.toFixed(2);
|
||
const green = +head.userData.lamps.green.material.emissiveIntensity.toFixed(2);
|
||
return {
|
||
axis,
|
||
x: +head.position.x.toFixed(2),
|
||
y: +head.position.y.toFixed(2),
|
||
z: +head.position.z.toFixed(2),
|
||
rotationY: +head.rotation.y.toFixed(3),
|
||
active: red > green ? 'red' : 'green',
|
||
};
|
||
});
|
||
return { vehicle, pedestrian };
|
||
},
|
||
/**
|
||
* Per-vehicle stop-line probe: travel axis, distance to the next stop
|
||
* line and current speed, for red-light compliance checks.
|
||
*/
|
||
trafficProbe() {
|
||
if (!vehicleSystem) return [];
|
||
const out = [];
|
||
for (const v of vehicleSystem.vehicles) {
|
||
if (!v.pts) continue;
|
||
const probe = probeAhead(v.pts, v.s, v.L);
|
||
const cur = new THREE.Vector3();
|
||
posOnPath(v.pts, v.s, v.L, cur);
|
||
out.push({
|
||
x: +cur.x.toFixed(2),
|
||
z: +cur.z.toFixed(2),
|
||
speed: +(v.speed || 0).toFixed(2),
|
||
axis: probe.axis,
|
||
distToStop: +distanceToStopLine(probe, cur).toFixed(2),
|
||
state: trafficSystem ? trafficSystem.axisState(probe.axis) : null,
|
||
});
|
||
}
|
||
return out;
|
||
},
|
||
playerCrossingAllowed,
|
||
/** Debug: the WebGL renderer (frame forensics for automated tests). */
|
||
get gl() {
|
||
return renderer;
|
||
},
|
||
/** Debug: live scene background/fog hex + camera pose. */
|
||
get sceneInfo() {
|
||
return {
|
||
bg: scene.background ? scene.background.getHexString() : null,
|
||
fog: scene.fog ? scene.fog.color.getHexString() : null,
|
||
fogNear: scene.fog ? scene.fog.near : null,
|
||
fogFar: scene.fog ? scene.fog.far : null,
|
||
cam: {
|
||
x: +camera.position.x.toFixed(2),
|
||
y: +camera.position.y.toFixed(2),
|
||
z: +camera.position.z.toFixed(2),
|
||
},
|
||
toneMapping: renderer.toneMapping,
|
||
exposure: renderer.toneMappingExposure,
|
||
};
|
||
},
|
||
/** Current bump-to-talk peer (null when the chat is closed). */
|
||
get talkPeer() {
|
||
return currentPeer;
|
||
},
|
||
/** Local player pose for movement/look tests. */
|
||
playerPose() {
|
||
return {
|
||
x: +player.pos.x.toFixed(3),
|
||
y: +player.pos.y.toFixed(3),
|
||
z: +player.pos.z.toFixed(3),
|
||
yaw: +player.yaw.toFixed(4),
|
||
pitch: +player.pitch.toFixed(4),
|
||
locked,
|
||
touchMode,
|
||
};
|
||
},
|
||
get barriersDown() { return trainSystem ? trainSystem.blocks : null; },
|
||
setHour(h) {
|
||
sim.hour = ((h % 24) + 24) % 24;
|
||
updateDayNight();
|
||
updateHUD();
|
||
},
|
||
setWeather,
|
||
audio: {
|
||
state: () => cityAudio.state(),
|
||
setVolume: (ch, v) => { cityAudio.setVolume(ch, v); return cityAudio.state(); },
|
||
setMuted: (m) => { cityAudio.setMuted(m); return cityAudio.state(); },
|
||
unlock: () => cityAudio.unlock(),
|
||
play: (name) => { cityAudio.unlock(); cityAudio.play(name); },
|
||
},
|
||
teleport(x, z, yaw = null, pitch = 0, y = null) {
|
||
player.pos.x = x;
|
||
player.pos.z = z;
|
||
player.pos.y = y !== null ? y : player.eyeHeight;
|
||
player.vel.set(0, 0, 0);
|
||
if (yaw !== null) player.yaw = yaw;
|
||
player.pitch = pitch;
|
||
updateCameraFromPlayer();
|
||
},
|
||
stats() {
|
||
return {
|
||
ready: this.ready,
|
||
hour: +sim.hour.toFixed(2),
|
||
weather: weatherState.state,
|
||
trafficPhase: this.trafficPhase,
|
||
vehicles: vehicleSystem ? vehicleSystem.count() : 0,
|
||
vehicleKinds: vehicleSystem ? vehicleSystem.kinds() : { car: 0, bus: 0, moto: 0, bike: 0 },
|
||
plazas: plazas.length,
|
||
gymEquipment: gymEquipment.length,
|
||
exercising: exercise.active,
|
||
nearGym: (() => {
|
||
const n = spawnApplied ? nearestGymEquipment(4) : null;
|
||
return n ? { id: n.eq.id, kind: n.eq.kind, x: n.eq.x, z: n.eq.z, dist: +n.dist.toFixed(2) } : null;
|
||
})(),
|
||
pedestrians: pedestrianSystem ? pedestrianSystem.count() : 0,
|
||
trafficLights: trafficSystem ? trafficSystem.heads.length / 2 : 0,
|
||
crosswalks: crosswalkCount,
|
||
sidewalkSegments: sidewalkSegmentCount,
|
||
hour: +(sim.hour ?? 0).toFixed(1),
|
||
night: +(sim.nightFactor ?? 0).toFixed(2),
|
||
beach: beachSystem ? beachSystem.counts() : null,
|
||
flights: airplaneSystem ? airplaneSystem.flights() : null,
|
||
port: portSystem ? portSystem.counts() : null,
|
||
drone: drone.mesh ? {
|
||
active: drone.active,
|
||
x: +drone.pos.x.toFixed(1),
|
||
y: +drone.pos.y.toFixed(1),
|
||
z: +drone.pos.z.toFixed(1),
|
||
yaw: +drone.yaw.toFixed(2),
|
||
fogCleared: !!drone.fogCleared,
|
||
view: drone.view || { x: 0, y: 0, w: 0, h: 0 },
|
||
} : null,
|
||
robots: pedestrianSystem
|
||
? pedestrianSystem.walkers.filter(w => w.robot).length
|
||
: 0,
|
||
robotWalkers: pedestrianSystem
|
||
? pedestrianSystem.walkers
|
||
.filter(w => w.robot)
|
||
.map(w => ({ x: +w.mesh.position.x.toFixed(1), z: +w.mesh.position.z.toFixed(1) }))
|
||
: [],
|
||
signs: {
|
||
stores: signCounts.stores,
|
||
plazas: signCounts.plazas,
|
||
beach: signCounts.beach,
|
||
airports: signCounts.airports,
|
||
buildings: signCounts.buildings,
|
||
filler: signCounts.filler,
|
||
lit: signMaterials.length,
|
||
avgIntensity: signMaterials.length
|
||
? +(signMaterials.reduce((sum, s) => sum + s.mat.emissiveIntensity, 0) / signMaterials.length).toFixed(3)
|
||
: 0,
|
||
},
|
||
remoteIds: [...remoteCitizens.keys()],
|
||
render: {
|
||
calls: renderer.info.render.calls,
|
||
triangles: renderer.info.render.triangles,
|
||
geometries: renderer.info.memory.geometries,
|
||
textures: renderer.info.memory.textures,
|
||
updateMs: +perfFrame.update.toFixed(1),
|
||
submitMs: +perfFrame.render.toFixed(1),
|
||
},
|
||
airplanes: airplaneSystem ? airplaneSystem.count() : 0,
|
||
trains: trainSystem ? 1 : 0,
|
||
river: !!riverSystem,
|
||
stores: cityData ? cityData.stores.length : 0,
|
||
storesOpen: shopSystem ? shopSystem.openCount() : 0,
|
||
agents: cityData ? cityData.agents.length : 0,
|
||
colliders: colliders.length,
|
||
};
|
||
},
|
||
vehiclePositions() { return vehicleSystem ? vehicleSystem.positions() : []; },
|
||
/** Sample vehicle heading vs velocity so tests can detect reverse-facing models. */
|
||
vehicleOrientations() {
|
||
if (!vehicleSystem) return [];
|
||
const out = [];
|
||
for (const v of vehicleSystem.vehicles) {
|
||
const p = new THREE.Vector3();
|
||
const ahead = new THREE.Vector3();
|
||
if (v.pts) {
|
||
posOnPath(v.pts, v.s, v.L, p);
|
||
posOnPath(v.pts, v.s + 0.8, v.L, ahead);
|
||
} else {
|
||
p.lerpVectors(v.a, v.b, v.t);
|
||
const lookT = Math.max(0, Math.min(1, v.t + v.dir * 0.05));
|
||
ahead.lerpVectors(v.a, v.b, lookT);
|
||
}
|
||
const dx = ahead.x - p.x;
|
||
const dz = ahead.z - p.z;
|
||
const len = Math.hypot(dx, dz) || 1;
|
||
const vx = dx / len;
|
||
const vz = dz / len;
|
||
// Mesh nose is local +X → world forward = (cos yaw, -sin yaw).
|
||
const yaw = v.mesh.rotation.y;
|
||
const fx = Math.cos(yaw);
|
||
const fz = -Math.sin(yaw);
|
||
const dot = fx * vx + fz * vz;
|
||
out.push({
|
||
kind: v.mesh?.userData?.kind || 'car',
|
||
speed: +(v.speed || 0).toFixed(2),
|
||
dot: +dot.toFixed(3),
|
||
aligned: dot > 0.5,
|
||
});
|
||
}
|
||
return out;
|
||
},
|
||
pedestrianPositions() { return pedestrianSystem ? pedestrianSystem.positions() : []; },
|
||
get me() { return meCharacter; },
|
||
refreshMe: async function () {
|
||
try {
|
||
const res = await fetch(cfg.dataUrl, { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
updateCharacterHud(data.me);
|
||
return data.me;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
},
|
||
action: runAction,
|
||
presence: { poll: pollPresence, remotes: () => remoteCitizens.size, lastOk: () => lastPresenceOk },
|
||
minimap: () => !!minimapCtx,
|
||
joystickBound: () => !!document.getElementById('joystick-base'),
|
||
agentPositions() {
|
||
return agentMeshes.map(m => ({
|
||
id: m.userData?.agent?.id ?? null,
|
||
name: m.userData?.agent?.name ?? '',
|
||
type: m.userData?.agent?.type ?? '',
|
||
x: +m.position.x.toFixed(2),
|
||
z: +m.position.z.toFixed(2),
|
||
}));
|
||
},
|
||
};
|
||
|
||
// ------------------------------------------------------------------
|
||
// Data loading
|
||
// ------------------------------------------------------------------
|
||
async function loadCity() {
|
||
loadingEl.style.display = 'grid';
|
||
errorEl.style.display = 'none';
|
||
window.__city10.ready = false;
|
||
try {
|
||
const res = await fetch(dataUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' });
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
cityData = await res.json();
|
||
buildCity(cityData);
|
||
applyWeatherVisuals();
|
||
updateDayNight();
|
||
loadingEl.style.display = 'none';
|
||
window.__city10.ready = true;
|
||
initVehicleHeadlights();
|
||
startPresenceLoop();
|
||
refreshMarketPanel();
|
||
} catch (err) {
|
||
console.error(err);
|
||
loadingEl.style.display = 'none';
|
||
errorEl.style.display = 'grid';
|
||
}
|
||
}
|
||
|
||
reloadBtn.addEventListener('click', () => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('reload');
|
||
loadCity();
|
||
});
|
||
bindCharacterUI();
|
||
bindAudioUI();
|
||
bindWorldUI();
|
||
initMinimap();
|
||
bindMobileJoystick();
|
||
|
||
// Unlock audio on the first gesture anywhere (autoplay policy).
|
||
const unlockAudio = () => {
|
||
cityAudio.unlock();
|
||
};
|
||
document.addEventListener('pointerdown', unlockAudio, { once: false });
|
||
document.addEventListener('keydown', unlockAudio, { once: false });
|
||
startGate?.addEventListener('click', () => {
|
||
cityAudio.unlock();
|
||
cityAudio.play('ok');
|
||
});
|
||
|
||
loadCity();
|
||
initWeather();
|
||
applyWeatherVisuals(); |