Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f562984b | ||
|
|
8cc0e8ce80 |
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "عملیات مشهد · روبات مارکت",
|
||||
"short_name": "روبات مارکت",
|
||||
"description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاههای وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
|
||||
"description": "از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
|
||||
"lang": "fa",
|
||||
"dir": "rtl",
|
||||
"start_url": "./",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user