diff --git a/README.md b/README.md index 97d5d7c..dc86802 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ node scripts/build-buildings.mjs <فایل quadkey ایران، مثلاً 12301 ### تصویر ماهواره‌ای (Esri World Imagery) -زمین بازی تصویر ماهواره‌ای واقعی است و جاده‌ها، لاین‌ها، پارک‌ها و حیاط‌ها همان‌طور که در واقعیت هستند دیده می‌شوند. با وجود تصویر، جاده‌ها و زمین‌های رسم‌شده ساخته نمی‌شوند. +**فعلاً خاموش است.** زمین بازی از خیابان‌ها، پارک‌ها و کاربری زمین OpenStreetMap کشیده می‌شود. برای برگرداندن زمین ماهواره‌ای، `SATELLITE_GROUND` در `src/constants/world.js` را `true` کنید. در آن حالت جاده‌ها، لاین‌ها، پارک‌ها و حیاط‌ها از تصویر واقعی دیده می‌شوند و جاده‌ها و زمین‌های رسم‌شده ساخته نمی‌شوند. ```sh node scripts/fetch-imagery.mjs diff --git a/docs/04-world-and-data.md b/docs/04-world-and-data.md index 48c13f1..e522eec 100644 --- a/docs/04-world-and-data.md +++ b/docs/04-world-and-data.md @@ -27,7 +27,7 @@ buildGraph گراف پیاده با همهٔ مسیرها، و سواره با r - city بخش‌های ۲۰۰۰ متری و ساختمان‌ها بلوک‌های ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند. - `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery هم‌زمان حداکثر ۶ درخواست می‌فرستد؛ tileهای دور geometry/material/texture آزاد می‌کنند. - زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است. -- با index ماهواره‌ای موجود، زمین/جادهٔ procedural شهر جایگزین می‌شود. نبود دانلود bin/index fallback دارد؛ index موجود با JPGهای غایب لزوماً به fallback بدون تصویر تبدیل نمی‌شود. +- زمین ماهواره‌ای با `SATELLITE_GROUND = false` در `src/constants/world.js` خاموش است. در این حالت index دانلود نمی‌شود و زمین و جاده‌ها از OSM کشیده می‌شوند. اگر روشن شود و index موجود باشد، زمین/جادهٔ procedural شهر جایگزین می‌شود. نبود دانلود bin/index fallback دارد؛ index موجود با JPGهای غایب لزوماً به fallback بدون تصویر تبدیل نمی‌شود. - portraitهای روبات: front، happy، wink، surprised، laugh، love، curious، serious، excited؛ نام فایل با face زیرنویس تطبیق دارد. - آیکون‌های PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند. diff --git a/src/constants/world.js b/src/constants/world.js new file mode 100644 index 0000000..f815b29 --- /dev/null +++ b/src/constants/world.js @@ -0,0 +1,4 @@ +// Ground style of the 3D world. When false the ground is drawn from OpenStreetMap streets, parks +// and land use; when true the hosted Esri satellite tiles (public/imagery) cover it instead. +// The user asked for the drawn street map, so the satellite ground is off. +export const SATELLITE_GROUND = false; diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index 03da211..be86b9b 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.js @@ -25,6 +25,10 @@ import { createTraffic } from '../systems/traffic.js'; import { createSportSedan } from '../models/carModels.js'; import { createMapAssets } from '../world/mapRender.js'; import { formatDistance, toLocal } from '../../utils/geo.js'; + +// City map zoom limits in metres per screen pixel (closest and furthest). +const MAP_ZOOM_MIN = 0.35; +const MAP_ZOOM_MAX = 30; /** * Initializes timers in dependency order. * @param {*} context - context. @@ -863,24 +867,72 @@ export async function registerMapTabs(context) { * @returns {*} Operation result, when applicable. */ export async function registerMapDragging(context) { + // Every finger (or the mouse) on the map, so two fingers can pinch to zoom on phones. + context.mapPointers = new Map(); + context.mapPinch = null; context.listen(context.mapCanvas, 'pointerdown', (e) => { - context.mapDrag = { - x: e.clientX, - y: e.clientY, - moved: 0, - }; + context.mapPointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); context.mapCanvas.setPointerCapture(e.pointerId); + // A second finger turns the gesture into a pinch; it never counts as a tap on a machine. + if (context.mapPointers.size > 1) { + context.mapDrag = null; + context.mapPinch = pointerSpread(context.mapPointers); + return; + } + context.mapDrag = { x: e.clientX, y: e.clientY, moved: 0 }; }); context.listen(context.mapCanvas, 'pointermove', (e) => { - if (!context.mapDrag) return; - context.mapView.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp; - context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp; - context.mapDrag.moved += - Math.abs(e.clientX - context.mapDrag.x) + Math.abs(e.clientY - context.mapDrag.y); - context.mapDrag.x = e.clientX; - context.mapDrag.y = e.clientY; - context.renderMap(); + if (!context.mapPointers.has(e.pointerId)) return; + context.mapPointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (context.mapPinch) pinchMap(context); + else if (context.mapDrag) dragMap(context, e); }); + const release = (e) => { + context.mapPointers.delete(e.pointerId); + if (context.mapPointers.size < 2) context.mapPinch = null; + }; + context.listen(context.mapCanvas, 'pointercancel', (e) => { + release(e); + context.mapDrag = null; + }); + context.mapRelease = release; +} +/** + * Measures the distance between the first two pointers on the map. + * @param {Map} pointers - Active pointers by id. + * @returns {number} Distance in CSS pixels. + */ +function pointerSpread(pointers) { + const [a, b] = [...pointers.values()]; + return Math.hypot(a.x - b.x, a.y - b.y) || 1; +} +/** + * Zooms the city map by how much the two fingers spread or closed since the last move. + * @param {*} context - Game context with the map view and active pointers. + */ +function pinchMap(context) { + const spread = pointerSpread(context.mapPointers); + context.mapView.mpp = THREE.MathUtils.clamp( + (context.mapView.mpp * context.mapPinch) / spread, + MAP_ZOOM_MIN, + MAP_ZOOM_MAX, + ); + context.mapPinch = spread; + context.renderMap(); +} +/** + * Pans the city map with a one-finger or mouse drag. + * @param {*} context - Game context with the map view and drag state. + * @param {PointerEvent} e - The move event. + */ +function dragMap(context, e) { + context.mapView.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp; + context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp; + context.mapDrag.moved += + Math.abs(e.clientX - context.mapDrag.x) + Math.abs(e.clientY - context.mapDrag.y); + context.mapDrag.x = e.clientX; + context.mapDrag.y = e.clientY; + context.renderMap(); } /** * Runs the register map selection and zoom operation for this subsystem. @@ -899,6 +951,7 @@ export async function registerMapSelectionAndZoom(context) { if (hit) context.selectMachine(hit.def.id); } context.mapDrag = null; + context.mapRelease(e); }); context.listen( context.mapCanvas, @@ -907,8 +960,8 @@ export async function registerMapSelectionAndZoom(context) { e.preventDefault(); context.mapView.mpp = THREE.MathUtils.clamp( context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), - 0.35, - 30, + MAP_ZOOM_MIN, + MAP_ZOOM_MAX, ); context.renderMap(); }, diff --git a/src/pages/game/MapDialog.jsx b/src/pages/game/MapDialog.jsx index edf115d..f414e84 100644 --- a/src/pages/game/MapDialog.jsx +++ b/src/pages/game/MapDialog.jsx @@ -59,7 +59,7 @@ export function MapDialog({ nodes, actions }) {