refactor: separate React views, Redux snapshots and Three.js runtime
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user