Rename the factory to Robot Market, fix GPS search and add GPS test points

- 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>
This commit is contained in:
2026-10-26 11:23:22 +03:30
co-authored by Claude Opus 5.5
parent 9832f303e8
commit 8cc0e8ce80
8 changed files with 79 additions and 31 deletions
+9 -1
View File
@@ -8,7 +8,7 @@
## داستان و هدف ## داستان و هدف
- **شروع:** سالن تولید کارخانهٔ **شرکت نگین سازان پیشگام رادمهر آتوسا** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است. - **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است.
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد. - **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد.
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند. - **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند.
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap: - **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap:
@@ -65,6 +65,14 @@
- در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمی‌شود. - در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمی‌شود.
- بیرون از محدودهٔ بازی، فاصله تا نزدیک‌ترین دستگاه نمایش داده می‌شود. - بیرون از محدودهٔ بازی، فاصله تا نزدیک‌ترین دستگاه نمایش داده می‌شود.
- موقعیت فقط روی خود گوشی پردازش می‌شود. دوربین و قطب‌نما به HTTPS نیاز دارند. - موقعیت فقط روی خود گوشی پردازش می‌شود. دوربین و قطب‌نما به HTTPS نیاز دارند.
- اول یک موقعیت سریع و تقریبی از Wi-Fi یا شبکهٔ موبایل گرفته می‌شود و بعد GPS دقیقش می‌کند. وضعیت زیر نام خیابان با کد خطا نمایش داده می‌شود:
- **کد ۱:** اجازهٔ Location داده نشده
- **کد ۲:** Location گوشی خاموش است
- **کد ۳:** هنوز سیگنال نرسیده (جست‌وجو ادامه دارد)
روی کامپیوتر معمولاً موقعیتی پیدا نمی‌شود، چون Chrome برای کامپیوتر از سرویس موقعیت گوگل استفاده می‌کند که از ایران در دسترس نیست. با گوشی تست کنید.
**نقطه‌های آزمایشی GPS:** سه دستگاه آزمایشی فقط در حالت دنیای واقعی کنار کارخانه ظاهر می‌شوند، در مختصات `36.450276, 59.423431`، `36.450172, 59.423209` و `36.449976, 59.422866`. مختصات، نقطه‌ای است که باید آنجا بایستید. با گوشی تا هر نقطه راه بروید: روبات بیرون می‌آید و دکمهٔ خرید فعال می‌شود. شعاع رسیدن دو برابر خطای GPS است (بین ۱۲ تا ۴۰ متر) و همیشه نزدیک‌ترین دستگاه انتخاب می‌شود.
فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته می‌شود: تصویر زندهٔ دوربین به‌عنوان پس‌زمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن. فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته می‌شود: تصویر زندهٔ دوربین به‌عنوان پس‌زمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "عملیات مشهد · روبات مارکت", "name": "عملیات مشهد · روبات مارکت",
"short_name": "روبات مارکت", "short_name": "روبات مارکت",
"description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.", "description": "از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
"lang": "fa", "lang": "fa",
"dir": "rtl", "dir": "rtl",
"start_url": "./", "start_url": "./",
+1 -1
View File
@@ -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 * 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. * gate, v across it. Returns world-space collision footprints and key positions.
*/ */
+15 -3
View File
@@ -6,14 +6,15 @@ export const CATEGORIES = {
university: {label: 'دانشگاه', icon: '🎓'}, university: {label: 'دانشگاه', icon: '🎓'},
school: {label: 'مدرسه', icon: '🏫'}, school: {label: 'مدرسه', icon: '🏫'},
metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'}, metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'},
mall: {label: 'مرکز خرید', icon: '🛍'} mall: {label: 'مرکز خرید', icon: '🛍'},
test: {label: 'نقطهٔ آزمایشی GPS', icon: '🧪'}
}; };
const ALL_DAY = [7 * 60, 23 * 60]; const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = { export const FACTORY = {
name: 'شرکت نگین سازان پیشگام رادمهر آتوسا', name: 'شرکت روبات مارکت',
tagline: 'تولیدکنندهٔ دستگاه‌های وندینگ روبات مارکت', tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند',
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان', address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street). // Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: {x: -10308.4, z: -14964.2}, 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. // Shown on the map as the next area to unlock.
export const lockedPlaces = [ export const lockedPlaces = [
{name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی'}, {name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی'},
+27 -12
View File
@@ -20,7 +20,7 @@ import {createFactory} from './factory.js';
import {createCar, stepCar, kmh} from './vehicle.js'; import {createCar, stepCar, kmh} from './vehicle.js';
import {createCarModel, createTraffic} from './traffic.js'; import {createCarModel, createTraffic} from './traffic.js';
import {createMapAssets, drawMinimap, drawFullMap, drawIran} from './map-render.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 $ = s => document.querySelector(s);
const canvas = $('#world'); 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; // 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. // the factory's demo machine has a fixed place inside the production hall.
const stations = machines.map(def => { 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); 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 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 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); 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 city = createCity(scene, data, {collision, segments});
const traffic = createTraffic(scene, data, 28); const traffic = createTraffic(scene, data, 28);
@@ -377,7 +390,7 @@ function toggleCar() {
// ---------- Machines, shop and payment ---------- // ---------- Machines, shop and payment ----------
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres. // 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 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])}`; const hoursText = m => m.hours[1] - m.hours[0] >= 24 * 60 ? 'شبانه‌روزی' : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
let shopStation = null; let shopStation = null;
@@ -570,7 +583,7 @@ const objectiveStation = () => {
if (mission?.target) return stationById(mission.target); if (mission?.target) return stationById(mission.target);
// Open-ended missions point to the nearest place not bought from yet. // Open-ended missions point to the nearest place not bought from yet.
const visited = new Set(state.purchases.map(p => p.machineId)); 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. // 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'; let selected = null, mapTab = 'city';
const markers = () => { const markers = () => {
const visited = new Set(state.purchases.map(p => p.machineId)), objective = objectiveStation(); 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) { function sizeCanvas(c) {
@@ -726,7 +739,7 @@ function renderMap() {
function renderMachineList() { function renderMachineList() {
const pos = mode === 'drive' ? car : player, visited = new Set(state.purchases.map(p => p.machineId)); 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); const li = document.createElement('li'), b = document.createElement('button'), open = isOpen(s.def, state.minutes);
b.type = 'button'; b.type = 'button';
b.className = s.def.id === selected ? 'selected' : ''; b.className = s.def.id === selected ? 'selected' : '';
@@ -812,7 +825,7 @@ mapCanvas.addEventListener('pointerup', e => {
if (mapDrag && mapDrag.moved < 6) { if (mapDrag && mapDrag.moved < 6) {
const r = mapCanvas.getBoundingClientRect(); 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 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); if (hit) selectMachine(hit.def.id);
} }
mapDrag = null; mapDrag = null;
@@ -853,12 +866,13 @@ $('#start-mission').onclick = startMission;
// ---------- Real-world (GPS) mode ---------- // ---------- Real-world (GPS) mode ----------
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where // 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. // 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) { function setRealMode(on) {
if (on === real.on) return; if (on === real.on) return;
real.stop?.(); 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-toggle').classList.toggle('on', on);
$('#gps-status').hidden = !on; $('#gps-status').hidden = !on;
if (!on) return; if (!on) return;
@@ -870,7 +884,8 @@ function setRealMode(on) {
real.error = null; real.error = null;
}, },
onHeading: heading => real.heading = heading, onHeading: heading => real.heading = heading,
onError: message => real.error = message onError: message => real.error = message,
onStatus: message => real.waiting = message
}); });
say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همون‌جا هستیم.'); say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همون‌جا هستیم.');
} }
@@ -897,9 +912,9 @@ $('#real-cancel').onclick = () => $('#real-intro').close();
function updateReal(dt) { function updateReal(dt) {
const status = $('#gps-status'); const status = $('#gps-status');
if (real.error) status.textContent = `⚠ ${real.error}`; 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)) { 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)} تا نزدیک‌ترین دستگاه`; status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`;
} else status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`; } 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)) { if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) {
@@ -1122,7 +1137,7 @@ function greet(station) {
} }
function updateGuide(dt) { 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. // The robot only moves to another machine after it has gone back into the previous one.
if (near && !guideState.station) guideState.station = near; if (near && !guideState.station) guideState.station = near;
const s = guideState.station, wasOut = guideOut(); const s = guideState.station, wasOut = guideOut();
+22 -9
View File
@@ -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}; 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. // 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(20, accuracy * 1.5)); 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; 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}), // 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). // onHeading(radians in the game's heading convention), onError(message) for a real problem and
export function startTracking({onFix, onHeading, onError}) { // onStatus(message) while it is still searching.
export function startTracking({onFix, onHeading, onError, onStatus = () => {}}) {
if (!('geolocation' in navigator)) { if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.'); onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
return () => {}; return () => {};
} }
let estimate = null; if (!window.isSecureContext) {
const watch = navigator.geolocation.watchPosition(position => { 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); 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}); estimate = smoothFix(estimate, {...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
fixes++;
onFix({...estimate, lat, lon}); 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). // Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
const orientation = event => { const orientation = event => {
const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null); const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null);
+2 -2
View File
@@ -1,7 +1,7 @@
import {CATEGORIES} from './locations.js'; import {CATEGORIES} from './locations.js';
// The bonus for visiting every kind of place counts city locations, not the factory. // 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}; 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, 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
} }
]; ];
+2 -2
View File
@@ -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', () => { test('Arrival radius widens with poor GPS and bounds check the world', () => {
assert.equal(arrivalRadius(5), 20); assert.equal(arrivalRadius(4), 12);
assert.equal(arrivalRadius(20), 30); assert.equal(arrivalRadius(10), 20);
assert.equal(arrivalRadius(100), 40); assert.equal(arrivalRadius(100), 40);
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10}; const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
assert.ok(insideWorld(bounds, 0, 0)); assert.ok(insideWorld(bounds, 0, 0));