Compare commits

...
2 Commits
Author SHA1 Message Date
Hamed 85f562984b Merge remote-tracking branch 'origin/main'
CI/CD / publish (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
CI/CD / test (push) Canceled after 6m8s
2026-10-26 11:23:41 +03:30
HamedandClaude Opus 5.5 8cc0e8ce80 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>
2026-10-26 11:23:22 +03:30
8 changed files with 79 additions and 31 deletions
+9 -1
View File
@@ -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)، روی همین پایه ساخته می‌شود: تصویر زندهٔ دوربین به‌عنوان پس‌زمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "عملیات مشهد · روبات مارکت",
"short_name": "روبات مارکت",
"description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
"description": "از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
"lang": "fa",
"dir": "rtl",
"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
* 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: '🎓'},
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: 'خارج از محدودهٔ فعلی · به‌زودی'},
+27 -12
View File
@@ -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();
+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};
}
// 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);
+2 -2
View File
@@ -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
}
];
+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', () => {
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));