First-person real-world mode and centred notifications on phones
CI/CD / test (push) Successful in 19s
CI/CD / publish (push) Successful in 15s
CI/CD / deploy (push) Successful in 5s

- Real-world mode is first person: the view stands at the GPS position at
  eye height and only real walking moves it; the phone's orientation
  aims it, with the camera picture (AR) or over the 3D city when the
  camera is off. Without an orientation sensor it follows the compass or
  walking direction and can be dragged. The family, car and run buttons
  are hidden in this mode
- Orientation sensors are now separate from the camera stream in ar.js
- On phones, dialogue, the buy prompt, banners, points and GPS status
  sit in the middle of the screen; the prompt can be tapped; the toolbar
  is one scrollable row and no longer covers the budget and clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:00:28 +03:30
co-authored by Claude Opus 5.5
parent 6ce560089f
commit 5cea802b97
4 changed files with 104 additions and 37 deletions
+5 -3
View File
@@ -31,7 +31,9 @@
| ترمز دستی | Space | — |
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
کنترل‌ها با صفحه‌کلید فارسی هم کار می‌کنند. دوربین فقط سوم‌شخص است.
کنترل‌ها با صفحه‌کلید فارسی هم کار می‌کنند. دوربین در بازی عادی سوم‌شخص است و در حالت دنیای واقعی اول‌شخص.
روی گوشی، اعلان‌ها (گفت‌وگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده می‌شوند. خود دکمهٔ خرید هم قابل لمس است.
## نقشه و مسیریابی
@@ -60,13 +62,13 @@
دکمهٔ «📍 بازی در دنیای واقعی» در صفحهٔ شروع یا «📍 دنیای واقعی» در نوار پایین این حالت را روشن می‌کند. اول نکته‌های ایمنی نمایش داده می‌شود و بعد اجازهٔ موقعیت و قطب‌نما گرفته می‌شود.
- خانواده روی نقشهٔ بازی همان‌جایی است که گوشی هست و با راه رفتن واقعی جابه‌جا می‌شود. جهتشان از قطب‌نمای گوشی یا جهت حرکت گرفته می‌شود.
- **اول‌شخص:** نما از چشم خودتان است. دوربین بازی در جای گوشی (GPS) و ارتفاع چشم قرار می‌گیرد و فقط با راه رفتن واقعی جابه‌جا می‌شود. صفحه‌کلید و جوی‌استیک در این حالت کاری نمی‌کنند. جهت نگاه از حسگر جهت گوشی گرفته می‌شود. اگر گوشی حسگر جهت نداشته باشد، جهت از قطب‌نما و جهت راه رفتن گرفته می‌شود و با کشیدن انگشت هم می‌چرخید.
- موقعیت‌های GPS بر اساس دقتشان نرم و ترکیب می‌شوند. «رسیدن» به دستگاه یعنی رسیدن به محدودهٔ اطرافش، با شعاع ۲٫۵ برابر خطای GPS (بین ۲۰ تا ۵۰ متر). این محدوده با یک دایره روی زمین نشان داده می‌شود. وقتی رسیدید، روبات از دستگاه بیرون می‌آید و خرید ۲۰۰ امتیاز اضافه دارد.
- **حالت پایدار** (دکمهٔ «🎯 پایدار»، پیش‌فرض روشن) جلوی پرش‌های GPS را می‌گیرد:
- پرش‌هایی که با راه رفتن ممکن نیستند نادیده گرفته می‌شوند.
- تا وقتی جابه‌جایی کمتر از حدود نصف خطای GPS باشد، مکان شما ثابت می‌ماند.
- قطب‌نما و چرخش دوربین آرام‌تر دنبال می‌شوند.
- **نمای دوربین (AR):** با روشن شدن حالت دنیای واقعی، دوربین پشت گوشی پس‌زمینهٔ بازی می‌شود. دوربین بازی در جای گوشی (GPS) و هم‌جهت با گوشی (قطب‌نما و حسگر جهت) قرار می‌گیرد، پس دستگاه‌ها، ستون نور، دایرهٔ محدوده، مسیر طلایی و روبات در جهت واقعی روی تصویر دوربین دیده می‌شوند. مدل شهر و ماشین‌ها در این نما پنهان‌اند. دکمهٔ «📷 دوربین» بین نمای دوربین و نقشهٔ سه‌بعدی جابه‌جا می‌کند. اگر گوشی حسگر جهت نداشته باشد، با کشیدن انگشت روی صفحه می‌چرخید.
- **نمای دوربین (AR):** با روشن شدن حالت دنیای واقعی، دوربین پشت گوشی پس‌زمینهٔ بازی می‌شود. دوربین بازی در جای گوشی (GPS) و هم‌جهت با گوشی (قطب‌نما و حسگر جهت) قرار می‌گیرد، پس دستگاه‌ها، ستون نور، دایرهٔ محدوده، مسیر طلایی و روبات در جهت واقعی روی تصویر دوربین دیده می‌شوند. مدل شهر و ماشین‌ها در این نما پنهان‌اند. دکمهٔ «📷 دوربین» بین نمای دوربین و شهر سه‌بعدیِ اول‌شخص جابه‌جا می‌کند. اگر گوشی حسگر جهت نداشته باشد، با کشیدن انگشت روی صفحه می‌چرخید.
- در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمی‌شود.
- بیرون از محدودهٔ بازی، فاصله تا نزدیک‌ترین دستگاه نمایش داده می‌شود.
- موقعیت فقط روی خود گوشی پردازش می‌شود. دوربین و قطب‌نما به HTTPS نیاز دارند.
+23 -15
View File
@@ -51,13 +51,29 @@ export function createArView(video) {
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;
@@ -71,37 +87,29 @@ export function createArView(video) {
video.srcObject = stream;
video.hidden = false;
await video.play().catch(() => {});
window.addEventListener('deviceorientationabsolute', onAbsolute);
window.addEventListener('deviceorientation', onOrientation);
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;
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.
// 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 screenAspect = innerWidth / innerHeight, videoAspect = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3;
const fov = coverFov(videoAspect, screenAspect);
const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62;
if (Math.abs(camera.fov - fov) > .1) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
if (!orientation) return;
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));
},
// 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;
return true;
}
};
}
+40 -19
View File
@@ -279,10 +279,11 @@ canvas.addEventListener('wheel', e => {
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 (real.on) {
if (arView.hasOrientation) return;
cam.yaw -= dx * k;
cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * .8, -1.2, 1.2);
cam.lastMouse = elapsed;
return;
}
cam.yaw -= dx * k;
@@ -491,6 +492,12 @@ function cancel() {
}
$('#swipe').onclick = swipe;
// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car.
$('#prompt').onclick = () => {
if (payment.phase === 'ready') swipe();
else if (nearStation()) interact();
else toggleCar();
};
$('#cancel-payment').onclick = cancel;
$('#shop-close').onclick = () => $('#shop').close();
@@ -906,10 +913,16 @@ function setRealMode(on) {
$('#gps-toggle').classList.toggle('on', on);
$('#gps-status').hidden = !on;
$('#ar-toggle').hidden = $('#stable-toggle').hidden = !on;
// Real-world mode is first person: the player is where the phone is and sees what it faces.
document.body.classList.toggle('real', on);
if (!on) {
setArMode(false);
arView.stopSensors();
return;
}
arView.startSensors();
cam.arPitch = 0;
cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
if (mode === 'drive') toggleCar();
mode = 'walk';
real.stop = startTracking({
@@ -923,13 +936,14 @@ function setRealMode(on) {
stabilizer: real.stabilizer = createStabilizer()
});
real.stabilizer.stable = stableMode;
say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همون‌جا هستیم.');
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.
// ---------- First-person real-world view ----------
// The view stands at the GPS position at eye height and turns with the phone. With the camera
// picture on (AR) the city model is hidden and only the machines, their reach circles, the route
// and the robot are drawn over the real street; with it off, the 3D city is shown first person.
const arView = createArView($('#camera'));
let arMode = false, stableMode = true;
const hiddenInAr = () => [sky, city.group, factory.group, traffic.group, carModel.group, parkGate];
@@ -938,7 +952,7 @@ async function setArMode(on) {
if (on && !arMode) {
const problem = await arView.start();
if (problem) {
say('father', `${problem} فعلاً روی نقشه بازی می‌کنیم.`);
say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`);
return;
}
if (!real.on) {
@@ -946,9 +960,8 @@ async function setArMode(on) {
return;
}
say('robot', 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', 'excited');
cam.arPitch = 0;
setTimeout(() => {
if (arMode && !arView.hasOrientation) say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
if (real.on && !arView.hasOrientation) say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
}, 3000);
} else if (!on && arMode) arView.stop();
else return;
@@ -957,10 +970,6 @@ async function setArMode(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);
@@ -1301,7 +1310,7 @@ function frame() {
if (mode === 'walk' && engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .2);
updateSubtitles(dt);
family.group.visible = mode === 'walk' && !arMode;
family.group.visible = mode === 'walk' && !real.on;
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});
@@ -1316,13 +1325,25 @@ function frame() {
updateReachCircles();
if (routeMesh) routeMaterial.opacity = .55 + Math.sin(elapsed * 4) * .15;
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');
if (playing && real.on) {
// First person: the phone is the camera, at the player's GPS position and eye height.
camera.position.set(player.x, 1.6, player.z);
if (!arView.update(camera, dt, stableMode ? 5 : 10)) {
// No orientation sensor: face the compass or walking direction, or wherever the player drags.
if (real.heading != null && elapsed - cam.lastMouse > 2) {
const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading));
cam.yaw += Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3);
}
camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ');
}
camPos.copy(camera.position);
} else if (playing) updateCamera(dt);
} else if (playing) {
if (camera.fov !== 62) {
camera.fov = 62;
camera.updateProjectionMatrix();
}
updateCamera(dt);
}
else {
const a = elapsed * .08;
camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30);
+36
View File
@@ -317,3 +317,39 @@ 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); }
/* ---------- Real-world first person, and notifications in the middle on phones ---------- */
body.real #crosshair, body.real #stick { display: none; }
#prompt { cursor: pointer; }
@media (max-width: 760px), (max-height: 520px) {
#banner { top: 24%; padding: 10px; }
#banner strong { font-size: 18px; }
#awards { top: 36%; }
#subtitle {
top: 50%; bottom: auto; transform: translate(-50%, -50%); width: min(92vw, 520px);
font-size: 15px; background: rgba(0, 0, 0, .7); border-radius: 10px;
}
#prompt { top: 64%; bottom: auto; font-size: 16px; padding: 12px 20px; border-radius: 10px; white-space: normal; text-align: center; max-width: 90vw; }
#gps-status {
position: fixed; top: 76px; left: 50%; transform: translateX(-50%); width: max-content; max-width: 92vw;
padding: 4px 12px; background: var(--panel-solid); border: 1px solid var(--line); border-radius: 999px; text-align: center;
}
#payment { bottom: auto; top: 50%; transform: translate(50%, -50%); }
}
/* Phone layout: one scrollable toolbar row, stats above it, compact rank bar above the compass,
and no car or run buttons while the GPS moves the player. */
body.real .touch-buttons [data-key='f'], body.real .touch-buttons [data-key='shift'] { display: none; }
@media (max-width: 760px) {
#rank { top: 4px; padding: 2px 8px; font-size: 11px; }
#rank .bar { height: 3px; margin-top: 2px; }
#compass { top: 36px; }
/* Between the mission banner and the dialogue, clear of the minimap and mission list. */
#gps-status { top: 42%; }
#toolbar {
left: 8px; right: 8px; bottom: 8px; overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
#toolbar button { flex: 0 0 auto; white-space: nowrap; }
#status { bottom: 52px; }
#touch { bottom: 150px; }
}