diff --git a/README.md b/README.md index 797a606..0e522df 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## داستان و هدف -- **شروع:** سالن تولید کارخانهٔ **شرکت نگین سازان پیشگام رادمهر آتوسا** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است. +- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است. - **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد. - **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند. - **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap: @@ -65,6 +65,14 @@ - در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمی‌شود. - بیرون از محدودهٔ بازی، فاصله تا نزدیک‌ترین دستگاه نمایش داده می‌شود. - موقعیت فقط روی خود گوشی پردازش می‌شود. دوربین و قطب‌نما به HTTPS نیاز دارند. +- اول یک موقعیت سریع و تقریبی از Wi-Fi یا شبکهٔ موبایل گرفته می‌شود و بعد GPS دقیقش می‌کند. وضعیت زیر نام خیابان با کد خطا نمایش داده می‌شود: + - **کد ۱:** اجازهٔ Location داده نشده + - **کد ۲:** Location گوشی خاموش است + - **کد ۳:** هنوز سیگنال نرسیده (جست‌وجو ادامه دارد) + + روی کامپیوتر معمولاً موقعیتی پیدا نمی‌شود، چون Chrome برای کامپیوتر از سرویس موقعیت گوگل استفاده می‌کند که از ایران در دسترس نیست. با گوشی تست کنید. + +**نقطه‌های آزمایشی GPS:** سه دستگاه آزمایشی فقط در حالت دنیای واقعی کنار کارخانه ظاهر می‌شوند، در مختصات `36.450276, 59.423431`، `36.450172, 59.423209` و `36.449976, 59.422866`. مختصات، نقطه‌ای است که باید آنجا بایستید. با گوشی تا هر نقطه راه بروید: روبات بیرون می‌آید و دکمهٔ خرید فعال می‌شود. شعاع رسیدن دو برابر خطای GPS است (بین ۱۲ تا ۴۰ متر) و همیشه نزدیک‌ترین دستگاه انتخاب می‌شود. فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته می‌شود: تصویر زندهٔ دوربین به‌عنوان پس‌زمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن. diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 29b1a1b..300ea0e 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,7 +1,7 @@ { "name": "عملیات مشهد · روبات مارکت", "short_name": "روبات مارکت", - "description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.", + "description": "از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.", "lang": "fa", "dir": "rtl", "start_url": "./", diff --git a/src/factory.js b/src/factory.js index 63f770b..ab6a30f 100644 --- a/src/factory.js +++ b/src/factory.js @@ -12,7 +12,7 @@ function stripes(c, w, h, base, line, every) { } /** - * The Negin Sazan Pishgam Radmehr Atousa compound: production hall (where the game starts), + * The Robot Market factory compound: production hall (where the game starts), * office, fenced yard and gate. Layout uses a local frame: u runs along the driveway towards the * gate, v across it. Returns world-space collision footprints and key positions. */ diff --git a/src/locations.js b/src/locations.js index 8e4b4f0..15069e5 100644 --- a/src/locations.js +++ b/src/locations.js @@ -6,14 +6,15 @@ export const CATEGORIES = { university: {label: 'دانشگاه', icon: '🎓'}, school: {label: 'مدرسه', icon: '🏫'}, metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'}, - mall: {label: 'مرکز خرید', icon: '🛍'} + mall: {label: 'مرکز خرید', icon: '🛍'}, + test: {label: 'نقطهٔ آزمایشی GPS', icon: '🧪'} }; const ALL_DAY = [7 * 60, 23 * 60]; export const FACTORY = { - name: 'شرکت نگین سازان پیشگام رادمهر آتوسا', - tagline: 'تولیدکنندهٔ دستگاه‌های وندینگ روبات مارکت', + name: 'شرکت روبات مارکت', + tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند', address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان', // Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street). center: {x: -10308.4, z: -14964.2}, @@ -79,6 +80,17 @@ export const machines = [ } ]; +// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone. +// They exist only while real-world mode is on; `spot` is where the player stands to buy. +const testPoint = (n, lat, lon) => ({ + id: `test-${n}`, name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`, category: 'test', test: true, + address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`, + anchor: null, spot: {lat, lon}, hours: [0, 24 * 60], priceFactor: .5, + products: ['water', 'biscuit'], + note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید' +}); +machines.push(testPoint(1, 36.450276, 59.423431), testPoint(2, 36.450172, 59.423209), testPoint(3, 36.449976, 59.422866)); + // Shown on the map as the next area to unlock. export const lockedPlaces = [ {name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی'}, diff --git a/src/main.js b/src/main.js index 6a8dd7e..79ef7f1 100644 --- a/src/main.js +++ b/src/main.js @@ -20,7 +20,7 @@ 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'; +import {bearing, formatDistance, toLocal} from './geo.js'; const $ = s => document.querySelector(s); const canvas = $('#world'); @@ -82,6 +82,12 @@ 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; @@ -112,6 +118,13 @@ function placeStation(def, pos, rot) { } 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); @@ -377,7 +390,7 @@ function toggleCar() { // ---------- 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 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; @@ -570,7 +583,7 @@ const objectiveStation = () => { 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; + 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. @@ -701,7 +714,7 @@ const mapView = {cx: 0, cz: 0, mpp: 2}, mapCanvas = $('#fullmap'), iranCanvas = 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})); + 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) { @@ -726,7 +739,7 @@ function renderMap() { function renderMachineList() { const pos = mode === 'drive' ? car : player, visited = new Set(state.purchases.map(p => p.machineId)); - const items = stations.map(s => { + 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' : ''; @@ -812,7 +825,7 @@ 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); + const hit = activeStations().find(s => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp); if (hit) selectMachine(hit.def.id); } mapDrag = null; @@ -853,12 +866,13 @@ $('#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}; +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, stop: null}); + 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; @@ -870,7 +884,8 @@ function setRealMode(on) { real.error = null; }, onHeading: heading => real.heading = heading, - onError: message => real.error = message + onError: message => real.error = message, + onStatus: message => real.waiting = message }); say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همون‌جا هستیم.'); } @@ -897,9 +912,9 @@ $('#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 (!real.fix) status.textContent = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`; 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))); + 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)) { @@ -1122,7 +1137,7 @@ function greet(station) { } 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; + 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(); diff --git a/src/realworld.js b/src/realworld.js index f18fba6..38a0088 100644 --- a/src/realworld.js +++ b/src/realworld.js @@ -16,26 +16,39 @@ export function smoothFix(previous, fix) { return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt}; } -// How close counts as "at the machine": never tighter than 20 m, wider when GPS is poor. -export const arrivalRadius = accuracy => Math.min(40, Math.max(20, accuracy * 1.5)); +// How close counts as "at the machine": twice the GPS error, between 12 m (good outdoor fix) and 40 m. +export const arrivalRadius = accuracy => Math.min(40, Math.max(12, accuracy * 2)); export const insideWorld = (bounds, x, z) => x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ; // Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}), -// onHeading(radians in the game's heading convention) and onError(message). -export function startTracking({onFix, onHeading, onError}) { +// onHeading(radians in the game's heading convention), onError(message) for a real problem and +// onStatus(message) while it is still searching. +export function startTracking({onFix, onHeading, onError, onStatus = () => {}}) { if (!('geolocation' in navigator)) { onError('این دستگاه موقعیت‌یابی (GPS) ندارد.'); return () => {}; } - let estimate = null; - const watch = navigator.geolocation.watchPosition(position => { + if (!window.isSecureContext) { + onError('موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.'); + return () => {}; + } + let estimate = null, fixes = 0; + const handle = position => { const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon); estimate = smoothFix(estimate, {...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp}); + fixes++; onFix({...estimate, lat, lon}); - }, error => onError(error.code === error.PERMISSION_DENIED ? 'اجازهٔ دسترسی به موقعیت داده نشد. از تنظیمات مرورگر اجازه بدهید.' : 'موقعیت پیدا نشد؛ به فضای باز بروید.'), { - enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 - }); + }; + const fail = error => { + if (error.code === error.PERMISSION_DENIED) onError('اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)'); + else if (error.code === error.POSITION_UNAVAILABLE) onError('موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)'); + // A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error. + else if (!fixes) onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)'); + }; + // A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it. + navigator.geolocation.getCurrentPosition(handle, () => {}, {enableHighAccuracy: false, maximumAge: 60000, timeout: 15000}); + const watch = navigator.geolocation.watchPosition(handle, fail, {enableHighAccuracy: true, maximumAge: 3000, timeout: 30000}); // Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise). const orientation = event => { const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null); diff --git a/src/scoring.js b/src/scoring.js index 1bba40b..e5c19b9 100644 --- a/src/scoring.js +++ b/src/scoring.js @@ -1,7 +1,7 @@ import {CATEGORIES} from './locations.js'; // The bonus for visiting every kind of place counts city locations, not the factory. -const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory'); +const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory' && c !== 'test'); export const POINTS = {purchase: 100, discovery: 250, combo: 150, allCategories: 500}; @@ -64,7 +64,7 @@ export const MISSIONS = [ }, { id: 'explorer', title: 'از ۵ مکان متفاوت شهر خرید کن', target: null, xp: 400, hetoman: 80, - done: ps => placesVisited(ps.filter(p => p.category !== 'factory')) >= 5 + done: ps => placesVisited(ps.filter(p => CITY_CATEGORIES.includes(p.category))) >= 5 } ]; diff --git a/tests/realworld.test.js b/tests/realworld.test.js index c195960..3354b86 100644 --- a/tests/realworld.test.js +++ b/tests/realworld.test.js @@ -15,8 +15,8 @@ test('GPS fixes blend by accuracy and a stale estimate is replaced', () => { }); test('Arrival radius widens with poor GPS and bounds check the world', () => { - assert.equal(arrivalRadius(5), 20); - assert.equal(arrivalRadius(20), 30); + assert.equal(arrivalRadius(4), 12); + assert.equal(arrivalRadius(10), 20); assert.equal(arrivalRadius(100), 40); const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10}; assert.ok(insideWorld(bounds, 0, 0));