feat: minimal HUD with one floating menu and objective chip
Phase 4 of turning the game into the company landing page. - Only the menu button, a compact objective chip and the touch controls stay on screen; speed shows only while driving. - The objective chip shows the next goal in one line, its distance and an arrow that turns towards it (replaces the compass strip); tapping it opens the mission list. - One floating menu holds map, missions, status, products, real-world, camera, stable mode, sound, graphics, minimap, fullscreen, help, restart and back to the start screen. It closes on a second tap, a tap outside, Escape, or after an action. - Missions, status and help open as sheets; street label, rank bar, status panel, toolbar and compass are gone. The minimap is optional and off by default. - Share the icon set and sheet dialog between start screen and HUD. - Layout checked for phone portrait, landscape and safe areas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,12 +61,18 @@ npm run format:check
|
|||||||
| خرید از دستگاه | E | دکمهٔ E |
|
| خرید از دستگاه | E | دکمهٔ E |
|
||||||
| کشیدن کارت | C | دکمهٔ E |
|
| کشیدن کارت | C | دکمهٔ E |
|
||||||
| ترمز دستی | Space | — |
|
| ترمز دستی | Space | — |
|
||||||
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
|
| نقشهٔ شهر | M یا «نقشه» در منو | «نقشه» در منو |
|
||||||
|
|
||||||
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین در بازی عادی سومشخص است و در حالت دنیای واقعی اولشخص.
|
||||||
|
|
||||||
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
روی گوشی، اعلانها (گفتوگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده میشوند. خود دکمهٔ خرید هم قابل لمس است.
|
||||||
|
|
||||||
|
### رابط کاربری کممتن
|
||||||
|
|
||||||
|
روی صفحه فقط اینها همیشه دیده میشوند: دکمهٔ گرد منو (گوشهٔ بالا)، نشانگر هدف بعدی (بالای صفحه، با فلشی که به سمت هدف میچرخد و فاصله؛ لمسش فهرست مأموریتها را باز میکند) و کنترلهای لمسی. سرعت فقط هنگام رانندگی دیده میشود.
|
||||||
|
|
||||||
|
بقیه در منو هستند: نقشه، مأموریتها، وضعیت (بودجه، ساعت، درجه و امتیاز)، محصولات (فقط داخل کارخانه)، دنیای واقعی، دوربین و حالت پایدار (فقط در حالت دنیای واقعی)، صدا، گرافیک، نقشهٔ کوچک (بهطور پیشفرض خاموش)، تمامصفحه، راهنما، شروع دوباره و بازگشت به منوی اصلی. منو با لمس دوباره، لمس بیرون آن یا Escape بسته میشود.
|
||||||
|
|
||||||
## نقشه و مسیریابی
|
## نقشه و مسیریابی
|
||||||
|
|
||||||
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Simple line icons (24 × 24, stroke drawn in currentColor) for the start screen.
|
// Simple line icons (24 × 24, stroke drawn in currentColor) shared by the start screen and HUD.
|
||||||
const PATHS = {
|
const PATHS = {
|
||||||
pin: 'M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21zm0-9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
pin: 'M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21zm0-9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
||||||
play: 'M7 5l12 7-12 7z',
|
play: 'M7 5l12 7-12 7z',
|
||||||
@@ -14,15 +14,27 @@ const PATHS = {
|
|||||||
'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4',
|
'M18 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4',
|
||||||
close: 'M6 6l12 12M18 6L6 18',
|
close: 'M6 6l12 12M18 6L6 18',
|
||||||
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
||||||
|
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||||
|
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||||
|
chart: 'M5 20V10M12 20V4M19 20v-7',
|
||||||
|
camera: 'M4 8h3l2-3h6l2 3h3v11H4zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
|
||||||
|
target: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 12h.01',
|
||||||
|
sound: 'M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11',
|
||||||
|
sliders: 'M4 7h10M18 7h2M4 17h4M12 17h8M16 4v6M10 14v6',
|
||||||
|
compass: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM15.5 8.5l-2 5-5 2 2-5z',
|
||||||
|
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
||||||
|
refresh: 'M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6',
|
||||||
|
home: 'M4 11l8-7 8 7M6 9.5V20h12V9.5',
|
||||||
|
arrow: 'M12 4l6 8h-4v8h-4v-8H6z',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @typedef {{name: keyof typeof PATHS, className?: string}} StartIconProps */
|
/** @typedef {{name: keyof typeof PATHS, className?: string}} IconProps */
|
||||||
/**
|
/**
|
||||||
* Draws one start-screen icon; decorative, so hidden from screen readers.
|
* Draws one icon; decorative, so hidden from screen readers.
|
||||||
* @param {StartIconProps} props - Icon name and classes.
|
* @param {IconProps} props - Icon name and classes.
|
||||||
* @returns {import('react').ReactElement} SVG icon.
|
* @returns {import('react').ReactElement} SVG icon.
|
||||||
*/
|
*/
|
||||||
export function StartIcon({ name, className = 'h-6 w-6' }) {
|
export function Icon({ name, className = 'h-6 w-6' }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Icon } from './Icon.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{id: string, open: boolean, title: string, closeLabel: string, onClose: function,
|
||||||
|
* children?: import('react').ReactNode}} SheetProps */
|
||||||
|
/**
|
||||||
|
* Modal panel built on the native dialog (focus trap and Escape for free). Styled by the
|
||||||
|
* `.info-sheet` rules: a centred card on wide screens, a bottom sheet on phones.
|
||||||
|
* @param {SheetProps} props - Visibility, title, close label and callback, and content.
|
||||||
|
* @returns {import('react').ReactElement} Dialog element.
|
||||||
|
*/
|
||||||
|
export function Sheet({ id, open, title, closeLabel, onClose, children }) {
|
||||||
|
const dialog = useRef(null);
|
||||||
|
|
||||||
|
// Keep the native dialog in step with `open`; closing by Escape reports back through onClose.
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (open && !element.open) element.showModal();
|
||||||
|
if (!open && element.open) element.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
id={id}
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
onClose={onClose}
|
||||||
|
className="info-sheet"
|
||||||
|
>
|
||||||
|
<header className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<h2 id={`${id}-title`} className="text-lg font-bold">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="start-icon-button"
|
||||||
|
aria-label={closeLabel}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{open && children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import data from '../../data/mellat.json';
|
import { routeBetween } from '../world/routing.js';
|
||||||
import { routeBetween, nearestSegment } from '../world/routing.js';
|
|
||||||
import { FACTORY } from '../../constants/locations.js';
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
import { isOpen, formatClock, fa } from '../economy/gameState.js';
|
import { isOpen, formatClock, fa } from '../economy/gameState.js';
|
||||||
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
|
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
|
||||||
@@ -116,49 +115,26 @@ export function updateRoute(context, dt) {
|
|||||||
context.rebuildRouteMesh();
|
context.rebuildRouteMesh();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the draw compass operation for this subsystem.
|
* Updates the objective chip: what to do next, how far it is, and an arrow that points at it
|
||||||
* @param {*} context - context.
|
* relative to where the camera looks (0° = straight ahead).
|
||||||
* @param {*} headingDeg - heading Deg.
|
* @param {object} context - Game context.
|
||||||
* @param {*} markers - markers.
|
* @param {{objective: object | null, pos: {x: number, z: number}, headingDeg: number}} view -
|
||||||
* @returns {*} Operation result, when applicable.
|
* Objective station, player position and camera bearing.
|
||||||
*/
|
*/
|
||||||
export function drawCompass(context, headingDeg, markers) {
|
function updateObjectiveChip(context, { objective, pos, headingDeg }) {
|
||||||
const w = 560,
|
const mission = activeMission(context.state);
|
||||||
h = 44;
|
// A route the player picked on the map shows the place; otherwise show the mission itself.
|
||||||
context.compass.clearRect(0, 0, w, h);
|
const ownRoute = context.waypoint && context.waypoint !== mission?.target;
|
||||||
context.compass.fillStyle = 'rgba(10,16,14,.45)';
|
const title = ownRoute ? objective?.def.name : (mission?.title ?? objective?.def.name);
|
||||||
context.compass.fillRect(0, 10, w, 24);
|
context.ui.patch('objective', { hidden: !title });
|
||||||
context.compass.textAlign = 'center';
|
if (!title) return;
|
||||||
context.compass.textBaseline = 'middle';
|
context.ui.patch('objective-title', { text: title });
|
||||||
const labels = strings.hud.compass;
|
context.ui.patch('objective-distance', {
|
||||||
for (let d = -100; d <= 100; d += 5) {
|
text: objective ? formatDistance(Math.hypot(objective.x - pos.x, objective.z - pos.z)) : '',
|
||||||
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
|
});
|
||||||
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
|
if (!objective) return;
|
||||||
if (x < 10 || x > w - 10) continue;
|
const turn = bearing(objective.x - pos.x, objective.z - pos.z) - headingDeg;
|
||||||
context.compass.fillStyle = 'rgba(236,242,238,.8)';
|
context.ui.style('objective-arrow', { transform: `rotate(${Math.round(turn)}deg)` });
|
||||||
if (labels[deg] !== undefined) {
|
|
||||||
context.compass.font = 'bold 13px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
|
|
||||||
context.compass.fillText(labels[deg], x, 22);
|
|
||||||
} else if (deg % 15 === 0) {
|
|
||||||
context.compass.font = '10px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillText(fa(deg), x, 22);
|
|
||||||
} else context.compass.fillRect(x - 0.5, 18, 1, 8);
|
|
||||||
}
|
|
||||||
for (const m of markers) {
|
|
||||||
const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6;
|
|
||||||
if (x < 10 || x > w - 10) continue;
|
|
||||||
context.compass.save();
|
|
||||||
context.compass.translate(x, 6);
|
|
||||||
context.compass.rotate(Math.PI / 4);
|
|
||||||
context.compass.fillStyle = m.color;
|
|
||||||
context.compass.fillRect(-4, -4, 8, 8);
|
|
||||||
context.compass.restore();
|
|
||||||
}
|
|
||||||
context.compass.fillStyle = '#ecf2ee';
|
|
||||||
context.compass.fillRect(w / 2 - 1, 34, 2, 8);
|
|
||||||
context.compass.font = 'bold 11px Vazirmatn, sans-serif';
|
|
||||||
context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Runs the refresh hud operation for this subsystem.
|
* Runs the refresh hud operation for this subsystem.
|
||||||
@@ -191,22 +167,10 @@ export function refreshHUD(context) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
context.ui.publishGame(context.state);
|
context.ui.publishGame(context.state);
|
||||||
context.ui.patch('mode-label', {
|
|
||||||
text: modeLabel(context),
|
|
||||||
});
|
|
||||||
context.ui.patch('speed-stat', {
|
context.ui.patch('speed-stat', {
|
||||||
hidden: context.mode !== 'drive',
|
hidden: context.mode !== 'drive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Describes how the player is travelling, for the HUD.
|
|
||||||
* @param {object} context - Game context.
|
|
||||||
* @returns {string} Persian label.
|
|
||||||
*/
|
|
||||||
function modeLabel(context) {
|
|
||||||
if (context.mode !== 'drive') return strings.common.walking;
|
|
||||||
return strings.travel.inVehicle(context.vehicle.label);
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Updates prompt from the active simulation.
|
* Updates prompt from the active simulation.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
@@ -249,10 +213,9 @@ export function updatePrompt(context) {
|
|||||||
/**
|
/**
|
||||||
* Updates hud from the active simulation.
|
* Updates hud from the active simulation.
|
||||||
* @param {*} context - context.
|
* @param {*} context - context.
|
||||||
* @param {*} dt - dt.
|
|
||||||
* @returns {*} Operation result, when applicable.
|
* @returns {*} Operation result, when applicable.
|
||||||
*/
|
*/
|
||||||
export function updateHUD(context, dt) {
|
export function updateHUD(context) {
|
||||||
context.ui.patch('showroom-open', {
|
context.ui.patch('showroom-open', {
|
||||||
hidden:
|
hidden:
|
||||||
context.real.on ||
|
context.real.on ||
|
||||||
@@ -282,17 +245,9 @@ export function updateHUD(context, dt) {
|
|||||||
};
|
};
|
||||||
const headingDeg = bearing(forward.x, forward.z);
|
const headingDeg = bearing(forward.x, forward.z);
|
||||||
const objective = context.objectiveStation();
|
const objective = context.objectiveStation();
|
||||||
context.drawCompass(
|
updateObjectiveChip(context, { objective, pos, headingDeg });
|
||||||
headingDeg,
|
// The minimap is optional (floating menu); skip drawing it while it is hidden.
|
||||||
objective
|
if (context.showMinimap)
|
||||||
? [
|
|
||||||
{
|
|
||||||
bearing: bearing(objective.x - pos.x, objective.z - pos.z),
|
|
||||||
color: '#f2c94c',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
drawMinimap(context.minimap, context.mapAssets, {
|
drawMinimap(context.minimap, context.mapAssets, {
|
||||||
size: 220,
|
size: 220,
|
||||||
x: pos.x,
|
x: pos.x,
|
||||||
@@ -338,18 +293,5 @@ export function updateHUD(context, dt) {
|
|||||||
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.streetTimer -= dt;
|
|
||||||
if (context.streetTimer <= 0) {
|
|
||||||
context.streetTimer = 0.6;
|
|
||||||
const street = nearestSegment(context.segments, pos.x, pos.z, 35, (s) => s[5] >= 0);
|
|
||||||
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
|
|
||||||
context.ui.patch('street', {
|
|
||||||
text: inFactory
|
|
||||||
? strings.hud.factory(FACTORY.name)
|
|
||||||
: street
|
|
||||||
? strings.hud.street(data.names[street.name])
|
|
||||||
: strings.hud.outskirts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
context.updatePrompt();
|
context.updatePrompt();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function frame(context) {
|
|||||||
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
|
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
|
||||||
if (playing) {
|
if (playing) {
|
||||||
context.hudTimer -= dt;
|
context.hudTimer -= dt;
|
||||||
context.updateHUD(dt);
|
context.updateHUD();
|
||||||
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
|
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
|
||||||
context.hudTimer = 0.5;
|
context.hudTimer = 0.5;
|
||||||
context.renderMap();
|
context.renderMap();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { placeStation, updateReachCircles, reset } from './runtimeLifecycle.js';
|
import { placeStation, updateReachCircles, reset } from './runtimeLifecycle.js';
|
||||||
import { startGame, restartGame, offerOffline, switchToOffline } from './startModes.js';
|
import { startGame, restartGame, backToMenu, offerOffline, switchToOffline } from './startModes.js';
|
||||||
import { look, toggleCar, updateWalk, updateDrive } from './movement.js';
|
import { look, toggleCar, updateWalk, updateDrive } from './movement.js';
|
||||||
import { say, updateSubtitles, tone } from './audioAndDialogue.js';
|
import { say, updateSubtitles, tone } from './audioAndDialogue.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
rebuildRouteMesh,
|
rebuildRouteMesh,
|
||||||
updateRoute,
|
updateRoute,
|
||||||
drawCompass,
|
|
||||||
refreshHUD,
|
refreshHUD,
|
||||||
updatePrompt,
|
updatePrompt,
|
||||||
updateHUD,
|
updateHUD,
|
||||||
@@ -98,7 +97,6 @@ const RUNTIME_METHODS = {
|
|||||||
checkEnd,
|
checkEnd,
|
||||||
rebuildRouteMesh,
|
rebuildRouteMesh,
|
||||||
updateRoute,
|
updateRoute,
|
||||||
drawCompass,
|
|
||||||
refreshHUD,
|
refreshHUD,
|
||||||
updatePrompt,
|
updatePrompt,
|
||||||
sizeCanvas,
|
sizeCanvas,
|
||||||
@@ -108,6 +106,7 @@ const RUNTIME_METHODS = {
|
|||||||
openMap,
|
openMap,
|
||||||
startGame,
|
startGame,
|
||||||
restartGame,
|
restartGame,
|
||||||
|
backToMenu,
|
||||||
offerOffline,
|
offerOffline,
|
||||||
switchToOffline,
|
switchToOffline,
|
||||||
setRealMode,
|
setRealMode,
|
||||||
|
|||||||
@@ -736,6 +736,25 @@ export async function registerPreferences(context) {
|
|||||||
context.ui.on('again', context.reset);
|
context.ui.on('again', context.reset);
|
||||||
context.ui.on('continue', () => context.ui.close('end'));
|
context.ui.on('continue', () => context.ui.close('end'));
|
||||||
context.ui.on('restart', context.restartGame);
|
context.ui.on('restart', context.restartGame);
|
||||||
|
context.ui.on('back-to-menu', context.backToMenu);
|
||||||
|
// The minimap is off by default to keep the screen clear; the floating menu toggles it.
|
||||||
|
try {
|
||||||
|
context.showMinimap = localStorage.getItem('minimap') === 'on';
|
||||||
|
} catch {
|
||||||
|
context.showMinimap = false;
|
||||||
|
}
|
||||||
|
const applyMinimap = () => {
|
||||||
|
context.ui.patch('minimap-button', { hidden: !context.showMinimap });
|
||||||
|
context.ui.patch('minimap-toggle', { pressed: context.showMinimap });
|
||||||
|
};
|
||||||
|
applyMinimap();
|
||||||
|
context.ui.on('minimap-toggle', () => {
|
||||||
|
context.showMinimap = !context.showMinimap;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('minimap', context.showMinimap ? 'on' : 'off');
|
||||||
|
} catch {}
|
||||||
|
applyMinimap();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Objective, route and HUD ----------
|
// ---------- Objective, route and HUD ----------
|
||||||
context.objectiveStation = () => {
|
context.objectiveStation = () => {
|
||||||
@@ -779,7 +798,6 @@ export async function initializeRouteMaterial(context) {
|
|||||||
z: 0,
|
z: 0,
|
||||||
};
|
};
|
||||||
context.routeMode = null;
|
context.routeMode = null;
|
||||||
context.compass = context.$('#compass').getContext('2d');
|
|
||||||
context.minimap = context.$('#minimap').getContext('2d');
|
context.minimap = context.$('#minimap').getContext('2d');
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -121,6 +121,19 @@ export function restartGame(context) {
|
|||||||
placeForMode(context, context.startMode ?? DEFAULT_START_MODE);
|
placeForMode(context, context.startMode ?? DEFAULT_START_MODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves play and shows the start screen again, with a fresh game behind it.
|
||||||
|
* @param {object} context - Game context.
|
||||||
|
*/
|
||||||
|
export function backToMenu(context) {
|
||||||
|
context.setRealMode(false);
|
||||||
|
for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'gps-fallback']) context.ui.close(id);
|
||||||
|
document.exitPointerLock?.();
|
||||||
|
context.reset();
|
||||||
|
context.ui.patch('hud', { hidden: true });
|
||||||
|
context.ui.patch('landing', { hidden: false });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explains that location is unavailable and offers to play offline instead. Shown once per
|
* Explains that location is unavailable and offers to play offline instead. Shown once per
|
||||||
* real-world session, the first time tracking reports an error.
|
* real-world session, the first time tracking reports an error.
|
||||||
|
|||||||
+45
-11
@@ -29,6 +29,7 @@ export const strings = {
|
|||||||
/** @type {(n: string) => string} */
|
/** @type {(n: string) => string} */
|
||||||
rial: (n) => `${n} ریال`,
|
rial: (n) => `${n} ریال`,
|
||||||
hetomanName: 'هتومان',
|
hetomanName: 'هتومان',
|
||||||
|
speed: 'km/h',
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
preparing: 'در حال آمادهسازی بازی…',
|
preparing: 'در حال آمادهسازی بازی…',
|
||||||
@@ -105,29 +106,62 @@ export const strings = {
|
|||||||
clockStart: '۰۸:۳۰',
|
clockStart: '۰۸:۳۰',
|
||||||
speed: 'سرعت',
|
speed: 'سرعت',
|
||||||
speedStart: '۰',
|
speedStart: '۰',
|
||||||
|
rank: 'درجه',
|
||||||
|
xp: 'امتیاز',
|
||||||
rankStart: 'تازهوارد',
|
rankStart: 'تازهوارد',
|
||||||
xpStart: '۰ XP',
|
xpStart: '۰ XP',
|
||||||
missions: 'مأموریتها',
|
missions: 'مأموریتها',
|
||||||
run: 'دویدن',
|
run: 'دویدن',
|
||||||
},
|
},
|
||||||
|
menu: {
|
||||||
|
open: 'منو',
|
||||||
|
label: 'منوی بازی',
|
||||||
|
map: 'نقشه',
|
||||||
|
missions: 'مأموریتها',
|
||||||
|
status: 'وضعیت',
|
||||||
|
products: 'محصولات',
|
||||||
|
realWorld: 'دنیای واقعی',
|
||||||
|
camera: 'دوربین',
|
||||||
|
stable: 'پایدار',
|
||||||
|
sound: 'صدا',
|
||||||
|
graphics: 'گرافیک',
|
||||||
|
minimap: 'نقشهٔ کوچک',
|
||||||
|
fullscreen: 'تمامصفحه',
|
||||||
|
help: 'راهنما',
|
||||||
|
restart: 'شروع دوباره',
|
||||||
|
home: 'منوی اصلی',
|
||||||
|
close: 'بستن',
|
||||||
|
},
|
||||||
|
objective: {
|
||||||
|
label: 'هدف بعدی',
|
||||||
|
open: 'نمایش مأموریتها',
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
keyboardTitle: 'صفحهکلید',
|
||||||
|
keyboard: [
|
||||||
|
['W A S D', 'حرکت'],
|
||||||
|
['Shift', 'دویدن'],
|
||||||
|
['ماوس', 'چرخاندن دوربین'],
|
||||||
|
['F', 'سوار و پیاده شدن'],
|
||||||
|
['E', 'خرید'],
|
||||||
|
['Q', 'معرفی محصولات'],
|
||||||
|
['M', 'نقشه'],
|
||||||
|
['Space', 'ترمز دستی'],
|
||||||
|
],
|
||||||
|
touchTitle: 'لمسی',
|
||||||
|
touch: [
|
||||||
|
['دایرهٔ پایین', 'حرکت و رانندگی'],
|
||||||
|
['کشیدن صفحه', 'چرخاندن دوربین'],
|
||||||
|
['دکمهٔ وسط پایین', 'کار بعدی: خرید، سوار شدن، معرفی'],
|
||||||
|
],
|
||||||
|
},
|
||||||
hud: {
|
hud: {
|
||||||
openMap: 'باز کردن نقشهٔ شهر (M)',
|
openMap: 'باز کردن نقشهٔ شهر (M)',
|
||||||
mapHint: 'نقشه · M',
|
|
||||||
interact: 'تعامل با بازی',
|
interact: 'تعامل با بازی',
|
||||||
compass: { 0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب' },
|
|
||||||
// Single-letter north marker on the minimap.
|
// Single-letter north marker on the minimap.
|
||||||
minimapNorth: 'ش',
|
minimapNorth: 'ش',
|
||||||
/** @type {(name: string) => string} */
|
|
||||||
factory: (name) => `کارخانهٔ ${name}`,
|
|
||||||
/** @type {(street: string) => string} */
|
|
||||||
street: (street) => `مشهد · ${street}`,
|
|
||||||
outskirts: 'حومهٔ مشهد',
|
|
||||||
metroPrefix: 'ایستگاه مترو ',
|
metroPrefix: 'ایستگاه مترو ',
|
||||||
},
|
},
|
||||||
travel: {
|
|
||||||
/** @type {(vehicle: string) => string} */
|
|
||||||
inVehicle: (vehicle) => `در ${vehicle} · F برای پیاده شدن`,
|
|
||||||
},
|
|
||||||
prompts: {
|
prompts: {
|
||||||
/** @type {(model: string) => string} */
|
/** @type {(model: string) => string} */
|
||||||
showroom: (model) => `Q · معرفی ${model} و پرسیدن قیمت`,
|
showroom: (model) => `Q · معرفی ${model} و پرسیدن قیمت`,
|
||||||
|
|||||||
+39
-43
@@ -1,39 +1,45 @@
|
|||||||
import { LocationStatus } from './LocationStatus.jsx';
|
import { useState } from 'react';
|
||||||
import { MissionList } from './MissionList.jsx';
|
|
||||||
import { StatusBar } from './StatusBar.jsx';
|
|
||||||
import { RankProgress } from './RankProgress.jsx';
|
|
||||||
import { GameToolbar } from './GameToolbar.jsx';
|
|
||||||
import { TouchControls } from './TouchControls.jsx';
|
|
||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { TouchControls } from './TouchControls.jsx';
|
||||||
|
import { ViewLists } from './ViewLists.jsx';
|
||||||
|
import { FloatingMenu } from './hud/FloatingMenu.jsx';
|
||||||
|
import { ObjectiveChip } from './hud/ObjectiveChip.jsx';
|
||||||
|
import { SpeedChip } from './hud/SpeedChip.jsx';
|
||||||
|
import { HudSheets } from './hud/HudSheets.jsx';
|
||||||
|
|
||||||
/** @typedef {{nodes: object, actions: object}} GameHudProps */
|
/** @typedef {{nodes: object, actions: object}} GameHudProps */
|
||||||
/** Presents the GameHud view using plain models and semantic native elements.
|
/**
|
||||||
* @param {GameHudProps} props - Interface snapshots and callbacks.
|
* Minimal in-game interface: the floating menu, the current objective and the controls are the
|
||||||
* @returns {import('react').ReactElement} Game panel.
|
* only things always on screen; everything else is contextual (prompt, dialogue, awards, speed
|
||||||
|
* while driving) or lives in a sheet behind the menu.
|
||||||
|
* @param {GameHudProps} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} HUD.
|
||||||
*/
|
*/
|
||||||
export function GameHud({ nodes, actions }) {
|
export function GameHud({ nodes, actions }) {
|
||||||
|
const [sheet, setSheet] = useState(null);
|
||||||
|
const emit = (name, value) => actions.emit(name, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<GameNode as="div" id="hud" hidden model={nodes.hud}>
|
||||||
<GameNode as="div" id="hud" hidden model={nodes['hud']}>
|
<FloatingMenu nodes={nodes} actions={actions} onOpenSheet={setSheet} />
|
||||||
<GameNode as="canvas" id="compass" width="560" height="44" model={nodes['compass']} />
|
<ObjectiveChip nodes={nodes} onOpen={() => setSheet('missions')} />
|
||||||
|
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']} />
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
id="minimap-button"
|
id="minimap-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
hidden
|
||||||
aria-label={strings.hud.openMap}
|
aria-label={strings.hud.openMap}
|
||||||
model={nodes['minimap-button']}
|
model={nodes['minimap-button']}
|
||||||
onAction={() => actions.emit('minimap-button')}
|
onAction={() => actions.emit('minimap-button')}
|
||||||
>
|
>
|
||||||
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
|
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes.minimap} />
|
||||||
<span className="map-hint">{strings.hud.mapHint}</span>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<LocationStatus nodes={nodes} actions={actions} />
|
<GameNode as="div" id="waypoint" hidden model={nodes.waypoint}>
|
||||||
<MissionList nodes={nodes} actions={actions} />
|
|
||||||
<GameNode as="div" id="waypoint" hidden model={nodes['waypoint']}>
|
|
||||||
<i></i>
|
<i></i>
|
||||||
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']}></GameNode>
|
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']} />
|
||||||
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']}></GameNode>
|
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode
|
<GameNode
|
||||||
as="button"
|
as="button"
|
||||||
@@ -41,37 +47,27 @@ export function GameHud({ nodes, actions }) {
|
|||||||
hidden
|
hidden
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={strings.hud.interact}
|
aria-label={strings.hud.interact}
|
||||||
model={nodes['prompt']}
|
model={nodes.prompt}
|
||||||
onAction={() => actions.emit('prompt')}
|
onAction={() => actions.emit('prompt')}
|
||||||
></GameNode>
|
/>
|
||||||
<GameNode as="div" id="subtitle" hidden model={nodes['subtitle']}>
|
<GameNode as="div" id="subtitle" hidden model={nodes.subtitle}>
|
||||||
<GameNode as="img" id="subtitle-face" alt="" hidden model={nodes['subtitle-face']} />
|
<GameNode as="img" id="subtitle-face" alt="" hidden model={nodes['subtitle-face']} />
|
||||||
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']}></GameNode>
|
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']} />
|
||||||
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']}></GameNode>
|
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode as="div" id="awards" aria-live="polite" model={nodes['awards']}>
|
<GameNode as="div" id="awards" aria-live="polite" model={nodes.awards}>
|
||||||
<ViewLists
|
<ViewLists id="awards" model={nodes.awards} onAction={emit} />
|
||||||
id="awards"
|
|
||||||
model={nodes['awards']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<GameNode as="div" id="banner" hidden model={nodes['banner']}>
|
<GameNode as="div" id="banner" hidden model={nodes.banner}>
|
||||||
<ViewLists
|
<ViewLists id="banner" model={nodes.banner} onAction={emit} />
|
||||||
id="banner"
|
|
||||||
model={nodes['banner']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
</GameNode>
|
||||||
<StatusBar nodes={nodes} actions={actions} />
|
<SpeedChip nodes={nodes} />
|
||||||
<RankProgress nodes={nodes} actions={actions} />
|
|
||||||
<GameToolbar nodes={nodes} actions={actions} />
|
|
||||||
<TouchControls nodes={nodes} actions={actions} />
|
<TouchControls nodes={nodes} actions={actions} />
|
||||||
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
|
<GameNode as="div" id="crosshair" model={nodes.crosshair} />
|
||||||
<GameNode as="small" id="attribution" model={nodes['attribution']}>
|
<GameNode as="small" id="attribution" model={nodes.attribution}>
|
||||||
{strings.app.osmCredit}
|
{strings.app.osmCredit}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
|
<HudSheets sheet={sheet} nodes={nodes} actions={actions} onClose={() => setSheet(null)} />
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
|
|
||||||
/** Presents the GameToolbar using the current interface snapshot. */
|
|
||||||
export function GameToolbar({ nodes, actions }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="showroom-open"
|
|
||||||
type="button"
|
|
||||||
hidden
|
|
||||||
model={nodes['showroom-open']}
|
|
||||||
onAction={() => actions.emit('showroom-open')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.showroom}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="gps-toggle"
|
|
||||||
type="button"
|
|
||||||
model={nodes['gps-toggle']}
|
|
||||||
onAction={() => actions.emit('gps-toggle')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.realWorld}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="ar-toggle"
|
|
||||||
type="button"
|
|
||||||
hidden
|
|
||||||
model={nodes['ar-toggle']}
|
|
||||||
onAction={() => actions.emit('ar-toggle')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.camera}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="stable-toggle"
|
|
||||||
type="button"
|
|
||||||
className="on"
|
|
||||||
hidden
|
|
||||||
model={nodes['stable-toggle']}
|
|
||||||
onAction={() => actions.emit('stable-toggle')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.stableOn}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="sound"
|
|
||||||
type="button"
|
|
||||||
aria-pressed="false"
|
|
||||||
model={nodes['sound']}
|
|
||||||
onAction={() => actions.emit('sound')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.soundOff}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="quality"
|
|
||||||
type="button"
|
|
||||||
model={nodes['quality']}
|
|
||||||
onAction={() => actions.emit('quality')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.graphics}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode
|
|
||||||
as="button"
|
|
||||||
id="restart"
|
|
||||||
type="button"
|
|
||||||
model={nodes['restart']}
|
|
||||||
onAction={() => actions.emit('restart')}
|
|
||||||
>
|
|
||||||
{strings.toolbar.restart}
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
|
|
||||||
/** Presents the LocationStatus using the current interface snapshot. */
|
|
||||||
export function LocationStatus({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="location" model={nodes['location']}>
|
|
||||||
<GameNode as="span" id="street" model={nodes['street']}>
|
|
||||||
{strings.common.mashhad}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
|
|
||||||
{strings.common.walking}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { ViewLists } from './ViewLists.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} MissionListProps */
|
|
||||||
/** Presents the MissionList using the current interface snapshot. */
|
|
||||||
export function MissionList({ nodes, actions }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="aside" id="objectives" model={nodes['objectives']}>
|
|
||||||
<h2>{strings.status.missions}</h2>
|
|
||||||
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
|
|
||||||
<ViewLists
|
|
||||||
id="mission-list"
|
|
||||||
model={nodes['mission-list']}
|
|
||||||
onAction={(name, value) => actions.emit(name, value)}
|
|
||||||
/>
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
|
|
||||||
/** Presents the RankProgress using the current interface snapshot. */
|
|
||||||
export function RankProgress({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="rank" model={nodes['rank']}>
|
|
||||||
<GameNode as="b" id="rank-name" model={nodes['rank-name']}>
|
|
||||||
{strings.status.rankStart}
|
|
||||||
</GameNode>
|
|
||||||
<GameNode as="span" id="xp" model={nodes['xp']}>
|
|
||||||
{strings.status.xpStart}
|
|
||||||
</GameNode>
|
|
||||||
<div className="bar">
|
|
||||||
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
|
|
||||||
</div>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
|
||||||
import { strings } from '@/i18n/fa.js';
|
|
||||||
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
|
|
||||||
/** Presents the StatusBar using the current interface snapshot. */
|
|
||||||
export function StatusBar({ nodes }) {
|
|
||||||
return (
|
|
||||||
<GameNode as="div" id="status" model={nodes['status']}>
|
|
||||||
<div className="stat">
|
|
||||||
<small>{strings.status.budget}</small>
|
|
||||||
<GameNode as="strong" id="balance" model={nodes['balance']}>
|
|
||||||
{strings.status.budgetStart}
|
|
||||||
</GameNode>
|
|
||||||
<span>{strings.units.hetomanName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
<small>{strings.status.clock}</small>
|
|
||||||
<GameNode as="strong" id="clock" model={nodes['clock']}>
|
|
||||||
{strings.status.clockStart}
|
|
||||||
</GameNode>
|
|
||||||
</div>
|
|
||||||
<GameNode as="div" className="stat" id="speed-stat" hidden model={nodes['speed-stat']}>
|
|
||||||
<small>{strings.status.speed}</small>
|
|
||||||
<GameNode as="strong" id="speed" model={nodes['speed']}>
|
|
||||||
{strings.status.speedStart}
|
|
||||||
</GameNode>
|
|
||||||
<span>{'km/h'}</span>
|
|
||||||
</GameNode>
|
|
||||||
</GameNode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { MenuItem } from './MenuItem.jsx';
|
||||||
|
|
||||||
|
// Menu actions in display order: [element id, icon, label key, what a click does]. `emit` sends
|
||||||
|
// the id to the game runtime; `sheet` opens a HUD sheet; `fullscreen` is handled here. Items
|
||||||
|
// marked `contextual` stay hidden until the runtime shows them (products in the factory, camera
|
||||||
|
// and stable mode in real-world play).
|
||||||
|
const ITEMS = [
|
||||||
|
['menu-map', 'map', 'map', { emit: 'minimap-button' }],
|
||||||
|
['menu-missions', 'list', 'missions', { sheet: 'missions' }],
|
||||||
|
['menu-status', 'chart', 'status', { sheet: 'status' }],
|
||||||
|
['showroom-open', 'grid', 'products', { emit: 'showroom-open', contextual: true }],
|
||||||
|
['gps-toggle', 'pin', 'realWorld', { emit: 'gps-toggle' }],
|
||||||
|
['ar-toggle', 'camera', 'camera', { emit: 'ar-toggle', contextual: true }],
|
||||||
|
['stable-toggle', 'target', 'stable', { emit: 'stable-toggle', contextual: true }],
|
||||||
|
['sound', 'sound', 'sound', { emit: 'sound' }],
|
||||||
|
['quality', 'sliders', 'graphics', { emit: 'quality' }],
|
||||||
|
['minimap-toggle', 'compass', 'minimap', { emit: 'minimap-toggle' }],
|
||||||
|
['menu-fullscreen', 'expand', 'fullscreen', { fullscreen: true }],
|
||||||
|
['menu-help', 'help', 'help', { sheet: 'help' }],
|
||||||
|
['restart', 'refresh', 'restart', { emit: 'restart' }],
|
||||||
|
['menu-home', 'home', 'home', { emit: 'back-to-menu' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the page in or out of fullscreen.
|
||||||
|
*/
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.();
|
||||||
|
else document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, actions: object, onOpenSheet: function}} FloatingMenuProps */
|
||||||
|
/**
|
||||||
|
* The single floating menu button; it reveals every secondary action in a list and closes on a
|
||||||
|
* second tap, a tap outside, Escape, or after choosing an action.
|
||||||
|
* @param {FloatingMenuProps} props - Interface snapshots, actions and the sheet opener.
|
||||||
|
* @returns {import('react').ReactElement} Menu.
|
||||||
|
*/
|
||||||
|
export function FloatingMenu({ nodes, actions, onOpenSheet }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const root = useRef(null);
|
||||||
|
const canFullscreen = Boolean(document.documentElement.requestFullscreen);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a menu action and closes the menu.
|
||||||
|
* @param {object} action - One of the ITEMS actions.
|
||||||
|
*/
|
||||||
|
const handleSelect = (action) => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (action.emit) actions.emit(action.emit);
|
||||||
|
if (action.sheet) onOpenSheet(action.sheet);
|
||||||
|
if (action.fullscreen) toggleFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
// While open, close on a press outside the menu or on Escape; listeners go when it closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const handlePointer = (event) => !root.current?.contains(event.target) && setIsOpen(false);
|
||||||
|
const handleKey = (event) => event.key === 'Escape' && setIsOpen(false);
|
||||||
|
document.addEventListener('pointerdown', handlePointer);
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointer);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={root} id="game-menu" className={isOpen ? 'open' : undefined}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="menu-button"
|
||||||
|
aria-label={isOpen ? strings.menu.close : strings.menu.open}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls="menu-items"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<Icon name={isOpen ? 'close' : 'menu'} />
|
||||||
|
</button>
|
||||||
|
<div id="menu-items" role="menu" aria-label={strings.menu.label} hidden={!isOpen}>
|
||||||
|
{ITEMS.map(([id, icon, labelKey, action]) => (
|
||||||
|
<MenuItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
icon={icon}
|
||||||
|
label={strings.menu[labelKey]}
|
||||||
|
hiddenByDefault={Boolean(action.contextual)}
|
||||||
|
model={action.fullscreen && !canFullscreen ? { hidden: true } : nodes[id]}
|
||||||
|
onSelect={() => handleSelect(action)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { FloatingMenu } from './FloatingMenu.jsx';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the menu with spies for runtime actions and sheets.
|
||||||
|
* @param {object} nodes - Interface snapshots.
|
||||||
|
* @returns {{actions: {emit: import('vitest').Mock}, onOpenSheet: import('vitest').Mock}} Spies.
|
||||||
|
*/
|
||||||
|
function renderMenu(nodes = {}) {
|
||||||
|
const actions = { emit: vi.fn() },
|
||||||
|
onOpenSheet = vi.fn();
|
||||||
|
render(<FloatingMenu nodes={nodes} actions={actions} onOpenSheet={onOpenSheet} />);
|
||||||
|
return { actions, onOpenSheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the menu with its button. */
|
||||||
|
const openMenu = () => fireEvent.click(screen.getByRole('button', { name: strings.menu.open }));
|
||||||
|
|
||||||
|
describe('FloatingMenu', () => {
|
||||||
|
it('reveals the actions on tap and hides them again on a second tap', () => {
|
||||||
|
renderMenu();
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
expect(screen.getByRole('menu')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: strings.menu.close })).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: strings.menu.close }));
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an action and closes, and closes on Escape or a tap outside', () => {
|
||||||
|
const { actions, onOpenSheet } = renderMenu();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: strings.menu.home }));
|
||||||
|
expect(actions.emit).toHaveBeenCalledWith('back-to-menu');
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: strings.menu.missions }));
|
||||||
|
expect(onOpenSheet).toHaveBeenCalledWith('missions');
|
||||||
|
openMenu();
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
openMenu();
|
||||||
|
fireEvent.pointerDown(document.body);
|
||||||
|
expect(screen.queryByRole('menu')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps real-world only items hidden until the game shows them', () => {
|
||||||
|
renderMenu({ 'ar-toggle': { hidden: false } });
|
||||||
|
openMenu();
|
||||||
|
expect(screen.getByRole('menuitem', { name: strings.menu.camera })).toBeVisible();
|
||||||
|
expect(screen.queryByRole('menuitem', { name: strings.menu.stable })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Sheet } from '@/components/shared/Sheet.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
import { ViewLists } from '../ViewLists.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission list with the active one highlighted and finished ones ticked.
|
||||||
|
* @param {{nodes: object, actions: object}} props - Interface snapshots and actions.
|
||||||
|
* @returns {import('react').ReactElement} Ordered list.
|
||||||
|
*/
|
||||||
|
function MissionsBody({ nodes, actions }) {
|
||||||
|
return (
|
||||||
|
<ol id="mission-list" className="mission-list">
|
||||||
|
<ViewLists
|
||||||
|
id="mission-list"
|
||||||
|
model={nodes['mission-list']}
|
||||||
|
onAction={(name, value) => actions.emit(name, value)}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget, game clock, rank and experience.
|
||||||
|
* @param {{nodes: object}} props - Interface snapshots.
|
||||||
|
* @returns {import('react').ReactElement} Status rows.
|
||||||
|
*/
|
||||||
|
function StatusBody({ nodes }) {
|
||||||
|
const rows = [
|
||||||
|
[strings.status.budget, 'balance', strings.status.budgetStart, strings.units.hetomanName],
|
||||||
|
[strings.status.clock, 'clock', strings.status.clockStart, ''],
|
||||||
|
[strings.status.rank, 'rank-name', strings.status.rankStart, ''],
|
||||||
|
[strings.status.xp, 'xp', strings.status.xpStart, ''],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3">
|
||||||
|
{rows.map(([label, id, fallback, unit]) => (
|
||||||
|
<div key={id} className="contents">
|
||||||
|
<dt className="opacity-70">{label}</dt>
|
||||||
|
<dd className="text-end font-bold">
|
||||||
|
<GameNode as="span" id={id} model={nodes[id]}>
|
||||||
|
{fallback}
|
||||||
|
</GameNode>{' '}
|
||||||
|
{unit}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls for keyboard and touch.
|
||||||
|
* @returns {import('react').ReactElement} Two short tables.
|
||||||
|
*/
|
||||||
|
function HelpBody() {
|
||||||
|
const groups = [
|
||||||
|
[strings.help.keyboardTitle, strings.help.keyboard],
|
||||||
|
[strings.help.touchTitle, strings.help.touch],
|
||||||
|
];
|
||||||
|
return groups.map(([title, rows]) => (
|
||||||
|
<section key={title} className="mb-4">
|
||||||
|
<h3 className="mb-2 font-bold text-primary">{title}</h3>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
{rows.map(([key, action]) => (
|
||||||
|
<div key={key} className="contents">
|
||||||
|
<dt>
|
||||||
|
<kbd>{key}</kbd>
|
||||||
|
</dt>
|
||||||
|
<dd className="opacity-80">{action}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sheet key → [title, body component].
|
||||||
|
const SHEETS = {
|
||||||
|
missions: [strings.menu.missions, MissionsBody],
|
||||||
|
status: [strings.menu.status, StatusBody],
|
||||||
|
help: [strings.menu.help, HelpBody],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @typedef {{sheet: string | null, nodes: object, actions: object, onClose: function}}
|
||||||
|
* HudSheetsProps */
|
||||||
|
/**
|
||||||
|
* The HUD's on-demand panels; longer text lives here instead of on screen.
|
||||||
|
* @param {HudSheetsProps} props - Open sheet key (or null), snapshots, actions and close callback.
|
||||||
|
* @returns {import('react').ReactElement} Sheet.
|
||||||
|
*/
|
||||||
|
export function HudSheets({ sheet, nodes, actions, onClose }) {
|
||||||
|
const [title, Body] = SHEETS[sheet] ?? ['', null];
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
id="hud-sheet"
|
||||||
|
open={Boolean(Body)}
|
||||||
|
title={title}
|
||||||
|
closeLabel={strings.menu.close}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{Body && <Body nodes={nodes} actions={actions} />}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
|
||||||
|
/** @typedef {{id: string, icon: string, label: string, model?: object, hiddenByDefault?: boolean,
|
||||||
|
* onSelect: function}} MenuItemProps */
|
||||||
|
/**
|
||||||
|
* One floating-menu action. Runtime state (hidden, pressed, disabled) comes from `model`; the
|
||||||
|
* label always comes from the strings file so every item keeps its icon and short name.
|
||||||
|
* @param {MenuItemProps} props - Element id, icon, label, runtime snapshot, whether it stays hidden
|
||||||
|
* until the runtime shows it, and click handler.
|
||||||
|
* @returns {import('react').ReactElement} Menu button.
|
||||||
|
*/
|
||||||
|
export function MenuItem({ id, icon, label, model = {}, hiddenByDefault = false, onSelect }) {
|
||||||
|
const { hidden, pressed, disabled } = model;
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="button"
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="menu-item"
|
||||||
|
hidden={hiddenByDefault}
|
||||||
|
model={{ hidden, pressed, disabled }}
|
||||||
|
onAction={onSelect}
|
||||||
|
>
|
||||||
|
<Icon name={icon} className="h-5 w-5 shrink-0" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object, onOpen: function}} ObjectiveChipProps */
|
||||||
|
/**
|
||||||
|
* Compact current-objective indicator: an arrow towards the target (relative to the camera),
|
||||||
|
* the objective in one line and its distance. Tapping it opens the mission list.
|
||||||
|
* @param {ObjectiveChipProps} props - Interface snapshots and the mission sheet opener.
|
||||||
|
* @returns {import('react').ReactElement} Objective button.
|
||||||
|
*/
|
||||||
|
export function ObjectiveChip({ nodes, onOpen }) {
|
||||||
|
return (
|
||||||
|
<GameNode
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
id="objective"
|
||||||
|
hidden
|
||||||
|
aria-label={strings.objective.open}
|
||||||
|
model={{ hidden: nodes.objective?.hidden ?? true }}
|
||||||
|
onAction={onOpen}
|
||||||
|
>
|
||||||
|
<GameNode as="span" id="objective-arrow" model={nodes['objective-arrow']}>
|
||||||
|
<Icon name="arrow" className="h-5 w-5" />
|
||||||
|
</GameNode>
|
||||||
|
<span className="grid min-w-0 text-start">
|
||||||
|
<small className="text-primary">{strings.objective.label}</small>
|
||||||
|
<GameNode as="b" id="objective-title" model={nodes['objective-title']} />
|
||||||
|
</span>
|
||||||
|
<GameNode as="span" id="objective-distance" model={nodes['objective-distance']} />
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||||
|
import { strings } from '@/i18n/fa.js';
|
||||||
|
|
||||||
|
/** @typedef {{nodes: object}} SpeedChipProps */
|
||||||
|
/**
|
||||||
|
* Small speedometer, shown only while driving.
|
||||||
|
* @param {SpeedChipProps} props - Interface snapshots.
|
||||||
|
* @returns {import('react').ReactElement} Speed readout.
|
||||||
|
*/
|
||||||
|
export function SpeedChip({ nodes }) {
|
||||||
|
return (
|
||||||
|
<GameNode as="div" id="speed-stat" hidden model={nodes['speed-stat']}>
|
||||||
|
<GameNode as="strong" id="speed" model={nodes.speed}>
|
||||||
|
{strings.status.speedStart}
|
||||||
|
</GameNode>
|
||||||
|
<small>{strings.units.speed}</small>
|
||||||
|
</GameNode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +1,25 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { Sheet } from '@/components/shared/Sheet.jsx';
|
||||||
import { strings } from '@/i18n/fa.js';
|
import { strings } from '@/i18n/fa.js';
|
||||||
import { StartIcon } from './StartIcon.jsx';
|
|
||||||
import { SECTION_BODIES } from './InfoSectionContent.jsx';
|
import { SECTION_BODIES } from './InfoSectionContent.jsx';
|
||||||
|
|
||||||
/** @typedef {{section: string | null, onClose: function}} InfoSheetProps */
|
/** @typedef {{section: string | null, onClose: function}} InfoSheetProps */
|
||||||
/**
|
/**
|
||||||
* Shows one company information section in a modal sheet (bottom sheet on phones).
|
* Shows one company information section in a sheet.
|
||||||
* @param {InfoSheetProps} props - Open section key, or null when closed, and the close callback.
|
* @param {InfoSheetProps} props - Open section key, or null when closed, and the close callback.
|
||||||
* @returns {import('react').ReactElement} Native dialog.
|
* @returns {import('react').ReactElement} Sheet.
|
||||||
*/
|
*/
|
||||||
export function InfoSheet({ section, onClose }) {
|
export function InfoSheet({ section, onClose }) {
|
||||||
const dialog = useRef(null);
|
|
||||||
const Body = SECTION_BODIES[section];
|
const Body = SECTION_BODIES[section];
|
||||||
const title = section === 'about' ? strings.landing.history : strings.landing.sections[section];
|
const title = section === 'about' ? strings.landing.history : strings.landing.sections[section];
|
||||||
|
|
||||||
// The native dialog gives focus trapping and Escape handling; keep it in step with `section`.
|
|
||||||
useEffect(() => {
|
|
||||||
const element = dialog.current;
|
|
||||||
if (!element) return;
|
|
||||||
if (section && !element.open) element.showModal();
|
|
||||||
if (!section && element.open) element.close();
|
|
||||||
}, [section]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Sheet
|
||||||
ref={dialog}
|
|
||||||
id="info-sheet"
|
id="info-sheet"
|
||||||
aria-labelledby="info-sheet-title"
|
open={Boolean(section)}
|
||||||
|
title={title ?? ''}
|
||||||
|
closeLabel={strings.landing.close}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="info-sheet"
|
|
||||||
>
|
>
|
||||||
<header className="mb-4 flex items-center justify-between gap-4">
|
|
||||||
<h2 id="info-sheet-title" className="text-lg font-bold">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="start-icon-button"
|
|
||||||
aria-label={strings.landing.close}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<StartIcon name="close" className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
{Body && <Body />}
|
{Body && <Body />}
|
||||||
</dialog>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { strings } from '@/i18n/fa.js';
|
import { strings } from '@/i18n/fa.js';
|
||||||
import { StartIcon } from './StartIcon.jsx';
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
|
|
||||||
// The four ways into the game, in display order, with their icons.
|
// The four ways into the game, in display order, with their icons.
|
||||||
const OPTIONS = [
|
const OPTIONS = [
|
||||||
@@ -35,7 +35,7 @@ export function StartOptions({ actions }) {
|
|||||||
index < 2 ? 'start-option start-option-primary' : 'start-option start-option-secondary'
|
index < 2 ? 'start-option start-option-primary' : 'start-option start-option-secondary'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StartIcon name={icon} className="h-7 w-7 shrink-0" />
|
<Icon name={icon} className="h-7 w-7 shrink-0" />
|
||||||
<span className="grid text-start">
|
<span className="grid text-start">
|
||||||
<span className="font-bold">{strings.landing.modes[mode].label}</span>
|
<span className="font-bold">{strings.landing.modes[mode].label}</span>
|
||||||
<span className="text-xs opacity-80">{strings.landing.modes[mode].hint}</span>
|
<span className="text-xs opacity-80">{strings.landing.modes[mode].hint}</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GameNode } from '@/components/shared/GameNode.jsx';
|
|||||||
import { COMPANY } from '@/config/company.js';
|
import { COMPANY } from '@/config/company.js';
|
||||||
import { ASSETS } from '@/config/assets.js';
|
import { ASSETS } from '@/config/assets.js';
|
||||||
import { strings } from '@/i18n/fa.js';
|
import { strings } from '@/i18n/fa.js';
|
||||||
import { StartIcon } from './StartIcon.jsx';
|
import { Icon } from '@/components/shared/Icon.jsx';
|
||||||
import { StartOptions } from './StartOptions.jsx';
|
import { StartOptions } from './StartOptions.jsx';
|
||||||
import { InfoSheet } from './InfoSheet.jsx';
|
import { InfoSheet } from './InfoSheet.jsx';
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function StartScreen({ nodes, actions }) {
|
|||||||
<nav aria-label={text.sectionsLabel} className="mt-6 flex flex-wrap gap-2">
|
<nav aria-label={text.sectionsLabel} className="mt-6 flex flex-wrap gap-2">
|
||||||
{SECTIONS.map(([key, icon]) => (
|
{SECTIONS.map(([key, icon]) => (
|
||||||
<button key={key} type="button" className="start-chip" onClick={() => setSection(key)}>
|
<button key={key} type="button" className="start-chip" onClick={() => setSection(key)}>
|
||||||
<StartIcon name={icon} className="h-4 w-4" />
|
<Icon name={icon} className="h-4 w-4" />
|
||||||
{key === 'about' ? text.history : text.sections[key]}
|
{key === 'about' ? text.history : text.sections[key]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -62,7 +62,7 @@ export function StartScreen({ nodes, actions }) {
|
|||||||
model={nodes.install}
|
model={nodes.install}
|
||||||
onAction={() => actions.emit('install')}
|
onAction={() => actions.emit('install')}
|
||||||
>
|
>
|
||||||
<StartIcon name="download" className="h-4 w-4" />
|
<Icon name="download" className="h-4 w-4" />
|
||||||
{text.install}
|
{text.install}
|
||||||
</GameNode>
|
</GameNode>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+157
-236
@@ -264,13 +264,145 @@ export const gameTheme = {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
'z-index': '10',
|
'z-index': '10',
|
||||||
},
|
},
|
||||||
'#compass': {
|
// Floating menu: one round button in the top corner (right in RTL) that reveals every
|
||||||
top: 'var(--game-token-28)',
|
// secondary action as a vertical list, sliding in under it.
|
||||||
|
'#game-menu': {
|
||||||
|
top: 'max(var(--game-token-30), env(safe-area-inset-top))',
|
||||||
|
right: 'max(var(--game-token-30), env(safe-area-inset-right))',
|
||||||
|
'z-index': '20',
|
||||||
|
display: 'grid',
|
||||||
|
'justify-items': 'end',
|
||||||
|
gap: 'var(--game-token-45)',
|
||||||
|
},
|
||||||
|
'#menu-button': {
|
||||||
|
display: 'grid',
|
||||||
|
'place-items': 'center',
|
||||||
|
width: 'var(--game-token-24)',
|
||||||
|
height: 'var(--game-token-24)',
|
||||||
|
border: '0',
|
||||||
|
'border-radius': '50%',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'var(--color-on-primary)',
|
||||||
|
'box-shadow': '0 var(--game-token-17) var(--game-token-30) var(--game-token-60)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'#menu-items': {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 'var(--game-token-17)',
|
||||||
|
'max-height': 'calc(100dvh - var(--game-token-56))',
|
||||||
|
'overflow-y': 'auto',
|
||||||
|
padding: 'var(--game-token-22)',
|
||||||
|
'border-radius': 'var(--game-token-28)',
|
||||||
|
background: 'var(--panel-solid)',
|
||||||
|
border: 'var(--game-token-16) solid var(--line)',
|
||||||
|
animation: 'menu-in 0.16s ease-out',
|
||||||
|
},
|
||||||
|
'#menu-items[hidden]': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'@keyframes menu-in': {
|
||||||
|
from: { opacity: '0', transform: 'translateY(calc(-1 * var(--game-token-45)))' },
|
||||||
|
to: { opacity: '1', transform: 'none' },
|
||||||
|
},
|
||||||
|
'.menu-item': {
|
||||||
|
display: 'flex',
|
||||||
|
'align-items': 'center',
|
||||||
|
gap: 'var(--game-token-30)',
|
||||||
|
'min-width': 'var(--game-token-80)',
|
||||||
|
'min-height': 'var(--game-token-32)',
|
||||||
|
padding: '0 var(--game-token-30)',
|
||||||
|
border: '0',
|
||||||
|
'border-radius': 'var(--game-token-36)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
'font-family': 'var(--font)',
|
||||||
|
'font-size': 'var(--game-token-28)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'.menu-item:hover,\n.menu-item:focus-visible': {
|
||||||
|
background: 'var(--game-token-58)',
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.menu-item.on': {
|
||||||
|
color: 'var(--color-primary-light)',
|
||||||
|
},
|
||||||
|
'.menu-item[hidden]': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
// Current objective: one compact line at the top centre; the arrow turns towards the target.
|
||||||
|
'#objective': {
|
||||||
|
top: 'max(var(--game-token-30), env(safe-area-inset-top))',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: 'min(var(--game-token-31), 80vw)',
|
display: 'flex',
|
||||||
height: 'var(--game-token-32)',
|
'align-items': 'center',
|
||||||
'pointer-events': 'none',
|
gap: 'var(--game-token-36)',
|
||||||
|
'max-width': 'min(var(--game-token-70), calc(100vw - 2 * var(--game-token-56)))',
|
||||||
|
'min-height': 'var(--game-token-24)',
|
||||||
|
padding: 'var(--game-token-22) var(--game-token-28)',
|
||||||
|
border: 'var(--game-token-16) solid var(--line)',
|
||||||
|
'border-radius': 'var(--game-token-88)',
|
||||||
|
background: 'var(--panel-solid)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
'font-family': 'var(--font)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'#objective[hidden]': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'#objective-arrow': {
|
||||||
|
display: 'grid',
|
||||||
|
'place-items': 'center',
|
||||||
|
'flex-shrink': '0',
|
||||||
|
width: 'var(--game-token-19)',
|
||||||
|
height: 'var(--game-token-19)',
|
||||||
|
'border-radius': '50%',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'var(--color-on-primary)',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
},
|
||||||
|
'#objective-title': {
|
||||||
|
overflow: 'hidden',
|
||||||
|
'white-space': 'nowrap',
|
||||||
|
'text-overflow': 'ellipsis',
|
||||||
|
'font-size': 'var(--game-token-41)',
|
||||||
|
},
|
||||||
|
'#objective-distance': {
|
||||||
|
'flex-shrink': '0',
|
||||||
|
color: 'var(--gold)',
|
||||||
|
'font-size': 'var(--game-token-30)',
|
||||||
|
},
|
||||||
|
'#gps-status': {
|
||||||
|
top: 'calc(max(var(--game-token-30), env(safe-area-inset-top)) + var(--game-token-56) - var(--game-token-42))',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
padding: 'var(--game-token-17) var(--game-token-30)',
|
||||||
|
'border-radius': 'var(--game-token-88)',
|
||||||
|
background: 'var(--panel-solid)',
|
||||||
|
color: 'var(--gold)',
|
||||||
|
'font-size': 'var(--game-token-30)',
|
||||||
|
},
|
||||||
|
'#gps-status[hidden]': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
// Speed while driving, small, above the bottom controls.
|
||||||
|
'#speed-stat': {
|
||||||
|
bottom: 'max(var(--game-token-11), env(safe-area-inset-bottom))',
|
||||||
|
right: 'max(var(--game-token-11), env(safe-area-inset-right))',
|
||||||
|
display: 'flex',
|
||||||
|
'align-items': 'baseline',
|
||||||
|
gap: 'var(--game-token-17)',
|
||||||
|
padding: 'var(--game-token-22) var(--game-token-28)',
|
||||||
|
'border-radius': 'var(--game-token-88)',
|
||||||
|
background: 'var(--panel-solid)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
},
|
||||||
|
'#speed-stat[hidden]': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'#speed': {
|
||||||
|
'font-size': 'var(--game-token-55)',
|
||||||
|
'font-variant-numeric': 'tabular-nums',
|
||||||
},
|
},
|
||||||
'#minimap-button': {
|
'#minimap-button': {
|
||||||
top: 'var(--game-token-11)',
|
top: 'var(--game-token-11)',
|
||||||
@@ -288,73 +420,33 @@ export const gameTheme = {
|
|||||||
'border-radius': '50%',
|
'border-radius': '50%',
|
||||||
'box-shadow': '0 0 0 var(--game-token-17) var(--game-token-34)',
|
'box-shadow': '0 0 0 var(--game-token-17) var(--game-token-34)',
|
||||||
},
|
},
|
||||||
'.map-hint': {
|
'.mission-list': {
|
||||||
position: 'absolute',
|
|
||||||
bottom: 'calc(-1 * var(--game-token-17))',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
padding: 'var(--game-token-35) var(--game-token-36)',
|
|
||||||
background: 'var(--panel-solid)',
|
|
||||||
color: 'var(--dim)',
|
|
||||||
'font-size': 'var(--game-token-37)',
|
|
||||||
border: 'var(--game-token-16) solid var(--line)',
|
|
||||||
},
|
|
||||||
'#location': {
|
|
||||||
top: 'var(--game-token-38)',
|
|
||||||
left: 'var(--game-token-11)',
|
|
||||||
width: 'var(--game-token-33)',
|
|
||||||
'text-align': 'center',
|
|
||||||
'text-shadow': '0 var(--game-token-16) var(--game-token-21) var(--game-token-39)',
|
|
||||||
},
|
|
||||||
'#street': {
|
|
||||||
display: 'block',
|
|
||||||
'font-weight': '600',
|
|
||||||
'font-size': 'var(--game-token-28)',
|
|
||||||
},
|
|
||||||
'#mode-label': {
|
|
||||||
color: 'var(--teal)',
|
|
||||||
},
|
|
||||||
'#objectives': {
|
|
||||||
top: 'var(--game-token-11)',
|
|
||||||
right: 'var(--game-token-11)',
|
|
||||||
width: 'min(var(--game-token-40), 40vw)',
|
|
||||||
padding: 'var(--game-token-30) var(--game-token-28)',
|
|
||||||
background: 'linear-gradient(270deg, var(--panel) 60%, transparent)',
|
|
||||||
'border-inline-start': 'var(--game-token-21) solid var(--gold)',
|
|
||||||
},
|
|
||||||
'#objectives h2': {
|
|
||||||
margin: '0 0 var(--game-token-22)',
|
|
||||||
'font-size': 'var(--game-token-41)',
|
|
||||||
color: 'var(--gold)',
|
|
||||||
'letter-spacing': 'var(--game-token-16)',
|
|
||||||
},
|
|
||||||
'#mission-list': {
|
|
||||||
margin: '0',
|
margin: '0',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
'list-style': 'none',
|
'list-style': 'none',
|
||||||
'font-size': 'var(--game-token-41)',
|
'font-size': 'var(--game-token-41)',
|
||||||
'line-height': '1.7',
|
'line-height': '1.7',
|
||||||
},
|
},
|
||||||
'#mission-list li': {
|
'.mission-list li': {
|
||||||
color: 'var(--dim)',
|
color: 'var(--dim)',
|
||||||
},
|
},
|
||||||
'#mission-list li.active': {
|
'.mission-list li.active': {
|
||||||
color: 'var(--hud)',
|
color: 'var(--hud)',
|
||||||
'font-weight': '600',
|
'font-weight': '600',
|
||||||
},
|
},
|
||||||
'#mission-list li.active::before': {
|
'.mission-list li.active::before': {
|
||||||
content: "'◆ '",
|
content: "'◆ '",
|
||||||
color: 'var(--gold)',
|
color: 'var(--gold)',
|
||||||
},
|
},
|
||||||
'#mission-list li.done': {
|
'.mission-list li.done': {
|
||||||
'text-decoration': 'line-through',
|
'text-decoration': 'line-through',
|
||||||
opacity: '0.55',
|
opacity: '0.55',
|
||||||
},
|
},
|
||||||
'#mission-list li.done::before': {
|
'.mission-list li.done::before': {
|
||||||
content: "'✓ '",
|
content: "'✓ '",
|
||||||
color: 'var(--teal)',
|
color: 'var(--teal)',
|
||||||
},
|
},
|
||||||
'#mission-list li.hidden': {
|
'.mission-list li.hidden': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
'#waypoint': {
|
'#waypoint': {
|
||||||
@@ -522,71 +614,7 @@ export const gameTheme = {
|
|||||||
color: 'var(--teal)',
|
color: 'var(--teal)',
|
||||||
'font-size': 'var(--game-token-28)',
|
'font-size': 'var(--game-token-28)',
|
||||||
},
|
},
|
||||||
'#status': {
|
'.ghost': {
|
||||||
bottom: 'var(--game-token-11)',
|
|
||||||
right: 'var(--game-token-11)',
|
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--game-token-36)',
|
|
||||||
},
|
|
||||||
'.stat': {
|
|
||||||
'min-width': 'var(--game-token-56)',
|
|
||||||
padding: 'var(--game-token-22) var(--game-token-30)',
|
|
||||||
background: 'var(--panel)',
|
|
||||||
'border-bottom': 'var(--game-token-35) solid var(--teal)',
|
|
||||||
display: 'grid',
|
|
||||||
'line-height': '1.3',
|
|
||||||
},
|
|
||||||
'.stat small': {
|
|
||||||
color: 'var(--dim)',
|
|
||||||
'font-size': 'var(--game-token-37)',
|
|
||||||
},
|
|
||||||
'.stat strong': {
|
|
||||||
'font-size': 'var(--game-token-55)',
|
|
||||||
'font-weight': '800',
|
|
||||||
},
|
|
||||||
'.stat span': {
|
|
||||||
'font-size': 'var(--game-token-37)',
|
|
||||||
color: 'var(--dim)',
|
|
||||||
},
|
|
||||||
'.stat[hidden]': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'#rank': {
|
|
||||||
bottom: 'var(--game-token-11)',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 'min(var(--game-token-57), 50vw)',
|
|
||||||
padding: 'var(--game-token-17) var(--game-token-36)',
|
|
||||||
'text-align': 'center',
|
|
||||||
'font-size': 'var(--game-token-30)',
|
|
||||||
background: 'var(--panel)',
|
|
||||||
},
|
|
||||||
'#rank b': {
|
|
||||||
color: 'var(--gold)',
|
|
||||||
'margin-inline-end': 'var(--game-token-45)',
|
|
||||||
},
|
|
||||||
'#rank span': {
|
|
||||||
color: 'var(--dim)',
|
|
||||||
},
|
|
||||||
'#rank .bar': {
|
|
||||||
height: 'var(--game-token-17)',
|
|
||||||
'margin-top': 'var(--game-token-17)',
|
|
||||||
background: 'var(--game-token-58)',
|
|
||||||
},
|
|
||||||
'#rank .bar i': {
|
|
||||||
display: 'block',
|
|
||||||
height: '100%',
|
|
||||||
width: '0',
|
|
||||||
background: 'var(--gold)',
|
|
||||||
transition: 'width 0.6s',
|
|
||||||
},
|
|
||||||
'#toolbar': {
|
|
||||||
bottom: 'var(--game-token-11)',
|
|
||||||
left: 'var(--game-token-11)',
|
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--game-token-22)',
|
|
||||||
},
|
|
||||||
'#toolbar button,\n.ghost': {
|
|
||||||
padding: 'var(--game-token-22) var(--game-token-30)',
|
padding: 'var(--game-token-22) var(--game-token-30)',
|
||||||
background: 'var(--panel)',
|
background: 'var(--panel)',
|
||||||
color: 'var(--hud)',
|
color: 'var(--hud)',
|
||||||
@@ -922,6 +950,14 @@ export const gameTheme = {
|
|||||||
color: 'var(--game-token-59)',
|
color: 'var(--game-token-59)',
|
||||||
},
|
},
|
||||||
'@media (max-width: 760px)': {
|
'@media (max-width: 760px)': {
|
||||||
|
// Phones: the objective chip takes the whole top row up to the menu button.
|
||||||
|
'#objective': {
|
||||||
|
left: 'max(var(--game-token-30), env(safe-area-inset-left))',
|
||||||
|
right:
|
||||||
|
'calc(max(var(--game-token-30), env(safe-area-inset-right)) + var(--game-token-24) + var(--game-token-36))',
|
||||||
|
transform: 'none',
|
||||||
|
'max-width': 'none',
|
||||||
|
},
|
||||||
// Phones: the menu sits at the bottom over a stronger fade; info opens as a bottom sheet.
|
// Phones: the menu sits at the bottom over a stronger fade; info opens as a bottom sheet.
|
||||||
'#landing': {
|
'#landing': {
|
||||||
background:
|
background:
|
||||||
@@ -945,57 +981,7 @@ export const gameTheme = {
|
|||||||
top: 'var(--game-token-79)',
|
top: 'var(--game-token-79)',
|
||||||
left: 'var(--game-token-30)',
|
left: 'var(--game-token-30)',
|
||||||
},
|
},
|
||||||
'#location': {
|
|
||||||
top: 'var(--game-token-80)',
|
|
||||||
left: 'var(--game-token-30)',
|
|
||||||
width: 'var(--game-token-72)',
|
|
||||||
},
|
|
||||||
'#objectives': {
|
|
||||||
top: 'var(--game-token-79)',
|
|
||||||
right: 'var(--game-token-30)',
|
|
||||||
width: '52vw',
|
|
||||||
padding: 'var(--game-token-45)',
|
|
||||||
},
|
|
||||||
'#mission-list li:not(.active)': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'#status': {
|
|
||||||
right: 'var(--game-token-30)',
|
|
||||||
bottom: 'var(--game-token-24)',
|
|
||||||
},
|
|
||||||
'.stat': {
|
|
||||||
'min-width': 'var(--game-token-81)',
|
|
||||||
padding: 'var(--game-token-17) var(--game-token-45)',
|
|
||||||
},
|
|
||||||
'.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.
|
// 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)',
|
|
||||||
},
|
|
||||||
'#compass': {
|
|
||||||
top: 'var(--game-token-89)',
|
|
||||||
},
|
|
||||||
'#toolbar': {
|
|
||||||
left: 'var(--game-token-45)',
|
|
||||||
right: 'var(--game-token-45)',
|
|
||||||
bottom: 'var(--game-token-45)',
|
|
||||||
'overflow-x': 'auto',
|
|
||||||
'flex-wrap': 'nowrap',
|
|
||||||
'scrollbar-width': 'none',
|
|
||||||
'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',
|
|
||||||
},
|
|
||||||
'#crosshair': {
|
'#crosshair': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
@@ -1007,13 +993,6 @@ export const gameTheme = {
|
|||||||
'border-inline-start': '0',
|
'border-inline-start': '0',
|
||||||
'border-top': 'var(--game-token-16) solid var(--line)',
|
'border-top': 'var(--game-token-16) solid var(--line)',
|
||||||
},
|
},
|
||||||
'#rank .bar': {
|
|
||||||
height: 'var(--game-token-21)',
|
|
||||||
'margin-top': 'var(--game-token-35)',
|
|
||||||
},
|
|
||||||
'#gps-status': {
|
|
||||||
top: '42%',
|
|
||||||
},
|
|
||||||
'#touch': {
|
'#touch': {
|
||||||
bottom: 'var(--game-token-78)',
|
bottom: 'var(--game-token-78)',
|
||||||
},
|
},
|
||||||
@@ -1023,6 +1002,10 @@ export const gameTheme = {
|
|||||||
// Only height is tested, so every phone held sideways matches whatever its width; narrow ones
|
// 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.
|
// also match the phone block above, and this block, coming later, wins.
|
||||||
'@media (max-height: 520px)': {
|
'@media (max-height: 520px)': {
|
||||||
|
// Sideways phones: lift the dialogue line above the row of touch buttons.
|
||||||
|
'#hud #subtitle': {
|
||||||
|
top: '34%',
|
||||||
|
},
|
||||||
'#minimap-button,\n #minimap': {
|
'#minimap-button,\n #minimap': {
|
||||||
width: 'var(--game-token-72)',
|
width: 'var(--game-token-72)',
|
||||||
height: 'var(--game-token-72)',
|
height: 'var(--game-token-72)',
|
||||||
@@ -1031,46 +1014,6 @@ export const gameTheme = {
|
|||||||
top: 'var(--game-token-30)',
|
top: 'var(--game-token-30)',
|
||||||
left: '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': {
|
'#touch': {
|
||||||
bottom: 'var(--game-token-24)',
|
bottom: 'var(--game-token-24)',
|
||||||
},
|
},
|
||||||
@@ -1122,20 +1065,6 @@ export const gameTheme = {
|
|||||||
color: 'var(--game-token-59)',
|
color: 'var(--game-token-59)',
|
||||||
'font-weight': '800',
|
'font-weight': '800',
|
||||||
},
|
},
|
||||||
'#gps-status': {
|
|
||||||
display: 'block',
|
|
||||||
'margin-top': 'var(--game-token-35)',
|
|
||||||
color: 'var(--gold)',
|
|
||||||
'font-size': 'var(--game-token-30)',
|
|
||||||
},
|
|
||||||
'#gps-status[hidden]': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'#gps-toggle.on': {
|
|
||||||
background: 'var(--teal)',
|
|
||||||
color: 'var(--game-token-59)',
|
|
||||||
'border-color': 'var(--teal)',
|
|
||||||
},
|
|
||||||
'#real-intro': {
|
'#real-intro': {
|
||||||
width: 'min(var(--game-token-85), 92vw)',
|
width: 'min(var(--game-token-85), 92vw)',
|
||||||
padding: 'var(--game-token-55)',
|
padding: 'var(--game-token-55)',
|
||||||
@@ -1183,14 +1112,6 @@ export const gameTheme = {
|
|||||||
'body.ar #crosshair': {
|
'body.ar #crosshair': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
'#ar-toggle[hidden],\n#stable-toggle[hidden]': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'#ar-toggle.on,\n#stable-toggle.on': {
|
|
||||||
background: 'var(--teal)',
|
|
||||||
color: 'var(--game-token-59)',
|
|
||||||
'border-color': 'var(--teal)',
|
|
||||||
},
|
|
||||||
'body.real #crosshair,\nbody.real #stick': {
|
'body.real #crosshair,\nbody.real #stick': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ describe('game theme', () => {
|
|||||||
expect(allValues(gameTheme).filter((value) => String(value).includes('-var('))).toEqual([]);
|
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', () => {
|
it('keeps the floating menu and objective clear of the phone notch', () => {
|
||||||
expect(gameTheme['@media (max-width: 760px)']['#rank']).toMatchObject({ bottom: 'auto' });
|
expect(gameTheme['#game-menu'].top).toContain('safe-area-inset-top');
|
||||||
|
expect(gameTheme['#objective'].top).toContain('safe-area-inset-top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every floating menu item a 44px tap target', () => {
|
||||||
|
expect(gameTheme['.menu-item']['min-height']).toBe('var(--game-token-32)');
|
||||||
|
expect(gameTheme[':root']['--game-token-32']).toBe('44px');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the landscape phone layout as a top-level media block', () => {
|
it('keeps the landscape phone layout as a top-level media block', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user