fix: draw street map ground and repair mobile layout
- 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:
@@ -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
|
||||
|
||||
@@ -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` هستند.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,16 +867,65 @@ 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;
|
||||
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.cz -= (e.clientY - context.mapDrag.y) * context.mapView.mpp;
|
||||
context.mapDrag.moved +=
|
||||
@@ -880,7 +933,6 @@ export async function registerMapDragging(context) {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MapDialog({ nodes, actions }) {
|
||||
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
|
||||
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
|
||||
<p className="map-help">
|
||||
{'کشیدن: جابهجایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب'}
|
||||
{'کشیدن: جابهجایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
|
||||
</p>
|
||||
</div>
|
||||
<aside className="map-side">
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('world asset service', () => {
|
||||
const { apiClient } = await import('./apiClient.js');
|
||||
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 { loadWorldFiles } = await import('./worldService.js');
|
||||
const get = vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('offline'));
|
||||
@@ -20,6 +20,6 @@ describe('world asset service', () => {
|
||||
responseType: 'arraybuffer',
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(get).toHaveBeenCalledWith('./imagery/index.json', { signal: controller.signal });
|
||||
expect(get).not.toHaveBeenCalledWith('./imagery/index.json', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from './apiClient.js';
|
||||
import { SATELLITE_GROUND } from '../constants/world.js';
|
||||
const WORLD_FILES = {
|
||||
buildings: './data/buildings.bin',
|
||||
imagery: './imagery/index.json',
|
||||
@@ -6,7 +7,7 @@ const WORLD_FILES = {
|
||||
|
||||
/** Loads optional world assets without preventing the OSM fallback on network failure.
|
||||
* @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) {
|
||||
const requests = [
|
||||
@@ -14,9 +15,12 @@ export async function loadWorldFiles(signal) {
|
||||
responseType: 'arraybuffer',
|
||||
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,
|
||||
}),
|
||||
})
|
||||
: Promise.resolve({ data: null }),
|
||||
];
|
||||
const results = await Promise.allSettled(requests);
|
||||
return results.map((result) => (result.status === 'fulfilled' ? result.value.data : null));
|
||||
|
||||
+81
-3
@@ -138,10 +138,13 @@ export const gameTheme = {
|
||||
'font-size': 'var(--game-token-11)',
|
||||
'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': {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
'z-index': '40',
|
||||
'overflow-y': 'auto',
|
||||
display: 'grid',
|
||||
'grid-template-columns': '1.3fr 1fr',
|
||||
background:
|
||||
@@ -250,7 +253,7 @@ export const gameTheme = {
|
||||
},
|
||||
'.map-hint': {
|
||||
position: 'absolute',
|
||||
bottom: '-var(--game-token-17)',
|
||||
bottom: 'calc(-1 * var(--game-token-17))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: 'var(--game-token-35) var(--game-token-36)',
|
||||
@@ -348,7 +351,7 @@ export const gameTheme = {
|
||||
left: '50%',
|
||||
width: 'var(--game-token-17)',
|
||||
height: 'var(--game-token-17)',
|
||||
margin: '-var(--game-token-35)',
|
||||
margin: 'calc(-1 * var(--game-token-35))',
|
||||
'border-radius': '50%',
|
||||
background: 'var(--game-token-44)',
|
||||
'pointer-events': 'none',
|
||||
@@ -918,6 +921,7 @@ export const gameTheme = {
|
||||
display: 'none',
|
||||
},
|
||||
'#status': {
|
||||
right: 'var(--game-token-30)',
|
||||
bottom: 'var(--game-token-24)',
|
||||
},
|
||||
'.stat': {
|
||||
@@ -927,8 +931,11 @@ export const gameTheme = {
|
||||
'.stat strong': {
|
||||
'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': {
|
||||
top: 'var(--game-token-17)',
|
||||
bottom: 'auto',
|
||||
width: '70vw',
|
||||
padding: 'var(--game-token-35) var(--game-token-45)',
|
||||
'font-size': 'var(--game-token-37)',
|
||||
},
|
||||
@@ -945,6 +952,8 @@ export const gameTheme = {
|
||||
'padding-bottom': 'env(safe-area-inset-bottom, var(--game-token-9))',
|
||||
},
|
||||
'#toolbar button': {
|
||||
padding: 'var(--game-token-17) var(--game-token-45)',
|
||||
'font-size': 'var(--game-token-37)',
|
||||
flex: '0 0 auto',
|
||||
'white-space': 'nowrap',
|
||||
},
|
||||
@@ -973,11 +982,80 @@ export const gameTheme = {
|
||||
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': {
|
||||
float: 'left',
|
||||
width: 'var(--game-token-82)',
|
||||
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))',
|
||||
},
|
||||
'.brief-actions': {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user