Files
vending-machine-game/src/realworld.js
T
HamedandClaude Opus 5.5 6ce560089f
CI/CD / test (push) Successful in 28s
CI/CD / publish (push) Successful in 17s
CI/CD / deploy (push) Successful in 4s
Add camera AR view, GPS stable mode and wider machine range
- Real-world mode opens the phone's rear camera as the game background;
  the 3D camera stands at the GPS position and follows the phone's
  orientation, so machines, beacons, range circles, the route and the
  robot appear in their real direction. City, traffic and family are
  hidden in this view; a toggle switches back to the 3D map, and drag
  turns the view where there is no orientation sensor
- Stable mode (default on): drops GPS jumps impossible on foot, holds the
  position until it drifts past half the GPS error, calms the compass
- Arrival range is 2.5x the GPS error (20–50 m), shown as a ground circle
- Tests for the stabilizer, AR orientation and field of view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:14:04 +03:30

103 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {toLocal} from './geo.js';
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, so buying pauses.
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
export function smoothFix(previous, fix) {
if (!previous || fix.time - previous.time > 30000) return {...fix};
const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
const dt = Math.max(.001, (fix.time - previous.time) / 1000);
const x = previous.x + (fix.x - previous.x) * weight, z = previous.z + (fix.z - previous.z) * weight;
// The blended estimate is a little better than either input, but never better than 3 m.
const accuracy = Math.max(3, Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) + Math.hypot(x - previous.x, z - previous.z) * .2);
return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt};
}
// Stable mode on top of smoothFix, against phone GPS jitter:
// - a fix further from the estimate than both errors plus a brisk walk allows is a jump and is
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
export function createStabilizer({maxSpeed = 3} = {}) {
let estimate = null, shown = null, rejected = 0;
return {
stable: true,
push(fix) {
if (estimate && this.stable && fix.time - estimate.time < 30000) {
const dt = Math.max(.001, (fix.time - estimate.time) / 1000);
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
if (Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach && fix.accuracy > estimate.accuracy / 2 && rejected < 3) {
rejected++;
return shown && {...shown};
}
}
rejected = 0;
estimate = smoothFix(estimate, fix);
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > .7) shown = {...estimate};
else shown = {...shown, accuracy: estimate.accuracy, time: estimate.time, speed: fix.speed ?? 0};
return {...shown};
}
};
}
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
// so reaching the area around a machine is enough.
export const arrivalRadius = accuracy => Math.min(50, Math.max(20, accuracy * 2.5));
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), onError(message) for a real problem and
// onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above).
export function startTracking({onFix, onHeading, onError, onStatus = () => {}, stabilizer = createStabilizer()}) {
if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
return () => {};
}
if (!window.isSecureContext) {
onError('موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.');
return () => {};
}
let fixes = 0;
const handle = position => {
const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon);
const shown = stabilizer.push({...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
fixes++;
if (shown) onFix({...shown, lat, lon});
};
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);
// Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
if (compass != null) onHeading(Math.PI / 2 - compass * Math.PI / 180);
};
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(eventName, orientation);
return () => {
navigator.geolocation.clearWatch(watch);
window.removeEventListener(eventName, orientation);
};
}
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
export async function requestCompass() {
try {
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) await DeviceOrientationEvent.requestPermission();
} catch {
// Without the compass the family faces the direction of travel instead.
}
}