- The factory company is now "شرکت روبات مارکت" on signs, dialogue, briefing, map label, manifest and README - Real-world mode no longer shows "location not found" for every problem: it takes a quick coarse Wi-Fi/network fix first, keeps searching after a GPS timeout, and reports permission denied (1), location off (2), still searching (3) or a non-HTTPS page separately - Three GPS test machines at real coordinates beside the factory, only present in real-world mode; the closest machine within reach is used and the arrival radius is twice the GPS error (12–40 m) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1282 lines
60 KiB
JavaScript
1282 lines
60 KiB
JavaScript
import * as THREE from 'three';
|
||
import '@fontsource/vazirmatn/400.css';
|
||
import '@fontsource/vazirmatn/600.css';
|
||
import '@fontsource/vazirmatn/800.css';
|
||
import './style.css';
|
||
import {setupPwa} from './pwa.js';
|
||
import {startTracking, requestCompass, arrivalRadius, insideWorld, MAX_WALKING_SPEED} from './realworld.js';
|
||
import data from './data/mellat.json';
|
||
import iran from './data/iran.json';
|
||
import {createCity} from './city.js';
|
||
import {createCollisionWorld, addPolygon, resolveCircle, clearFraction} from './collision.js';
|
||
import {buildGraph, routeBetween, buildSegmentIndex, nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
|
||
import {machines, CATEGORIES, START, CITY_START, FACTORY, lockedPlaces} from './locations.js';
|
||
import {createState, catalog, priceAt, isOpen, purchaseBlocker, formatHetoman, formatClock, fa, canAffordAnything} from './game-state.js';
|
||
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from './payment.js';
|
||
import {awardsFor, rankFor, completeMissions, activeMission, MISSIONS} from './scoring.js';
|
||
import {createFamily} from './characters.js';
|
||
import {createVendingMachine} from './vending.js';
|
||
import {createFactory} from './factory.js';
|
||
import {createCar, stepCar, kmh} from './vehicle.js';
|
||
import {createCarModel, createTraffic} from './traffic.js';
|
||
import {createMapAssets, drawMinimap, drawFullMap, drawIran} from './map-render.js';
|
||
import {bearing, formatDistance, toLocal} from './geo.js';
|
||
|
||
const $ = s => document.querySelector(s);
|
||
const canvas = $('#world');
|
||
let renderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({canvas, antialias: true, powerPreference: 'high-performance'});
|
||
} catch {
|
||
$('#loading').textContent = 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.';
|
||
throw new Error('WebGL unavailable');
|
||
}
|
||
// Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button.
|
||
const quality = new URLSearchParams(location.search).get('quality') ?? (() => {
|
||
try {
|
||
return localStorage.getItem('quality');
|
||
} catch {
|
||
return null;
|
||
}
|
||
})() ?? (matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
|
||
renderer.setPixelRatio(quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75));
|
||
renderer.shadowMap.enabled = quality !== 'low';
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.05;
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.fog = new THREE.Fog('#d8cdb4', 220, 1500);
|
||
const camera = new THREE.PerspectiveCamera(62, 1, .3, 7000);
|
||
|
||
// Morning sky over Mashhad: a gradient dome, warm haze at the horizon.
|
||
const sky = new THREE.Mesh(new THREE.SphereGeometry(5000, 32, 16), new THREE.ShaderMaterial({
|
||
side: THREE.BackSide, depthWrite: false, fog: false,
|
||
uniforms: {top: {value: new THREE.Color('#6f9fd0')}, horizon: {value: new THREE.Color('#e6dbc3')}},
|
||
vertexShader: 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
|
||
fragmentShader: 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }'
|
||
}));
|
||
scene.add(sky);
|
||
scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6));
|
||
const sun = new THREE.DirectionalLight('#ffe9c2', 3.2);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.set(2048, 2048);
|
||
Object.assign(sun.shadow.camera, {left: -75, right: 75, top: 75, bottom: -75, near: 10, far: 700});
|
||
sun.shadow.bias = -.0004;
|
||
sun.shadow.normalBias = .4;
|
||
scene.add(sun, sun.target);
|
||
|
||
// ---------- World data ----------
|
||
const collision = createCollisionWorld(data.buildings.map(b => b.slice(1)));
|
||
const segments = buildSegmentIndex(data.roads);
|
||
const walkGraph = buildGraph(data.roads), driveGraph = buildGraph(data.roads, {drivable: true});
|
||
const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2];
|
||
|
||
const rotateLocal = (lx, lz, rot) => ({x: lx * Math.cos(rot) + lz * Math.sin(rot), z: -lx * Math.sin(rot) + lz * Math.cos(rot)});
|
||
|
||
// The game starts inside the vending-machine factory on the Mashhad–Chenaran road.
|
||
const factory = createFactory(scene);
|
||
factory.footprints.forEach(f => addPolygon(collision, f));
|
||
|
||
// City machines stand beside their path point, on whichever side is free, facing the path;
|
||
// the factory's demo machine has a fixed place inside the production hall.
|
||
const stations = machines.map(def => {
|
||
if (def.spot) {
|
||
// Test points: the given coordinate is where the player stands; the machine faces the factory.
|
||
const front = toLocal(def.spot.lat, def.spot.lon);
|
||
const dx = FACTORY.center.x - front.x, dz = FACTORY.center.z - front.z, len = Math.hypot(dx, dz) || 1;
|
||
return placeStation(def, {x: front.x - dx / len * 1.9, z: front.z - dz / len * 1.9}, Math.atan2(dx, dz));
|
||
}
|
||
if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot);
|
||
const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? {dx: 1, dz: 0, type: 9};
|
||
const len = Math.hypot(seg.dx, seg.dz) || 1, nx = -seg.dz / len, nz = seg.dx / len;
|
||
const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4;
|
||
let pos = null;
|
||
for (const d of [off, -off, off + 1.5, -off - 1.5]) {
|
||
const x = def.anchor.x + nx * d, z = def.anchor.z + nz * d;
|
||
if (!resolveCircle(collision, x, z, 1.4).hit) {
|
||
pos = {x, z};
|
||
break;
|
||
}
|
||
}
|
||
pos ??= {x: def.anchor.x + nx * off, z: def.anchor.z + nz * off};
|
||
return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z));
|
||
});
|
||
|
||
function placeStation(def, pos, rot) {
|
||
const model = createVendingMachine(def);
|
||
model.group.position.set(pos.x, 0, pos.z);
|
||
model.group.rotation.y = rot;
|
||
scene.add(model.group);
|
||
addPolygon(collision, [[-1, -.6], [1, -.6], [1, .6], [-1, .6]].flatMap(([lx, lz]) => {
|
||
const p = rotateLocal(lx, lz, rot);
|
||
return [pos.x + p.x, pos.z + p.z];
|
||
}));
|
||
const front = rotateLocal(0, 1.9, rot);
|
||
return {def, model, x: pos.x, z: pos.z, rot, front: {x: pos.x + front.x, z: pos.z + front.z}};
|
||
}
|
||
|
||
const stationById = id => stations.find(s => s.def.id === id);
|
||
for (const s of stations) if (s.def.test) s.model.group.visible = false;
|
||
// GPS test points only take part while real-world mode is on.
|
||
const activeStations = () => stations.filter(s => !s.def.test || real.on);
|
||
// The closest active machine whose front is within `radius` of (x, z).
|
||
const closestStation = (x, z, radius) => activeStations()
|
||
.map(s => ({s, d: Math.hypot(s.front.x - x, s.front.z - z)}))
|
||
.filter(({d}) => d < radius).sort((a, b) => a.d - b.d)[0]?.s ?? null;
|
||
|
||
const city = createCity(scene, data, {collision, segments});
|
||
const traffic = createTraffic(scene, data, 28);
|
||
const mapAssets = createMapAssets(data, [factory.label]);
|
||
|
||
// Park gate sign at the first city machine.
|
||
{
|
||
const start = stationById(CITY_START), sign = document.createElement('canvas');
|
||
sign.width = 512;
|
||
sign.height = 128;
|
||
const c = sign.getContext('2d');
|
||
c.fillStyle = '#244d2e';
|
||
c.fillRect(0, 0, 512, 128);
|
||
c.fillStyle = '#f4efd9';
|
||
c.textAlign = 'center';
|
||
c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
|
||
c.fillText('بوستان ملت', 256, 86);
|
||
const texture = new THREE.CanvasTexture(sign);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
const gate = new THREE.Group(), post = new THREE.MeshStandardMaterial({color: '#6b5a49'});
|
||
for (const side of [-1, 1]) {
|
||
const p = new THREE.Mesh(new THREE.BoxGeometry(.3, 4, .3), post);
|
||
p.position.set(side * 2.6, 2, 0);
|
||
p.castShadow = true;
|
||
gate.add(p);
|
||
}
|
||
const board = new THREE.Mesh(new THREE.PlaneGeometry(5.6, 1.4), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
|
||
board.position.y = 4;
|
||
gate.add(board);
|
||
const at = rotateLocal(-4.5, 1.5, start.rot);
|
||
gate.position.set(start.x + at.x, 0, start.z + at.z);
|
||
gate.rotation.y = start.rot;
|
||
scene.add(gate);
|
||
}
|
||
|
||
// ---------- Actors ----------
|
||
const family = createFamily();
|
||
scene.add(family.group);
|
||
// The Robot Market guide lives inside the machines and comes out when the family arrives.
|
||
const guide = createFamily({robot: true});
|
||
guide.group.visible = false;
|
||
scene.add(guide.group);
|
||
const carModel = createCarModel('#f2f2ee');
|
||
scene.add(carModel.group);
|
||
|
||
let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended;
|
||
const keys = new Set();
|
||
|
||
// The family car waits in the factory yard, pointing at the gate.
|
||
const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading);
|
||
|
||
function reset() {
|
||
state = createState();
|
||
payment = createPayment();
|
||
const start = stationById(START.machineId);
|
||
const spot = resolveCircle(collision, start.front.x + (start.front.x - start.x) * 3, start.front.z + (start.front.z - start.z) * 3, 1);
|
||
player = {x: spot.x, z: spot.z, heading: Math.atan2(-(start.z - spot.z), start.x - spot.x), speed: 0, gait: 0};
|
||
car = spawnCar();
|
||
mode = 'walk';
|
||
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
|
||
cam = {yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)), pitch: .32, distance: 5.5, lastMouse: -10};
|
||
waypoint = null;
|
||
route = null;
|
||
routeTimer = 0;
|
||
deliveryTimer = 0;
|
||
ended = false;
|
||
hints = {closed: false, car: false};
|
||
Object.assign(guideState, {station: null, t: 0, greeted: new Set(), arrived: 0});
|
||
$('#payment').hidden = true;
|
||
for (const s of stations) {
|
||
s.model.delivery.visible = s.model.card.visible = false;
|
||
s.model.flap.rotation.x = 0;
|
||
}
|
||
for (const d of ['#shop', '#map-dialog', '#end']) if ($(d).open) $(d).close();
|
||
keys.clear();
|
||
refreshHUD();
|
||
}
|
||
|
||
// ---------- Input ----------
|
||
const isTouch = matchMedia('(pointer: coarse)').matches;
|
||
const anyDialogOpen = () => $('#shop').open || $('#map-dialog').open || $('#end').open || !$('#briefing').hidden;
|
||
// Physical key codes, so the controls also work with a Persian keyboard layout.
|
||
const keyName = e => e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase();
|
||
window.addEventListener('keydown', e => {
|
||
if (e.target instanceof HTMLInputElement) return;
|
||
const k = keyName(e);
|
||
if (k === 'm' && !e.repeat && $('#briefing').hidden) {
|
||
e.preventDefault();
|
||
$('#map-dialog').open ? $('#map-dialog').close() : openMap();
|
||
return;
|
||
}
|
||
if (anyDialogOpen()) return;
|
||
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) e.preventDefault();
|
||
if (e.repeat) return;
|
||
keys.add(k);
|
||
if (k === 'f') toggleCar();
|
||
if (k === 'e') interact();
|
||
if (k === 'c') swipe();
|
||
if (k === 'escape' && payment.phase !== 'idle') cancel();
|
||
});
|
||
window.addEventListener('keyup', e => keys.delete(keyName(e)));
|
||
window.addEventListener('blur', () => keys.clear());
|
||
|
||
// Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch.
|
||
canvas.addEventListener('click', () => {
|
||
if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas) canvas.requestPointerLock?.()?.catch?.(() => {});
|
||
});
|
||
document.addEventListener('mousemove', e => {
|
||
if (document.pointerLockElement !== canvas) return;
|
||
look(e.movementX, e.movementY, .0024);
|
||
});
|
||
let drag = null;
|
||
canvas.addEventListener('pointerdown', e => {
|
||
if (document.pointerLockElement === canvas) return;
|
||
drag = {id: e.pointerId, x: e.clientX, y: e.clientY};
|
||
canvas.setPointerCapture(e.pointerId);
|
||
});
|
||
canvas.addEventListener('pointermove', e => {
|
||
if (!drag || drag.id !== e.pointerId) return;
|
||
look(e.clientX - drag.x, e.clientY - drag.y, .005);
|
||
drag.x = e.clientX;
|
||
drag.y = e.clientY;
|
||
});
|
||
canvas.addEventListener('pointerup', () => drag = null);
|
||
canvas.addEventListener('pointercancel', () => drag = null);
|
||
canvas.addEventListener('wheel', e => {
|
||
cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * .6, 3, 12);
|
||
}, {passive: true});
|
||
|
||
function look(dx, dy, k) {
|
||
cam.yaw -= dx * k;
|
||
cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * .8, -.15, 1.15);
|
||
cam.lastMouse = elapsed;
|
||
}
|
||
|
||
// Touch joystick: x steers / strafes, y walks / throttles.
|
||
const stick = {x: 0, y: 0, id: null};
|
||
if (isTouch) {
|
||
$('#touch').hidden = false;
|
||
const pad = $('#stick'), knob = pad.querySelector('i');
|
||
const move = e => {
|
||
const r = pad.getBoundingClientRect(), dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
|
||
const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), a = Math.atan2(dy, dx);
|
||
stick.x = Math.cos(a) * len;
|
||
stick.y = -Math.sin(a) * len;
|
||
knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`;
|
||
};
|
||
pad.addEventListener('pointerdown', e => {
|
||
stick.id = e.pointerId;
|
||
pad.setPointerCapture(e.pointerId);
|
||
move(e);
|
||
});
|
||
pad.addEventListener('pointermove', e => e.pointerId === stick.id && move(e));
|
||
const end = () => {
|
||
stick.id = null;
|
||
stick.x = stick.y = 0;
|
||
knob.style.transform = '';
|
||
};
|
||
pad.addEventListener('pointerup', end);
|
||
pad.addEventListener('pointercancel', end);
|
||
for (const b of document.querySelectorAll('.touch-buttons button')) b.addEventListener('click', () => {
|
||
const k = b.dataset.key;
|
||
if (k === 'shift') b.classList.toggle('on', keys.has('shift') ? (keys.delete('shift'), false) : (keys.add('shift'), true));
|
||
if (k === 'f') toggleCar();
|
||
if (k === 'e') payment.phase === 'ready' ? swipe() : interact();
|
||
});
|
||
}
|
||
|
||
const axis = (pos, neg) => Number(pos.some(k => keys.has(k))) - Number(neg.some(k => keys.has(k)));
|
||
|
||
// ---------- Subtitles ----------
|
||
const SPEAKERS = {robot: 'روبات', father: 'پدر', child: 'بچه'};
|
||
let lines = [], lineTimer = 0, talking = false;
|
||
|
||
// `face` picks the robot's expression portrait (public/robot/<face>.webp).
|
||
function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * .06)) {
|
||
if (lines.some(l => l.text === text)) return;
|
||
lines.push({speaker, text, face, seconds});
|
||
}
|
||
|
||
function updateSubtitles(dt) {
|
||
lineTimer -= dt;
|
||
if (lineTimer > 0) return;
|
||
const next = lines.shift();
|
||
$('#subtitle').hidden = !next;
|
||
talking = next?.speaker === 'robot';
|
||
if (!next) return;
|
||
$('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`;
|
||
$('#subtitle-speaker').className = next.speaker;
|
||
$('#subtitle-text').textContent = next.text;
|
||
$('#subtitle-face').hidden = next.speaker !== 'robot';
|
||
if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`;
|
||
lineTimer = next.seconds;
|
||
}
|
||
|
||
// ---------- Sound ----------
|
||
let audio = null, soundOn = false, engine = null;
|
||
|
||
function tone(freq, duration = .12, volume = .05, type = 'sine') {
|
||
if (!soundOn) return;
|
||
const o = audio.createOscillator(), g = audio.createGain();
|
||
o.type = type;
|
||
o.frequency.setValueAtTime(freq, audio.currentTime);
|
||
o.frequency.exponentialRampToValueAtTime(freq * .7, audio.currentTime + duration);
|
||
g.gain.setValueAtTime(volume, audio.currentTime);
|
||
g.gain.exponentialRampToValueAtTime(.001, audio.currentTime + duration);
|
||
o.connect(g).connect(audio.destination);
|
||
o.start();
|
||
o.stop(audio.currentTime + duration);
|
||
}
|
||
|
||
$('#sound').onclick = async () => {
|
||
try {
|
||
audio ??= new AudioContext();
|
||
await audio.resume();
|
||
soundOn = !soundOn;
|
||
if (!engine) {
|
||
const o = audio.createOscillator(), g = audio.createGain();
|
||
o.type = 'sawtooth';
|
||
g.gain.value = 0;
|
||
o.connect(g).connect(audio.destination);
|
||
o.start();
|
||
engine = {o, g};
|
||
}
|
||
$('#sound').textContent = soundOn ? 'صدا: روشن' : 'صدا: خاموش';
|
||
$('#sound').setAttribute('aria-pressed', String(soundOn));
|
||
tone(700);
|
||
} catch {
|
||
$('#sound').textContent = 'صدا در دسترس نیست';
|
||
}
|
||
};
|
||
|
||
// ---------- Car ----------
|
||
function toggleCar() {
|
||
if (payment.phase !== 'idle' || state.busy) return;
|
||
if (real.on) {
|
||
say('father', 'توی حالت دنیای واقعی پیاده راه میریم؛ ماشین مجازی لازم نیست.');
|
||
return;
|
||
}
|
||
if (mode === 'walk') {
|
||
if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return;
|
||
mode = 'drive';
|
||
cam.distance = Math.max(cam.distance, 8);
|
||
tone(320, .2, .05, 'square');
|
||
say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون میده.');
|
||
} else {
|
||
if (Math.abs(car.speed) > 3) {
|
||
say('father', 'اول ماشین را نگه دار، بعد پیاده میشویم.');
|
||
return;
|
||
}
|
||
// Step out on the driver's (left) side, or wherever is free.
|
||
const left = {x: -Math.sin(car.heading), z: -Math.cos(car.heading)};
|
||
const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1);
|
||
Object.assign(player, {x: spot.x, z: spot.z, heading: car.heading});
|
||
car.speed = 0;
|
||
mode = 'walk';
|
||
cam.distance = 5.5;
|
||
tone(260, .2, .05, 'square');
|
||
}
|
||
refreshHUD();
|
||
}
|
||
|
||
// ---------- Machines, shop and payment ----------
|
||
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
|
||
const reachRadius = () => real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4;
|
||
const nearStation = () => mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null;
|
||
const hoursText = m => m.hours[1] - m.hours[0] >= 24 * 60 ? 'شبانهروزی' : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
|
||
let shopStation = null;
|
||
|
||
function interact() {
|
||
const station = nearStation();
|
||
if (!station || state.busy || payment.phase !== 'idle' || ended) return;
|
||
if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
|
||
say('father', 'داریم سریع حرکت میکنیم؛ اول بایستیم، بعد خرید کنیم.');
|
||
return;
|
||
}
|
||
if (!isOpen(station.def, state.minutes)) {
|
||
say(guideOut() ? 'robot' : 'father', `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, 'serious');
|
||
return;
|
||
}
|
||
shopStation = station;
|
||
renderShop();
|
||
document.exitPointerLock?.();
|
||
keys.clear();
|
||
$('#shop').showModal();
|
||
tone(750);
|
||
}
|
||
|
||
function renderShop() {
|
||
const def = shopStation.def;
|
||
$('#shop-category').textContent = `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`;
|
||
$('#shop-name').textContent = `روبات مارکت · ${def.name}`;
|
||
$('#shop-address').textContent = def.address;
|
||
$('#shop-note').textContent = `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`;
|
||
$('#products').replaceChildren(...def.products.map(id => {
|
||
const p = catalog[id], price = priceAt(def, id), b = document.createElement('button');
|
||
b.type = 'button';
|
||
const blocker = purchaseBlocker(state, def, id);
|
||
b.disabled = Boolean(blocker);
|
||
b.innerHTML = `<span class="icon">${p.icon}</span><b>${p.name}</b><span class="price">${formatHetoman(price)}</span>${price !== p.cost ? `<s>${formatHetoman(p.cost)}</s>` : ''}<small>${p.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}${blocker === 'funds' ? ' · موجودی کافی نیست' : ''}</small>`;
|
||
b.onclick = () => startPayment(id);
|
||
return b;
|
||
}));
|
||
}
|
||
|
||
function startPayment(productId) {
|
||
if (!beginPayment(state, payment, shopStation.def, productId)) return;
|
||
payment.station = shopStation;
|
||
payment.timer = 0;
|
||
$('#shop').close();
|
||
const p = catalog[productId];
|
||
$('#payment').hidden = false;
|
||
$('#payment-title').textContent = `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`;
|
||
$('#payment-step').textContent = 'در حال نزدیک شدن به کارتخوان…';
|
||
$('#swipe').disabled = true;
|
||
$('#cancel-payment').hidden = false;
|
||
say('child', 'بابا، کارت رو بده من بکشم!');
|
||
}
|
||
|
||
function swipe() {
|
||
if (!swipePayment(state, payment)) return;
|
||
payment.timer = 0;
|
||
payment.station.model.card.visible = true;
|
||
$('#swipe').disabled = true;
|
||
$('#cancel-payment').hidden = true;
|
||
$('#payment-step').textContent = 'پرداخت موفق ✓ محصول در حال تحویل است';
|
||
tone(1100, .3);
|
||
refreshHUD();
|
||
}
|
||
|
||
function cancel() {
|
||
if (!cancelPayment(state, payment)) return;
|
||
$('#payment').hidden = true;
|
||
}
|
||
|
||
$('#swipe').onclick = swipe;
|
||
$('#cancel-payment').onclick = cancel;
|
||
$('#shop-close').onclick = () => $('#shop').close();
|
||
|
||
function updatePayment(dt) {
|
||
if (payment.phase === 'idle') return;
|
||
payment.timer += dt;
|
||
const model = payment.station.model;
|
||
if (payment.phase === 'approaching' && payment.timer > 1) {
|
||
readyPayment(payment);
|
||
$('#swipe').disabled = false;
|
||
$('#payment-step').textContent = 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C';
|
||
}
|
||
if (payment.phase === 'approved') {
|
||
const t = Math.min(1, payment.timer);
|
||
model.card.position.set(.95 - t * .25, 1.7 - t * .1, .66);
|
||
if (payment.timer > 1.2) {
|
||
model.card.visible = false;
|
||
$('#payment').hidden = true;
|
||
model.setDelivery(payment.productId);
|
||
deliveryTimer = 2.6;
|
||
payment.delivering = {station: payment.station, record: payment.record};
|
||
Object.assign(payment, createPayment());
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateDelivery(dt) {
|
||
if (deliveryTimer <= 0) return;
|
||
deliveryTimer = Math.max(0, deliveryTimer - dt);
|
||
const {station, record} = payment.delivering, model = station.model, t = 2.6 - deliveryTimer;
|
||
const slot = record ? catalog[record.productId] : null;
|
||
model.delivery.visible = t < 2.4;
|
||
// Falls from a shelf, waits in the tray, then lifts toward the child's hands.
|
||
const shelf = new THREE.Vector3(-.5 + (record.price % 7) * .1, 1.2 + (record.price % 5) * .15, .45);
|
||
if (t < .8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / .8);
|
||
else model.delivery.position.copy(model.slotPoint).add(new THREE.Vector3(0, Math.max(0, t - 1.6) * .6, Math.max(0, t - 1.6) * .8));
|
||
model.flap.rotation.x = t > .7 && t < 2 ? -1 : 0;
|
||
if (deliveryTimer > 0) return;
|
||
model.flap.rotation.x = 0;
|
||
state.busy = false;
|
||
tone(980, .18);
|
||
reward(record, slot);
|
||
}
|
||
|
||
function reward(record, product) {
|
||
const awards = awardsFor(state.purchases.slice(0, -1), record), rankBefore = rankFor(state.xp).index;
|
||
if (real.on) awards.push({label: 'خرید در دنیای واقعی', points: 200});
|
||
const total = awards.reduce((s, a) => s + a.points, 0);
|
||
state.xp += total;
|
||
$('#awards').replaceChildren(
|
||
Object.assign(document.createElement('div'), {className: 'total', textContent: `+${fa(total)}`}),
|
||
...awards.map((a, i) => {
|
||
const line = document.createElement('div');
|
||
line.className = 'line';
|
||
line.style.animationDelay = `${i * .12}s`;
|
||
line.innerHTML = `${a.label}<b>+${fa(a.points)}</b>`;
|
||
return line;
|
||
})
|
||
);
|
||
clearTimeout(reward.timeout);
|
||
reward.timeout = setTimeout(() => $('#awards').replaceChildren(), 3200);
|
||
say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
|
||
const done = completeMissions(state);
|
||
for (const m of done) {
|
||
showBanner('مأموریت انجام شد', m.title, `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`);
|
||
tone(660, .5);
|
||
}
|
||
if (rankFor(state.xp).index > rankBefore) setTimeout(() => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاههای بیشتری پیدا کن'), done.length ? 3400 : 0);
|
||
const next = activeMission(state);
|
||
if (next) {
|
||
waypoint = null;
|
||
route = null;
|
||
setTimeout(() => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), 1500);
|
||
}
|
||
refreshHUD();
|
||
checkEnd();
|
||
}
|
||
|
||
function showBanner(tag, title, sub) {
|
||
const b = $('#banner');
|
||
b.innerHTML = `<small>${tag}</small><strong>${title}</strong><span>${sub}</span>`;
|
||
b.hidden = false;
|
||
b.style.animation = 'none';
|
||
void b.offsetWidth;
|
||
b.style.animation = '';
|
||
clearTimeout(showBanner.timeout);
|
||
showBanner.timeout = setTimeout(() => b.hidden = true, 3200);
|
||
}
|
||
|
||
function checkEnd() {
|
||
const allDone = state.missionsDone.length === MISSIONS.length;
|
||
if (ended || (!allDone && canAffordAnything(state, machines))) return;
|
||
ended = true;
|
||
document.exitPointerLock?.();
|
||
$('#end-tag').textContent = allDone ? 'عملیات موفق' : 'پایان بودجه';
|
||
$('#end-title').textContent = allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد';
|
||
$('#end-text').textContent = allDone ? 'همهٔ مأموریتهای محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد بهزودی باز میشوند.' : 'دیگر نمیتوانید از دستگاهها خرید کنید. امتیاز امروز:';
|
||
const places = new Set(state.purchases.map(p => p.machineId)).size;
|
||
$('#end-stats').innerHTML = [['امتیاز', `${fa(state.xp)} XP`], ['درجه', rankFor(state.xp).name], ['خریدها', fa(state.purchases.length)], ['مکانهای کشفشده', `${fa(places)} از ${fa(machines.length)}`], ['مأموریتها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`], ['باقیمانده', formatHetoman(state.balance)]]
|
||
.map(([k, v]) => `<li><span>${k}</span><b>${v}</b></li>`).join('');
|
||
$('#continue').hidden = !allDone;
|
||
$('#end').showModal();
|
||
}
|
||
|
||
$('#quality').textContent = quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل';
|
||
$('#quality').onclick = () => {
|
||
try {
|
||
localStorage.setItem('quality', quality === 'low' ? 'high' : 'low');
|
||
} catch {}
|
||
location.replace(location.pathname);
|
||
};
|
||
$('#again').onclick = reset;
|
||
$('#continue').onclick = () => $('#end').close();
|
||
$('#restart').onclick = reset;
|
||
|
||
// ---------- Objective, route and HUD ----------
|
||
const objectiveStation = () => {
|
||
if (waypoint) return stationById(waypoint);
|
||
const mission = activeMission(state);
|
||
if (mission?.target) return stationById(mission.target);
|
||
// Open-ended missions point to the nearest place not bought from yet.
|
||
const visited = new Set(state.purchases.map(p => p.machineId));
|
||
return activeStations().filter(s => !visited.has(s.def.id)).sort((a, b) => Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z))[0] ?? null;
|
||
};
|
||
|
||
// The gold route ribbon on the ground, rebuilt when the route changes.
|
||
const routeMaterial = new THREE.MeshBasicMaterial({color: '#f2c94c', transparent: true, opacity: .75, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -12, polygonOffsetUnits: -24});
|
||
let routeMesh = null;
|
||
|
||
function rebuildRouteMesh() {
|
||
if (routeMesh) {
|
||
scene.remove(routeMesh);
|
||
routeMesh.geometry.dispose();
|
||
routeMesh = null;
|
||
}
|
||
if (!route) return;
|
||
const pts = [], pos = [], idx = [];
|
||
let total = 0;
|
||
for (const p of route.points) {
|
||
if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z);
|
||
if (total > 700) break;
|
||
pts.push(p);
|
||
}
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
|
||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1, w = mode === 'drive' ? 1.1 : .55;
|
||
pos.push(pts[i].x - dz / len * w, .14, pts[i].z + dx / len * w, pts[i].x + dz / len * w, .14, pts[i].z - dx / len * w);
|
||
if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1);
|
||
}
|
||
const g = new THREE.BufferGeometry();
|
||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||
g.setIndex(idx);
|
||
routeMesh = new THREE.Mesh(g, routeMaterial);
|
||
scene.add(routeMesh);
|
||
}
|
||
|
||
let routeTarget = null, routeFrom = {x: 0, z: 0}, routeMode = null;
|
||
|
||
function updateRoute(dt) {
|
||
routeTimer -= dt;
|
||
const target = objectiveStation();
|
||
if (!target) {
|
||
if (route) {
|
||
route = null;
|
||
rebuildRouteMesh();
|
||
}
|
||
return;
|
||
}
|
||
const pos = mode === 'drive' ? car : player;
|
||
const changed = target !== routeTarget || routeMode !== mode || !route;
|
||
if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10)) return;
|
||
routeTimer = 1.2;
|
||
routeTarget = target;
|
||
routeMode = mode;
|
||
routeFrom = {x: pos.x, z: pos.z};
|
||
// Close targets are reached directly; the street network would detour out of buildings.
|
||
const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
|
||
route = direct < 60 ? {points: [{x: pos.x, z: pos.z}, {x: target.front.x, z: target.front.z}], length: direct}
|
||
: routeBetween(mode === 'drive' ? driveGraph : walkGraph, pos.x, pos.z, target.front.x, target.front.z);
|
||
rebuildRouteMesh();
|
||
}
|
||
|
||
const compass = $('#compass').getContext('2d'), minimap = $('#minimap').getContext('2d');
|
||
|
||
function drawCompass(headingDeg, markers) {
|
||
const w = 560, h = 44;
|
||
compass.clearRect(0, 0, w, h);
|
||
compass.fillStyle = 'rgba(10,16,14,.45)';
|
||
compass.fillRect(0, 10, w, 24);
|
||
compass.textAlign = 'center';
|
||
compass.textBaseline = 'middle';
|
||
const labels = {0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب'};
|
||
for (let d = -100; d <= 100; d += 5) {
|
||
const deg = ((Math.round(headingDeg / 5) * 5 + d) % 360 + 360) % 360, x = w / 2 + (deg - headingDeg + 540) % 360 * 2.6 - 180 * 2.6;
|
||
if (x < 10 || x > w - 10) continue;
|
||
compass.fillStyle = 'rgba(236,242,238,.8)';
|
||
if (labels[deg] !== undefined) {
|
||
compass.font = 'bold 13px Vazirmatn, sans-serif';
|
||
compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
|
||
compass.fillText(labels[deg], x, 22);
|
||
} else if (deg % 15 === 0) {
|
||
compass.font = '10px Vazirmatn, sans-serif';
|
||
compass.fillText(fa(deg), x, 22);
|
||
} else compass.fillRect(x - .5, 18, 1, 8);
|
||
}
|
||
for (const m of markers) {
|
||
const x = w / 2 + ((m.bearing - headingDeg + 540) % 360 - 180) * 2.6;
|
||
if (x < 10 || x > w - 10) continue;
|
||
compass.save();
|
||
compass.translate(x, 6);
|
||
compass.rotate(Math.PI / 4);
|
||
compass.fillStyle = m.color;
|
||
compass.fillRect(-4, -4, 8, 8);
|
||
compass.restore();
|
||
}
|
||
compass.fillStyle = '#ecf2ee';
|
||
compass.fillRect(w / 2 - 1, 34, 2, 8);
|
||
compass.font = 'bold 11px Vazirmatn, sans-serif';
|
||
compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
|
||
}
|
||
|
||
function refreshHUD() {
|
||
$('#balance').textContent = fa(state.balance);
|
||
const rank = rankFor(state.xp);
|
||
$('#rank-name').textContent = rank.name;
|
||
$('#xp').textContent = rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`;
|
||
$('#xp-bar').style.width = `${Math.round(rank.progress * 100)}%`;
|
||
const active = activeMission(state);
|
||
$('#mission-list').innerHTML = MISSIONS.map(m => {
|
||
const done = state.missionsDone.includes(m.id);
|
||
return `<li class="${done ? 'done' : m === active ? 'active' : ''}">${m.title}</li>`;
|
||
}).join('');
|
||
$('#mode-label').textContent = mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده';
|
||
$('#speed-stat').hidden = mode !== 'drive';
|
||
}
|
||
|
||
function updatePrompt() {
|
||
let html = '';
|
||
const station = nearStation();
|
||
// The payment panel carries its own swipe button, so no prompt while paying or collecting.
|
||
if (state.busy || payment.phase !== 'idle') html = '';
|
||
else if (station) html = isOpen(station.def, state.minutes) ? `<kbd>E</kbd>خرید از روبات مارکت · ${station.def.name}` : `دستگاه بسته است · ${hoursText(station.def)}`;
|
||
else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) html = '<kbd>F</kbd>سوار ماشین شو';
|
||
else if (mode === 'drive' && Math.abs(car.speed) < 1 && stations.some(s => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25)) html = '<kbd>F</kbd>پیاده شو؛ دستگاه نزدیک است';
|
||
$('#prompt').hidden = !html;
|
||
if ($('#prompt').innerHTML !== html) $('#prompt').innerHTML = html;
|
||
}
|
||
|
||
// ---------- Map dialog ----------
|
||
const mapView = {cx: 0, cz: 0, mpp: 2}, mapCanvas = $('#fullmap'), iranCanvas = $('#iran-map');
|
||
let selected = null, mapTab = 'city';
|
||
const markers = () => {
|
||
const visited = new Set(state.purchases.map(p => p.machineId)), objective = objectiveStation();
|
||
return activeStations().map(s => ({id: s.def.id, x: s.x, z: s.z, visited: visited.has(s.def.id), open: isOpen(s.def, state.minutes), objective: s === objective}));
|
||
};
|
||
|
||
function sizeCanvas(c) {
|
||
const r = c.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2);
|
||
c.width = Math.max(1, r.width * dpr);
|
||
c.height = Math.max(1, r.height * dpr);
|
||
return dpr;
|
||
}
|
||
|
||
function renderMap() {
|
||
if (!$('#map-dialog').open) return;
|
||
if (mapTab === 'city') {
|
||
const dpr = sizeCanvas(mapCanvas);
|
||
const pos = mode === 'drive' ? car : player;
|
||
drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, {dpr, markers: markers(), route: route?.points, selected, player: {x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading}});
|
||
} else {
|
||
const dpr = sizeCanvas(iranCanvas), c = iranCanvas.getContext('2d');
|
||
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
drawIran(c, iran, {width: iranCanvas.width / dpr, height: iranCanvas.height / dpr, t: elapsed, cities: CITIES});
|
||
}
|
||
}
|
||
|
||
function renderMachineList() {
|
||
const pos = mode === 'drive' ? car : player, visited = new Set(state.purchases.map(p => p.machineId));
|
||
const items = activeStations().map(s => {
|
||
const li = document.createElement('li'), b = document.createElement('button'), open = isOpen(s.def, state.minutes);
|
||
b.type = 'button';
|
||
b.className = s.def.id === selected ? 'selected' : '';
|
||
b.innerHTML = `<span>${CATEGORIES[s.def.category].icon}</span><span>${s.def.name}${visited.has(s.def.id) ? ' ✓' : ''}<span class="meta">${CATEGORIES[s.def.category].label} · ${open ? 'باز' : 'بسته'}</span></span><span class="dist">${formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z))}</span>`;
|
||
b.onclick = () => selectMachine(s.def.id, true);
|
||
li.append(b);
|
||
return li;
|
||
});
|
||
for (const place of lockedPlaces) {
|
||
const li = document.createElement('li');
|
||
li.innerHTML = `<button type="button" class="locked" disabled><span>🔒</span><span>${place.name}<span class="meta">${place.note}</span></span><span></span></button>`;
|
||
items.push(li);
|
||
}
|
||
$('#machine-list').replaceChildren(...items);
|
||
}
|
||
|
||
function selectMachine(id, center = false) {
|
||
selected = id;
|
||
const s = stationById(id), def = s.def;
|
||
if (center) {
|
||
mapView.cx = s.x;
|
||
mapView.cz = s.z;
|
||
mapView.mpp = Math.min(mapView.mpp, 1.4);
|
||
}
|
||
$('#machine-detail').hidden = false;
|
||
$('#detail-name').textContent = `${CATEGORIES[def.category].icon} ${def.name}`;
|
||
$('#detail-address').textContent = `📍 ${def.address}`;
|
||
$('#detail-meta').textContent = `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`;
|
||
$('#detail-products').textContent = def.products.map(id => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`).join(' · ');
|
||
$('#route-button').textContent = waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا';
|
||
renderMachineList();
|
||
renderMap();
|
||
}
|
||
|
||
$('#route-button').onclick = () => {
|
||
if (!selected) return;
|
||
waypoint = selected;
|
||
route = null;
|
||
routeTimer = 0;
|
||
updateRoute(0);
|
||
const s = stationById(selected);
|
||
say('father', `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`);
|
||
selectMachine(selected);
|
||
};
|
||
|
||
function openMap() {
|
||
if (anyDialogOpen()) return;
|
||
document.exitPointerLock?.();
|
||
keys.clear();
|
||
const pos = mode === 'drive' ? car : player;
|
||
Object.assign(mapView, {cx: pos.x, cz: pos.z, mpp: 2.2});
|
||
$('#map-dialog').showModal();
|
||
selected ??= objectiveStation()?.def.id ?? null;
|
||
if (selected) selectMachine(selected);
|
||
renderMachineList();
|
||
renderMap();
|
||
}
|
||
|
||
$('#minimap-button').onclick = openMap;
|
||
$('#map-close').onclick = () => $('#map-dialog').close();
|
||
for (const tab of document.querySelectorAll('.tabs button')) tab.onclick = () => {
|
||
mapTab = tab.dataset.tab;
|
||
document.querySelectorAll('.tabs button').forEach(t => t.setAttribute('aria-selected', String(t === tab)));
|
||
mapCanvas.hidden = mapTab !== 'city';
|
||
iranCanvas.hidden = mapTab !== 'iran';
|
||
renderMap();
|
||
};
|
||
let mapDrag = null;
|
||
mapCanvas.addEventListener('pointerdown', e => {
|
||
mapDrag = {x: e.clientX, y: e.clientY, moved: 0};
|
||
mapCanvas.setPointerCapture(e.pointerId);
|
||
});
|
||
mapCanvas.addEventListener('pointermove', e => {
|
||
if (!mapDrag) return;
|
||
mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp;
|
||
mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp;
|
||
mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y);
|
||
mapDrag.x = e.clientX;
|
||
mapDrag.y = e.clientY;
|
||
renderMap();
|
||
});
|
||
mapCanvas.addEventListener('pointerup', e => {
|
||
if (mapDrag && mapDrag.moved < 6) {
|
||
const r = mapCanvas.getBoundingClientRect();
|
||
const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp, wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp;
|
||
const hit = activeStations().find(s => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp);
|
||
if (hit) selectMachine(hit.def.id);
|
||
}
|
||
mapDrag = null;
|
||
});
|
||
mapCanvas.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), .35, 30);
|
||
renderMap();
|
||
}, {passive: false});
|
||
|
||
// ---------- Briefing ----------
|
||
const CITIES = [
|
||
{name: 'مشهد', lat: 36.30, lon: 59.60, active: true}, {name: 'تهران', lat: 35.69, lon: 51.39}, {name: 'اصفهان', lat: 32.65, lon: 51.67},
|
||
{name: 'شیراز', lat: 29.59, lon: 52.58}, {name: 'تبریز', lat: 38.08, lon: 46.29}, {name: 'اهواز', lat: 31.32, lon: 48.67},
|
||
{name: 'کرمان', lat: 30.28, lon: 57.08}, {name: 'رشت', lat: 37.28, lon: 49.58}, {name: 'یزد', lat: 31.90, lon: 54.37},
|
||
{name: 'زاهدان', lat: 29.50, lon: 60.86}, {name: 'کرمانشاه', lat: 34.31, lon: 47.07}, {name: 'بندرعباس', lat: 27.18, lon: 56.27}
|
||
];
|
||
const BRIEF = [
|
||
`نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
|
||
'تیم: پدر و فرزند · روبات راهنما در دستگاهها منتظر شماست',
|
||
'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاههای روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
|
||
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
|
||
'بودجه: ۵۰۰ هتومان · مأموریتها پاداش نقدی دارند.',
|
||
'رفتوآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاهها را ببینید.'
|
||
].join('\n');
|
||
let briefChars = 0;
|
||
|
||
function startMission() {
|
||
$('#briefing').hidden = true;
|
||
$('#hud').hidden = false;
|
||
say('child', 'بابا، اینجا همون کارخونهایه که دستگاههای روبات مارکت رو میسازه؟');
|
||
say('father', 'آره! بیا جلوی دستگاه نمونه وایسیم ببینیم چی میشه.');
|
||
if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {});
|
||
}
|
||
|
||
$('#start-mission').onclick = startMission;
|
||
|
||
// ---------- Real-world (GPS) mode ----------
|
||
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where
|
||
// a machine stands lets the player buy there, with a bonus.
|
||
const real = {on: false, fix: null, heading: null, error: null, waiting: null, stop: null};
|
||
|
||
function setRealMode(on) {
|
||
if (on === real.on) return;
|
||
real.stop?.();
|
||
Object.assign(real, {on, fix: null, heading: null, error: null, waiting: null, stop: null});
|
||
for (const s of stations) if (s.def.test) s.model.group.visible = on;
|
||
$('#gps-toggle').classList.toggle('on', on);
|
||
$('#gps-status').hidden = !on;
|
||
if (!on) return;
|
||
if (mode === 'drive') toggleCar();
|
||
mode = 'walk';
|
||
real.stop = startTracking({
|
||
onFix: fix => {
|
||
real.fix = fix;
|
||
real.error = null;
|
||
},
|
||
onHeading: heading => real.heading = heading,
|
||
onError: message => real.error = message,
|
||
onStatus: message => real.waiting = message
|
||
});
|
||
say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همونجا هستیم.');
|
||
}
|
||
|
||
// The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
|
||
function askRealMode(thenStart) {
|
||
if (real.on) {
|
||
setRealMode(false);
|
||
return;
|
||
}
|
||
$('#real-intro').showModal();
|
||
$('#real-go').onclick = async () => {
|
||
$('#real-intro').close();
|
||
await requestCompass();
|
||
setRealMode(true);
|
||
if (thenStart) startMission();
|
||
};
|
||
}
|
||
|
||
$('#real-mode').onclick = () => askRealMode(true);
|
||
$('#gps-toggle').onclick = () => askRealMode(false);
|
||
$('#real-cancel').onclick = () => $('#real-intro').close();
|
||
|
||
function updateReal(dt) {
|
||
const status = $('#gps-status');
|
||
if (real.error) status.textContent = `⚠ ${real.error}`;
|
||
else if (!real.fix) status.textContent = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`;
|
||
else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) {
|
||
const nearest = Math.min(...activeStations().map(s => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)));
|
||
status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیکترین دستگاه`;
|
||
} else status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
|
||
if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) {
|
||
player.speed = 0;
|
||
return;
|
||
}
|
||
// Walk the avatar towards the (building-free) GPS point instead of teleporting it.
|
||
const target = resolveCircle(collision, real.fix.x, real.fix.z, 1);
|
||
const dx = target.x - player.x, dz = target.z - player.z, d = Math.hypot(dx, dz);
|
||
if (d > 150) Object.assign(player, {x: target.x, z: target.z});
|
||
const speed = d > .5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
|
||
player.speed += (speed - player.speed) * Math.min(1, dt * 6);
|
||
if (d > .5) {
|
||
const step = Math.min(d, player.speed * dt);
|
||
player.x += dx / d * step;
|
||
player.z += dz / d * step;
|
||
}
|
||
const want = real.heading ?? (d > .5 ? Math.atan2(-dz, dx) : player.heading);
|
||
player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 5);
|
||
player.gait += dt * player.speed * 1.9;
|
||
}
|
||
|
||
// ---------- Loop ----------
|
||
const camPos = new THREE.Vector3(), camTarget = new THREE.Vector3(), lookAt = new THREE.Vector3();
|
||
let elapsed = 0, last = performance.now(), hudTimer = 0, streetTimer = 0;
|
||
// Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps.
|
||
const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : .25;
|
||
|
||
function resize() {
|
||
renderer.setSize(innerWidth, innerHeight);
|
||
camera.aspect = innerWidth / innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderMap();
|
||
}
|
||
|
||
window.addEventListener('resize', resize);
|
||
|
||
function updateWalk(dt) {
|
||
const forward = {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)}, right = {x: Math.cos(cam.yaw), z: -Math.sin(cam.yaw)};
|
||
const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x;
|
||
const frozen = state.busy || payment.phase !== 'idle';
|
||
let dx = forward.x * iy + right.x * ix, dz = forward.z * iy + right.z * ix;
|
||
const len = Math.hypot(dx, dz);
|
||
const run = keys.has('shift');
|
||
const target = frozen || len < .1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len);
|
||
player.speed += (target - player.speed) * Math.min(1, dt * 8);
|
||
if (len > .1 && !frozen) {
|
||
dx /= len;
|
||
dz /= len;
|
||
const want = Math.atan2(-dz, dx);
|
||
player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 9);
|
||
}
|
||
// Face the machine while buying.
|
||
const station = payment.station ?? payment.delivering?.station;
|
||
if (frozen && station) {
|
||
const want = Math.atan2(-(station.z - player.z), station.x - player.x);
|
||
player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 6);
|
||
}
|
||
const mx = Math.cos(player.heading) * player.speed * dt, mz = -Math.sin(player.heading) * player.speed * dt;
|
||
const p = resolveCircle(collision, player.x + mx, player.z + mz, 1);
|
||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
||
player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
|
||
player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
|
||
player.gait += dt * player.speed * 1.9;
|
||
}
|
||
|
||
// Collision log for ?debug runs; trimmed so it never grows without bound.
|
||
const debugHits = [];
|
||
|
||
function updateDrive(dt) {
|
||
if (debugHits.length > 200) debugHits.splice(0, 100);
|
||
const throttle = THREE.MathUtils.clamp(axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, -1, 1);
|
||
const steer = THREE.MathUtils.clamp(axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, -1, 1);
|
||
stepCar(car, {throttle, steer, handbrake: keys.has(' ')}, dt);
|
||
// Two collision circles along the body keep the nose and tail out of buildings.
|
||
for (const along of [1.3, -1.3]) {
|
||
const cx = car.x + Math.cos(car.heading) * along, cz = car.z - Math.sin(car.heading) * along;
|
||
const p = resolveCircle(collision, cx, cz, 1.05);
|
||
if (p.hit) {
|
||
debugHits.push({kind: 'building', x: Math.round(cx), z: Math.round(cz), v: +car.speed.toFixed(1)});
|
||
car.x += p.x - cx;
|
||
car.z += p.z - cz;
|
||
if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square');
|
||
car.speed *= .35;
|
||
}
|
||
}
|
||
// Bumping a traffic car slows you down but never throws you backwards. If you stay pressed
|
||
// against one, it pulls away (respawns out of sight) so you can never get stuck behind it.
|
||
for (const npc of traffic.cars) {
|
||
const dx = car.x - npc.x, dz = car.z - npc.z, d = Math.hypot(dx, dz);
|
||
if (d >= 3.4 || d === 0) {
|
||
if (npc.contact) npc.contact = 0;
|
||
continue;
|
||
}
|
||
debugHits.push({kind: 'traffic', x: Math.round(car.x), z: Math.round(car.z), v: +car.speed.toFixed(1), npcDir: npc.dir});
|
||
car.x = npc.x + dx / d * 3.4;
|
||
car.z = npc.z + dz / d * 3.4;
|
||
const towards = (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0;
|
||
if (towards && Math.abs(car.speed) > 4) tone(110, .25, .08, 'square');
|
||
if (towards) car.speed *= .4;
|
||
npc.contact = (npc.contact ?? 0) + dt;
|
||
if (npc.contact > .8) {
|
||
npc.contact = 0;
|
||
traffic.remove(npc);
|
||
}
|
||
}
|
||
const {minX, minZ, maxX, maxZ} = data.bounds;
|
||
car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15);
|
||
car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15);
|
||
Object.assign(player, {x: car.x, z: car.z, heading: car.heading});
|
||
// Chase camera swings back behind the car when the mouse is idle.
|
||
if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) {
|
||
const want = Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0);
|
||
cam.yaw += Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2);
|
||
cam.pitch += (.28 - cam.pitch) * Math.min(1, dt * 1.5);
|
||
}
|
||
if (engine && soundOn) {
|
||
engine.o.frequency.setTargetAtTime(45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15, audio.currentTime, .1);
|
||
engine.g.gain.setTargetAtTime(.025, audio.currentTime, .2);
|
||
}
|
||
}
|
||
|
||
function updateCamera(dt) {
|
||
const station = payment.station;
|
||
if (station && payment.phase !== 'idle') {
|
||
// Close-up of the card reader during payment.
|
||
const reader = station.model.readerPoint.clone();
|
||
station.model.group.localToWorld(reader);
|
||
const side = rotateLocal(1.2, 1.9, station.rot);
|
||
camTarget.set(reader.x + side.x, reader.y + .4, reader.z + side.z);
|
||
lookAt.lerp(reader, 1 - Math.exp(-dt * 6));
|
||
} else {
|
||
const driving = mode === 'drive', height = driving ? 1.6 : 1.75;
|
||
const focus = new THREE.Vector3(player.x, height, player.z);
|
||
// Over-the-shoulder offset to the right, like a third-person shooter.
|
||
const shoulder = driving ? 0 : .75;
|
||
focus.x += Math.cos(cam.yaw) * shoulder;
|
||
focus.z += -Math.sin(cam.yaw) * shoulder;
|
||
let distance = cam.distance;
|
||
const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), oz = Math.cos(cam.yaw) * Math.cos(cam.pitch);
|
||
const free = clearFraction(collision, focus.x, focus.z, focus.x + ox * distance, focus.z + oz * distance, .8);
|
||
if (cam.pitch < .9) distance = Math.max(1.6, distance * free - .4);
|
||
camTarget.set(focus.x + ox * distance, Math.max(.6, focus.y + Math.sin(cam.pitch) * distance), focus.z + oz * distance);
|
||
lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
|
||
}
|
||
camPos.lerp(camTarget, 1 - Math.exp(-dt * 12));
|
||
camera.position.copy(camPos);
|
||
camera.lookAt(lookAt);
|
||
}
|
||
|
||
function updateHUD(dt) {
|
||
const pos = mode === 'drive' ? car : player;
|
||
$('#clock').textContent = formatClock(state.minutes);
|
||
if (mode === 'drive') $('#speed').textContent = fa(kmh(car));
|
||
const forward = {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)}, headingDeg = bearing(forward.x, forward.z);
|
||
const objective = objectiveStation();
|
||
drawCompass(headingDeg, objective ? [{bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c'}] : []);
|
||
drawMinimap(minimap, mapAssets, {
|
||
size: 220, x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading, camForward: forward,
|
||
radius: mode === 'drive' ? 650 : 200, markers: markers(), route: route?.points
|
||
});
|
||
// Objective marker above the machine, clamped to the screen edge when off-screen.
|
||
const wp = $('#waypoint');
|
||
wp.hidden = !objective;
|
||
if (objective) {
|
||
const d = Math.hypot(objective.x - pos.x, objective.z - pos.z), p = new THREE.Vector3(objective.x, 3.4, objective.z).project(camera);
|
||
let sx = (p.x * .5 + .5) * innerWidth, sy = (-p.y * .5 + .5) * innerHeight;
|
||
if (p.z > 1) {
|
||
sx = innerWidth - sx;
|
||
sy = innerHeight - 60;
|
||
}
|
||
$('#waypoint').style.left = `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`;
|
||
$('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`;
|
||
$('#waypoint-name').textContent = objective.def.name;
|
||
$('#waypoint-distance').textContent = formatDistance(d);
|
||
wp.hidden = d < 6;
|
||
for (const s of stations) {
|
||
const isObjective = s === objective;
|
||
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
|
||
s.model.beacon.material.opacity = (isObjective ? .32 : .14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
||
}
|
||
}
|
||
streetTimer -= dt;
|
||
if (streetTimer <= 0) {
|
||
streetTimer = .6;
|
||
const street = nearestSegment(segments, pos.x, pos.z, 35, s => s[5] >= 0);
|
||
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
|
||
$('#street').textContent = inFactory ? `کارخانهٔ ${FACTORY.name}` : street ? `مشهد · ${data.names[street.name]}` : 'حومهٔ مشهد';
|
||
}
|
||
updatePrompt();
|
||
}
|
||
|
||
let hints = {closed: false, car: false};
|
||
|
||
// ---------- Robot guide ----------
|
||
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
|
||
// do; when they leave it goes back inside. It is never a walking companion.
|
||
const guideState = {station: null, t: 0, greeted: new Set(), arrived: 0};
|
||
const guideOut = () => guideState.t >= 1;
|
||
const easeOutBack = t => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
|
||
|
||
function greet(station) {
|
||
const def = station.def, first = !guideState.greeted.has(def.id);
|
||
guideState.greeted.add(def.id);
|
||
if (def.id === 'factory' && first) {
|
||
say('robot', 'سلام! من روبات راهنمای روبات مارکتم؛ همین الان از خط تولید اومدم بیرون!', 'excited');
|
||
say('robot', `به ${FACTORY.name} خوش اومدین. همهٔ دستگاههای شهر همینجا ساخته میشن.`, 'happy');
|
||
say('robot', 'اول از همین دستگاه نمونه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
|
||
say('robot', 'بعد ماشینتون تو حیاطه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاهها منتظرتونم!', 'love');
|
||
return;
|
||
}
|
||
if (!isOpen(def, state.minutes)) {
|
||
say('robot', `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, 'serious');
|
||
return;
|
||
}
|
||
if (first) {
|
||
say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
|
||
say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
|
||
} else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
|
||
}
|
||
|
||
function updateGuide(dt) {
|
||
const near = mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null;
|
||
// The robot only moves to another machine after it has gone back into the previous one.
|
||
if (near && !guideState.station) guideState.station = near;
|
||
const s = guideState.station, wasOut = guideOut();
|
||
if (!s) return;
|
||
guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1);
|
||
if (guideOut() && !wasOut) {
|
||
guideState.arrived = elapsed;
|
||
greet(s);
|
||
tone(990, .15);
|
||
tone(1320, .2);
|
||
}
|
||
if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
|
||
if (!guideState.t) {
|
||
guideState.station = null;
|
||
guide.group.visible = false;
|
||
return;
|
||
}
|
||
// From inside the machine front out to its left side (the card reader is on the right).
|
||
const e = easeOutBack(guideState.t), from = {x: -.2, y: .9, z: .2}, to = {x: -1.7, y: 0, z: 1.3};
|
||
const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot);
|
||
guide.group.visible = true;
|
||
guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
|
||
guide.group.scale.setScalar(.15 + .85 * Math.min(1, e));
|
||
// Face the family (the rig looks along its local +X).
|
||
guide.group.rotation.y = Math.atan2(-(player.z - guide.group.position.z), player.x - guide.group.position.x);
|
||
if (!state.busy && payment.phase === 'idle') s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0;
|
||
guide.update({dt, time: elapsed, gait: 0, moving: false, talking, wave: elapsed - guideState.arrived < 2.5 || !guideOut(), point: talking && guideOut()});
|
||
}
|
||
|
||
function updateHints() {
|
||
const target = objectiveStation();
|
||
if (target && !isOpen(target.def, state.minutes) && !hints.closed) {
|
||
hints.closed = true;
|
||
say('robot', `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`);
|
||
}
|
||
if (route && route.length > 900 && mode === 'walk' && !hints.car) {
|
||
hints.car = true;
|
||
say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
|
||
}
|
||
}
|
||
|
||
function frame() {
|
||
const now = performance.now();
|
||
requestAnimationFrame(frame);
|
||
// The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are
|
||
// simulated in short substeps, so slow devices keep real-time speed without unstable physics.
|
||
const dt = THREE.MathUtils.clamp((now - last) / 1000, 0, MAX_FRAME);
|
||
last = now;
|
||
elapsed += dt;
|
||
const playing = $('#briefing').hidden;
|
||
const paused = !playing || $('#shop').open || $('#map-dialog').open || $('#end').open || document.hidden;
|
||
const steps = Math.max(1, Math.ceil(dt / (1 / 30))), step = dt / steps;
|
||
if (!playing) {
|
||
// Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay.
|
||
const ic = $('#iran'), dpr = sizeCanvas(ic), c = ic.getContext('2d');
|
||
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
drawIran(c, iran, {width: ic.width / dpr, height: ic.height / dpr, t: elapsed, cities: CITIES});
|
||
briefChars = Math.min(BRIEF.length, briefChars + dt * 55);
|
||
$('#brief-text').textContent = BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : '');
|
||
}
|
||
if (!paused) {
|
||
for (let i = 0; i < steps; i++) {
|
||
state.minutes += step * 10 / 60;
|
||
if (real.on) updateReal(step);
|
||
else if (mode === 'drive') updateDrive(step);
|
||
else updateWalk(step);
|
||
updatePayment(step);
|
||
updateDelivery(step);
|
||
traffic.update(step, [{x: player.x, z: player.z}, {x: car.x, z: car.z}], player);
|
||
}
|
||
updateRoute(dt);
|
||
updateHints();
|
||
updateGuide(dt);
|
||
if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd();
|
||
} else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .1);
|
||
if (mode === 'walk' && engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .2);
|
||
updateSubtitles(dt);
|
||
|
||
family.group.visible = mode === 'walk';
|
||
family.group.position.set(player.x, 0, player.z);
|
||
family.group.rotation.y = player.heading;
|
||
family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0});
|
||
factory.update(elapsed);
|
||
carModel.group.position.set(car.x, 0, car.z);
|
||
carModel.group.rotation.y = car.heading;
|
||
for (const [i, wheel] of carModel.wheels.entries()) {
|
||
wheel.rotation.z -= car.speed * dt / .36;
|
||
if (i >= 2) wheel.rotation.y = -car.steer;
|
||
}
|
||
for (const s of stations) s.model.ring.material.opacity = .35 + Math.sin(elapsed * 3) * .25;
|
||
if (routeMesh) routeMaterial.opacity = .55 + Math.sin(elapsed * 4) * .15;
|
||
|
||
if (playing) updateCamera(dt);
|
||
else {
|
||
const a = elapsed * .08;
|
||
camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30);
|
||
camera.lookAt(player.x, 2, player.z);
|
||
camPos.copy(camera.position);
|
||
}
|
||
sun.position.set(player.x + 160, 230, player.z + 110);
|
||
sun.target.position.set(player.x, 0, player.z);
|
||
sky.position.copy(camera.position);
|
||
city.update(camera.position);
|
||
if (playing) {
|
||
hudTimer -= dt;
|
||
updateHUD(dt);
|
||
if ($('#map-dialog').open && hudTimer <= 0) {
|
||
hudTimer = .5;
|
||
renderMap();
|
||
}
|
||
}
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
// ?debug exposes live game state in the console for development and automated checks.
|
||
if (new URLSearchParams(location.search).has('debug')) window.game = {
|
||
get state() {
|
||
return state;
|
||
}, get player() {
|
||
return player;
|
||
}, get car() {
|
||
return car;
|
||
}, get mode() {
|
||
return mode;
|
||
}, get payment() {
|
||
return payment;
|
||
}, get elapsed() {
|
||
return elapsed;
|
||
}, get route() {
|
||
return route;
|
||
}, setWaypoint(id) {
|
||
waypoint = id;
|
||
route = null;
|
||
routeTimer = 0;
|
||
}, debugHits, nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, s => s[4] <= 3), stations, guideState, real, keys, traffic
|
||
};
|
||
reset();
|
||
resize();
|
||
setupPwa();
|
||
$('#loading').remove();
|
||
requestAnimationFrame(frame);
|