Files
vending-machine-game/src/game/device/realworld.js
T
HamedandClaude Opus 5.5 8dbaf3d97e refactor: centralize brand, company info, assets and Persian strings
Phase 1 of turning the game into the company landing page.

- Add src/config/company.js with name, tagline, contact, about and the
  product line, plus a brand palette built around #00a693 (contrast
  checked); placeholders are marked TODO(company).
- Add src/config/assets.js listing every loaded file with its expected
  format; placeholders are marked TODO(asset).
- Move all Persian interface text, dialogue, missions and scenery text
  into src/i18n/fa.js; components and game code read from it.
- Expose the palette as CSS variables (--color-primary and shades) and
  Tailwind colours, and use it for 3D materials and map drawings.
- Fix the arrival line that still called the manager's car a white
  pickup.
- Add tests that block hard-coded Persian text and low-contrast colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:13:00 +03:30

216 lines
7.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 '../../utils/geo.js';
import { strings } from '../../i18n/fa.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.
/**
* Runs the smooth fix operation for this subsystem.
* @param {*} previous - previous.
* @param {*} fix - fix.
* @returns {*} Operation result, when applicable.
*/
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.
/**
* Creates stabilizer resources for the game.
* @param {*} options - Operation options.
* @returns {*} Operation result, when applicable.
*/
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.
/**
* Runs the arrival radius operation for this subsystem.
* @param {*} accuracy - accuracy.
* @returns {*} Operation result, when applicable.
*/
export const arrivalRadius = (accuracy) => Math.min(50, Math.max(20, accuracy * 2.5));
/**
* Runs the inside world operation for this subsystem.
* @param {*} bounds - bounds.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
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).
/**
* Runs the start tracking operation for this subsystem.
* @param {*} options - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function startTracking({
onFix,
onHeading,
onError,
onStatus = () => {},
stabilizer = createStabilizer(),
}) {
if (!('geolocation' in navigator)) {
onError(strings.real.noGps);
return () => {};
}
if (!window.isSecureContext) {
onError(strings.real.needsHttps);
return () => {};
}
let fixes = 0;
/**
* Runs the handle operation for this subsystem.
* @param {*} position - position.
* @returns {*} Operation result, when applicable.
*/
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,
});
};
/**
* Runs the fail operation for this subsystem.
* @param {*} error - error.
* @returns {*} Operation result, when applicable.
*/
const fail = (error) => {
if (error.code === error.PERMISSION_DENIED) onError(strings.real.denied);
else if (error.code === error.POSITION_UNAVAILABLE) onError(strings.real.unavailable);
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
else if (!fixes) onStatus(strings.real.timeout);
};
// 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).
/**
* Runs the orientation operation for this subsystem.
* @param {*} event - event.
* @returns {*} Operation result, when applicable.
*/
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.
/**
* Runs the request compass operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
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.
}
}