refactor: separate React views, Redux snapshots and Three.js runtime
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user