refactor: separate React views, Redux snapshots and Three.js runtime
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import hooks from 'eslint-plugin-react-hooks';
|
||||
export default [
|
||||
{ignores:['dist/**','node_modules/**','public/**','src/data/**','docs/**']},
|
||||
js.configs.recommended,
|
||||
{files:['**/*.{js,jsx,mjs}'],languageOptions:{ecmaVersion:2022,sourceType:'module',globals:{...globals.browser,...globals.node},parserOptions:{ecmaFeatures:{jsx:true}}},rules:{'no-unused-vars':['error',{argsIgnorePattern:'^_',varsIgnorePattern:'^[A-Z]',ignoreRestSiblings:true}],'no-empty':['error',{allowEmptyCatch:true}]}},
|
||||
{files:['src/**/*.{js,jsx}'],plugins:{'react-hooks':hooks},rules:{'react-hooks/rules-of-hooks':'error','react-hooks/exhaustive-deps':'error'}},
|
||||
];
|
||||
+1
-160
@@ -14,163 +14,4 @@
|
||||
<link rel="icon" type="image/png" href="./icons/favicon-64.png">
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<video id="camera" playsinline muted autoplay hidden></video>
|
||||
<canvas id="world"></canvas>
|
||||
<div id="loading">در حال ساختن شهر مشهد…</div>
|
||||
|
||||
<!-- Mission briefing over the map of Iran -->
|
||||
<section id="briefing">
|
||||
<div class="brief-map"><canvas id="iran"></canvas></div>
|
||||
<div class="brief-panel">
|
||||
<img class="brief-robot" src="./robot/front.webp" alt="روبات راهنمای روبات مارکت" width="136" height="223">
|
||||
<small class="tag">عملیات ۰۱ · خراسان رضوی</small>
|
||||
<h1>عملیات مشهد</h1>
|
||||
<p class="sub">روبات مارکت · شکار دستگاههای وندینگ</p>
|
||||
<pre id="brief-text" aria-live="polite"></pre>
|
||||
<button id="start-mission" type="button">شروع عملیات</button>
|
||||
<div class="brief-actions">
|
||||
<button id="real-mode" type="button" class="ghost">📍 بازی در دنیای واقعی (GPS)</button>
|
||||
<button id="install" type="button" class="ghost" hidden>⬇ نصب بازی روی گوشی</button>
|
||||
</div>
|
||||
<p class="keys">حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- In-game HUD -->
|
||||
<div id="hud" hidden>
|
||||
<canvas id="compass" width="560" height="44"></canvas>
|
||||
<button id="minimap-button" type="button" aria-label="باز کردن نقشهٔ شهر (M)">
|
||||
<canvas id="minimap" width="220" height="220"></canvas>
|
||||
<span class="map-hint">نقشه · M</span>
|
||||
</button>
|
||||
<div id="location"><span id="street">مشهد</span><small id="mode-label">پیاده</small><small id="gps-status" hidden></small></div>
|
||||
<aside id="objectives">
|
||||
<h2>مأموریتها</h2>
|
||||
<ol id="mission-list"></ol>
|
||||
</aside>
|
||||
<div id="waypoint" hidden><i></i><b id="waypoint-name"></b><span id="waypoint-distance"></span></div>
|
||||
<div id="prompt" hidden></div>
|
||||
<div id="subtitle" hidden><img id="subtitle-face" alt="" hidden><b id="subtitle-speaker"></b><span id="subtitle-text"></span></div>
|
||||
<div id="awards" aria-live="polite"></div>
|
||||
<div id="banner" hidden></div>
|
||||
<div id="status">
|
||||
<div class="stat"><small>بودجه</small><strong id="balance">۵۰۰</strong><span>هتومان</span></div>
|
||||
<div class="stat"><small>ساعت</small><strong id="clock">۰۸:۳۰</strong></div>
|
||||
<div class="stat" id="speed-stat" hidden><small>سرعت</small><strong id="speed">۰</strong><span>km/h</span></div>
|
||||
</div>
|
||||
<div id="rank"><b id="rank-name">تازهوارد</b><span id="xp">۰ XP</span><div class="bar"><i id="xp-bar"></i></div></div>
|
||||
<div id="toolbar">
|
||||
<button id="gps-toggle" type="button">📍 دنیای واقعی</button>
|
||||
<button id="ar-toggle" type="button" hidden>📷 دوربین</button>
|
||||
<button id="stable-toggle" type="button" class="on" hidden>🎯 پایدار: روشن</button>
|
||||
<button id="sound" type="button" aria-pressed="false">صدا: خاموش</button>
|
||||
<button id="quality" type="button">گرافیک</button>
|
||||
<button id="restart" type="button">شروع دوباره</button>
|
||||
</div>
|
||||
<div id="touch" hidden>
|
||||
<div id="stick"><i></i></div>
|
||||
<div class="touch-buttons">
|
||||
<button type="button" data-key="f">F</button>
|
||||
<button type="button" data-key="e">E</button>
|
||||
<button type="button" data-key="shift">دویدن</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="crosshair"></div>
|
||||
<small id="attribution">نقشه: © مشارکتکنندگان OpenStreetMap</small>
|
||||
</div>
|
||||
|
||||
<!-- Payment at the card reader -->
|
||||
<section id="payment" hidden>
|
||||
<small>کارتخوان روبات مارکت</small>
|
||||
<h2 id="payment-title"></h2>
|
||||
<p id="payment-step"></p>
|
||||
<div class="actions">
|
||||
<button id="swipe" type="button" disabled>کشیدن کارت · C</button>
|
||||
<button id="cancel-payment" type="button" class="ghost">انصراف</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dialog id="map-dialog">
|
||||
<header>
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" role="tab" data-tab="city" aria-selected="true">مشهد</button>
|
||||
<button type="button" role="tab" data-tab="iran" aria-selected="false">ایران</button>
|
||||
</div>
|
||||
<button type="button" class="close" id="map-close" aria-label="بستن">✕</button>
|
||||
</header>
|
||||
<div class="map-body">
|
||||
<div class="map-view">
|
||||
<canvas id="fullmap"></canvas>
|
||||
<canvas id="iran-map" hidden></canvas>
|
||||
<p class="map-help">کشیدن: جابهجایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب</p>
|
||||
</div>
|
||||
<aside class="map-side">
|
||||
<h3>دستگاههای روبات مارکت</h3>
|
||||
<div id="machine-detail" hidden>
|
||||
<h4 id="detail-name"></h4>
|
||||
<p id="detail-address"></p>
|
||||
<p id="detail-meta"></p>
|
||||
<p id="detail-products"></p>
|
||||
<button id="route-button" type="button">مسیریابی تا اینجا</button>
|
||||
</div>
|
||||
<ul id="machine-list"></ul>
|
||||
</aside>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="shop">
|
||||
<header>
|
||||
<div>
|
||||
<small id="shop-category"></small>
|
||||
<h2 id="shop-name"></h2>
|
||||
<p id="shop-address"></p>
|
||||
</div>
|
||||
<button type="button" class="close" id="shop-close" aria-label="بستن">✕</button>
|
||||
</header>
|
||||
<p id="shop-note"></p>
|
||||
<div id="products"></div>
|
||||
<p class="rate">۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشیاند و پرداخت واقعی انجام نمیشود.</p>
|
||||
</dialog>
|
||||
|
||||
<dialog id="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 class="actions">
|
||||
<button type="button" id="real-go">قبول دارم، شروع کن</button>
|
||||
<button type="button" id="real-cancel" class="ghost">انصراف</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="ios-install">
|
||||
<h2>نصب روی آیفون</h2>
|
||||
<ol>
|
||||
<li>در Safari دکمهٔ <b>Share</b> (مربع با فلش رو به بالا) را بزنید.</li>
|
||||
<li>گزینهٔ <b>Add to Home Screen</b> را انتخاب کنید.</li>
|
||||
<li>روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار میگیرد و آفلاین هم اجرا میشود.</li>
|
||||
</ol>
|
||||
<button type="button" id="ios-close">متوجه شدم</button>
|
||||
</dialog>
|
||||
|
||||
<div id="update-toast" hidden>نسخهٔ جدید بازی آماده است <button type="button" id="update-reload">بهروزرسانی</button></div>
|
||||
|
||||
<dialog id="end">
|
||||
<small id="end-tag">پایان عملیات</small>
|
||||
<h2 id="end-title"></h2>
|
||||
<p id="end-text"></p>
|
||||
<ul id="end-stats"></ul>
|
||||
<div class="actions">
|
||||
<button id="again" type="button">عملیات دوباره</button>
|
||||
<button id="continue" type="button" class="ghost">ادامهٔ گشت در شهر</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
||||
|
||||
Generated
+120
-205
@@ -33,7 +33,7 @@
|
||||
"prettier": "^3.9.10",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -1898,39 +1898,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -1942,42 +1943,42 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1985,28 +1986,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2305,16 +2303,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -2370,18 +2358,11 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2403,16 +2384,6 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -2603,16 +2574,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2714,9 +2675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3810,13 +3771,6 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -4029,6 +3983,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -4139,16 +4107,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4779,9 +4737,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4811,26 +4769,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -4962,11 +4900,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
@@ -4985,30 +4926,10 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
|
||||
"integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5226,89 +5147,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
@@ -5319,6 +5231,9 @@
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+5
-2
@@ -10,7 +10,10 @@
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js",
|
||||
"predev": "npm run format",
|
||||
"prebuild": "npm run format",
|
||||
"test": "node --test tests/*.test.js"
|
||||
"test": "npm run test:logic && npm run test:ui",
|
||||
"test:logic": "node --test tests/*.test.js",
|
||||
"test:ui": "vitest run",
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -41,6 +44,6 @@
|
||||
"prettier": "^3.9.10",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default {plugins:{tailwindcss:{},autoprefixer:{}}};
|
||||
@@ -17,9 +17,9 @@
|
||||
import {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
||||
import {createGunzip} from 'node:zlib';
|
||||
import {createInterface} from 'node:readline';
|
||||
import {toLocal} from '../src/geo.js';
|
||||
import {FACTORY} from '../src/locations.js';
|
||||
import {createCollisionWorld, blocked} from '../src/collision.js';
|
||||
import {toLocal} from '../src/utils/geo.js';
|
||||
import {FACTORY} from '../src/constants/locations.js';
|
||||
import {createCollisionWorld, blocked} from '../src/game/world/collision.js';
|
||||
|
||||
const [source] = process.argv.slice(2);
|
||||
if (!source) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
||||
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
||||
import {readFileSync, writeFileSync} from 'node:fs';
|
||||
import {ORIGIN, toLocal} from '../src/geo.js';
|
||||
import {ORIGIN, toLocal} from '../src/utils/geo.js';
|
||||
|
||||
const args = {osm: []};
|
||||
for (let i = 2, key = null; i < process.argv.length; i++) {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
// Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and
|
||||
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
||||
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
|
||||
import {toLatLon} from '../src/geo.js';
|
||||
import {buildGraph, routeBetween} from '../src/routing.js';
|
||||
import {FACTORY, machineById} from '../src/locations.js';
|
||||
import {toLatLon} from '../src/utils/geo.js';
|
||||
import {buildGraph, routeBetween} from '../src/game/world/routing.js';
|
||||
import {FACTORY, machineById} from '../src/constants/locations.js';
|
||||
|
||||
const URL_TEMPLATE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
||||
const OUT = new URL('../public/imagery/', import.meta.url);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** @typedef {{as?:string, model?:object, children?:import('react').ReactNode, onAction?:function, onClose?:function}} GameNodeProps */
|
||||
/** Renders an accessible view-model node without exposing the game engine to DOM mutations.
|
||||
* @param {GameNodeProps} props - Plain presentation properties and callbacks.
|
||||
* @returns {import('react').ReactElement} Native element.
|
||||
*/
|
||||
export function GameNode({ as: Tag = 'div', model = {}, children, onAction, onClose, ...props }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (Tag !== 'dialog' || !ref.current) return;
|
||||
if (model.open && !ref.current.open) ref.current.showModal();
|
||||
if (!model.open && ref.current.open) ref.current.close();
|
||||
}, [Tag, model.open]);
|
||||
const activeClasses = Object.entries(model.classes ?? {})
|
||||
.filter(([, active]) => active)
|
||||
.map(([name]) => name);
|
||||
const selected = model.selected ?? props['aria-selected'];
|
||||
const hidden = model.hidden ?? props.hidden;
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
ref={ref}
|
||||
hidden={hidden}
|
||||
disabled={model.disabled ?? props.disabled}
|
||||
className={clsx(model.className ?? props.className, activeClasses, {
|
||||
on: model.pressed === true,
|
||||
})}
|
||||
style={model.style}
|
||||
src={model.src ?? props.src}
|
||||
aria-pressed={model.pressed ?? props['aria-pressed']}
|
||||
aria-selected={selected}
|
||||
onClick={onAction}
|
||||
onCancel={onClose}
|
||||
onClose={Tag === 'dialog' ? onClose : undefined}
|
||||
>
|
||||
{model.text ?? children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
/** Connects install/update lifecycle to the React interface and returns cleanup.
|
||||
* @param {object} ui - Interface bridge.
|
||||
* @returns {function} Releases browser event subscriptions.
|
||||
*/
|
||||
export function setupPwa(ui) {
|
||||
const controller = new AbortController(),
|
||||
{ signal } = controller;
|
||||
let deferred = null,
|
||||
reloading = false,
|
||||
disposed = false;
|
||||
const standalone =
|
||||
matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches ||
|
||||
navigator.standalone;
|
||||
const ios =
|
||||
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
if (!standalone && ios) ui.patch('install', { hidden: false });
|
||||
window.addEventListener(
|
||||
'beforeinstallprompt',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
deferred = event;
|
||||
ui.patch('install', { hidden: false });
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
window.addEventListener('appinstalled', () => ui.patch('install', { hidden: true }), { signal });
|
||||
ui.on('install', async () => {
|
||||
if (deferred) {
|
||||
deferred.prompt();
|
||||
await deferred.userChoice;
|
||||
deferred = null;
|
||||
ui.patch('install', { hidden: true });
|
||||
} else if (ios) ui.open('ios-install');
|
||||
});
|
||||
ui.on('ios-close', () => ui.close('ios-install'));
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
/** Offers a version change only after a worker has installed successfully. */
|
||||
const offer = (worker) => {
|
||||
ui.patch('update-toast', { hidden: false });
|
||||
ui.on('update-reload', () => worker.postMessage('skip-waiting'));
|
||||
};
|
||||
navigator.serviceWorker
|
||||
.register('./sw.js')
|
||||
.then((registration) => {
|
||||
if (disposed) return;
|
||||
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
||||
registration.addEventListener(
|
||||
'updatefound',
|
||||
() => {
|
||||
const worker = registration.installing;
|
||||
worker?.addEventListener(
|
||||
'statechange',
|
||||
() => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller)
|
||||
offer(worker);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
location.reload();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toLocal } from './geo.js';
|
||||
import { toLocal } from '../../utils/geo.js';
|
||||
|
||||
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
|
||||
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { purchase, purchaseBlocker } from './game-state.js';
|
||||
import { purchase, purchaseBlocker } from './gameState.js';
|
||||
|
||||
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
||||
export function createPayment() {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CATEGORIES } from './locations.js';
|
||||
import { CATEGORIES } from '../../constants/locations.js';
|
||||
|
||||
// The bonus for visiting every kind of place counts city locations, not the factory.
|
||||
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test');
|
||||
@@ -0,0 +1,42 @@
|
||||
import { publishInterface } from '../store/slices/interfaceSlice.js';
|
||||
import { publishProgress } from '../store/slices/gameSlice.js';
|
||||
|
||||
/** Bridges the high-frequency renderer and React using serializable, batched snapshots.
|
||||
* @param {object} store - Application Redux store.
|
||||
* @returns {object} View publication and action registration API.
|
||||
*/
|
||||
export function createInterfaceBridge(store) {
|
||||
let nodes = { briefing: { hidden: false }, hud: { hidden: true } },
|
||||
actions = new Map(),
|
||||
timer = null,
|
||||
disposed = false;
|
||||
/** Publishes at most ten interface frames per second, independently of WebGL FPS. */
|
||||
function flush() {
|
||||
timer = null;
|
||||
if (!disposed) store.dispatch(publishInterface(structuredClone(nodes)));
|
||||
}
|
||||
/** Merges a node view model, scheduling only actual changes. */
|
||||
function patch(id, values) {
|
||||
const previous = nodes[id] ?? {};
|
||||
if (Object.keys(values).every((key) => Object.is(previous[key], values[key]))) return;
|
||||
nodes = { ...nodes, [id]: { ...previous, ...values } };
|
||||
if (timer === null && !disposed) timer = setTimeout(flush, 100);
|
||||
}
|
||||
return {
|
||||
patch,
|
||||
read: (id) => nodes[id] ?? {},
|
||||
open: (id) => patch(id, { open: true }),
|
||||
close: (id) => patch(id, { open: false }),
|
||||
on: (id, handler) => actions.set(id, handler),
|
||||
emit: (id, value) => actions.get(id)?.(value),
|
||||
style: (id, style) => patch(id, { style: { ...nodes[id]?.style, ...style } }),
|
||||
classState: (id, name, on) => patch(id, { classes: { ...nodes[id]?.classes, [name]: on } }),
|
||||
publishGame: (state) => store.dispatch(publishProgress(structuredClone(state))),
|
||||
clearActions: () => actions.clear(),
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
actions.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { createAppStore } from '../store/index.js';
|
||||
import { createInterfaceBridge } from './interfaceBridge.js';
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe('renderer to React bridge', () => {
|
||||
it('batches UI changes, retains synchronous pause state and serializes economic progress', () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createAppStore(),
|
||||
ui = createInterfaceBridge(store);
|
||||
ui.open('shop');
|
||||
expect(ui.read('shop').open).toBe(true);
|
||||
expect(store.getState().interface.nodes.shop).toBeUndefined();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(store.getState().interface.nodes.shop.open).toBe(true);
|
||||
ui.publishGame({ balance: 465, purchases: [], missionsDone: [], xp: 350, minutes: 510 });
|
||||
expect(store.getState().game.balance).toBe(465);
|
||||
ui.dispose();
|
||||
});
|
||||
it('runs registered commands and does not publish stale UI after disposal', () => {
|
||||
vi.useFakeTimers();
|
||||
const store = createAppStore(),
|
||||
ui = createInterfaceBridge(store),
|
||||
handler = vi.fn();
|
||||
ui.on('select-product', handler);
|
||||
ui.emit('select-product', 'water');
|
||||
expect(handler).toHaveBeenCalledWith('water');
|
||||
ui.patch('balance', { text: 'test' });
|
||||
ui.dispose();
|
||||
vi.runAllTimers();
|
||||
expect(store.getState().interface.nodes.balance).toBeUndefined();
|
||||
ui.emit('select-product', 'water');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -196,12 +196,11 @@ export function createFamily({ robot: robotOnly = false } = {}) {
|
||||
1.59 * scale,
|
||||
0,
|
||||
]);
|
||||
const seam = shape(
|
||||
torso,
|
||||
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
|
||||
mat('#bda77f'),
|
||||
[0, 1.2 * scale, 0.19 * scale],
|
||||
);
|
||||
shape(torso, new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale), mat('#bda77f'), [
|
||||
0,
|
||||
1.2 * scale,
|
||||
0.19 * scale,
|
||||
]);
|
||||
for (const side of [-1, 1]) {
|
||||
const collar = shape(
|
||||
torso,
|
||||
@@ -374,7 +373,7 @@ export function createFamily({ robot: robotOnly = false } = {}) {
|
||||
);
|
||||
// With characters facing +X, father's left and child's right hands meet.
|
||||
const contact = new THREE.Vector3(0, 1.06, 0);
|
||||
actors.forEach((actor, index) =>
|
||||
actors.forEach((actor) =>
|
||||
actor.arms.forEach((arm, i) => {
|
||||
const side = i === 0 ? -1 : 1,
|
||||
inside = actor === father ? side === -1 : actor === child && side === 1;
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as THREE from 'three';
|
||||
import { createVendingMachine } from './vending.js';
|
||||
import { createCarModel } from './traffic.js';
|
||||
import { createPickup, createTruck } from './car-models.js';
|
||||
import { createCarModel } from '../systems/traffic.js';
|
||||
import { createPickup, createTruck } from './carModels.js';
|
||||
import { createPerson, createCrew, nameTag } from './staff.js';
|
||||
import { FACTORY } from './locations.js';
|
||||
import { FACTORY } from '../../constants/locations.js';
|
||||
import { textTexture } from './textures.js';
|
||||
|
||||
function stripes(c, w, h, base, line, every) {
|
||||
@@ -201,8 +201,7 @@ export function createFactory(scene) {
|
||||
const steel = mat('#59646b', { metalness: 0.5 }),
|
||||
teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
|
||||
const yellow = mat('#f2c94c', { roughness: 0.5 }),
|
||||
darkSteel = mat('#3d4549', { metalness: 0.5 }),
|
||||
silver = mat('#c4ccd0', { metalness: 0.8, roughness: 0.3 });
|
||||
darkSteel = mat('#3d4549', { metalness: 0.5 });
|
||||
const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' });
|
||||
const animated = [];
|
||||
|
||||
@@ -247,7 +246,6 @@ export function createFactory(scene) {
|
||||
c.fillStyle = '#f2c94c';
|
||||
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
|
||||
});
|
||||
const signMaterial = new THREE.MeshBasicMaterial({ map: nameSign, side: THREE.DoubleSide });
|
||||
// Two faces, so the name also reads correctly from inside the yard.
|
||||
const frontSign = new THREE.MeshBasicMaterial({ map: nameSign });
|
||||
for (const side of [1, -1]) {
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as THREE from 'three';
|
||||
import { catalog } from './game-state.js';
|
||||
import { catalog } from '../economy/gameState.js';
|
||||
import { textTexture } from './textures.js';
|
||||
|
||||
const PRODUCT_COLORS = {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadTileUrl } from './services/worldService.js';
|
||||
import { loadTileUrl } from '../../services/worldService.js';
|
||||
import * as THREE from 'three';
|
||||
import { toLocal } from './geo.js';
|
||||
import { toLocal } from '../../utils/geo.js';
|
||||
|
||||
// Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on
|
||||
// the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further
|
||||
@@ -41,6 +41,8 @@ export function createImagery(
|
||||
}),
|
||||
}));
|
||||
let loads = 0;
|
||||
let disposed = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function show(level, tile) {
|
||||
if (tile.mesh || tile.loading || loads >= 6) return;
|
||||
@@ -48,7 +50,10 @@ export function createImagery(
|
||||
loads++;
|
||||
let tileUrl;
|
||||
try {
|
||||
tileUrl = await loadTileUrl(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`);
|
||||
tileUrl = await loadTileUrl(
|
||||
`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
|
||||
controller.signal,
|
||||
);
|
||||
} catch {
|
||||
loads--;
|
||||
tile.loading = false;
|
||||
@@ -61,6 +66,10 @@ export function createImagery(
|
||||
URL.revokeObjectURL(tileUrl);
|
||||
loads--;
|
||||
tile.loading = false;
|
||||
if (disposed) {
|
||||
texture.dispose();
|
||||
return;
|
||||
}
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
// Sharp at the low angles a driver sees the road from.
|
||||
texture.anisotropy = anisotropy;
|
||||
@@ -109,6 +118,11 @@ export function createImagery(
|
||||
let last = null;
|
||||
return {
|
||||
group,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
for (const level of levels) for (const tile of level.tiles) hide(tile);
|
||||
},
|
||||
available: levels.some((level) => level.tiles.length),
|
||||
// Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
|
||||
update(x, z) {
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
|
||||
* @param {object} root - React ref containing all render surfaces.
|
||||
* @param {object} bridge - Interface publisher owned by this page.
|
||||
* @returns {{error:string|null}} Startup failure for the accessible loading surface.
|
||||
*/
|
||||
export function useGameEngine(root, bridge) {
|
||||
const [error, setError] = useState(null);
|
||||
const disposeRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
async function start() {
|
||||
try {
|
||||
const { createGame } = await import('@/game/createGame.js');
|
||||
if (controller.signal.aborted) return;
|
||||
const dispose = await createGame({
|
||||
root: root.current,
|
||||
ui: bridge,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) dispose();
|
||||
else disposeRef.current = dispose;
|
||||
} catch (problem) {
|
||||
if (!controller.signal.aborted) setError(problem.message);
|
||||
}
|
||||
}
|
||||
start();
|
||||
// Abort-scoped input and network work must stop before disposing graphics.
|
||||
return () => {
|
||||
controller.abort();
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
};
|
||||
}, [root, bridge]);
|
||||
return { error };
|
||||
}
|
||||
-1922
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/store/index.js';
|
||||
import '@fontsource/vazirmatn/400.css';
|
||||
import '@fontsource/vazirmatn/600.css';
|
||||
import '@fontsource/vazirmatn/800.css';
|
||||
import '@/styles/game.css';
|
||||
const GamePage = lazy(() =>
|
||||
import('@/pages/game/GamePage.jsx').then((module) => ({ default: module.GamePage })),
|
||||
);
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<Provider store={store}>
|
||||
<Suspense fallback={<p role="status">در حال آمادهسازی بازی…</p>}>
|
||||
<GamePage />
|
||||
</Suspense>
|
||||
</Provider>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
// Installable app: registers the offline service worker, offers "install" where the browser allows
|
||||
// it (Android/desktop Chrome), shows Add-to-Home-Screen steps on iOS, and offers a reload on updates.
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
export function setupPwa() {
|
||||
const installButton = $('#install');
|
||||
const standalone =
|
||||
matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches ||
|
||||
navigator.standalone;
|
||||
const ios =
|
||||
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
let deferred = null;
|
||||
|
||||
if (!standalone && ios) installButton.hidden = false;
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
deferred = event;
|
||||
installButton.hidden = false;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => (installButton.hidden = true));
|
||||
installButton.onclick = async () => {
|
||||
if (deferred) {
|
||||
deferred.prompt();
|
||||
await deferred.userChoice;
|
||||
deferred = null;
|
||||
installButton.hidden = true;
|
||||
} else if (ios) $('#ios-install').showModal();
|
||||
};
|
||||
$('#ios-close').onclick = () => $('#ios-install').close();
|
||||
|
||||
// Only built pages have sw.js; the dev server serves source files directly.
|
||||
if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
|
||||
navigator.serviceWorker
|
||||
.register('./sw.js')
|
||||
.then((registration) => {
|
||||
const offer = (worker) => {
|
||||
$('#update-toast').hidden = false;
|
||||
$('#update-reload').onclick = () => worker.postMessage('skip-waiting');
|
||||
};
|
||||
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing;
|
||||
worker?.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
let reloading = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import axios from 'axios';
|
||||
|
||||
/** Shared HTTP client; data paths resolve below the deployment directory. */
|
||||
export const apiClient = axios.create({
|
||||
baseURL: typeof document === 'undefined' ? 'http://localhost/' : document.baseURI,
|
||||
baseURL:
|
||||
typeof document === 'undefined' ? 'http://localhost/' : new URL('./', document.baseURI).href,
|
||||
timeout: 15000,
|
||||
});
|
||||
apiClient.interceptors.response.use(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
export const useAppDispatch = useDispatch;
|
||||
export const useAppSelector = useSelector;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { interfaceReducer } from './slices/interfaceSlice.js';
|
||||
import { gameReducer } from './slices/gameSlice.js';
|
||||
/** Creates one Redux store for interface snapshots and shared economic progress. */
|
||||
export function createAppStore() {
|
||||
return configureStore({ reducer: { interface: interfaceReducer, game: gameReducer } });
|
||||
}
|
||||
export const store = createAppStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
const gameSlice = createSlice({
|
||||
name: 'game',
|
||||
initialState: { balance: 500, xp: 0, purchases: [], missionsDone: [], minutes: 510 },
|
||||
reducers: {
|
||||
publishProgress(_state, action) {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { publishProgress } = gameSlice.actions;
|
||||
export const gameReducer = gameSlice.reducer;
|
||||
/** Selects the economic progress shared by the HUD and game panels. */
|
||||
export const selectProgress = (state) => state.game;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = { nodes: { briefing: { hidden: false }, hud: { hidden: true } } };
|
||||
const interfaceSlice = createSlice({
|
||||
name: 'interface',
|
||||
initialState,
|
||||
reducers: {
|
||||
publishInterface(state, action) {
|
||||
state.nodes = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { publishInterface } = interfaceSlice.actions;
|
||||
export const interfaceReducer = interfaceSlice.reducer;
|
||||
/** Selects serializable interface snapshots; renderer objects never enter Redux. */
|
||||
export const selectNodes = (state) => state.interface.nodes;
|
||||
-1333
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @typedef {object} PurchaseRecord
|
||||
* @property {string} machineId - Stable machine identifier.
|
||||
* @property {string} category - Machine location category.
|
||||
* @property {string} productId - Purchased consumable identifier.
|
||||
* @property {'drink'|'snack'} type - Picnic product category.
|
||||
* @property {number} price - Integer hetoman charged.
|
||||
* @property {number} minutes - Simulation clock at approval.
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} GameProgress
|
||||
* @property {number} balance - Integer hetoman balance.
|
||||
* @property {number} xp - Earned experience.
|
||||
* @property {PurchaseRecord[]} purchases - Approved purchases.
|
||||
* @property {string[]} missionsDone - Rewarded mission identifiers.
|
||||
* @property {number} minutes - Simulation clock in minutes.
|
||||
* @property {boolean} busy - Transaction or delivery lock.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
import plugin from 'tailwindcss/plugin.js';
|
||||
import {gameTheme} from './src/styles/gameTheme.js';
|
||||
export default {content:['./index.html','./src/**/*.{js,jsx}'],theme:{extend:{colors:{surface:'#0d1412',hud:'var(--hud)',gold:'var(--gold)',teal:'var(--teal)'}}},plugins:[plugin(({addBase})=>addBase(gameTheme))]};
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
priceAt,
|
||||
canAffordAnything,
|
||||
formatClock,
|
||||
} from '../src/game-state.js';
|
||||
} from '../src/game/economy/gameState.js';
|
||||
import {
|
||||
createPayment,
|
||||
beginPayment,
|
||||
readyPayment,
|
||||
swipePayment,
|
||||
cancelPayment,
|
||||
} from '../src/payment.js';
|
||||
import { machineById, machines } from '../src/locations.js';
|
||||
} from '../src/game/economy/payment.js';
|
||||
import { machineById, machines } from '../src/constants/locations.js';
|
||||
|
||||
const park = machineById('mellat-park'),
|
||||
school = machineById('school-rahil'),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { smoothFix, arrivalRadius, insideWorld, createStabilizer } from '../src/realworld.js';
|
||||
import {
|
||||
smoothFix,
|
||||
arrivalRadius,
|
||||
insideWorld,
|
||||
createStabilizer,
|
||||
} from '../src/game/device/realworld.js';
|
||||
|
||||
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
|
||||
const first = smoothFix(null, { x: 0, z: 0, accuracy: 10, time: 0 });
|
||||
@@ -47,7 +52,7 @@ test('Stable mode holds still through jitter, drops jumps and follows real walki
|
||||
|
||||
test('AR camera faces north/west with the phone held upright, and the FOV fits the screen', async () => {
|
||||
const { Quaternion, Vector3 } = await import('three');
|
||||
const { orientationQuaternion, coverFov } = await import('../src/ar.js');
|
||||
const { orientationQuaternion, coverFov } = await import('../src/game/device/ar.js');
|
||||
const look = (alpha, beta = 90, gamma = 0, angle = 0) =>
|
||||
new Vector3(0, 0, -1).applyQuaternion(
|
||||
orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle),
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
activeMission,
|
||||
POINTS,
|
||||
RANKS,
|
||||
} from '../src/scoring.js';
|
||||
import { createState, purchase } from '../src/game-state.js';
|
||||
import { machineById } from '../src/locations.js';
|
||||
} from '../src/game/economy/scoring.js';
|
||||
import { createState, purchase } from '../src/game/economy/gameState.js';
|
||||
import { machineById } from '../src/constants/locations.js';
|
||||
|
||||
const total = (awards) => awards.reduce((s, a) => s + a.points, 0);
|
||||
|
||||
|
||||
+18
-8
@@ -1,13 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { toLocal, toLatLon, bearing, ORIGIN } from '../src/geo.js';
|
||||
import { createCollisionWorld, resolveCircle, blocked, clearFraction } from '../src/collision.js';
|
||||
import { buildGraph, routeBetween, nearestSegment, buildSegmentIndex } from '../src/routing.js';
|
||||
import { createCar, stepCar } from '../src/vehicle.js';
|
||||
import { createTraffic } from '../src/traffic.js';
|
||||
import { toLocal, toLatLon, bearing, ORIGIN } from '../src/utils/geo.js';
|
||||
import {
|
||||
createCollisionWorld,
|
||||
resolveCircle,
|
||||
blocked,
|
||||
clearFraction,
|
||||
} from '../src/game/world/collision.js';
|
||||
import {
|
||||
buildGraph,
|
||||
routeBetween,
|
||||
nearestSegment,
|
||||
buildSegmentIndex,
|
||||
} from '../src/game/world/routing.js';
|
||||
import { createCar, stepCar } from '../src/game/systems/vehicle.js';
|
||||
import { createTraffic } from '../src/game/systems/traffic.js';
|
||||
import { Scene } from 'three';
|
||||
import { machines, machineById, FACTORY } from '../src/locations.js';
|
||||
import { machines, machineById, FACTORY } from '../src/constants/locations.js';
|
||||
|
||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
|
||||
|
||||
@@ -80,7 +90,7 @@ test('Traffic drives with the flow on one-way carriageways and stays inside them
|
||||
});
|
||||
|
||||
test('Microsoft buildings decode into the world, stay clear of the factory and leave machines reachable', async () => {
|
||||
const { decodeBuildings } = await import('../src/buildings-data.js');
|
||||
const { decodeBuildings } = await import('../src/game/world/buildingsData.js');
|
||||
const file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url));
|
||||
const buildings = decodeBuildings(
|
||||
file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength),
|
||||
@@ -106,7 +116,7 @@ test('Microsoft buildings decode into the world, stay clear of the factory and l
|
||||
});
|
||||
|
||||
test('Satellite tiles land where the map is: a tile over Park Mellat covers the park', async () => {
|
||||
const { tileCorners } = await import('../src/imagery.js');
|
||||
const { tileCorners } = await import('../src/game/world/imagery.js');
|
||||
const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url)));
|
||||
assert.ok(index[17].length > 100 && index[15].length > 100);
|
||||
const park = toLocal(36.32092, 59.53744),
|
||||
|
||||
+4
-1
@@ -1,3 +1,5 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
@@ -63,7 +65,8 @@ self.addEventListener('fetch', event => {
|
||||
export default defineConfig({
|
||||
// Relative assets also load under a GitHub Pages repository path.
|
||||
base: './',
|
||||
plugins: [offlineServiceWorker()],
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||
plugins: [react(), offlineServiceWorker()],
|
||||
// Top-level await loads the building and imagery files before the world is built.
|
||||
build: { target: 'es2022' },
|
||||
server: {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import {defineConfig} from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
export default defineConfig({plugins:[react()],resolve:{alias:{'@':fileURLToPath(new URL('./src',import.meta.url))}},test:{environment:'jsdom',include:['src/**/*.test.{js,jsx}'],setupFiles:['./src/tests/setup.js']}});
|
||||
Reference in New Issue
Block a user