fix: draw street map ground and repair mobile layout
CI/CD / test (push) Successful in 57s
CI/CD / publish (push) Successful in 1m8s
CI/CD / deploy (push) Successful in 6s

- Turn the satellite ground off behind SATELLITE_GROUND so OSM streets,
  parks and land use are drawn; the imagery index is no longer fetched.
- Restore phone rules lost in the CSS-to-JS theme migration (#rank
  height, toolbar button size) and replace invalid -var() values.
- Add a landscape-phone layout and make the briefing scrollable.
- Add two-finger pinch zoom to the big map.
- Add regression tests for the theme and the skipped imagery request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 07:51:40 +03:30
co-authored by Claude Opus 5.5
parent df930694f7
commit 526796126d
9 changed files with 200 additions and 27 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ node scripts/build-buildings.mjs <فایل quadkey ایران، مثلاً 12301
### تصویر ماهواره‌ای (Esri World Imagery) ### تصویر ماهواره‌ای (Esri World Imagery)
زمین بازی تصویر ماهواره‌ای واقعی است و جاده‌ها، لاین‌ها، پارک‌ها و حیاط‌ها همان‌طور که در واقعیت هستند دیده می‌شوند. با وجود تصویر، جاده‌ها و زمین‌های رسم‌شده ساخته نمی‌شوند. **فعلاً خاموش است.** زمین بازی از خیابان‌ها، پارک‌ها و کاربری زمین OpenStreetMap کشیده می‌شود. برای برگرداندن زمین ماهواره‌ای، `SATELLITE_GROUND` در `src/constants/world.js` را `true` کنید. در آن حالت جاده‌ها، لاین‌ها، پارک‌ها و حیاط‌ها از تصویر واقعی دیده می‌شوند و جاده‌ها و زمین‌های رسم‌شده ساخته نمی‌شوند.
```sh ```sh
node scripts/fetch-imagery.mjs node scripts/fetch-imagery.mjs
+1 -1
View File
@@ -27,7 +27,7 @@ buildGraph گراف پیاده با همهٔ مسیرها، و سواره با r
- city بخش‌های ۲۰۰۰ متری و ساختمان‌ها بلوک‌های ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند. - city بخش‌های ۲۰۰۰ متری و ساختمان‌ها بلوک‌های ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند.
- `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery هم‌زمان حداکثر ۶ درخواست می‌فرستد؛ tileهای دور geometry/material/texture آزاد می‌کنند. - `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery هم‌زمان حداکثر ۶ درخواست می‌فرستد؛ tileهای دور geometry/material/texture آزاد می‌کنند.
- زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است. - زوم۱۷: شعاع ۴۵۰ متر در 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 زیرنویس تطبیق دارد. - portraitهای روبات: front، happy، wink، surprised، laugh، love، curious، serious، excited؛ نام فایل با face زیرنویس تطبیق دارد.
- آیکون‌های PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند. - آیکون‌های PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند.
+4
View File
@@ -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;
+62 -9
View File
@@ -25,6 +25,10 @@ import { createTraffic } from '../systems/traffic.js';
import { createSportSedan } from '../models/carModels.js'; import { createSportSedan } from '../models/carModels.js';
import { createMapAssets } from '../world/mapRender.js'; import { createMapAssets } from '../world/mapRender.js';
import { formatDistance, toLocal } from '../../utils/geo.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. * Initializes timers in dependency order.
* @param {*} context - context. * @param {*} context - context.
@@ -863,16 +867,65 @@ export async function registerMapTabs(context) {
* @returns {*} Operation result, when applicable. * @returns {*} Operation result, when applicable.
*/ */
export async function registerMapDragging(context) { 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.listen(context.mapCanvas, 'pointerdown', (e) => {
context.mapDrag = { context.mapPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
x: e.clientX,
y: e.clientY,
moved: 0,
};
context.mapCanvas.setPointerCapture(e.pointerId); 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) => { context.listen(context.mapCanvas, 'pointermove', (e) => {
if (!context.mapDrag) return; 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<number, {x: number, y: number}>} 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.cx -= (e.clientX - context.mapDrag.x) * context.mapView.mpp;
context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp; context.mapView.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp;
context.mapDrag.moved += context.mapDrag.moved +=
@@ -880,7 +933,6 @@ export async function registerMapDragging(context) {
context.mapDrag.x = e.clientX; context.mapDrag.x = e.clientX;
context.mapDrag.y = e.clientY; context.mapDrag.y = e.clientY;
context.renderMap(); context.renderMap();
});
} }
/** /**
* Runs the register map selection and zoom operation for this subsystem. * 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); if (hit) context.selectMachine(hit.def.id);
} }
context.mapDrag = null; context.mapDrag = null;
context.mapRelease(e);
}); });
context.listen( context.listen(
context.mapCanvas, context.mapCanvas,
@@ -907,8 +960,8 @@ export async function registerMapSelectionAndZoom(context) {
e.preventDefault(); e.preventDefault();
context.mapView.mpp = THREE.MathUtils.clamp( context.mapView.mpp = THREE.MathUtils.clamp(
context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), context.mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15),
0.35, MAP_ZOOM_MIN,
30, MAP_ZOOM_MAX,
); );
context.renderMap(); context.renderMap();
}, },
+1 -1
View File
@@ -59,7 +59,7 @@ export function MapDialog({ nodes, actions }) {
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} /> <GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} /> <GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
<p className="map-help"> <p className="map-help">
{'کشیدن: جابه‌جایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب'} {'کشیدن: جابه‌جایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
</p> </p>
</div> </div>
<aside className="map-side"> <aside className="map-side">
+2 -2
View File
@@ -10,7 +10,7 @@ describe('world asset service', () => {
const { apiClient } = await import('./apiClient.js'); const { apiClient } = await import('./apiClient.js');
expect(apiClient.defaults.baseURL).toBe(`${location.origin}/vending-machine-game/`); expect(apiClient.defaults.baseURL).toBe(`${location.origin}/vending-machine-game/`);
}); });
it('retains the optional-data fallback and passes cancellation to both requests', async () => { it('retains the optional-data fallback and skips the satellite index while it is off', async () => {
const { apiClient } = await import('./apiClient.js'); const { apiClient } = await import('./apiClient.js');
const { loadWorldFiles } = await import('./worldService.js'); const { loadWorldFiles } = await import('./worldService.js');
const get = vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('offline')); const get = vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('offline'));
@@ -20,6 +20,6 @@ describe('world asset service', () => {
responseType: 'arraybuffer', responseType: 'arraybuffer',
signal: controller.signal, signal: controller.signal,
}); });
expect(get).toHaveBeenCalledWith('./imagery/index.json', { signal: controller.signal }); expect(get).not.toHaveBeenCalledWith('./imagery/index.json', expect.anything());
}); });
}); });
+7 -3
View File
@@ -1,4 +1,5 @@
import { apiClient } from './apiClient.js'; import { apiClient } from './apiClient.js';
import { SATELLITE_GROUND } from '../constants/world.js';
const WORLD_FILES = { const WORLD_FILES = {
buildings: './data/buildings.bin', buildings: './data/buildings.bin',
imagery: './imagery/index.json', imagery: './imagery/index.json',
@@ -6,7 +7,7 @@ const WORLD_FILES = {
/** Loads optional world assets without preventing the OSM fallback on network failure. /** Loads optional world assets without preventing the OSM fallback on network failure.
* @param {AbortSignal} signal - Cancels startup when the game unmounts. * @param {AbortSignal} signal - Cancels startup when the game unmounts.
* @returns {Promise<Array>} Binary buildings and imagery index, or null fallbacks. * @returns {Promise<Array>} Binary buildings and imagery index (null while SATELLITE_GROUND is off), or null fallbacks.
*/ */
export async function loadWorldFiles(signal) { export async function loadWorldFiles(signal) {
const requests = [ const requests = [
@@ -14,9 +15,12 @@ export async function loadWorldFiles(signal) {
responseType: 'arraybuffer', responseType: 'arraybuffer',
signal, signal,
}), }),
apiClient.get(WORLD_FILES.imagery, { // Without the satellite ground the tile index is not needed, so it is not downloaded.
SATELLITE_GROUND
? apiClient.get(WORLD_FILES.imagery, {
signal, signal,
}), })
: Promise.resolve({ data: null }),
]; ];
const results = await Promise.allSettled(requests); const results = await Promise.allSettled(requests);
return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null)); return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null));
+81 -3
View File
@@ -138,10 +138,13 @@ export const gameTheme = {
'font-size': 'var(--game-token-11)', 'font-size': 'var(--game-token-11)',
'letter-spacing': 'var(--game-token-12)', 'letter-spacing': 'var(--game-token-12)',
}, },
// The briefing can be taller than a phone screen (portrait text, or any landscape phone), so it
// scrolls; otherwise the start and install buttons end up below the edge, out of reach.
'#briefing': { '#briefing': {
position: 'fixed', position: 'fixed',
inset: '0', inset: '0',
'z-index': '40', 'z-index': '40',
'overflow-y': 'auto',
display: 'grid', display: 'grid',
'grid-template-columns': '1.3fr 1fr', 'grid-template-columns': '1.3fr 1fr',
background: background:
@@ -250,7 +253,7 @@ export const gameTheme = {
}, },
'.map-hint': { '.map-hint': {
position: 'absolute', position: 'absolute',
bottom: '-var(--game-token-17)', bottom: 'calc(-1 * var(--game-token-17))',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
padding: 'var(--game-token-35) var(--game-token-36)', padding: 'var(--game-token-35) var(--game-token-36)',
@@ -348,7 +351,7 @@ export const gameTheme = {
left: '50%', left: '50%',
width: 'var(--game-token-17)', width: 'var(--game-token-17)',
height: 'var(--game-token-17)', height: 'var(--game-token-17)',
margin: '-var(--game-token-35)', margin: 'calc(-1 * var(--game-token-35))',
'border-radius': '50%', 'border-radius': '50%',
background: 'var(--game-token-44)', background: 'var(--game-token-44)',
'pointer-events': 'none', 'pointer-events': 'none',
@@ -918,6 +921,7 @@ export const gameTheme = {
display: 'none', display: 'none',
}, },
'#status': { '#status': {
right: 'var(--game-token-30)',
bottom: 'var(--game-token-24)', bottom: 'var(--game-token-24)',
}, },
'.stat': { '.stat': {
@@ -927,8 +931,11 @@ export const gameTheme = {
'.stat strong': { '.stat strong': {
'font-size': 'var(--game-token-42)', 'font-size': 'var(--game-token-42)',
}, },
// The rank bar moves to the top on phones; without bottom: auto it would stretch to full height.
'#rank': { '#rank': {
top: 'var(--game-token-17)', top: 'var(--game-token-17)',
bottom: 'auto',
width: '70vw',
padding: 'var(--game-token-35) var(--game-token-45)', padding: 'var(--game-token-35) var(--game-token-45)',
'font-size': 'var(--game-token-37)', 'font-size': 'var(--game-token-37)',
}, },
@@ -945,6 +952,8 @@ export const gameTheme = {
'padding-bottom': 'env(safe-area-inset-bottom, var(--game-token-9))', 'padding-bottom': 'env(safe-area-inset-bottom, var(--game-token-9))',
}, },
'#toolbar button': { '#toolbar button': {
padding: 'var(--game-token-17) var(--game-token-45)',
'font-size': 'var(--game-token-37)',
flex: '0 0 auto', flex: '0 0 auto',
'white-space': 'nowrap', 'white-space': 'nowrap',
}, },
@@ -973,11 +982,80 @@ export const gameTheme = {
bottom: 'var(--game-token-78)', bottom: 'var(--game-token-78)',
}, },
}, },
// Phones held sideways: the desktop layout is too tall for ~390px, so shrink the map and panels,
// move the rank bar to the top and lay the touch buttons out in a row above the stats.
// Only height is tested, so every phone held sideways matches whatever its width; narrow ones
// also match the phone block above, and this block, coming later, wins.
'@media (max-height: 520px)': {
'.brief-panel': {
'align-self': 'start',
margin: 'var(--game-token-42)',
padding: 'var(--game-token-11)',
},
'#brief-text': {
'font-size': 'var(--game-token-41)',
},
'#minimap-button,\n #minimap': {
width: 'var(--game-token-72)',
height: 'var(--game-token-72)',
},
'#minimap-button': {
top: 'var(--game-token-30)',
left: 'var(--game-token-30)',
},
'#location': {
top: 'calc(var(--game-token-72) + var(--game-token-42))',
left: 'var(--game-token-30)',
width: 'var(--game-token-72)',
},
'#objectives': {
top: 'var(--game-token-30)',
right: 'var(--game-token-30)',
width: '30vw',
padding: 'var(--game-token-45)',
},
'#mission-list li:not(.active)': {
display: 'none',
},
'#rank': {
top: 'var(--game-token-17)',
bottom: 'auto',
width: '40vw',
padding: 'var(--game-token-35) var(--game-token-45)',
'font-size': 'var(--game-token-37)',
},
'#rank .bar': {
height: 'var(--game-token-21)',
'margin-top': 'var(--game-token-35)',
},
'#compass': {
top: 'var(--game-token-89)',
width: '34vw',
},
'.stat': {
'min-width': 'var(--game-token-81)',
padding: 'var(--game-token-17) var(--game-token-45)',
},
'.stat strong': {
'font-size': 'var(--game-token-42)',
},
'#toolbar button': {
padding: 'var(--game-token-17) var(--game-token-45)',
'font-size': 'var(--game-token-37)',
},
'#touch': {
bottom: 'var(--game-token-24)',
},
'.touch-buttons': {
'grid-auto-flow': 'column',
'margin-bottom': 'var(--game-token-69)',
},
},
'.brief-robot': { '.brief-robot': {
float: 'left', float: 'left',
width: 'var(--game-token-82)', width: 'var(--game-token-82)',
height: 'auto', height: 'auto',
margin: '-var(--game-token-45) 0 0 var(--game-token-45)', margin: 'calc(-1 * var(--game-token-45)) 0 0 var(--game-token-45)',
filter: 'drop-shadow(0 var(--game-token-45) var(--game-token-11) var(--game-token-83))', filter: 'drop-shadow(0 var(--game-token-45) var(--game-token-11) var(--game-token-83))',
}, },
'.brief-actions': { '.brief-actions': {
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { gameTheme } from './gameTheme.js';
/**
* Collects every CSS value in the theme, including those inside media blocks.
* @param {object} rules - Theme rules keyed by selector or at-rule.
* @returns {string[]} All declaration values.
*/
function allValues(rules) {
return Object.values(rules).flatMap((body) =>
Object.values(body).flatMap((value) =>
typeof value === 'object' ? allValues({ value }) : [value],
),
);
}
describe('game theme', () => {
it('uses no negated var(), which browsers drop as invalid', () => {
expect(allValues(gameTheme).filter((value) => String(value).includes('-var('))).toEqual([]);
});
it('keeps the phone rank bar at the top instead of stretching it to full height', () => {
expect(gameTheme['@media (max-width: 760px)']['#rank']).toMatchObject({ bottom: 'auto' });
});
it('keeps the landscape phone layout as a top-level media block', () => {
expect(gameTheme['@media (max-height: 520px)']?.['#minimap-button']).toBeDefined();
expect(gameTheme['@media (max-width: 760px)']['@media (max-height: 520px)']).toBeUndefined();
});
it('lets the briefing scroll so its buttons stay reachable on short screens', () => {
expect(gameTheme['#briefing']['overflow-y']).toBe('auto');
});
});