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

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