refactor: separate React views, Redux snapshots and Three.js runtime

This commit is contained in:
2026-10-28 12:26:40 +03:30
parent 6bd9b17173
commit d0da416923
77 changed files with 4941 additions and 3723 deletions
+136
View File
@@ -0,0 +1,136 @@
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 };
};
let sensing = false;
return {
// Camera picture on (AR) or off (the 3D world fills the screen).
get active() {
return Boolean(stream);
},
get hasOrientation() {
return Boolean(orientation);
},
// Phone orientation, used by the first-person view with or without the camera picture.
startSensors() {
if (sensing) return;
sensing = true;
window.addEventListener('deviceorientationabsolute', onAbsolute);
window.addEventListener('deviceorientation', onOrientation);
},
stopSensors() {
sensing = false;
orientation = null;
absoluteSeen = false;
window.removeEventListener('deviceorientationabsolute', onAbsolute);
window.removeEventListener('deviceorientation', onOrientation);
},
// 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(() => {});
this.startSensors();
return null;
},
// Closes the camera picture; the sensors keep running for the first-person view.
stop() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
video.srcObject = null;
video.hidden = true;
},
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
update(camera, dt, smoothing = 8) {
const fov = stream
? coverFov(
video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3,
innerWidth / innerHeight,
)
: 62;
if (Math.abs(camera.fov - fov) > 0.1) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
if (!orientation) return false;
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));
return true;
},
};
}
+79
View File
@@ -0,0 +1,79 @@
/** Connects install/update lifecycle to the React interface and returns cleanup.
* @param {object} ui - Interface bridge.
* @returns {function} Releases browser event subscriptions.
*/
export function setupPwa(ui) {
const controller = new AbortController(),
{ signal } = controller;
let deferred = null,
reloading = false,
disposed = false;
const standalone =
matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches ||
navigator.standalone;
const ios =
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!standalone && ios) ui.patch('install', { hidden: false });
window.addEventListener(
'beforeinstallprompt',
(event) => {
event.preventDefault();
deferred = event;
ui.patch('install', { hidden: false });
},
{ signal },
);
window.addEventListener('appinstalled', () => ui.patch('install', { hidden: true }), { signal });
ui.on('install', async () => {
if (deferred) {
deferred.prompt();
await deferred.userChoice;
deferred = null;
ui.patch('install', { hidden: true });
} else if (ios) ui.open('ios-install');
});
ui.on('ios-close', () => ui.close('ios-install'));
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
/** Offers a version change only after a worker has installed successfully. */
const offer = (worker) => {
ui.patch('update-toast', { hidden: false });
ui.on('update-reload', () => worker.postMessage('skip-waiting'));
};
navigator.serviceWorker
.register('./sw.js')
.then((registration) => {
if (disposed) return;
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
registration.addEventListener(
'updatefound',
() => {
const worker = registration.installing;
worker?.addEventListener(
'statechange',
() => {
if (worker.state === 'installed' && navigator.serviceWorker.controller)
offer(worker);
},
{ signal },
);
},
{ signal },
);
})
.catch(() => {});
navigator.serviceWorker.addEventListener(
'controllerchange',
() => {
if (reloading) return;
reloading = true;
location.reload();
},
{ signal },
);
}
return () => {
disposed = true;
controller.abort();
};
}
+158
View File
@@ -0,0 +1,158 @@
import { toLocal } from '../../utils/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(0.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) * 0.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(0.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) > 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.
}
}