refactor: centralize brand, company info, assets and Persian strings

Phase 1 of turning the game into the company landing page.

- Add src/config/company.js with name, tagline, contact, about and the
  product line, plus a brand palette built around #00a693 (contrast
  checked); placeholders are marked TODO(company).
- Add src/config/assets.js listing every loaded file with its expected
  format; placeholders are marked TODO(asset).
- Move all Persian interface text, dialogue, missions and scenery text
  into src/i18n/fa.js; components and game code read from it.
- Expose the palette as CSS variables (--color-primary and shades) and
  Tailwind colours, and use it for 3D materials and map drawings.
- Fix the arrival line that still called the manager's car a white
  pickup.
- Add tests that block hard-coded Persian text and low-contrast colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 08:13:00 +03:30
co-authored by Claude Opus 5.5
parent e02347142b
commit 8dbaf3d97e
54 changed files with 1184 additions and 486 deletions
+11 -13
View File
@@ -1,4 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
import { robotFace } from '@/config/assets.js';
/** @typedef {{nodes:object,actions:object}} BriefingPanelProps */
/** Presents the BriefingPanel view using plain models and semantic native elements.
* @param {BriefingPanelProps} props - Interface snapshots and callbacks.
@@ -14,14 +16,14 @@ export function BriefingPanel({ nodes, actions }) {
<div className="brief-panel">
<img
className="brief-robot"
src="./robot/front.webp"
alt="روبات راهنمای روبات مارکت"
src={robotFace('front')}
alt={strings.briefing.robotAlt}
width="136"
height="223"
/>
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
<h1>{'عملیات مشهد'}</h1>
<p className="sub">{'روبات مارکت · شکار دستگاه‌های وندینگ'}</p>
<small className="tag">{strings.briefing.tag}</small>
<h1>{strings.briefing.title}</h1>
<p className="sub">{strings.briefing.subtitle}</p>
<GameNode
as="pre"
id="brief-text"
@@ -35,7 +37,7 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['start-mission']}
onAction={() => actions.emit('start-mission')}
>
{'شروع عملیات'}
{strings.briefing.start}
</GameNode>
<div className="brief-actions">
<GameNode
@@ -46,7 +48,7 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['real-mode']}
onAction={() => actions.emit('real-mode')}
>
{'📍 بازی در دنیای واقعی (GPS)'}
{strings.briefing.realWorld}
</GameNode>
<GameNode
as="button"
@@ -57,14 +59,10 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['install']}
onAction={() => actions.emit('install')}
>
{'⬇ نصب بازی روی گوشی'}
{strings.briefing.install}
</GameNode>
</div>
<p className="keys">
{
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
}
</p>
<p className="keys">{strings.briefing.keys}</p>
</div>
</GameNode>
</>
+4 -3
View File
@@ -1,5 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
/** Presents the EndDialog view using plain models and semantic native elements.
* @param {EndDialogProps} props - Interface snapshots and callbacks.
@@ -10,7 +11,7 @@ export function EndDialog({ nodes, actions }) {
<>
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
{'پایان عملیات'}
{strings.end.title}
</GameNode>
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
@@ -29,7 +30,7 @@ export function EndDialog({ nodes, actions }) {
model={nodes['again']}
onAction={() => actions.emit('again')}
>
{'عملیات دوباره'}
{strings.end.again}
</GameNode>
<GameNode
as="button"
@@ -39,7 +40,7 @@ export function EndDialog({ nodes, actions }) {
model={nodes['continue']}
onAction={() => actions.emit('continue')}
>
{'ادامهٔ گشت در شهر'}
{strings.end.keepExploring}
</GameNode>
</div>
</GameNode>
+5 -4
View File
@@ -6,6 +6,7 @@ import { GameToolbar } from './GameToolbar.jsx';
import { TouchControls } from './TouchControls.jsx';
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.
@@ -20,12 +21,12 @@ export function GameHud({ nodes, actions }) {
as="button"
id="minimap-button"
type="button"
aria-label="باز کردن نقشهٔ شهر (M)"
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">{'نقشه · M'}</span>
<span className="map-hint">{strings.hud.mapHint}</span>
</GameNode>
<LocationStatus nodes={nodes} actions={actions} />
<MissionList nodes={nodes} actions={actions} />
@@ -39,7 +40,7 @@ export function GameHud({ nodes, actions }) {
id="prompt"
hidden
type="button"
aria-label="تعامل با بازی"
aria-label={strings.hud.interact}
model={nodes['prompt']}
onAction={() => actions.emit('prompt')}
></GameNode>
@@ -68,7 +69,7 @@ export function GameHud({ nodes, actions }) {
<TouchControls nodes={nodes} actions={actions} />
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
<GameNode as="small" id="attribution" model={nodes['attribution']}>
{'نقشه: © مشارکت‌کنندگان OpenStreetMap'}
{strings.app.osmCredit}
</GameNode>
</GameNode>
</>
+3 -2
View File
@@ -9,6 +9,7 @@ import { GameSurfaces } from './GameSurfaces.jsx';
import { BriefingPanel } from './BriefingPanel.jsx';
import { GameHud } from './GameHud.jsx';
import { GameDialogs } from './GameDialogs.jsx';
import { strings } from '@/i18n/fa.js';
/** Composes React views around the isolated Three.js runtime. */
export function GamePage() {
@@ -49,9 +50,9 @@ export function GamePage() {
role="alert"
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
>
<p>بازی اجرا نشد: {error}</p>
<p>{strings.app.failed(error)}</p>
<button type="button" onClick={() => location.reload()}>
تلاش دوباره
{strings.app.retry}
</button>
</div>
)}
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
/** Presents the GameSurfaces view using plain models and semantic native elements.
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
@@ -18,7 +19,7 @@ export function GameSurfaces({ nodes }) {
></GameNode>
<GameNode as="canvas" id="world" model={nodes['world']} />
<GameNode as="div" id="loading" model={nodes['loading']}>
{'در حال ساختن شهر مشهد…'}
{strings.app.buildingCity}
</GameNode>
</>
);
+8 -7
View File
@@ -1,4 +1,5 @@
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 }) {
@@ -12,7 +13,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['showroom-open']}
onAction={() => actions.emit('showroom-open')}
>
مدل‌های کارخانه · Q
{strings.toolbar.showroom}
</GameNode>
<GameNode
as="button"
@@ -21,7 +22,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['gps-toggle']}
onAction={() => actions.emit('gps-toggle')}
>
{'📍 دنیای واقعی'}
{strings.toolbar.realWorld}
</GameNode>
<GameNode
as="button"
@@ -31,7 +32,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['ar-toggle']}
onAction={() => actions.emit('ar-toggle')}
>
{'📷 دوربین'}
{strings.toolbar.camera}
</GameNode>
<GameNode
as="button"
@@ -42,7 +43,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['stable-toggle']}
onAction={() => actions.emit('stable-toggle')}
>
{'🎯 پایدار: روشن'}
{strings.toolbar.stableOn}
</GameNode>
<GameNode
as="button"
@@ -52,7 +53,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['sound']}
onAction={() => actions.emit('sound')}
>
{'صدا: خاموش'}
{strings.toolbar.soundOff}
</GameNode>
<GameNode
as="button"
@@ -61,7 +62,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['quality']}
onAction={() => actions.emit('quality')}
>
{'گرافیک'}
{strings.toolbar.graphics}
</GameNode>
<GameNode
as="button"
@@ -70,7 +71,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['restart']}
onAction={() => actions.emit('restart')}
>
{'شروع دوباره'}
{strings.toolbar.restart}
</GameNode>
</GameNode>
);
+8 -9
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
/** Presents the InstallDialog view using plain models and semantic native elements.
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
@@ -13,21 +14,19 @@ export function InstallDialog({ nodes, actions }) {
model={nodes['ios-install']}
onClose={() => actions.close('ios-install')}
>
<h2>{'نصب روی آیفون'}</h2>
<h2>{strings.install.title}</h2>
<ol>
<li>
{'در Safari دکمهٔ '}
{strings.install.shareBefore}
<b>{'Share'}</b>
{' (مربع با فلش رو به بالا) را بزنید.'}
{strings.install.shareAfter}
</li>
<li>
{'گزینهٔ '}
{strings.install.addBefore}
<b>{'Add to Home Screen'}</b>
{' را انتخاب کنید.'}
</li>
<li>
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.'}
{strings.install.addAfter}
</li>
<li>{strings.install.note}</li>
</ol>
<GameNode
as="button"
@@ -36,7 +35,7 @@ export function InstallDialog({ nodes, actions }) {
model={nodes['ios-close']}
onAction={() => actions.emit('ios-close')}
>
{'متوجه شدم'}
{strings.common.gotIt}
</GameNode>
</GameNode>
</>
+3 -2
View File
@@ -1,14 +1,15 @@
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>
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
/** Presents the MachineDetails using the current interface snapshot. */
export function MachineDetails({ nodes, actions }) {
@@ -15,7 +16,7 @@ export function MachineDetails({ nodes, actions }) {
model={nodes['route-button']}
onAction={() => actions.emit('route-button')}
>
{'مسیریابی تا اینجا'}
{strings.map.route}
</GameNode>
</GameNode>
);
+6 -7
View File
@@ -1,6 +1,7 @@
import { MachineDetails } from './MachineDetails.jsx';
import { MachineList } from './MachineList.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
/** Presents the MapDialog view using plain models and semantic native elements.
* @param {MapDialogProps} props - Interface snapshots and callbacks.
@@ -27,7 +28,7 @@ export function MapDialog({ nodes, actions }) {
model={nodes['tab-city']}
onAction={() => actions.emit('tab-city')}
>
{'مشهد'}
{strings.common.mashhad}
</GameNode>
<GameNode
as="button"
@@ -39,7 +40,7 @@ export function MapDialog({ nodes, actions }) {
model={nodes['tab-iran']}
onAction={() => actions.emit('tab-iran')}
>
{'ایران'}
{strings.common.iran}
</GameNode>
</div>
<GameNode
@@ -47,7 +48,7 @@ export function MapDialog({ nodes, actions }) {
type="button"
className="close"
id="map-close"
aria-label="بستن"
aria-label={strings.map.close}
model={nodes['map-close']}
onAction={() => actions.emit('map-close')}
>
@@ -58,12 +59,10 @@ export function MapDialog({ nodes, actions }) {
<div className="map-view">
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
<p className="map-help">
{'کشیدن: جابه‌جایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
</p>
<p className="map-help">{strings.map.help}</p>
</div>
<aside className="map-side">
<h3>{'دستگاه‌های روبات مارکت'}</h3>
<h3>{strings.map.machines}</h3>
<MachineDetails nodes={nodes} actions={actions} />
<MachineList nodes={nodes} actions={actions} />
</aside>
+2 -1
View File
@@ -1,11 +1,12 @@
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>{'مأموریت‌ها'}</h2>
<h2>{strings.status.missions}</h2>
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
<ViewLists
id="mission-list"
+4 -3
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
/** Presents the PaymentPanel view using plain models and semantic native elements.
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
@@ -8,7 +9,7 @@ export function PaymentPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
<small>{'کارت‌خوان روبات مارکت'}</small>
<small>{strings.payment.reader}</small>
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
<div className="actions">
@@ -20,7 +21,7 @@ export function PaymentPanel({ nodes, actions }) {
model={nodes['swipe']}
onAction={() => actions.emit('swipe')}
>
{'کشیدن کارت · C'}
{strings.payment.swipe}
</GameNode>
<GameNode
as="button"
@@ -30,7 +31,7 @@ export function PaymentPanel({ nodes, actions }) {
model={nodes['cancel-payment']}
onAction={() => actions.emit('cancel-payment')}
>
{'انصراف'}
{strings.common.cancel}
</GameNode>
</div>
</GameNode>
+3 -2
View File
@@ -1,14 +1,15 @@
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']}>
{'۰ XP'}
{strings.status.xpStart}
</GameNode>
<div className="bar">
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
+10 -13
View File
@@ -1,4 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
import { robotFace } from '@/config/assets.js';
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
/** Presents the RealIntroDialog view using plain models and semantic native elements.
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
@@ -13,18 +15,13 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-intro']}
onClose={() => actions.close('real-intro')}
>
<img src="./robot/serious.webp" alt="" width="72" height="68" />
<h2>{'بازی در دنیای واقعی'}</h2>
<p>
{
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
}
</p>
<img src={robotFace('serious')} alt="" width="72" height="68" />
<h2>{strings.realIntro.title}</h2>
<p>{strings.realIntro.body}</p>
<ul>
<li>{'هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.'}</li>
<li>{'بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.'}</li>
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.'}</li>
<li>{'موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.'}</li>
{strings.realIntro.safety.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<div className="actions">
<GameNode
@@ -34,7 +31,7 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-go']}
onAction={() => actions.emit('real-go')}
>
{'قبول دارم، شروع کن'}
{strings.realIntro.accept}
</GameNode>
<GameNode
as="button"
@@ -44,7 +41,7 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-cancel']}
onAction={() => actions.emit('real-cancel')}
>
{'انصراف'}
{strings.common.cancel}
</GameNode>
</div>
</GameNode>
+3 -4
View File
@@ -1,5 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
/** Presents the ShopDialog view using plain models and semantic native elements.
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
@@ -20,7 +21,7 @@ export function ShopDialog({ nodes, actions }) {
type="button"
className="close"
id="shop-close"
aria-label="بستن"
aria-label={strings.shop.close}
model={nodes['shop-close']}
onAction={() => actions.emit('shop-close')}
>
@@ -35,9 +36,7 @@ export function ShopDialog({ nodes, actions }) {
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<p className="rate">
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.'}
</p>
<p className="rate">{strings.shop.note}</p>
</GameNode>
</>
);
+12 -15
View File
@@ -1,7 +1,9 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
import { robotFace } from '@/config/assets.js';
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
import { ShowroomImage } from './ShowroomImage.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
/** Presents the factory sample catalog and questions answered by the robot host.
@@ -22,17 +24,17 @@ export function ShowroomDialog({ nodes, actions }) {
>
<header className="mb-5 flex items-center justify-between gap-4">
<h2 id="showroom-title" className="text-xl font-bold">
نمایشگاه کارخانه · {model.name}
{strings.showroom.heading(model.name)}
</h2>
<button
type="button"
aria-label="بستن معرفی دستگاه"
aria-label={strings.showroom.close}
onClick={() => actions.close('showroom')}
>
✕
</button>
</header>
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
<nav aria-label={strings.showroom.models} className="mb-5 flex flex-wrap gap-3">
{SHOWROOM_CATALOG.map((item) => (
<button
key={item.id}
@@ -49,16 +51,14 @@ export function ShowroomDialog({ nodes, actions }) {
<ShowroomImage key={model.id} model={model} />
<section>
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
<p className="mb-3 text-gold">{strings.showroom.price(formatRial(model.priceNumeric))}</p>
<p>{model.description}</p>
<ul className="my-4 list-inside list-disc">
{model.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="text-sm opacity-75">
این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند.
</p>
<p className="text-sm opacity-75">{strings.showroom.displayOnly}</p>
</section>
</div>
<div className="mt-5 flex flex-wrap gap-3">
@@ -72,7 +72,7 @@ export function ShowroomDialog({ nodes, actions }) {
})
}
>
روبات، قیمتش چنده؟
{strings.showroom.askPrice}
</button>
<button
className="rounded border border-teal px-4 py-3"
@@ -84,19 +84,16 @@ export function ShowroomDialog({ nodes, actions }) {
})
}
>
روبات، امکاناتش چیه؟
{strings.showroom.askFeatures}
</button>
</div>
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
<img
className="h-16 w-16 shrink-0 object-contain"
src="./robot/happy.webp"
alt="روبات راهنمای نمایشگاه"
src={robotFace('happy')}
alt={strings.showroom.robotAlt}
/>
<p role="status">
{nodes['showroom-response']?.text ??
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
</p>
<p role="status">{nodes['showroom-response']?.text ?? strings.showroom.greeting}</p>
</div>
</GameNode>
);
+5 -8
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { strings } from '@/i18n/fa.js';
/** @typedef {{model:object}} ShowroomImageProps */
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
@@ -10,7 +11,7 @@ export function ShowroomImage({ model }) {
const [failed, setFailed] = useState({});
const path = model[variant];
const unavailable = failed[path];
const src = new URL(`.${path}`, document.baseURI).href;
const src = new URL(path, document.baseURI).href;
return (
<div className="grid gap-3">
{unavailable ? (
@@ -18,13 +19,13 @@ export function ShowroomImage({ model }) {
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
role="status"
>
نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
{strings.showroom.noImage}
</div>
) : (
<img
className="mx-auto max-h-64 max-w-full object-contain"
src={src}
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
alt={`${model.name} · ${strings.showroom.views[variant]}`}
onError={() =>
setFailed((previous) => ({
...previous,
@@ -34,11 +35,7 @@ export function ShowroomImage({ model }) {
/>
)}
<div className="flex flex-wrap gap-2">
{[
['image', 'نمای اصلی'],
['imageAlt', 'رنگ سفید'],
['gif', 'نمای متحرک'],
].map(([key, label]) => (
{Object.entries(strings.showroom.views).map(([key, label]) => (
<button
className="rounded border border-white/20 px-3 py-2"
key={key}
+8 -7
View File
@@ -1,26 +1,27 @@
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>{'بودجه'}</small>
<small>{strings.status.budget}</small>
<GameNode as="strong" id="balance" model={nodes['balance']}>
{'۵۰۰'}
{strings.status.budgetStart}
</GameNode>
<span>{'هتومان'}</span>
<span>{strings.units.hetomanName}</span>
</div>
<div className="stat">
<small>{'ساعت'}</small>
<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>{'سرعت'}</small>
<small>{strings.status.speed}</small>
<GameNode as="strong" id="speed" model={nodes['speed']}>
{'۰'}
{strings.status.speedStart}
</GameNode>
<span>{'km/h'}</span>
</GameNode>
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
/** Presents the TouchControls using the current interface snapshot. */
export function TouchControls({ nodes, actions }) {
@@ -36,7 +37,7 @@ export function TouchControls({ nodes, actions }) {
model={nodes['touch-shift']}
onAction={() => actions.emit('touch-shift')}
>
{'دویدن'}
{strings.status.run}
</GameNode>
</div>
</GameNode>
+3 -2
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} UpdateNoticeProps */
/** Presents the UpdateNotice view using plain models and semantic native elements.
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
@@ -8,7 +9,7 @@ export function UpdateNotice({ nodes, actions }) {
return (
<>
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
{'نسخهٔ جدید بازی آماده است '}
{strings.app.updateReady}
<GameNode
as="button"
type="button"
@@ -16,7 +17,7 @@ export function UpdateNotice({ nodes, actions }) {
model={nodes['update-reload']}
onAction={() => actions.emit('update-reload')}
>
{'به‌روزرسانی'}
{strings.app.update}
</GameNode>
</GameNode>
</>
+4 -3
View File
@@ -1,4 +1,5 @@
import { formatHetoman } from '@/game/economy/gameState.js';
import { strings } from '@/i18n/fa.js';
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
@@ -20,8 +21,8 @@ export function ViewLists({ id, model = {}, onAction }) {
<span className="price">{formatHetoman(item.price)}</span>
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
<small>
{item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
{item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
{item.type === 'drink' ? strings.lists.drink : strings.lists.snack}
{item.blocked === 'funds' ? strings.lists.noFunds : ''}
</small>
</button>
));
@@ -52,7 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) {
{item.name}
{item.visited ? ' ✓' : ''}
<span className="meta">
{item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
{item.locked ? item.note : strings.lists.machineState(item.category, item.open)}
</span>
</span>
<span className="dist">{item.distance}</span>