diff --git a/README.md b/README.md index 4622576..ca07c9b 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,18 @@ npm run format:check | خرید از دستگاه | E | دکمهٔ E | | کشیدن کارت | C | دکمهٔ E | | ترمز دستی | Space | — | -| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه | +| نقشهٔ شهر | M یا «نقشه» در منو | «نقشه» در منو | کنترل‌ها با صفحه‌کلید فارسی هم کار می‌کنند. دوربین در بازی عادی سوم‌شخص است و در حالت دنیای واقعی اول‌شخص. روی گوشی، اعلان‌ها (گفت‌وگوها، دکمهٔ خرید، پیام مأموریت، امتیاز و وضعیت GPS) وسط صفحه نمایش داده می‌شوند. خود دکمهٔ خرید هم قابل لمس است. +### رابط کاربری کم‌متن + +روی صفحه فقط این‌ها همیشه دیده می‌شوند: دکمهٔ گرد منو (گوشهٔ بالا)، نشانگر هدف بعدی (بالای صفحه، با فلشی که به سمت هدف می‌چرخد و فاصله؛ لمسش فهرست مأموریت‌ها را باز می‌کند) و کنترل‌های لمسی. سرعت فقط هنگام رانندگی دیده می‌شود. + +بقیه در منو هستند: نقشه، مأموریت‌ها، وضعیت (بودجه، ساعت، درجه و امتیاز)، محصولات (فقط داخل کارخانه)، دنیای واقعی، دوربین و حالت پایدار (فقط در حالت دنیای واقعی)، صدا، گرافیک، نقشهٔ کوچک (به‌طور پیش‌فرض خاموش)، تمام‌صفحه، راهنما، شروع دوباره و بازگشت به منوی اصلی. منو با لمس دوباره، لمس بیرون آن یا Escape بسته می‌شود. + ## نقشه و مسیریابی با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز می‌شود. فهرست دستگاه‌ها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده می‌شود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابان‌ها پیدا می‌کند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابان‌های سواره‌رو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطب‌نمای بالای صفحه نمایش داده می‌شود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است. diff --git a/src/pages/game/startScreen/StartIcon.jsx b/src/components/shared/Icon.jsx similarity index 54% rename from src/pages/game/startScreen/StartIcon.jsx rename to src/components/shared/Icon.jsx index 1be993e..d492b98 100644 --- a/src/pages/game/startScreen/StartIcon.jsx +++ b/src/components/shared/Icon.jsx @@ -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 ( { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + return ( + +
+

+ {title} +

+ + + {open && children} + + ); +} diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js index afba8f4..6199cbe 100644 --- a/src/game/runtime/navigationHud.js +++ b/src/game/runtime/navigationHud.js @@ -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,27 +245,19 @@ 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', - }, - ] - : [], - ); - drawMinimap(context.minimap, context.mapAssets, { - size: 220, - x: pos.x, - z: pos.z, - heading: context.mode === 'drive' ? context.car.heading : context.player.heading, - camForward: forward, - radius: context.mode === 'drive' ? 650 : 200, - markers: context.markers(), - route: context.route?.points, - }); + 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, + z: pos.z, + heading: context.mode === 'drive' ? context.car.heading : context.player.heading, + camForward: forward, + radius: context.mode === 'drive' ? 650 : 200, + markers: context.markers(), + route: context.route?.points, + }); // Objective marker above the machine, clamped to the screen edge when off-screen. context.ui.patch('waypoint', { hidden: !objective, @@ -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(); } diff --git a/src/game/runtime/renderLoop.js b/src/game/runtime/renderLoop.js index b4cfae5..8c02dcd 100644 --- a/src/game/runtime/renderLoop.js +++ b/src/game/runtime/renderLoop.js @@ -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(); diff --git a/src/game/runtime/runtimeBindings.js b/src/game/runtime/runtimeBindings.js index 1612156..6a2de4c 100644 --- a/src/game/runtime/runtimeBindings.js +++ b/src/game/runtime/runtimeBindings.js @@ -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, diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index d3167d4..4728c9d 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.js @@ -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'); } /** diff --git a/src/game/runtime/startModes.js b/src/game/runtime/startModes.js index b0d89f7..8dc90ce 100644 --- a/src/game/runtime/startModes.js +++ b/src/game/runtime/startModes.js @@ -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. diff --git a/src/i18n/fa.js b/src/i18n/fa.js index 72183bb..1a3d009 100644 --- a/src/i18n/fa.js +++ b/src/i18n/fa.js @@ -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} و پرسیدن قیمت`, diff --git a/src/pages/game/GameHud.jsx b/src/pages/game/GameHud.jsx index a15a04f..a560e3c 100644 --- a/src/pages/game/GameHud.jsx +++ b/src/pages/game/GameHud.jsx @@ -1,77 +1,73 @@ -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'; -/** @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. +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 */ +/** + * 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 ( - <> -