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} 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.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); 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/.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' ? stations.find(s => Math.hypot(s.front.x - player.x, s.front.z - 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 = `${p.icon}${p.name}${formatHetoman(price)}${price !== p.cost ? `${formatHetoman(p.cost)}` : ''}${p.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}${blocker === 'funds' ? ' · موجودی کافی نیست' : ''}`; 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}+${fa(a.points)}`; 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 = `${tag}${title}${sub}`; 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]) => `
  • ${k}${v}
  • `).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 stations.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 `
  • ${m.title}
  • `; }).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) ? `Eخرید از روبات مارکت · ${station.def.name}` : `دستگاه بسته است · ${hoursText(station.def)}`; else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) html = 'Fسوار ماشین شو'; 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 = 'Fپیاده شو؛ دستگاه نزدیک است'; $('#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 stations.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 = stations.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 = `${CATEGORIES[s.def.category].icon}${s.def.name}${visited.has(s.def.id) ? ' ✓' : ''}${CATEGORIES[s.def.category].label} · ${open ? 'باز' : 'بسته'}${formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z))}`; b.onclick = () => selectMachine(s.def.id, true); li.append(b); return li; }); for (const place of lockedPlaces) { const li = document.createElement('li'); li.innerHTML = ``; 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 = stations.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, stop: null}; function setRealMode(on) { if (on === real.on) return; real.stop?.(); Object.assign(real, {on, fix: null, heading: null, error: null, stop: null}); $('#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 }); 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 = '📍 در حال یافتن موقعیت…'; else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) { const nearest = Math.min(...stations.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; } function updateDrive(dt) { 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) { car.x += p.x - cx; car.z += p.z - cz; if (Math.abs(car.speed) > 4) tone(90, .2, .08, 'square'); car.speed *= .35; } } 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.6 && d > 0) { car.x = npc.x + dx / d * 3.6; car.z = npc.z + dz / d * 3.6; if (Math.abs(car.speed) > 4) tone(110, .25, .08, 'square'); car.speed *= -.2; } } 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' ? stations.find(s => Math.hypot(s.front.x - player.x, s.front.z - 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; }, stations, guideState, real, keys }; reset(); resize(); setupPwa(); $('#loading').remove(); requestAnimationFrame(frame);