import * as THREE from 'three'; import data from '../../data/mellat.json'; import { routeBetween, nearestSegment } from '../world/routing.js'; import { FACTORY } from '../../constants/locations.js'; import { isOpen, formatClock, fa } from '../economy/gameState.js'; import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js'; import { kmh } from '../systems/vehicle.js'; import { drawMinimap } from '../world/mapRender.js'; import { bearing, formatDistance } from '../../utils/geo.js'; /** * Runs the rebuild route mesh operation for this subsystem. * @param {*} context - context. * @returns {*} Operation result, when applicable. */ export function rebuildRouteMesh(context) { if (context.routeMesh) { context.scene.remove(context.routeMesh); context.routeMesh.geometry.dispose(); context.routeMesh = null; } if (!context.route) return; const pts = [], pos = [], idx = []; let total = 0; for (const p of context.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 = context.mode === 'drive' ? 1.1 : 0.55; pos.push( pts[i].x - (dz / len) * w, 0.14, pts[i].z + (dx / len) * w, pts[i].x + (dz / len) * w, 0.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); context.routeMesh = new THREE.Mesh(g, context.routeMaterial); context.scene.add(context.routeMesh); } /** * Updates route from the active simulation. * @param {*} context - context. * @param {*} dt - dt. * @returns {*} Operation result, when applicable. */ export function updateRoute(context, dt) { context.routeTimer -= dt; const target = context.objectiveStation(); if (!target) { if (context.route) { context.route = null; context.rebuildRouteMesh(); } return; } const pos = context.mode === 'drive' ? context.car : context.player; const changed = target !== context.routeTarget || context.routeMode !== context.mode || !context.route; if ( !changed && (context.routeTimer > 0 || Math.hypot(pos.x - context.routeFrom.x, pos.z - context.routeFrom.z) < 10) ) return; context.routeTimer = 1.2; context.routeTarget = target; context.routeMode = context.mode; context.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); context.route = direct < 60 ? { points: [ { x: pos.x, z: pos.z, }, { x: target.front.x, z: target.front.z, }, ], length: direct, } : routeBetween( context.mode === 'drive' ? context.driveGraph : context.walkGraph, pos.x, pos.z, target.front.x, target.front.z, ); context.rebuildRouteMesh(); } /** * Runs the draw compass operation for this subsystem. * @param {*} context - context. * @param {*} headingDeg - heading Deg. * @param {*} markers - markers. * @returns {*} Operation result, when applicable. */ export function drawCompass(context, headingDeg, markers) { const w = 560, h = 44; context.compass.clearRect(0, 0, w, h); context.compass.fillStyle = 'rgba(10,16,14,.45)'; context.compass.fillRect(0, 10, w, 24); context.compass.textAlign = 'center'; context.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; context.compass.fillStyle = 'rgba(236,242,238,.8)'; if (labels[deg] !== undefined) { context.compass.font = 'bold 13px Vazirmatn, sans-serif'; context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee'; context.compass.fillText(labels[deg], x, 22); } else if (deg % 15 === 0) { context.compass.font = '10px Vazirmatn, sans-serif'; context.compass.fillText(fa(deg), x, 22); } else context.compass.fillRect(x - 0.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; context.compass.save(); context.compass.translate(x, 6); context.compass.rotate(Math.PI / 4); context.compass.fillStyle = m.color; context.compass.fillRect(-4, -4, 8, 8); context.compass.restore(); } context.compass.fillStyle = '#ecf2ee'; context.compass.fillRect(w / 2 - 1, 34, 2, 8); context.compass.font = 'bold 11px Vazirmatn, sans-serif'; context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6); } /** * Runs the refresh hud operation for this subsystem. * @param {*} context - context. * @returns {*} Operation result, when applicable. */ export function refreshHUD(context) { context.ui.patch('balance', { text: fa(context.state.balance), }); const rank = rankFor(context.state.xp); context.ui.patch('rank-name', { text: rank.name, }); context.ui.patch('xp', { text: rank.next ? `${fa(context.state.xp)} / ${fa(rank.next)} XP` : `${fa(context.state.xp)} XP`, }); context.ui.style('xp-bar', { width: `${Math.round(rank.progress * 100)}%`, }); const active = activeMission(context.state); context.ui.patch('mission-list', { items: MISSIONS.map((m) => ({ id: m.id, title: m.title, done: context.state.missionsDone.includes(m.id), active: m === active, })), }); context.ui.publishGame(context.state); context.ui.patch('mode-label', { text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', }); context.ui.patch('speed-stat', { hidden: context.mode !== 'drive', }); } /** * Updates prompt from the active simulation. * @param {*} context - context. * @returns {*} Operation result, when applicable. */ export function updatePrompt(context) { const sample = context.nearShowroom(); if (sample && !context.state.busy && context.payment.phase === 'idle') { context.ui.patch('prompt', { text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`, hidden: false, }); return; } let text = ''; const station = context.nearStation(); if (!context.state.busy && context.payment.phase === 'idle') { if (station) text = isOpen(station.def, context.state.minutes) ? `E · خرید از روبات مارکت · ${station.def.name}` : `دستگاه بسته است · ${context.hoursText(station.def)}`; else if ( context.mode === 'walk' && Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5 ) text = 'F · سوار ماشین شو'; else if ( context.mode === 'drive' && Math.abs(context.car.speed) < 1 && context.stations.some( (s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25, ) ) text = 'F · پیاده شو؛ دستگاه نزدیک است'; } context.ui.patch('prompt', { text, hidden: !text, }); } // ---------- Map dialog ---------- /** * Updates hud from the active simulation. * @param {*} context - context. * @param {*} dt - dt. * @returns {*} Operation result, when applicable. */ export function updateHUD(context, dt) { context.ui.patch('showroom-open', { hidden: context.real.on || context.mode !== 'walk' || Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) >= 130, }); const pos = context.mode === 'drive' ? context.car : context.player; context.ui.patch('clock', { text: formatClock(context.state.minutes), }); if (context.mode === 'drive') context.ui.patch('speed', { text: fa(kmh(context.car)), }); // Where the camera actually looks: the orbit camera, or the phone itself in AR view. const look3 = context.camera.getWorldDirection(new THREE.Vector3()), flat = Math.hypot(look3.x, look3.z); const forward = flat > 0.05 ? { x: look3.x / flat, z: look3.z / flat, } : { x: -Math.sin(context.cam.yaw), z: -Math.cos(context.cam.yaw), }; const headingDeg = bearing(forward.x, forward.z); const objective = context.objectiveStation(); context.drawCompass( headingDeg, objective ? [ { bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c', }, ] : [], ); drawMinimap(context.minimap, context.mapAssets, { size: 220, x: pos.x, z: pos.z, heading: context.mode === 'drive' ? context.car.heading : context.player.heading, camForward: forward, radius: context.mode === 'drive' ? 650 : 200, markers: context.markers(), route: context.route?.points, }); // Objective marker above the machine, clamped to the screen edge when off-screen. context.ui.patch('waypoint', { 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(context.camera); let sx = (p.x * 0.5 + 0.5) * innerWidth, sy = (-p.y * 0.5 + 0.5) * innerHeight; if (p.z > 1) { sx = innerWidth - sx; sy = innerHeight - 60; } context.ui.style('waypoint', { left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`, }); context.ui.style('waypoint', { top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`, }); context.ui.patch('waypoint-name', { text: objective.def.name, }); context.ui.patch('waypoint-distance', { text: formatDistance(d), }); context.ui.patch('waypoint', { hidden: d < 6, }); for (const s of context.stations) { const isObjective = s === objective; s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9'); s.model.beacon.material.opacity = (isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40); } } context.streetTimer -= dt; if (context.streetTimer <= 0) { context.streetTimer = 0.6; const street = nearestSegment(context.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; context.ui.patch('street', { text: inFactory ? `کارخانهٔ ${FACTORY.name}` : street ? `مشهد · ${data.names[street.name]}` : 'حومهٔ مشهد', }); } context.updatePrompt(); }