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="icon" type="image/png" href="./icons/favicon-64.png">
|
||||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
||||||
<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>
|
|
||||||
|
|||||||
Generated
+120
-205
@@ -33,7 +33,7 @@
|
|||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
"vitest": "^3.2.7"
|
"vitest": "^4.1.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -1898,39 +1898,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "3.2.7",
|
"@vitest/spy": "4.1.12",
|
||||||
"@vitest/utils": "3.2.7",
|
"@vitest/utils": "4.1.12",
|
||||||
"chai": "^5.2.0",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "3.2.7",
|
"@vitest/spy": "4.1.12",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.17"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"msw": "^2.4.9",
|
"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": {
|
"peerDependenciesMeta": {
|
||||||
"msw": {
|
"msw": {
|
||||||
@@ -1942,42 +1943,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "3.2.7",
|
"@vitest/utils": "4.1.12",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3"
|
||||||
"strip-literal": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "3.2.7",
|
"@vitest/pretty-format": "4.1.12",
|
||||||
"magic-string": "^0.30.17",
|
"@vitest/utils": "4.1.12",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1985,28 +1986,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"tinyspy": "^4.0.3"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "3.2.7",
|
"@vitest/pretty-format": "4.1.12",
|
||||||
"loupe": "^3.1.4",
|
"convert-source-map": "^2.0.0",
|
||||||
"tinyrainbow": "^2.0.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
@@ -2305,16 +2303,6 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"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": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"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"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"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": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2403,16 +2384,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -2603,16 +2574,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -2714,9 +2675,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -3810,13 +3771,6 @@
|
|||||||
"loose-envify": "cli.js"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -4029,6 +3983,20 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -4139,16 +4107,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4779,9 +4737,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -4811,26 +4769,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
@@ -4962,11 +4900,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "0.3.2",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
||||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
@@ -4985,30 +4926,10 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tinyrainbow": {
|
||||||
"version": "2.0.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||||
"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==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"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": {
|
"node_modules/vitest": {
|
||||||
"version": "3.2.7",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@vitest/expect": "4.1.12",
|
||||||
"@vitest/expect": "3.2.7",
|
"@vitest/mocker": "4.1.12",
|
||||||
"@vitest/mocker": "3.2.7",
|
"@vitest/pretty-format": "4.1.12",
|
||||||
"@vitest/pretty-format": "^3.2.7",
|
"@vitest/runner": "4.1.12",
|
||||||
"@vitest/runner": "3.2.7",
|
"@vitest/snapshot": "4.1.12",
|
||||||
"@vitest/snapshot": "3.2.7",
|
"@vitest/spy": "4.1.12",
|
||||||
"@vitest/spy": "3.2.7",
|
"@vitest/utils": "4.1.12",
|
||||||
"@vitest/utils": "3.2.7",
|
"es-module-lexer": "^2.0.0",
|
||||||
"chai": "^5.2.0",
|
"expect-type": "^1.3.0",
|
||||||
"debug": "^4.4.1",
|
"magic-string": "^0.30.21",
|
||||||
"expect-type": "^1.2.1",
|
"obug": "^2.1.1",
|
||||||
"magic-string": "^0.30.17",
|
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.3",
|
||||||
"std-env": "^3.9.0",
|
"std-env": "^4.0.0-rc.1",
|
||||||
"tinybench": "^2.9.0",
|
"tinybench": "^2.9.0",
|
||||||
"tinyexec": "^0.3.2",
|
"tinyexec": "^1.0.2",
|
||||||
"tinyglobby": "^0.2.14",
|
"tinyglobby": "^0.2.15",
|
||||||
"tinypool": "^1.1.1",
|
"tinyrainbow": "^3.1.0",
|
||||||
"tinyrainbow": "^2.0.0",
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
|
||||||
"vite-node": "3.2.4",
|
|
||||||
"why-is-node-running": "^2.3.0"
|
"why-is-node-running": "^2.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vitest": "vitest.mjs"
|
"vitest": "vitest.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@types/debug": "^4.1.12",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser": "3.2.7",
|
"@vitest/browser-playwright": "4.1.12",
|
||||||
"@vitest/ui": "3.2.7",
|
"@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": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*"
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@edge-runtime/vm": {
|
"@edge-runtime/vm": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@types/debug": {
|
"@opentelemetry/api": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"optional": true
|
"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
|
"optional": true
|
||||||
},
|
},
|
||||||
"@vitest/ui": {
|
"@vitest/ui": {
|
||||||
@@ -5319,6 +5231,9 @@
|
|||||||
},
|
},
|
||||||
"jsdom": {
|
"jsdom": {
|
||||||
"optional": true
|
"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",
|
"format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js",
|
||||||
"predev": "npm run format",
|
"predev": "npm run format",
|
||||||
"prebuild": "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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -41,6 +44,6 @@
|
|||||||
"prettier": "^3.9.10",
|
"prettier": "^3.9.10",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"vite": "^6.4.3",
|
"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 {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
||||||
import {createGunzip} from 'node:zlib';
|
import {createGunzip} from 'node:zlib';
|
||||||
import {createInterface} from 'node:readline';
|
import {createInterface} from 'node:readline';
|
||||||
import {toLocal} from '../src/geo.js';
|
import {toLocal} from '../src/utils/geo.js';
|
||||||
import {FACTORY} from '../src/locations.js';
|
import {FACTORY} from '../src/constants/locations.js';
|
||||||
import {createCollisionWorld, blocked} from '../src/collision.js';
|
import {createCollisionWorld, blocked} from '../src/game/world/collision.js';
|
||||||
|
|
||||||
const [source] = process.argv.slice(2);
|
const [source] = process.argv.slice(2);
|
||||||
if (!source) {
|
if (!source) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
|
// 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).
|
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
|
||||||
import {readFileSync, writeFileSync} from 'node:fs';
|
import {readFileSync, writeFileSync} from 'node:fs';
|
||||||
import {ORIGIN, toLocal} from '../src/geo.js';
|
import {ORIGIN, toLocal} from '../src/utils/geo.js';
|
||||||
|
|
||||||
const args = {osm: []};
|
const args = {osm: []};
|
||||||
for (let i = 2, key = null; i < process.argv.length; i++) {
|
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
|
// 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.
|
// hosting copies require a suitable Esri licence; check Esri's terms for your use.
|
||||||
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
|
import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs';
|
||||||
import {toLatLon} from '../src/geo.js';
|
import {toLatLon} from '../src/utils/geo.js';
|
||||||
import {buildGraph, routeBetween} from '../src/routing.js';
|
import {buildGraph, routeBetween} from '../src/game/world/routing.js';
|
||||||
import {FACTORY, machineById} from '../src/locations.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 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);
|
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
|
// 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.
|
// 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).
|
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
||||||
export function createPayment() {
|
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.
|
// 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');
|
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,
|
1.59 * scale,
|
||||||
0,
|
0,
|
||||||
]);
|
]);
|
||||||
const seam = shape(
|
shape(torso, new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale), mat('#bda77f'), [
|
||||||
torso,
|
0,
|
||||||
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
|
1.2 * scale,
|
||||||
mat('#bda77f'),
|
0.19 * scale,
|
||||||
[0, 1.2 * scale, 0.19 * scale],
|
]);
|
||||||
);
|
|
||||||
for (const side of [-1, 1]) {
|
for (const side of [-1, 1]) {
|
||||||
const collar = shape(
|
const collar = shape(
|
||||||
torso,
|
torso,
|
||||||
@@ -374,7 +373,7 @@ export function createFamily({ robot: robotOnly = false } = {}) {
|
|||||||
);
|
);
|
||||||
// With characters facing +X, father's left and child's right hands meet.
|
// With characters facing +X, father's left and child's right hands meet.
|
||||||
const contact = new THREE.Vector3(0, 1.06, 0);
|
const contact = new THREE.Vector3(0, 1.06, 0);
|
||||||
actors.forEach((actor, index) =>
|
actors.forEach((actor) =>
|
||||||
actor.arms.forEach((arm, i) => {
|
actor.arms.forEach((arm, i) => {
|
||||||
const side = i === 0 ? -1 : 1,
|
const side = i === 0 ? -1 : 1,
|
||||||
inside = actor === father ? side === -1 : actor === child && side === 1;
|
inside = actor === father ? side === -1 : actor === child && side === 1;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { createVendingMachine } from './vending.js';
|
import { createVendingMachine } from './vending.js';
|
||||||
import { createCarModel } from './traffic.js';
|
import { createCarModel } from '../systems/traffic.js';
|
||||||
import { createPickup, createTruck } from './car-models.js';
|
import { createPickup, createTruck } from './carModels.js';
|
||||||
import { createPerson, createCrew, nameTag } from './staff.js';
|
import { createPerson, createCrew, nameTag } from './staff.js';
|
||||||
import { FACTORY } from './locations.js';
|
import { FACTORY } from '../../constants/locations.js';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
|
||||||
function stripes(c, w, h, base, line, every) {
|
function stripes(c, w, h, base, line, every) {
|
||||||
@@ -201,8 +201,7 @@ export function createFactory(scene) {
|
|||||||
const steel = mat('#59646b', { metalness: 0.5 }),
|
const steel = mat('#59646b', { metalness: 0.5 }),
|
||||||
teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
|
teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 });
|
||||||
const yellow = mat('#f2c94c', { roughness: 0.5 }),
|
const yellow = mat('#f2c94c', { roughness: 0.5 }),
|
||||||
darkSteel = mat('#3d4549', { metalness: 0.5 }),
|
darkSteel = mat('#3d4549', { metalness: 0.5 });
|
||||||
silver = mat('#c4ccd0', { metalness: 0.8, roughness: 0.3 });
|
|
||||||
const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' });
|
const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' });
|
||||||
const animated = [];
|
const animated = [];
|
||||||
|
|
||||||
@@ -247,7 +246,6 @@ export function createFactory(scene) {
|
|||||||
c.fillStyle = '#f2c94c';
|
c.fillStyle = '#f2c94c';
|
||||||
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
|
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.
|
// Two faces, so the name also reads correctly from inside the yard.
|
||||||
const frontSign = new THREE.MeshBasicMaterial({ map: nameSign });
|
const frontSign = new THREE.MeshBasicMaterial({ map: nameSign });
|
||||||
for (const side of [1, -1]) {
|
for (const side of [1, -1]) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { catalog } from './game-state.js';
|
import { catalog } from '../economy/gameState.js';
|
||||||
import { textTexture } from './textures.js';
|
import { textTexture } from './textures.js';
|
||||||
|
|
||||||
const PRODUCT_COLORS = {
|
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 * 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
|
// 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
|
// 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 loads = 0;
|
||||||
|
let disposed = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
async function show(level, tile) {
|
async function show(level, tile) {
|
||||||
if (tile.mesh || tile.loading || loads >= 6) return;
|
if (tile.mesh || tile.loading || loads >= 6) return;
|
||||||
@@ -48,7 +50,10 @@ export function createImagery(
|
|||||||
loads++;
|
loads++;
|
||||||
let tileUrl;
|
let tileUrl;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
loads--;
|
loads--;
|
||||||
tile.loading = false;
|
tile.loading = false;
|
||||||
@@ -61,6 +66,10 @@ export function createImagery(
|
|||||||
URL.revokeObjectURL(tileUrl);
|
URL.revokeObjectURL(tileUrl);
|
||||||
loads--;
|
loads--;
|
||||||
tile.loading = false;
|
tile.loading = false;
|
||||||
|
if (disposed) {
|
||||||
|
texture.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
// Sharp at the low angles a driver sees the road from.
|
// Sharp at the low angles a driver sees the road from.
|
||||||
texture.anisotropy = anisotropy;
|
texture.anisotropy = anisotropy;
|
||||||
@@ -109,6 +118,11 @@ export function createImagery(
|
|||||||
let last = null;
|
let last = null;
|
||||||
return {
|
return {
|
||||||
group,
|
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),
|
available: levels.some((level) => level.tiles.length),
|
||||||
// Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
|
// Loads tiles in range of (x, z), nearest first, and releases ones well out of range.
|
||||||
update(x, z) {
|
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. */
|
/** Shared HTTP client; data paths resolve below the deployment directory. */
|
||||||
export const apiClient = axios.create({
|
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,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
apiClient.interceptors.response.use(
|
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,
|
priceAt,
|
||||||
canAffordAnything,
|
canAffordAnything,
|
||||||
formatClock,
|
formatClock,
|
||||||
} from '../src/game-state.js';
|
} from '../src/game/economy/gameState.js';
|
||||||
import {
|
import {
|
||||||
createPayment,
|
createPayment,
|
||||||
beginPayment,
|
beginPayment,
|
||||||
readyPayment,
|
readyPayment,
|
||||||
swipePayment,
|
swipePayment,
|
||||||
cancelPayment,
|
cancelPayment,
|
||||||
} from '../src/payment.js';
|
} from '../src/game/economy/payment.js';
|
||||||
import { machineById, machines } from '../src/locations.js';
|
import { machineById, machines } from '../src/constants/locations.js';
|
||||||
|
|
||||||
const park = machineById('mellat-park'),
|
const park = machineById('mellat-park'),
|
||||||
school = machineById('school-rahil'),
|
school = machineById('school-rahil'),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
|
||||||
const first = smoothFix(null, { x: 0, z: 0, accuracy: 10, time: 0 });
|
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 () => {
|
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 { 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) =>
|
const look = (alpha, beta = 90, gamma = 0, angle = 0) =>
|
||||||
new Vector3(0, 0, -1).applyQuaternion(
|
new Vector3(0, 0, -1).applyQuaternion(
|
||||||
orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle),
|
orientationQuaternion(new Quaternion(), alpha, beta, gamma, angle),
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
activeMission,
|
activeMission,
|
||||||
POINTS,
|
POINTS,
|
||||||
RANKS,
|
RANKS,
|
||||||
} from '../src/scoring.js';
|
} from '../src/game/economy/scoring.js';
|
||||||
import { createState, purchase } from '../src/game-state.js';
|
import { createState, purchase } from '../src/game/economy/gameState.js';
|
||||||
import { machineById } from '../src/locations.js';
|
import { machineById } from '../src/constants/locations.js';
|
||||||
|
|
||||||
const total = (awards) => awards.reduce((s, a) => s + a.points, 0);
|
const total = (awards) => awards.reduce((s, a) => s + a.points, 0);
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -1,13 +1,23 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { toLocal, toLatLon, bearing, ORIGIN } from '../src/geo.js';
|
import { toLocal, toLatLon, bearing, ORIGIN } from '../src/utils/geo.js';
|
||||||
import { createCollisionWorld, resolveCircle, blocked, clearFraction } from '../src/collision.js';
|
import {
|
||||||
import { buildGraph, routeBetween, nearestSegment, buildSegmentIndex } from '../src/routing.js';
|
createCollisionWorld,
|
||||||
import { createCar, stepCar } from '../src/vehicle.js';
|
resolveCircle,
|
||||||
import { createTraffic } from '../src/traffic.js';
|
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 { 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)));
|
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 () => {
|
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 file = readFileSync(new URL('../public/data/buildings.bin', import.meta.url));
|
||||||
const buildings = decodeBuildings(
|
const buildings = decodeBuildings(
|
||||||
file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength),
|
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 () => {
|
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)));
|
const index = JSON.parse(readFileSync(new URL('../public/imagery/index.json', import.meta.url)));
|
||||||
assert.ok(index[17].length > 100 && index[15].length > 100);
|
assert.ok(index[17].length > 100 && index[15].length > 100);
|
||||||
const park = toLocal(36.32092, 59.53744),
|
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 { defineConfig } from 'vite';
|
||||||
import { readdirSync, statSync } from 'node:fs';
|
import { readdirSync, statSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
@@ -63,7 +65,8 @@ self.addEventListener('fetch', event => {
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// Relative assets also load under a GitHub Pages repository path.
|
// Relative assets also load under a GitHub Pages repository path.
|
||||||
base: './',
|
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.
|
// Top-level await loads the building and imagery files before the world is built.
|
||||||
build: { target: 'es2022' },
|
build: { target: 'es2022' },
|
||||||
server: {
|
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