refactor: separate React views, Redux snapshots and Three.js runtime

This commit is contained in:
2026-10-28 12:26:40 +03:30
parent 6bd9b17173
commit d0da416923
77 changed files with 4941 additions and 3723 deletions
+72
View File
@@ -0,0 +1,72 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} BriefingPanelProps */
/** Presents the BriefingPanel view using plain models and semantic native elements.
* @param {BriefingPanelProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function BriefingPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="briefing" model={nodes['briefing']}>
<div className="brief-map">
<GameNode as="canvas" id="iran" model={nodes['iran']} />
</div>
<div className="brief-panel">
<img
className="brief-robot"
src="./robot/front.webp"
alt="روبات راهنمای روبات مارکت"
width="136"
height="223"
/>
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
<h1>{'عملیات مشهد'}</h1>
<p className="sub">{'روبات مارکت · شکار دستگاه‌های وندینگ'}</p>
<GameNode
as="pre"
id="brief-text"
aria-live="polite"
model={nodes['brief-text']}
></GameNode>
<GameNode
as="button"
id="start-mission"
type="button"
model={nodes['start-mission']}
onAction={() => actions.emit('start-mission')}
>
{'شروع عملیات'}
</GameNode>
<div className="brief-actions">
<GameNode
as="button"
id="real-mode"
type="button"
className="ghost"
model={nodes['real-mode']}
onAction={() => actions.emit('real-mode')}
>
{'📍 بازی در دنیای واقعی (GPS)'}
</GameNode>
<GameNode
as="button"
id="install"
type="button"
className="ghost"
hidden
model={nodes['install']}
onAction={() => actions.emit('install')}
>
{'⬇ نصب بازی روی گوشی'}
</GameNode>
</div>
<p className="keys">
{
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
}
</p>
</div>
</GameNode>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
/** Presents the EndDialog view using plain models and semantic native elements.
* @param {EndDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function EndDialog({ nodes, actions }) {
return (
<>
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
{'پایان عملیات'}
</GameNode>
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
<GameNode as="ul" id="end-stats" model={nodes['end-stats']}>
<ViewLists
id="end-stats"
model={nodes['end-stats']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<div className="actions">
<GameNode
as="button"
id="again"
type="button"
model={nodes['again']}
onAction={() => actions.emit('again')}
>
{'عملیات دوباره'}
</GameNode>
<GameNode
as="button"
id="continue"
type="button"
className="ghost"
model={nodes['continue']}
onAction={() => actions.emit('continue')}
>
{'ادامهٔ گشت در شهر'}
</GameNode>
</div>
</GameNode>
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { PaymentPanel } from './PaymentPanel.jsx';
import { MapDialog } from './MapDialog.jsx';
import { ShopDialog } from './ShopDialog.jsx';
import { RealIntroDialog } from './RealIntroDialog.jsx';
import { InstallDialog } from './InstallDialog.jsx';
import { UpdateNotice } from './UpdateNotice.jsx';
import { EndDialog } from './EndDialog.jsx';
/** Composes independently managed game dialogs. */
export function GameDialogs(props) {
return (
<>
<PaymentPanel {...props} />
<MapDialog {...props} />
<ShopDialog {...props} />
<RealIntroDialog {...props} />
<InstallDialog {...props} />
<UpdateNotice {...props} />
<EndDialog {...props} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
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 { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.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.
*/
export function GameHud({ nodes, actions }) {
return (
<>
<GameNode as="div" id="hud" hidden model={nodes['hud']}>
<GameNode as="canvas" id="compass" width="560" height="44" model={nodes['compass']} />
<GameNode
as="button"
id="minimap-button"
type="button"
aria-label="باز کردن نقشهٔ شهر (M)"
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>
</GameNode>
<LocationStatus nodes={nodes} actions={actions} />
<MissionList nodes={nodes} actions={actions} />
<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>
<GameNode
as="button"
id="prompt"
hidden
type="button"
aria-label="تعامل با بازی"
model={nodes['prompt']}
onAction={() => actions.emit('prompt')}
></GameNode>
<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>
<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>
<GameNode as="div" id="banner" hidden model={nodes['banner']}>
<ViewLists
id="banner"
model={nodes['banner']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<StatusBar nodes={nodes} actions={actions} />
<RankProgress nodes={nodes} actions={actions} />
<GameToolbar nodes={nodes} actions={actions} />
<TouchControls nodes={nodes} actions={actions} />
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
<GameNode as="small" id="attribution" model={nodes['attribution']}>
{'نقشه: © مشارکت‌کنندگان OpenStreetMap'}
</GameNode>
</GameNode>
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'react-redux';
import { useAppSelector } from '@/store/hooks.js';
import { selectNodes } from '@/store/slices/interfaceSlice.js';
import { createInterfaceBridge } from '@/game/interfaceBridge.js';
import { useGameEngine } from '@/hooks/useGameEngine.js';
import { setupPwa } from '@/game/device/pwa.js';
import { GameSurfaces } from './GameSurfaces.jsx';
import { BriefingPanel } from './BriefingPanel.jsx';
import { GameHud } from './GameHud.jsx';
import { GameDialogs } from './GameDialogs.jsx';
/** Composes React views around the isolated Three.js runtime. */
export function GamePage() {
const root = useRef(null),
store = useStore();
const bridge = useMemo(() => createInterfaceBridge(store), [store]);
const nodes = useAppSelector(selectNodes);
const { error } = useGameEngine(root, bridge);
useEffect(() => {
const dispose = setupPwa(bridge);
return () => {
dispose();
bridge.dispose();
};
}, [bridge]);
useEffect(() => {
const classes = nodes.body?.classes ?? {};
for (const [name, enabled] of Object.entries(classes))
document.body.classList.toggle(name, enabled);
return () => {
for (const name of Object.keys(classes)) document.body.classList.remove(name);
};
}, [nodes.body]);
const props = { nodes, actions: bridge };
return (
<main ref={root}>
<GameSurfaces {...props} />
<BriefingPanel {...props} />
<GameHud {...props} />
<GameDialogs {...props} />
{error && (
<div
id="engine-error"
role="alert"
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
>
<p>بازی اجرا نشد: {error}</p>
<button type="button" onClick={() => location.reload()}>
تلاش دوباره
</button>
</div>
)}
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
/** Presents the GameSurfaces view using plain models and semantic native elements.
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function GameSurfaces({ nodes }) {
return (
<>
<GameNode
as="video"
id="camera"
playsInline
muted
autoPlay
hidden
model={nodes['camera']}
></GameNode>
<GameNode as="canvas" id="world" model={nodes['world']} />
<GameNode as="div" id="loading" model={nodes['loading']}>
{'در حال ساختن شهر مشهد…'}
</GameNode>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @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="gps-toggle"
type="button"
model={nodes['gps-toggle']}
onAction={() => actions.emit('gps-toggle')}
>
{'📍 دنیای واقعی'}
</GameNode>
<GameNode
as="button"
id="ar-toggle"
type="button"
hidden
model={nodes['ar-toggle']}
onAction={() => actions.emit('ar-toggle')}
>
{'📷 دوربین'}
</GameNode>
<GameNode
as="button"
id="stable-toggle"
type="button"
className="on"
hidden
model={nodes['stable-toggle']}
onAction={() => actions.emit('stable-toggle')}
>
{'🎯 پایدار: روشن'}
</GameNode>
<GameNode
as="button"
id="sound"
type="button"
aria-pressed="false"
model={nodes['sound']}
onAction={() => actions.emit('sound')}
>
{'صدا: خاموش'}
</GameNode>
<GameNode
as="button"
id="quality"
type="button"
model={nodes['quality']}
onAction={() => actions.emit('quality')}
>
{'گرافیک'}
</GameNode>
<GameNode
as="button"
id="restart"
type="button"
model={nodes['restart']}
onAction={() => actions.emit('restart')}
>
{'شروع دوباره'}
</GameNode>
</GameNode>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
/** Presents the InstallDialog view using plain models and semantic native elements.
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function InstallDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="ios-install"
model={nodes['ios-install']}
onClose={() => actions.close('ios-install')}
>
<h2>{'نصب روی آیفون'}</h2>
<ol>
<li>
{'در Safari دکمهٔ '}
<b>{'Share'}</b>
{' (مربع با فلش رو به بالا) را بزنید.'}
</li>
<li>
{'گزینهٔ '}
<b>{'Add to Home Screen'}</b>
{' را انتخاب کنید.'}
</li>
<li>
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.'}
</li>
</ol>
<GameNode
as="button"
type="button"
id="ios-close"
model={nodes['ios-close']}
onAction={() => actions.emit('ios-close')}
>
{'متوجه شدم'}
</GameNode>
</GameNode>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @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']}>
{'مشهد'}
</GameNode>
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
{'پیاده'}
</GameNode>
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
</GameNode>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
/** Presents the MachineDetails using the current interface snapshot. */
export function MachineDetails({ nodes, actions }) {
return (
<GameNode as="div" id="machine-detail" hidden model={nodes['machine-detail']}>
<GameNode as="h4" id="detail-name" model={nodes['detail-name']}></GameNode>
<GameNode as="p" id="detail-address" model={nodes['detail-address']}></GameNode>
<GameNode as="p" id="detail-meta" model={nodes['detail-meta']}></GameNode>
<GameNode as="p" id="detail-products" model={nodes['detail-products']}></GameNode>
<GameNode
as="button"
id="route-button"
type="button"
model={nodes['route-button']}
onAction={() => actions.emit('route-button')}
>
{'مسیریابی تا اینجا'}
</GameNode>
</GameNode>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MachineListProps */
/** Presents the MachineList using the current interface snapshot. */
export function MachineList({ nodes, actions }) {
return (
<GameNode as="ul" id="machine-list" model={nodes['machine-list']}>
<ViewLists
id="machine-list"
model={nodes['machine-list']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { MachineDetails } from './MachineDetails.jsx';
import { MachineList } from './MachineList.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
/** Presents the MapDialog view using plain models and semantic native elements.
* @param {MapDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function MapDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="map-dialog"
model={nodes['map-dialog']}
onClose={() => actions.close('map-dialog')}
>
<header>
<div className="tabs" role="tablist">
<GameNode
as="button"
type="button"
role="tab"
data-tab="city"
aria-selected="true"
id="tab-city"
model={nodes['tab-city']}
onAction={() => actions.emit('tab-city')}
>
{'مشهد'}
</GameNode>
<GameNode
as="button"
type="button"
role="tab"
data-tab="iran"
aria-selected="false"
id="tab-iran"
model={nodes['tab-iran']}
onAction={() => actions.emit('tab-iran')}
>
{'ایران'}
</GameNode>
</div>
<GameNode
as="button"
type="button"
className="close"
id="map-close"
aria-label="بستن"
model={nodes['map-close']}
onAction={() => actions.emit('map-close')}
>
{'✕'}
</GameNode>
</header>
<div className="map-body">
<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>
</div>
<aside className="map-side">
<h3>{'دستگاه‌های روبات مارکت'}</h3>
<MachineDetails nodes={nodes} actions={actions} />
<MachineList nodes={nodes} actions={actions} />
</aside>
</div>
</GameNode>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @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>
<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>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
/** Presents the PaymentPanel view using plain models and semantic native elements.
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function PaymentPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
<small>{'کارت‌خوان روبات مارکت'}</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">
<GameNode
as="button"
id="swipe"
type="button"
disabled
model={nodes['swipe']}
onAction={() => actions.emit('swipe')}
>
{'کشیدن کارت · C'}
</GameNode>
<GameNode
as="button"
id="cancel-payment"
type="button"
className="ghost"
model={nodes['cancel-payment']}
onAction={() => actions.emit('cancel-payment')}
>
{'انصراف'}
</GameNode>
</div>
</GameNode>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @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']}>
{'تازه‌وارد'}
</GameNode>
<GameNode as="span" id="xp" model={nodes['xp']}>
{'۰ XP'}
</GameNode>
<div className="bar">
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
</div>
</GameNode>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
/** Presents the RealIntroDialog view using plain models and semantic native elements.
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function RealIntroDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="real-intro"
model={nodes['real-intro']}
onClose={() => actions.close('real-intro')}
>
<img src="./robot/serious.webp" alt="" width="72" height="68" />
<h2>{'بازی در دنیای واقعی'}</h2>
<p>
{
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
}
</p>
<ul>
<li>{'هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.'}</li>
<li>{'بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.'}</li>
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.'}</li>
<li>{'موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.'}</li>
</ul>
<div className="actions">
<GameNode
as="button"
type="button"
id="real-go"
model={nodes['real-go']}
onAction={() => actions.emit('real-go')}
>
{'قبول دارم، شروع کن'}
</GameNode>
<GameNode
as="button"
type="button"
id="real-cancel"
className="ghost"
model={nodes['real-cancel']}
onAction={() => actions.emit('real-cancel')}
>
{'انصراف'}
</GameNode>
</div>
</GameNode>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
/** Presents the ShopDialog view using plain models and semantic native elements.
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function ShopDialog({ nodes, actions }) {
return (
<>
<GameNode as="dialog" id="shop" model={nodes['shop']} onClose={() => actions.close('shop')}>
<header>
<div>
<GameNode as="small" id="shop-category" model={nodes['shop-category']}></GameNode>
<GameNode as="h2" id="shop-name" model={nodes['shop-name']}></GameNode>
<GameNode as="p" id="shop-address" model={nodes['shop-address']}></GameNode>
</div>
<GameNode
as="button"
type="button"
className="close"
id="shop-close"
aria-label="بستن"
model={nodes['shop-close']}
onAction={() => actions.emit('shop-close')}
>
{'✕'}
</GameNode>
</header>
<GameNode as="p" id="shop-note" model={nodes['shop-note']}></GameNode>
<GameNode as="div" id="products" model={nodes['products']}>
<ViewLists
id="products"
model={nodes['products']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<p className="rate">
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.'}
</p>
</GameNode>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { ViewLists } from './ViewLists.jsx';
import { PaymentPanel } from './PaymentPanel.jsx';
afterEach(cleanup);
describe('React purchase interface', () => {
it('selects an affordable product and blocks unaffordable products', () => {
const onAction = vi.fn();
render(
<ViewLists
id="products"
onAction={onAction}
model={{
items: [
{ id: 'water', name: 'آب معدنی', type: 'drink', cost: 15, price: 15 },
{ id: 'nuts', name: 'آجیل', type: 'snack', cost: 65, price: 65, blocked: 'funds' },
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /آب معدنی/ }));
expect(onAction).toHaveBeenCalledWith('select-product', 'water');
expect(screen.getByRole('button', { name: /آجیل/ })).toBeDisabled();
});
it('uses the payment ready state for the explicit swipe action', () => {
const actions = { emit: vi.fn() },
nodes = {
payment: { hidden: false },
swipe: { disabled: false },
'payment-title': { text: 'آب معدنی' },
};
render(<PaymentPanel nodes={nodes} actions={actions} />);
fireEvent.click(screen.getByRole('button', { name: /کشیدن کارت/ }));
expect(actions.emit).toHaveBeenCalledWith('swipe');
fireEvent.click(screen.getByRole('button', { name: 'انصراف' }));
expect(actions.emit).toHaveBeenCalledWith('cancel-payment');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @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>
<GameNode as="strong" id="balance" model={nodes['balance']}>
{'۵۰۰'}
</GameNode>
<span>{'هتومان'}</span>
</div>
<div className="stat">
<small>{'ساعت'}</small>
<GameNode as="strong" id="clock" model={nodes['clock']}>
{'۰۸:۳۰'}
</GameNode>
</div>
<GameNode as="div" className="stat" id="speed-stat" hidden model={nodes['speed-stat']}>
<small>{'سرعت'}</small>
<GameNode as="strong" id="speed" model={nodes['speed']}>
{'۰'}
</GameNode>
<span>{'km/h'}</span>
</GameNode>
</GameNode>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
/** Presents the TouchControls using the current interface snapshot. */
export function TouchControls({ nodes, actions }) {
return (
<GameNode as="div" id="touch" hidden model={nodes['touch']}>
<GameNode as="div" id="stick" model={nodes['stick']}>
<i></i>
</GameNode>
<div className="touch-buttons">
<GameNode
as="button"
type="button"
data-key="f"
id="touch-f"
model={nodes['touch-f']}
onAction={() => actions.emit('touch-f')}
>
{'F'}
</GameNode>
<GameNode
as="button"
type="button"
data-key="e"
id="touch-e"
model={nodes['touch-e']}
onAction={() => actions.emit('touch-e')}
>
{'E'}
</GameNode>
<GameNode
as="button"
type="button"
data-key="shift"
id="touch-shift"
model={nodes['touch-shift']}
onAction={() => actions.emit('touch-shift')}
>
{'دویدن'}
</GameNode>
</div>
</GameNode>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} UpdateNoticeProps */
/** Presents the UpdateNotice view using plain models and semantic native elements.
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function UpdateNotice({ nodes, actions }) {
return (
<>
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
{'نسخهٔ جدید بازی آماده است '}
<GameNode
as="button"
type="button"
id="update-reload"
model={nodes['update-reload']}
onAction={() => actions.emit('update-reload')}
>
{'به‌روزرسانی'}
</GameNode>
</GameNode>
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { formatHetoman } from '@/game/economy/gameState.js';
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
* @param {ViewListsProps} props - List identifier, snapshot, and command callback.
* @returns {import('react').ReactNode} List content.
*/
export function ViewLists({ id, model = {}, onAction }) {
const items = model.items ?? [];
if (id === 'products')
return items.map((item) => (
<button
key={item.id}
type="button"
disabled={Boolean(item.blocked)}
onClick={() => onAction('select-product', item.id)}
>
<span className="icon">{item.icon}</span>
<b>{item.name}</b>
<span className="price">{formatHetoman(item.price)}</span>
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
<small>
{item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
{item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
</small>
</button>
));
if (id === 'mission-list')
return items.map((item) => (
<li key={item.id} className={item.done ? 'done' : item.active ? 'active' : ''}>
{item.title}
</li>
));
if (id === 'end-stats')
return items.map(([label, value]) => (
<li key={label}>
<span>{label}</span>
<b>{value}</b>
</li>
));
if (id === 'machine-list')
return items.map((item) => (
<li key={item.id}>
<button
type="button"
className={item.locked ? 'locked' : item.selected ? 'selected' : ''}
disabled={item.locked}
onClick={() => onAction('select-machine', item.id)}
>
<span>{item.icon ?? '🔒'}</span>
<span>
{item.name}
{item.visited ? ' ✓' : ''}
<span className="meta">
{item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
</span>
</span>
<span className="dist">{item.distance}</span>
</button>
</li>
));
if (id === 'awards')
return (
items.length > 0 && (
<>
<div className="total">+{model.total?.toLocaleString('fa-IR')}</div>
{items.map((item) => (
<div key={item.label} className="line">
{item.label}
<b>+{item.points.toLocaleString('fa-IR')}</b>
</div>
))}
</>
)
);
if (id === 'banner')
return (
<>
<small>{model.tag}</small>
<strong>{model.title}</strong>
<span>{model.sub}</span>
</>
);
return null;
}