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:
2026-10-31 08:50:29 +03:30
co-authored by Claude Opus 5.5
parent d9ae8a45da
commit 5a4dffc9a3
26 changed files with 773 additions and 621 deletions
+7 -1
View File
@@ -61,12 +61,18 @@ npm run format:check
| خرید از دستگاه | E | دکمهٔ E |
| کشیدن کارت | C | دکمهٔ E |
| ترمز دستی | Space | — |
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
| نقشهٔ شهر | M یا «نقشه» در منو | «نقشه» در منو |
کنترل‌ها با صفحه‌کلید فارسی هم کار می‌کنند. دوربین در بازی عادی سوم‌شخص است و در حالت دنیای واقعی اول‌شخص.
روی گوشی، اعلان‌ها (گفت‌وگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت 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 = {
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',
@@ -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',
close: 'M6 6l12 12M18 6L6 18',
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.
* @param {StartIconProps} props - Icon name and classes.
* Draws one icon; decorative, so hidden from screen readers.
* @param {IconProps} props - Icon name and classes.
* @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 (
<svg
className={className}
+47
View File
@@ -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>
);
}
+24 -82
View File
@@ -1,6 +1,5 @@
import * as THREE from 'three';
import data from '../../data/mellat.json';
import { routeBetween, nearestSegment } from '../world/routing.js';
import { routeBetween } from '../world/routing.js';
import { FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock, fa } from '../economy/gameState.js';
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
@@ -116,49 +115,26 @@ export function updateRoute(context, dt) {
context.rebuildRouteMesh();
}
/**
* Runs the draw compass operation for this subsystem.
* @param {*} context - context.
* @param {*} headingDeg - heading Deg.
* @param {*} markers - markers.
* @returns {*} Operation result, when applicable.
* Updates the objective chip: what to do next, how far it is, and an arrow that points at it
* relative to where the camera looks (0° = straight ahead).
* @param {object} context - Game context.
* @param {{objective: object | null, pos: {x: number, z: number}, headingDeg: number}} view -
* Objective station, player position and camera bearing.
*/
export function drawCompass(context, headingDeg, markers) {
const w = 560,
h = 44;
context.compass.clearRect(0, 0, w, h);
context.compass.fillStyle = 'rgba(10,16,14,.45)';
context.compass.fillRect(0, 10, w, 24);
context.compass.textAlign = 'center';
context.compass.textBaseline = 'middle';
const labels = strings.hud.compass;
for (let d = -100; d <= 100; d += 5) {
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
if (x < 10 || x > w - 10) continue;
context.compass.fillStyle = 'rgba(236,242,238,.8)';
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);
function updateObjectiveChip(context, { objective, pos, headingDeg }) {
const mission = activeMission(context.state);
// A route the player picked on the map shows the place; otherwise show the mission itself.
const ownRoute = context.waypoint && context.waypoint !== mission?.target;
const title = ownRoute ? objective?.def.name : (mission?.title ?? objective?.def.name);
context.ui.patch('objective', { hidden: !title });
if (!title) return;
context.ui.patch('objective-title', { text: title });
context.ui.patch('objective-distance', {
text: objective ? formatDistance(Math.hypot(objective.x - pos.x, objective.z - pos.z)) : '',
});
if (!objective) return;
const turn = bearing(objective.x - pos.x, objective.z - pos.z) - headingDeg;
context.ui.style('objective-arrow', { transform: `rotate(${Math.round(turn)}deg)` });
}
/**
* Runs the refresh hud operation for this subsystem.
@@ -191,22 +167,10 @@ export function refreshHUD(context) {
})),
});
context.ui.publishGame(context.state);
context.ui.patch('mode-label', {
text: modeLabel(context),
});
context.ui.patch('speed-stat', {
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.
* @param {*} context - context.
@@ -249,10 +213,9 @@ export function updatePrompt(context) {
/**
* Updates hud from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateHUD(context, dt) {
export function updateHUD(context) {
context.ui.patch('showroom-open', {
hidden:
context.real.on ||
@@ -282,17 +245,9 @@ export function updateHUD(context, dt) {
};
const headingDeg = bearing(forward.x, forward.z);
const objective = context.objectiveStation();
context.drawCompass(
headingDeg,
objective
? [
{
bearing: bearing(objective.x - pos.x, objective.z - pos.z),
color: '#f2c94c',
},
]
: [],
);
updateObjectiveChip(context, { objective, pos, headingDeg });
// The minimap is optional (floating menu); skip drawing it while it is hidden.
if (context.showMinimap)
drawMinimap(context.minimap, context.mapAssets, {
size: 220,
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);
}
}
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();
}
+1 -1
View File
@@ -169,7 +169,7 @@ export function frame(context) {
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
if (playing) {
context.hudTimer -= dt;
context.updateHUD(dt);
context.updateHUD();
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
context.hudTimer = 0.5;
context.renderMap();
+2 -3
View File
@@ -1,5 +1,5 @@
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 { say, updateSubtitles, tone } from './audioAndDialogue.js';
import {
@@ -18,7 +18,6 @@ import {
import {
rebuildRouteMesh,
updateRoute,
drawCompass,
refreshHUD,
updatePrompt,
updateHUD,
@@ -98,7 +97,6 @@ const RUNTIME_METHODS = {
checkEnd,
rebuildRouteMesh,
updateRoute,
drawCompass,
refreshHUD,
updatePrompt,
sizeCanvas,
@@ -108,6 +106,7 @@ const RUNTIME_METHODS = {
openMap,
startGame,
restartGame,
backToMenu,
offerOffline,
switchToOffline,
setRealMode,
+19 -1
View File
@@ -736,6 +736,25 @@ export async function registerPreferences(context) {
context.ui.on('again', context.reset);
context.ui.on('continue', () => context.ui.close('end'));
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 ----------
context.objectiveStation = () => {
@@ -779,7 +798,6 @@ export async function initializeRouteMaterial(context) {
z: 0,
};
context.routeMode = null;
context.compass = context.$('#compass').getContext('2d');
context.minimap = context.$('#minimap').getContext('2d');
}
/**
+13
View File
@@ -121,6 +121,19 @@ export function restartGame(context) {
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
* real-world session, the first time tracking reports an error.
+45 -11
View File
@@ -29,6 +29,7 @@ export const strings = {
/** @type {(n: string) => string} */
rial: (n) => `${n} ریال`,
hetomanName: 'هتومان',
speed: 'km/h',
},
app: {
preparing: 'در حال آماده‌سازی بازی…',
@@ -105,29 +106,62 @@ export const strings = {
clockStart: '۰۸:۳۰',
speed: 'سرعت',
speedStart: '۰',
rank: 'درجه',
xp: 'امتیاز',
rankStart: 'تازه‌وارد',
xpStart: '۰ XP',
missions: 'مأموریت‌ها',
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: {
openMap: 'باز کردن نقشهٔ شهر (M)',
mapHint: 'نقشه · M',
interact: 'تعامل با بازی',
compass: { 0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب' },
// Single-letter north marker on the minimap.
minimapNorth: 'ش',
/** @type {(name: string) => string} */
factory: (name) => `کارخانهٔ ${name}`,
/** @type {(street: string) => string} */
street: (street) => `مشهد · ${street}`,
outskirts: 'حومهٔ مشهد',
metroPrefix: 'ایستگاه مترو ',
},
travel: {
/** @type {(vehicle: string) => string} */
inVehicle: (vehicle) => `در ${vehicle} · F برای پیاده شدن`,
},
prompts: {
/** @type {(model: string) => string} */
showroom: (model) => `Q · معرفی ${model} و پرسیدن قیمت`,
+39 -43
View File
@@ -1,39 +1,45 @@
import { LocationStatus } from './LocationStatus.jsx';
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 { useState } from 'react';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
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 */
/** Presents the GameHud view using plain models and semantic native elements.
* @param {GameHudProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
/**
* Minimal in-game interface: the floating menu, the current objective and the controls are the
* 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 }) {
const [sheet, setSheet] = useState(null);
const emit = (name, value) => actions.emit(name, value);
return (
<>
<GameNode as="div" id="hud" hidden model={nodes['hud']}>
<GameNode as="canvas" id="compass" width="560" height="44" model={nodes['compass']} />
<GameNode as="div" id="hud" hidden model={nodes.hud}>
<FloatingMenu nodes={nodes} actions={actions} onOpenSheet={setSheet} />
<ObjectiveChip nodes={nodes} onOpen={() => setSheet('missions')} />
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']} />
<GameNode
as="button"
id="minimap-button"
type="button"
hidden
aria-label={strings.hud.openMap}
model={nodes['minimap-button']}
onAction={() => actions.emit('minimap-button')}
>
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
<span className="map-hint">{strings.hud.mapHint}</span>
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes.minimap} />
</GameNode>
<LocationStatus nodes={nodes} actions={actions} />
<MissionList nodes={nodes} actions={actions} />
<GameNode as="div" id="waypoint" hidden model={nodes['waypoint']}>
<GameNode as="div" id="waypoint" hidden model={nodes.waypoint}>
<i></i>
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']}></GameNode>
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']}></GameNode>
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']} />
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']} />
</GameNode>
<GameNode
as="button"
@@ -41,37 +47,27 @@ export function GameHud({ nodes, actions }) {
hidden
type="button"
aria-label={strings.hud.interact}
model={nodes['prompt']}
model={nodes.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="b" id="subtitle-speaker" model={nodes['subtitle-speaker']}></GameNode>
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']}></GameNode>
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']} />
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']} />
</GameNode>
<GameNode as="div" id="awards" aria-live="polite" model={nodes['awards']}>
<ViewLists
id="awards"
model={nodes['awards']}
onAction={(name, value) => actions.emit(name, value)}
/>
<GameNode as="div" id="awards" aria-live="polite" model={nodes.awards}>
<ViewLists id="awards" model={nodes.awards} onAction={emit} />
</GameNode>
<GameNode as="div" id="banner" hidden model={nodes['banner']}>
<ViewLists
id="banner"
model={nodes['banner']}
onAction={(name, value) => actions.emit(name, value)}
/>
<GameNode as="div" id="banner" hidden model={nodes.banner}>
<ViewLists id="banner" model={nodes.banner} onAction={emit} />
</GameNode>
<StatusBar nodes={nodes} actions={actions} />
<RankProgress nodes={nodes} actions={actions} />
<GameToolbar nodes={nodes} actions={actions} />
<SpeedChip nodes={nodes} />
<TouchControls nodes={nodes} actions={actions} />
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
<GameNode as="small" id="attribution" model={nodes['attribution']}>
<GameNode as="div" id="crosshair" model={nodes.crosshair} />
<GameNode as="small" id="attribution" model={nodes.attribution}>
{strings.app.osmCredit}
</GameNode>
<HudSheets sheet={sheet} nodes={nodes} actions={actions} onClose={() => setSheet(null)} />
</GameNode>
</>
);
}
-78
View File
@@ -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>
);
}
-17
View File
@@ -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>
);
}
-19
View File
@@ -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>
);
}
-19
View File
@@ -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>
);
}
-30
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+60
View File
@@ -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();
});
});
+105
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+8 -32
View File
@@ -1,49 +1,25 @@
import { useEffect, useRef } from 'react';
import { Sheet } from '@/components/shared/Sheet.jsx';
import { strings } from '@/i18n/fa.js';
import { StartIcon } from './StartIcon.jsx';
import { SECTION_BODIES } from './InfoSectionContent.jsx';
/** @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.
* @returns {import('react').ReactElement} Native dialog.
* @returns {import('react').ReactElement} Sheet.
*/
export function InfoSheet({ section, onClose }) {
const dialog = useRef(null);
const Body = SECTION_BODIES[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 (
<dialog
ref={dialog}
<Sheet
id="info-sheet"
aria-labelledby="info-sheet-title"
open={Boolean(section)}
title={title ?? ''}
closeLabel={strings.landing.close}
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 />}
</dialog>
</Sheet>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
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.
const OPTIONS = [
@@ -35,7 +35,7 @@ export function StartOptions({ actions }) {
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="font-bold">{strings.landing.modes[mode].label}</span>
<span className="text-xs opacity-80">{strings.landing.modes[mode].hint}</span>
+3 -3
View File
@@ -3,7 +3,7 @@ import { GameNode } from '@/components/shared/GameNode.jsx';
import { COMPANY } from '@/config/company.js';
import { ASSETS } from '@/config/assets.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 { 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">
{SECTIONS.map(([key, icon]) => (
<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]}
</button>
))}
@@ -62,7 +62,7 @@ export function StartScreen({ nodes, actions }) {
model={nodes.install}
onAction={() => actions.emit('install')}
>
<StartIcon name="download" className="h-4 w-4" />
<Icon name="download" className="h-4 w-4" />
{text.install}
</GameNode>
</nav>
+157 -236
View File
@@ -264,13 +264,145 @@ export const gameTheme = {
position: 'fixed',
'z-index': '10',
},
'#compass': {
top: 'var(--game-token-28)',
// Floating menu: one round button in the top corner (right in RTL) that reveals every
// 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%',
transform: 'translateX(-50%)',
width: 'min(var(--game-token-31), 80vw)',
height: 'var(--game-token-32)',
'pointer-events': 'none',
display: 'flex',
'align-items': 'center',
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': {
top: 'var(--game-token-11)',
@@ -288,73 +420,33 @@ export const gameTheme = {
'border-radius': '50%',
'box-shadow': '0 0 0 var(--game-token-17) var(--game-token-34)',
},
'.map-hint': {
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': {
'.mission-list': {
margin: '0',
padding: '0',
'list-style': 'none',
'font-size': 'var(--game-token-41)',
'line-height': '1.7',
},
'#mission-list li': {
'.mission-list li': {
color: 'var(--dim)',
},
'#mission-list li.active': {
'.mission-list li.active': {
color: 'var(--hud)',
'font-weight': '600',
},
'#mission-list li.active::before': {
'.mission-list li.active::before': {
content: "'◆ '",
color: 'var(--gold)',
},
'#mission-list li.done': {
'.mission-list li.done': {
'text-decoration': 'line-through',
opacity: '0.55',
},
'#mission-list li.done::before': {
'.mission-list li.done::before': {
content: "'✓ '",
color: 'var(--teal)',
},
'#mission-list li.hidden': {
'.mission-list li.hidden': {
display: 'none',
},
'#waypoint': {
@@ -522,71 +614,7 @@ export const gameTheme = {
color: 'var(--teal)',
'font-size': 'var(--game-token-28)',
},
'#status': {
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': {
'.ghost': {
padding: 'var(--game-token-22) var(--game-token-30)',
background: 'var(--panel)',
color: 'var(--hud)',
@@ -922,6 +950,14 @@ export const gameTheme = {
color: 'var(--game-token-59)',
},
'@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.
'#landing': {
background:
@@ -945,57 +981,7 @@ export const gameTheme = {
top: 'var(--game-token-79)',
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.
'#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': {
display: 'none',
},
@@ -1007,13 +993,6 @@ export const gameTheme = {
'border-inline-start': '0',
'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': {
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
// also match the phone block above, and this block, coming later, wins.
'@media (max-height: 520px)': {
// Sideways phones: lift the dialogue line above the row of touch buttons.
'#hud #subtitle': {
top: '34%',
},
'#minimap-button,\n #minimap': {
width: 'var(--game-token-72)',
height: 'var(--game-token-72)',
@@ -1031,46 +1014,6 @@ export const gameTheme = {
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)',
},
@@ -1122,20 +1065,6 @@ export const gameTheme = {
color: 'var(--game-token-59)',
'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': {
width: 'min(var(--game-token-85), 92vw)',
padding: 'var(--game-token-55)',
@@ -1183,14 +1112,6 @@ export const gameTheme = {
'body.ar #crosshair': {
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': {
display: 'none',
},
+8 -2
View File
@@ -19,8 +19,14 @@ describe('game theme', () => {
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 floating menu and objective clear of the phone notch', () => {
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', () => {