diff --git a/README.md b/README.md
index 0e522df..e47efbb 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,12 @@
دکمهٔ «📍 بازی در دنیای واقعی» در صفحهٔ شروع یا «📍 دنیای واقعی» در نوار پایین این حالت را روشن میکند. اول نکتههای ایمنی نمایش داده میشود و بعد اجازهٔ موقعیت و قطبنما گرفته میشود.
- خانواده روی نقشهٔ بازی همانجایی است که گوشی هست و با راه رفتن واقعی جابهجا میشود. جهتشان از قطبنمای گوشی یا جهت حرکت گرفته میشود.
-- موقعیتهای GPS بر اساس دقتشان نرم و ترکیب میشوند. «رسیدن» به دستگاه با شعاع ۲۰ تا ۴۰ متر (بسته به دقت GPS) سنجیده میشود. وقتی رسیدید، روبات از دستگاه بیرون میآید و خرید ۲۰۰ امتیاز اضافه دارد.
+- موقعیتهای GPS بر اساس دقتشان نرم و ترکیب میشوند. «رسیدن» به دستگاه یعنی رسیدن به محدودهٔ اطرافش، با شعاع ۲٫۵ برابر خطای GPS (بین ۲۰ تا ۵۰ متر). این محدوده با یک دایره روی زمین نشان داده میشود. وقتی رسیدید، روبات از دستگاه بیرون میآید و خرید ۲۰۰ امتیاز اضافه دارد.
+- **حالت پایدار** (دکمهٔ «🎯 پایدار»، پیشفرض روشن) جلوی پرشهای GPS را میگیرد:
+ - پرشهایی که با راه رفتن ممکن نیستند نادیده گرفته میشوند.
+ - تا وقتی جابهجایی کمتر از حدود نصف خطای GPS باشد، مکان شما ثابت میماند.
+ - قطبنما و چرخش دوربین آرامتر دنبال میشوند.
+- **نمای دوربین (AR):** با روشن شدن حالت دنیای واقعی، دوربین پشت گوشی پسزمینهٔ بازی میشود. دوربین بازی در جای گوشی (GPS) و همجهت با گوشی (قطبنما و حسگر جهت) قرار میگیرد، پس دستگاهها، ستون نور، دایرهٔ محدوده، مسیر طلایی و روبات در جهت واقعی روی تصویر دوربین دیده میشوند. مدل شهر و ماشینها در این نما پنهاناند. دکمهٔ «📷 دوربین» بین نمای دوربین و نقشهٔ سهبعدی جابهجا میکند. اگر گوشی حسگر جهت نداشته باشد، با کشیدن انگشت روی صفحه میچرخید.
- در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمیشود.
- بیرون از محدودهٔ بازی، فاصله تا نزدیکترین دستگاه نمایش داده میشود.
- موقعیت فقط روی خود گوشی پردازش میشود. دوربین و قطبنما به HTTPS نیاز دارند.
@@ -72,9 +77,9 @@
روی کامپیوتر معمولاً موقعیتی پیدا نمیشود، چون Chrome برای کامپیوتر از سرویس موقعیت گوگل استفاده میکند که از ایران در دسترس نیست. با گوشی تست کنید.
-**نقطههای آزمایشی GPS:** سه دستگاه آزمایشی فقط در حالت دنیای واقعی کنار کارخانه ظاهر میشوند، در مختصات `36.450276, 59.423431`، `36.450172, 59.423209` و `36.449976, 59.422866`. مختصات، نقطهای است که باید آنجا بایستید. با گوشی تا هر نقطه راه بروید: روبات بیرون میآید و دکمهٔ خرید فعال میشود. شعاع رسیدن دو برابر خطای GPS است (بین ۱۲ تا ۴۰ متر) و همیشه نزدیکترین دستگاه انتخاب میشود.
+**نقطههای آزمایشی GPS:** سه دستگاه آزمایشی فقط در حالت دنیای واقعی کنار کارخانه ظاهر میشوند، در مختصات `36.450276, 59.423431`، `36.450172, 59.423209` و `36.449976, 59.422866`. مختصات، نقطهای است که باید آنجا بایستید. با گوشی تا هر نقطه راه بروید: روبات بیرون میآید و دکمهٔ خرید فعال میشود. شعاع رسیدن ۲٫۵ برابر خطای GPS است (بین ۲۰ تا ۵۰ متر) و همیشه نزدیکترین دستگاه انتخاب میشود.
-فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته میشود: تصویر زندهٔ دوربین بهعنوان پسزمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن.
+دوربین، GPS و حسگرها فقط روی HTTPS (یا localhost) کار میکنند.
## اجرا
@@ -164,7 +169,8 @@ node scripts/build-map-data.mjs --osm city.json zone-factory.json corridor.json
- `src/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاهها بیرون میآید
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها
- `src/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام بهروزرسانی
-- `src/realworld.js`: GPS، قطبنما، نرمسازی موقعیت و شعاع رسیدن
+- `src/realworld.js`: GPS، قطبنما، نرمسازی و حالت پایدار موقعیت، و شعاع رسیدن
+- `src/ar.js`: نمای دوربین (AR)، شامل تصویر دوربین پشت گوشی، جهت گوشی و زاویهٔ دید
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
diff --git a/index.html b/index.html
index e518f6d..ac38228 100644
--- a/index.html
+++ b/index.html
@@ -15,6 +15,7 @@
+
در حال ساختن شهر مشهد…
@@ -61,6 +62,8 @@
تازهوارد۰ XP
+
+
diff --git a/src/ar.js b/src/ar.js
new file mode 100644
index 0000000..7cb3312
--- /dev/null
+++ b/src/ar.js
@@ -0,0 +1,107 @@
+import * as THREE from 'three';
+
+// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
+// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
+// robot appear in their real direction over the camera picture.
+
+// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
+const CAMERA_LONG_FOV = 65;
+
+const zee = new THREE.Vector3(0, 0, 1), euler = new THREE.Euler(), q0 = new THREE.Quaternion();
+const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
+
+// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
+// `alpha` must be measured from north (absolute), counter-clockwise.
+export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
+ const r = Math.PI / 180;
+ euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
+ target.setFromEuler(euler);
+ target.multiply(q1);
+ target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
+ return target;
+}
+
+// Vertical field of view of what is visible once the video is scaled to cover the screen.
+export function coverFov(videoAspect, screenAspect) {
+ const long = Math.tan(CAMERA_LONG_FOV * Math.PI / 360);
+ // The video's own vertical and horizontal half-angle tangents.
+ const vTan = videoAspect >= 1 ? long / videoAspect : long, hTan = videoAspect >= 1 ? long : long * videoAspect;
+ // "cover" fills the screen: if the screen is wider than the video, the full width shows.
+ const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
+ return Math.atan(visibleV) * 360 / Math.PI;
+}
+
+export function createArView(video) {
+ let stream = null, orientation = null, compassOffset = null, absoluteSeen = false;
+ const target = new THREE.Quaternion();
+ // Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
+ // only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
+ const onAbsolute = event => {
+ if (event.alpha == null) return;
+ absoluteSeen = true;
+ orientation = {alpha: event.alpha, beta: event.beta, gamma: event.gamma};
+ };
+ const onOrientation = event => {
+ if (event.alpha == null || absoluteSeen) return;
+ let alpha = event.alpha;
+ if (event.webkitCompassHeading != null) {
+ // iOS: alpha is relative to the start pose; the compass heading pins it to north.
+ compassOffset = (360 - event.webkitCompassHeading) - event.alpha;
+ }
+ if (!event.absolute && compassOffset != null) alpha += compassOffset;
+ orientation = {alpha, beta: event.beta, gamma: event.gamma};
+ };
+ return {
+ get active() {
+ return Boolean(stream);
+ },
+ get hasOrientation() {
+ return Boolean(orientation);
+ },
+ // Opens the rear camera. Resolves to null on success or to a message explaining the failure.
+ async start() {
+ if (stream) return null;
+ if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمیدهد (HTTPS لازم است).';
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false});
+ } catch (error) {
+ stream = null;
+ return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمیکند؟';
+ }
+ video.srcObject = stream;
+ video.hidden = false;
+ await video.play().catch(() => {});
+ window.addEventListener('deviceorientationabsolute', onAbsolute);
+ window.addEventListener('deviceorientation', onOrientation);
+ return null;
+ },
+ stop() {
+ stream?.getTracks().forEach(track => track.stop());
+ stream = null;
+ orientation = null;
+ absoluteSeen = false;
+ video.srcObject = null;
+ video.hidden = true;
+ window.removeEventListener('deviceorientationabsolute', onAbsolute);
+ window.removeEventListener('deviceorientation', onOrientation);
+ },
+ // Points the 3D camera like the phone. `smoothing` (per second) trades lag for steadiness.
+ update(camera, dt, smoothing = 8) {
+ const screenAspect = innerWidth / innerHeight, videoAspect = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3;
+ const fov = coverFov(videoAspect, screenAspect);
+ if (Math.abs(camera.fov - fov) > .1) {
+ camera.fov = fov;
+ camera.updateProjectionMatrix();
+ }
+ if (!orientation) return;
+ const angle = screen.orientation?.angle ?? window.orientation ?? 0;
+ orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
+ camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
+ },
+ // Compass bearing (degrees from north) the camera is looking along.
+ heading(camera) {
+ const dir = camera.getWorldDirection(new THREE.Vector3());
+ return (Math.atan2(dir.x, -dir.z) * 180 / Math.PI + 360) % 360;
+ }
+ };
+}
diff --git a/src/factory.js b/src/factory.js
index ab6a30f..99df189 100644
--- a/src/factory.js
+++ b/src/factory.js
@@ -182,6 +182,7 @@ export function createFactory(scene) {
const demo = toWorld(-8, 41.5), facing = {x: -normal.x, z: -normal.z};
const car = toWorld(52, -6);
return {
+ group,
footprints,
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
car: {x: car.x, z: car.z, heading: Math.atan2(-axis.z, axis.x)},
diff --git a/src/main.js b/src/main.js
index 79ef7f1..e61c011 100644
--- a/src/main.js
+++ b/src/main.js
@@ -4,7 +4,8 @@ import '@fontsource/vazirmatn/600.css';
import '@fontsource/vazirmatn/800.css';
import './style.css';
import {setupPwa} from './pwa.js';
-import {startTracking, requestCompass, arrivalRadius, insideWorld, MAX_WALKING_SPEED} from './realworld.js';
+import {startTracking, requestCompass, arrivalRadius, insideWorld, createStabilizer, MAX_WALKING_SPEED} from './realworld.js';
+import {createArView} from './ar.js';
import data from './data/mellat.json';
import iran from './data/iran.json';
import {createCity} from './city.js';
@@ -26,7 +27,8 @@ const $ = s => document.querySelector(s);
const canvas = $('#world');
let renderer;
try {
- renderer = new THREE.WebGLRenderer({canvas, antialias: true, powerPreference: 'high-performance'});
+ // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn.
+ renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: true, powerPreference: 'high-performance'});
} catch {
$('#loading').textContent = 'مرورگر شما گرافیک سهبعدی (WebGL) را اجرا نمیکند. شتابدهی گرافیکی را فعال کنید.';
throw new Error('WebGL unavailable');
@@ -47,7 +49,8 @@ renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
-scene.fog = new THREE.Fog('#d8cdb4', 220, 1500);
+const fog = new THREE.Fog('#d8cdb4', 220, 1500);
+scene.fog = fog;
const camera = new THREE.PerspectiveCamera(62, 1, .3, 7000);
// Morning sky over Mashhad: a gradient dome, warm haze at the horizon.
@@ -119,6 +122,25 @@ 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;
+
+// In real-world mode a circle on the ground marks how close counts as "at the machine".
+for (const s of stations) {
+ s.reach = new THREE.Mesh(new THREE.RingGeometry(.96, 1, 96), new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .55, depthWrite: false, fog: false, side: THREE.DoubleSide}));
+ s.reach.rotation.x = -Math.PI / 2;
+ s.reach.position.set(s.front.x, .08, s.front.z);
+ s.reach.visible = false;
+ scene.add(s.reach);
+}
+
+function updateReachCircles() {
+ const radius = reachRadius(), objective = real.on ? objectiveStation() : null;
+ for (const s of stations) {
+ s.reach.visible = real.on;
+ if (!real.on) continue;
+ s.reach.scale.setScalar(radius);
+ s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
+ }
+}
// 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).
@@ -131,6 +153,7 @@ const traffic = createTraffic(scene, data, 28);
const mapAssets = createMapAssets(data, [factory.label]);
// Park gate sign at the first city machine.
+const parkGate = new THREE.Group();
{
const start = stationById(CITY_START), sign = document.createElement('canvas');
sign.width = 512;
@@ -144,7 +167,7 @@ const mapAssets = createMapAssets(data, [factory.label]);
c.fillText('بوستان ملت', 256, 86);
const texture = new THREE.CanvasTexture(sign);
texture.colorSpace = THREE.SRGBColorSpace;
- const gate = new THREE.Group(), post = new THREE.MeshStandardMaterial({color: '#6b5a49'});
+ const gate = parkGate, post = new THREE.MeshStandardMaterial({color: '#6b5a49'});
for (const side of [-1, 1]) {
const p = new THREE.Mesh(new THREE.BoxGeometry(.3, 4, .3), post);
p.position.set(side * 2.6, 2, 0);
@@ -255,6 +278,13 @@ canvas.addEventListener('wheel', e => {
}, {passive: true});
function look(dx, dy, k) {
+ // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor.
+ if (arMode) {
+ if (arView.hasOrientation) return;
+ cam.yaw -= dx * k;
+ cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * .8, -1.2, 1.2);
+ return;
+ }
cam.yaw -= dx * k;
cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * .8, -.15, 1.15);
cam.lastMouse = elapsed;
@@ -866,7 +896,7 @@ $('#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, waiting: null, stop: null};
+const real = {on: false, fix: null, heading: null, error: null, waiting: null, stop: null, stabilizer: createStabilizer()};
function setRealMode(on) {
if (on === real.on) return;
@@ -875,7 +905,11 @@ function setRealMode(on) {
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;
+ $('#ar-toggle').hidden = $('#stable-toggle').hidden = !on;
+ if (!on) {
+ setArMode(false);
+ return;
+ }
if (mode === 'drive') toggleCar();
mode = 'walk';
real.stop = startTracking({
@@ -885,11 +919,58 @@ function setRealMode(on) {
},
onHeading: heading => real.heading = heading,
onError: message => real.error = message,
- onStatus: message => real.waiting = message
+ onStatus: message => real.waiting = message,
+ stabilizer: real.stabilizer = createStabilizer()
});
+ real.stabilizer.stable = stableMode;
say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همونجا هستیم.');
+ setArMode(true);
}
+// ---------- Camera AR view ----------
+// In real-world mode the phone camera is the background; the city model is hidden and only the
+// machines, their reach circles, the route and the robot are drawn over the real street.
+const arView = createArView($('#camera'));
+let arMode = false, stableMode = true;
+const hiddenInAr = () => [sky, city.group, factory.group, traffic.group, carModel.group, parkGate];
+
+async function setArMode(on) {
+ if (on && !arMode) {
+ const problem = await arView.start();
+ if (problem) {
+ say('father', `${problem} فعلاً روی نقشه بازی میکنیم.`);
+ return;
+ }
+ if (!real.on) {
+ arView.stop();
+ return;
+ }
+ say('robot', 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', 'excited');
+ cam.arPitch = 0;
+ setTimeout(() => {
+ if (arMode && !arView.hasOrientation) say('father', 'حسگر جهت گوشی جواب نمیده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
+ }, 3000);
+ } else if (!on && arMode) arView.stop();
+ else return;
+ arMode = on;
+ for (const object of hiddenInAr()) object.visible = !on;
+ scene.fog = on ? null : fog;
+ document.body.classList.toggle('ar', on);
+ $('#ar-toggle').classList.toggle('on', on);
+ if (!on) {
+ camera.fov = 62;
+ camera.updateProjectionMatrix();
+ }
+}
+
+$('#ar-toggle').onclick = () => setArMode(!arMode);
+$('#stable-toggle').onclick = () => {
+ stableMode = !stableMode;
+ real.stabilizer.stable = stableMode;
+ $('#stable-toggle').classList.toggle('on', stableMode);
+ $('#stable-toggle').textContent = stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش';
+};
+
// The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
function askRealMode(thenStart) {
if (real.on) {
@@ -933,7 +1014,8 @@ function updateReal(dt) {
player.z += dz / d * step;
}
const want = real.heading ?? (d > .5 ? Math.atan2(-dz, dx) : player.heading);
- player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 5);
+ // Stable mode also calms the compass, which jitters near cars and metal.
+ player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * (stableMode ? 2 : 5));
player.gait += dt * player.speed * 1.9;
}
@@ -1069,7 +1151,10 @@ function updateHUD(dt) {
const pos = mode === 'drive' ? car : player;
$('#clock').textContent = formatClock(state.minutes);
if (mode === 'drive') $('#speed').textContent = fa(kmh(car));
- const forward = {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)}, headingDeg = bearing(forward.x, forward.z);
+ // Where the camera actually looks: the orbit camera, or the phone itself in AR view.
+ const look3 = camera.getWorldDirection(new THREE.Vector3()), flat = Math.hypot(look3.x, look3.z);
+ const forward = flat > .05 ? {x: look3.x / flat, z: look3.z / flat} : {x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw)};
+ const headingDeg = bearing(forward.x, forward.z);
const objective = objectiveStation();
drawCompass(headingDeg, objective ? [{bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c'}] : []);
drawMinimap(minimap, mapAssets, {
@@ -1216,7 +1301,7 @@ function frame() {
if (mode === 'walk' && engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .2);
updateSubtitles(dt);
- family.group.visible = mode === 'walk';
+ family.group.visible = mode === 'walk' && !arMode;
family.group.position.set(player.x, 0, player.z);
family.group.rotation.y = player.heading;
family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0});
@@ -1228,9 +1313,16 @@ function frame() {
if (i >= 2) wheel.rotation.y = -car.steer;
}
for (const s of stations) s.model.ring.material.opacity = .35 + Math.sin(elapsed * 3) * .25;
+ updateReachCircles();
if (routeMesh) routeMaterial.opacity = .55 + Math.sin(elapsed * 4) * .15;
- if (playing) updateCamera(dt);
+ if (playing && arMode) {
+ // The phone is the camera: at the player's GPS position, eye height, pointing like the phone.
+ camera.position.set(player.x, 1.5, player.z);
+ arView.update(camera, dt, stableMode ? 5 : 10);
+ if (!arView.hasOrientation) camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ');
+ camPos.copy(camera.position);
+ } else if (playing) updateCamera(dt);
else {
const a = elapsed * .08;
camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30);
@@ -1272,7 +1364,9 @@ if (new URLSearchParams(location.search).has('debug')) window.game = {
waypoint = id;
route = null;
routeTimer = 0;
- }, debugHits, nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, s => s[4] <= 3), stations, guideState, real, keys, traffic
+ }, get arMode() {
+ return arMode;
+ }, arView, setArMode, debugHits, nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, s => s[4] <= 3), stations, guideState, real, keys, traffic
};
reset();
resize();
diff --git a/src/realworld.js b/src/realworld.js
index 38a0088..ba1ef33 100644
--- a/src/realworld.js
+++ b/src/realworld.js
@@ -16,15 +16,44 @@ 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": 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));
+// 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.
-export function startTracking({onFix, onHeading, onError, onStatus = () => {}}) {
+// 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 () => {};
@@ -33,12 +62,12 @@ export function startTracking({onFix, onHeading, onError, onStatus = () => {}})
onError('موقعیتیابی فقط روی HTTPS کار میکند؛ بازی را از نشانی https:// باز کنید.');
return () => {};
}
- let estimate = null, fixes = 0;
+ let 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});
+ const shown = stabilizer.push({...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
fixes++;
- onFix({...estimate, lat, lon});
+ if (shown) onFix({...shown, lat, lon});
};
const fail = error => {
if (error.code === error.PERMISSION_DENIED) onError('اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)');
diff --git a/src/style.css b/src/style.css
index 29e981c..09391a7 100644
--- a/src/style.css
+++ b/src/style.css
@@ -308,3 +308,12 @@ dialog header { display: flex; justify-content: space-between; align-items: star
#real-intro p, #real-intro li { line-height: 1.9; font-size: 14px; }
#real-intro ul { padding-inline-start: 18px; color: var(--dim); }
#real-go { flex: 1; padding: 10px; border: 0; background: var(--gold); color: #1b1406; font-weight: 800; }
+
+/* ---------- Camera AR view ---------- */
+#camera { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; background: #000; }
+#camera[hidden] { display: none; }
+#world { z-index: 1; }
+body.ar { background: #000; }
+body.ar #crosshair { display: none; }
+#ar-toggle[hidden], #stable-toggle[hidden] { display: none; }
+#ar-toggle.on, #stable-toggle.on { background: var(--teal); color: #04201b; border-color: var(--teal); }
diff --git a/src/traffic.js b/src/traffic.js
index f8dc6a5..7f600a3 100644
--- a/src/traffic.js
+++ b/src/traffic.js
@@ -92,10 +92,12 @@ export function createTraffic(scene, data, count = 28) {
car.model.group.visible = false;
return false;
}
- const cars = [];
+ // All traffic sits in one group so it can be hidden as a whole (camera AR view).
+ const group = new THREE.Group(), cars = [];
+ scene.add(group);
for (let i = 0; i < count; i++) {
const model = createCarModel(COLORS[i % COLORS.length]);
- scene.add(model.group);
+ group.add(model.group);
model.group.visible = false;
cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9});
}
@@ -141,5 +143,5 @@ export function createTraffic(scene, data, count = 28) {
for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36;
}
}
- return {cars, update, remove};
+ return {group, cars, update, remove};
}
diff --git a/tests/realworld.test.js b/tests/realworld.test.js
index 3354b86..27774fa 100644
--- a/tests/realworld.test.js
+++ b/tests/realworld.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import {smoothFix, arrivalRadius, insideWorld} from '../src/realworld.js';
+import {smoothFix, arrivalRadius, insideWorld, createStabilizer} from '../src/realworld.js';
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
@@ -15,10 +15,45 @@ 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(4), 12);
- assert.equal(arrivalRadius(10), 20);
- assert.equal(arrivalRadius(100), 40);
+ assert.equal(arrivalRadius(4), 20);
+ assert.equal(arrivalRadius(12), 30);
+ assert.equal(arrivalRadius(100), 50);
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
assert.ok(insideWorld(bounds, 0, 0));
assert.equal(insideWorld(bounds, 11, 0), false);
});
+
+test('Stable mode holds still through jitter, drops jumps and follows real walking', () => {
+ const s = createStabilizer();
+ s.push({x: 0, z: 0, accuracy: 8, time: 0, speed: 0});
+ // Standing still: fixes wander by a few metres, the shown position does not move.
+ for (let i = 1; i <= 10; i++) {
+ const p = s.push({x: (i % 2 ? 3 : -3), z: (i % 3) - 1, accuracy: 8, time: i * 1000, speed: 0});
+ assert.deepEqual([p.x, p.z], [0, 0]);
+ }
+ // A single 80 m jump one second later is impossible on foot and is dropped.
+ assert.deepEqual([s.push({x: 80, z: 0, accuracy: 10, time: 11000, speed: 0}).x], [0]);
+ // Walking east at 1.4 m/s: the shown position follows.
+ let p;
+ for (let i = 1; i <= 20; i++) p = s.push({x: 1.4 * i, z: 0, accuracy: 6, time: 11000 + i * 1000, speed: 1.4});
+ assert.ok(p.x > 15, `followed the walk (${p.x})`);
+ // With stable mode off every smoothed fix is shown.
+ const raw = createStabilizer();
+ raw.stable = false;
+ raw.push({x: 0, z: 0, accuracy: 8, time: 0});
+ assert.ok(raw.push({x: 3, z: 0, accuracy: 8, time: 1000}).x > 1);
+});
+
+test('AR camera faces north/west with the phone held upright, and the FOV fits the screen', async () => {
+ const {Quaternion, Vector3} = await import('three');
+ const {orientationQuaternion, coverFov} = await import('../src/ar.js');
+ const look = (alpha, beta = 90, gamma = 0, angle = 0) => new Vector3(0, 0, -1).applyQuaternion(orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle));
+ const north = look(0);
+ assert.ok(north.z < -.99, 'upright phone with alpha 0 looks north (−z)');
+ assert.ok(look(90).x < -.99, 'alpha 90 (turned left) looks west (−x)');
+ assert.ok(look(0, 0).y < -.99, 'phone lying flat looks at the ground');
+ // Portrait 9:19.5 screen over a 3:4 portrait video: the full long-side 65° shows vertically.
+ assert.ok(Math.abs(coverFov(3 / 4, 9 / 19.5) - 65) < .5);
+ // Landscape screen wider than the 4:3 video: the width shows 65°, so the vertical view is narrower.
+ assert.ok(coverFov(4 / 3, 19.5 / 9) < 35);
+});