From d0da41692390083179eb14117286f6676a88b663 Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Wed, 28 Oct 2026 12:26:40 +0330 Subject: [PATCH] refactor: separate React views, Redux snapshots and Three.js runtime --- eslint.config.js | 9 + index.html | 161 +- package-lock.json | 325 +-- package.json | 7 +- postcss.config.js | 1 + scripts/build-buildings.mjs | 6 +- scripts/build-map-data.mjs | 2 +- scripts/fetch-imagery.mjs | 6 +- src/components/shared/GameNode.jsx | 41 + src/{ => constants}/locations.js | 0 src/game/createGame.js | 2306 +++++++++++++++++ src/{ => game/device}/ar.js | 0 src/game/device/pwa.js | 79 + src/{ => game/device}/realworld.js | 2 +- .../economy/gameState.js} | 0 src/{ => game/economy}/payment.js | 2 +- src/{ => game/economy}/scoring.js | 2 +- src/game/interfaceBridge.js | 42 + src/game/interfaceBridge.test.js | 34 + .../models/carModels.js} | 0 src/{ => game/models}/characters.js | 13 +- src/{ => game/models}/factory.js | 10 +- src/{ => game/models}/staff.js | 0 src/{ => game/models}/textures.js | 0 src/{ => game/models}/vending.js | 2 +- src/{ => game/systems}/traffic.js | 0 src/{ => game/systems}/vehicle.js | 0 .../world/buildingsData.js} | 0 src/{ => game/world}/city.js | 0 src/{ => game/world}/collision.js | 0 src/{ => game/world}/imagery.js | 20 +- .../world/mapRender.js} | 0 src/{ => game/world}/routing.js | 0 src/hooks/useGameEngine.js | 37 + src/main.js | 1922 -------------- src/main.jsx | 18 + src/pages/game/BriefingPanel.jsx | 72 + src/pages/game/EndDialog.jsx | 48 + src/pages/game/GameDialogs.jsx | 21 + src/pages/game/GameHud.jsx | 76 + src/pages/game/GamePage.jsx | 56 + src/pages/game/GameSurfaces.jsx | 25 + src/pages/game/GameToolbar.jsx | 68 + src/pages/game/InstallDialog.jsx | 44 + src/pages/game/LocationStatus.jsx | 17 + src/pages/game/MachineDetails.jsx | 23 + src/pages/game/MachineList.jsx | 15 + src/pages/game/MapDialog.jsx | 75 + src/pages/game/MissionList.jsx | 18 + src/pages/game/PaymentPanel.jsx | 39 + src/pages/game/RankProgress.jsx | 19 + src/pages/game/RealIntroDialog.jsx | 53 + src/pages/game/ShopDialog.jsx | 44 + src/pages/game/ShopDialog.test.jsx | 39 + src/pages/game/StatusBar.jsx | 30 + src/pages/game/TouchControls.jsx | 45 + src/pages/game/UpdateNotice.jsx | 24 + src/pages/game/ViewLists.jsx | 85 + src/pwa.js | 56 - src/services/apiClient.js | 3 +- src/store/hooks.js | 3 + src/store/index.js | 8 + src/store/slices/gameSlice.js | 14 + src/store/slices/interfaceSlice.js | 16 + src/style.css | 1333 ---------- src/styles/game.css | 3 + src/styles/gameTheme.js | 1166 +++++++++ src/tests/setup.js | 1 + src/types/gameTypes.js | 19 + src/{ => utils}/geo.js | 0 tailwind.config.js | 3 + tests/payment.test.js | 6 +- tests/realworld.test.js | 9 +- tests/scoring.test.js | 6 +- tests/world.test.js | 26 +- vite.config.js | 5 +- vitest.config.js | 4 + 77 files changed, 4941 insertions(+), 3723 deletions(-) create mode 100644 eslint.config.js create mode 100644 postcss.config.js create mode 100644 src/components/shared/GameNode.jsx rename src/{ => constants}/locations.js (100%) create mode 100644 src/game/createGame.js rename src/{ => game/device}/ar.js (100%) create mode 100644 src/game/device/pwa.js rename src/{ => game/device}/realworld.js (99%) rename src/{game-state.js => game/economy/gameState.js} (100%) rename src/{ => game/economy}/payment.js (95%) rename src/{ => game/economy}/scoring.js (98%) create mode 100644 src/game/interfaceBridge.js create mode 100644 src/game/interfaceBridge.test.js rename src/{car-models.js => game/models/carModels.js} (100%) rename src/{ => game/models}/characters.js (98%) rename src/{ => game/models}/factory.js (99%) rename src/{ => game/models}/staff.js (100%) rename src/{ => game/models}/textures.js (100%) rename src/{ => game/models}/vending.js (99%) rename src/{ => game/systems}/traffic.js (100%) rename src/{ => game/systems}/vehicle.js (100%) rename src/{buildings-data.js => game/world/buildingsData.js} (100%) rename src/{ => game/world}/city.js (100%) rename src/{ => game/world}/collision.js (100%) rename src/{ => game/world}/imagery.js (88%) rename src/{map-render.js => game/world/mapRender.js} (100%) rename src/{ => game/world}/routing.js (100%) create mode 100644 src/hooks/useGameEngine.js delete mode 100644 src/main.js create mode 100644 src/main.jsx create mode 100644 src/pages/game/BriefingPanel.jsx create mode 100644 src/pages/game/EndDialog.jsx create mode 100644 src/pages/game/GameDialogs.jsx create mode 100644 src/pages/game/GameHud.jsx create mode 100644 src/pages/game/GamePage.jsx create mode 100644 src/pages/game/GameSurfaces.jsx create mode 100644 src/pages/game/GameToolbar.jsx create mode 100644 src/pages/game/InstallDialog.jsx create mode 100644 src/pages/game/LocationStatus.jsx create mode 100644 src/pages/game/MachineDetails.jsx create mode 100644 src/pages/game/MachineList.jsx create mode 100644 src/pages/game/MapDialog.jsx create mode 100644 src/pages/game/MissionList.jsx create mode 100644 src/pages/game/PaymentPanel.jsx create mode 100644 src/pages/game/RankProgress.jsx create mode 100644 src/pages/game/RealIntroDialog.jsx create mode 100644 src/pages/game/ShopDialog.jsx create mode 100644 src/pages/game/ShopDialog.test.jsx create mode 100644 src/pages/game/StatusBar.jsx create mode 100644 src/pages/game/TouchControls.jsx create mode 100644 src/pages/game/UpdateNotice.jsx create mode 100644 src/pages/game/ViewLists.jsx delete mode 100644 src/pwa.js create mode 100644 src/store/hooks.js create mode 100644 src/store/index.js create mode 100644 src/store/slices/gameSlice.js create mode 100644 src/store/slices/interfaceSlice.js delete mode 100644 src/style.css create mode 100644 src/styles/game.css create mode 100644 src/styles/gameTheme.js create mode 100644 src/tests/setup.js create mode 100644 src/types/gameTypes.js rename src/{ => utils}/geo.js (100%) create mode 100644 tailwind.config.js create mode 100644 vitest.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ac170ae --- /dev/null +++ b/eslint.config.js @@ -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'}}, +]; diff --git a/index.html b/index.html index ac38228..ebc7ff0 100644 --- a/index.html +++ b/index.html @@ -14,163 +14,4 @@ - - - -
در حال ساختن شهر مشهد…
- - -
-
-
- روبات راهنمای روبات مارکت - عملیات ۰۱ · خراسان رضوی -

عملیات مشهد

-

روبات مارکت · شکار دستگاه‌های وندینگ

-

-        
-        
- - -
-

حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M

-
-
- - - - - - - - -
-
- - -
- -
-
-
- - -

کشیدن: جابه‌جایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب

-
- -
-
- - -
-
- -

-

-
- -
-

-
-

۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.

-
- - - -

بازی در دنیای واقعی

-

با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.

- -
- - -
-
- - -

نصب روی آیفون

-
    -
  1. در Safari دکمهٔ Share (مربع با فلش رو به بالا) را بزنید.
  2. -
  3. گزینهٔ Add to Home Screen را انتخاب کنید.
  4. -
  5. روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.
  6. -
- -
- - - - - پایان عملیات -

-

- -
- - -
-
- - - +
diff --git a/package-lock.json b/package-lock.json index b0161d8..2858657 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "prettier": "^3.9.10", "tailwindcss": "^3.4.19", "vite": "^6.4.3", - "vitest": "^3.2.7" + "vitest": "^4.1.12" } }, "node_modules/@adobe/css-tools": { @@ -1898,39 +1898,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1942,42 +1943,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1985,28 +1986,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2305,16 +2303,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2370,18 +2358,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2403,16 +2384,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2603,16 +2574,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2714,9 +2675,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -3810,13 +3771,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4029,6 +3983,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4139,16 +4107,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4779,9 +4737,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -4811,26 +4769,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4962,11 +4900,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -4985,30 +4926,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -5226,89 +5147,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -5319,6 +5231,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 20433de..a98e3f3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" vite.config.js", "predev": "npm run format", "prebuild": "npm run format", - "test": "node --test tests/*.test.js" + "test": "npm run test:logic && npm run test:ui", + "test:logic": "node --test tests/*.test.js", + "test:ui": "vitest run", + "lint": "eslint . --max-warnings 0" }, "keywords": [], "author": "", @@ -41,6 +44,6 @@ "prettier": "^3.9.10", "tailwindcss": "^3.4.19", "vite": "^6.4.3", - "vitest": "^3.2.7" + "vitest": "^4.1.12" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..e24d46b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1 @@ +export default {plugins:{tailwindcss:{},autoprefixer:{}}}; diff --git a/scripts/build-buildings.mjs b/scripts/build-buildings.mjs index dac006c..c91e09d 100644 --- a/scripts/build-buildings.mjs +++ b/scripts/build-buildings.mjs @@ -17,9 +17,9 @@ import {createReadStream, readFileSync, writeFileSync, mkdirSync} from 'node:fs'; import {createGunzip} from 'node:zlib'; import {createInterface} from 'node:readline'; -import {toLocal} from '../src/geo.js'; -import {FACTORY} from '../src/locations.js'; -import {createCollisionWorld, blocked} from '../src/collision.js'; +import {toLocal} from '../src/utils/geo.js'; +import {FACTORY} from '../src/constants/locations.js'; +import {createCollisionWorld, blocked} from '../src/game/world/collision.js'; const [source] = process.argv.slice(2); if (!source) { diff --git a/scripts/build-map-data.mjs b/scripts/build-map-data.mjs index bb8a25e..a638057 100644 --- a/scripts/build-map-data.mjs +++ b/scripts/build-map-data.mjs @@ -15,7 +15,7 @@ // poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags; // Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain). import {readFileSync, writeFileSync} from 'node:fs'; -import {ORIGIN, toLocal} from '../src/geo.js'; +import {ORIGIN, toLocal} from '../src/utils/geo.js'; const args = {osm: []}; for (let i = 2, key = null; i < process.argv.length; i++) { diff --git a/scripts/fetch-imagery.mjs b/scripts/fetch-imagery.mjs index c1c38b7..4ab1fc3 100644 --- a/scripts/fetch-imagery.mjs +++ b/scripts/fetch-imagery.mjs @@ -10,9 +10,9 @@ // Imagery: Esri, Maxar, Earthstar Geographics, and the GIS User Community. Commercial use and // hosting copies require a suitable Esri licence; check Esri's terms for your use. import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs'; -import {toLatLon} from '../src/geo.js'; -import {buildGraph, routeBetween} from '../src/routing.js'; -import {FACTORY, machineById} from '../src/locations.js'; +import {toLatLon} from '../src/utils/geo.js'; +import {buildGraph, routeBetween} from '../src/game/world/routing.js'; +import {FACTORY, machineById} from '../src/constants/locations.js'; const URL_TEMPLATE = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; const OUT = new URL('../public/imagery/', import.meta.url); diff --git a/src/components/shared/GameNode.jsx b/src/components/shared/GameNode.jsx new file mode 100644 index 0000000..129c4d4 --- /dev/null +++ b/src/components/shared/GameNode.jsx @@ -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 ( + + ); +} diff --git a/src/locations.js b/src/constants/locations.js similarity index 100% rename from src/locations.js rename to src/constants/locations.js diff --git a/src/game/createGame.js b/src/game/createGame.js new file mode 100644 index 0000000..38fbb0f --- /dev/null +++ b/src/game/createGame.js @@ -0,0 +1,2306 @@ +import { loadWorldFiles } from '../services/worldService.js'; +import * as THREE from 'three'; +import '../styles/game.css'; +import { + startTracking, + requestCompass, + arrivalRadius, + insideWorld, + createStabilizer, + MAX_WALKING_SPEED, +} from './device/realworld.js'; +import { createArView } from './device/ar.js'; +import data from '../data/mellat.json'; +import iran from '../data/iran.json'; +import { createCity } from './world/city.js'; +import { createImagery } from './world/imagery.js'; +import { decodeBuildings } from './world/buildingsData.js'; +import { + createCollisionWorld, + addPolygon, + resolveCircle, + clearFraction, +} from './world/collision.js'; +import { + buildGraph, + routeBetween, + buildSegmentIndex, + nearestSegment, + DRIVABLE_MAX_TYPE, +} from './world/routing.js'; +import { machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces } from '../constants/locations.js'; +import { + createState, + catalog, + priceAt, + isOpen, + purchaseBlocker, + formatHetoman, + formatClock, + fa, + canAffordAnything, +} from './economy/gameState.js'; +import { + createPayment, + beginPayment, + readyPayment, + swipePayment, + cancelPayment, +} from './economy/payment.js'; +import { + awardsFor, + rankFor, + completeMissions, + activeMission, + MISSIONS, +} from './economy/scoring.js'; +import { createFamily } from './models/characters.js'; +import { createVendingMachine } from './models/vending.js'; +import { createFactory } from './models/factory.js'; +import { createCar, stepCar, kmh } from './systems/vehicle.js'; +import { createTraffic } from './systems/traffic.js'; +import { createSportSedan } from './models/carModels.js'; +import { createMapAssets, drawMinimap, drawFullMap, drawIran } from './world/mapRender.js'; +import { bearing, formatDistance, toLocal } from '../utils/geo.js'; +export async function createGame({ root, ui, signal }) { + const timers = new Set(); + const setTimeout = (callback, delay) => { + const timer = window.setTimeout(() => { + timers.delete(timer); + if (!signal.aborted) callback(); + }, delay); + timers.add(timer); + return timer; + }; + const clearTimeout = (timer) => { + timers.delete(timer); + window.clearTimeout(timer); + }; + const listen = (target, type, handler, options = {}) => + target.addEventListener(type, handler, { ...options, signal }); + let frameId; + + const $ = (s) => root.querySelector(s); + const canvas = $('#world'); + let renderer; + try { + // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn. + renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + powerPreference: 'high-performance', + }); + } catch { + ui.patch('loading', { + text: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.', + }); + throw new Error('WebGL unavailable'); + } + // Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button. + const quality = + new URLSearchParams(location.search).get('quality') ?? + (() => { + try { + return localStorage.getItem('quality'); + } catch { + return null; + } + })() ?? + (matchMedia('(pointer: coarse)').matches ? 'low' : 'high'); + renderer.setPixelRatio(quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75)); + renderer.shadowMap.enabled = quality !== 'low'; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.05; + const scene = new THREE.Scene(); + const fog = new THREE.Fog('#d8cdb4', 220, 1500); + scene.fog = fog; + const camera = new THREE.PerspectiveCamera(62, 1, 0.3, 7000); + + // Morning sky over Mashhad: a gradient dome, warm haze at the horizon. + const sky = new THREE.Mesh( + new THREE.SphereGeometry(5000, 32, 16), + new THREE.ShaderMaterial({ + side: THREE.BackSide, + depthWrite: false, + fog: false, + uniforms: { + top: { + value: new THREE.Color('#6f9fd0'), + }, + horizon: { + value: new THREE.Color('#e6dbc3'), + }, + }, + vertexShader: + 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', + fragmentShader: + 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }', + }), + ); + scene.add(sky); + scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6)); + const sun = new THREE.DirectionalLight('#ffe9c2', 3.2); + sun.castShadow = true; + sun.shadow.mapSize.set(2048, 2048); + Object.assign(sun.shadow.camera, { + left: -75, + right: 75, + top: 75, + bottom: -75, + near: 10, + far: 700, + }); + sun.shadow.bias = -0.0004; + sun.shadow.normalBias = 0.4; + scene.add(sun, sun.target); + + // ---------- World data ---------- + // Microsoft's building footprints and the satellite tile list are downloaded files, not bundled code. + // The game still runs without them (OSM buildings and drawn roads). + const [buildingsFile, imageryIndex] = await loadWorldFiles(signal); + if (signal.aborted) { + renderer.dispose(); + return () => {}; + } + // Footprints reaching into the factory compound are left out: the game's own buildings stand there. + const inFactory = (x, z) => { + const dx = x - FACTORY.center.x, + dz = z - FACTORY.center.z; + const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, + v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x; + return u > -112 && u < 82 && v > -52 && v < 52; + }; + const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter((b) => { + for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false; + return true; + }); + const collision = createCollisionWorld( + [...data.buildings, ...extraBuildings].map((b) => b.slice(1)), + ); + const segments = buildSegmentIndex(data.roads); + const walkGraph = buildGraph(data.roads), + driveGraph = buildGraph(data.roads, { + drivable: true, + }); + const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2]; + const rotateLocal = (lx, lz, rot) => ({ + x: lx * Math.cos(rot) + lz * Math.sin(rot), + z: -lx * Math.sin(rot) + lz * Math.cos(rot), + }); + + // The game starts inside the vending-machine factory on the Mashhad–Chenaran road. + const factory = createFactory(scene); + factory.footprints.forEach((f) => addPolygon(collision, f)); + + // City machines stand beside their path point, on whichever side is free, facing the path; + // the factory's demo machine has a fixed place inside the production hall. + const stations = machines.map((def) => { + if (def.spot) { + // Test points: the given coordinate is where the player stands; the machine faces the factory. + const front = toLocal(def.spot.lat, def.spot.lon); + const dx = FACTORY.center.x - front.x, + dz = FACTORY.center.z - front.z, + len = Math.hypot(dx, dz) || 1; + return placeStation( + def, + { + x: front.x - (dx / len) * 1.9, + z: front.z - (dz / len) * 1.9, + }, + Math.atan2(dx, dz), + ); + } + if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot); + const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? { + dx: 1, + dz: 0, + type: 9, + }; + const len = Math.hypot(seg.dx, seg.dz) || 1, + nx = -seg.dz / len, + nz = seg.dx / len; + const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4; + let pos = null; + for (const d of [off, -off, off + 1.5, -off - 1.5]) { + const x = def.anchor.x + nx * d, + z = def.anchor.z + nz * d; + if (!resolveCircle(collision, x, z, 1.4).hit) { + pos = { + x, + z, + }; + break; + } + } + pos ??= { + x: def.anchor.x + nx * off, + z: def.anchor.z + nz * off, + }; + return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z)); + }); + function placeStation(def, pos, rot) { + const model = createVendingMachine(def); + model.group.position.set(pos.x, 0, pos.z); + model.group.rotation.y = rot; + scene.add(model.group); + addPolygon( + collision, + [ + [-1, -0.6], + [1, -0.6], + [1, 0.6], + [-1, 0.6], + ].flatMap(([lx, lz]) => { + const p = rotateLocal(lx, lz, rot); + return [pos.x + p.x, pos.z + p.z]; + }), + ); + const front = rotateLocal(0, 1.9, rot); + return { + def, + model, + x: pos.x, + z: pos.z, + rot, + front: { + x: pos.x + front.x, + z: pos.z + front.z, + }, + }; + } + const stationById = (id) => stations.find((s) => s.def.id === id); + for (const s of stations) if (s.def.test) s.model.group.visible = false; + + // In real-world mode a circle on the ground marks how close counts as "at the machine". + for (const s of stations) { + s.reach = new THREE.Mesh( + new THREE.RingGeometry(0.96, 1, 96), + new THREE.MeshBasicMaterial({ + color: '#3ee0c9', + transparent: true, + opacity: 0.55, + depthWrite: false, + fog: false, + side: THREE.DoubleSide, + }), + ); + s.reach.rotation.x = -Math.PI / 2; + s.reach.position.set(s.front.x, 0.08, s.front.z); + s.reach.visible = false; + scene.add(s.reach); + } + function updateReachCircles() { + const radius = reachRadius(), + objective = real.on ? objectiveStation() : null; + for (const s of stations) { + s.reach.visible = real.on; + if (!real.on) continue; + s.reach.scale.setScalar(radius); + s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9'); + } + } + // GPS test points only take part while real-world mode is on. + const activeStations = () => stations.filter((s) => !s.def.test || real.on); + // The closest active machine whose front is within `radius` of (x, z). + const closestStation = (x, z, radius) => + activeStations() + .map((s) => ({ + s, + d: Math.hypot(s.front.x - x, s.front.z - z), + })) + .filter(({ d }) => d < radius) + .sort((a, b) => a.d - b.d)[0]?.s ?? null; + const imagery = createImagery(scene, imageryIndex ?? {}, { + lowQuality: quality === 'low', + anisotropy: Math.min(16, renderer.capabilities.getMaxAnisotropy()), + }); + const city = createCity(scene, data, { + collision, + segments, + extraBuildings, + satellite: imagery.available, + lowQuality: quality === 'low', + }); + if (imagery.available) + ui.patch('attribution', { + text: `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${imageryIndex.attribution}`, + }); + const traffic = createTraffic(scene, data, 28); + const mapAssets = createMapAssets(data, [factory.label]); + + // Park gate sign at the first city machine. + const parkGate = new THREE.Group(); + { + const start = stationById(CITY_START), + sign = document.createElement('canvas'); + sign.width = 512; + sign.height = 128; + const c = sign.getContext('2d'); + c.fillStyle = '#244d2e'; + c.fillRect(0, 0, 512, 128); + c.fillStyle = '#f4efd9'; + c.textAlign = 'center'; + c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif'; + c.fillText('بوستان ملت', 256, 86); + const texture = new THREE.CanvasTexture(sign); + texture.colorSpace = THREE.SRGBColorSpace; + const gate = parkGate, + post = new THREE.MeshStandardMaterial({ + color: '#6b5a49', + }); + for (const side of [-1, 1]) { + const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 4, 0.3), post); + p.position.set(side * 2.6, 2, 0); + p.castShadow = true; + gate.add(p); + } + const board = new THREE.Mesh( + new THREE.PlaneGeometry(5.6, 1.4), + new THREE.MeshBasicMaterial({ + map: texture, + side: THREE.DoubleSide, + }), + ); + board.position.y = 4; + gate.add(board); + const at = rotateLocal(-4.5, 1.5, start.rot); + gate.position.set(start.x + at.x, 0, start.z + at.z); + gate.rotation.y = start.rot; + scene.add(gate); + } + + // ---------- Actors ---------- + const family = createFamily(); + scene.add(family.group); + // The Robot Market guide lives inside the machines and comes out when the family arrives. + const guide = createFamily({ + robot: true, + }); + guide.group.visible = false; + scene.add(guide.group); + const carModel = createSportSedan('#1d3f78'); + scene.add(carModel.group); + let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended; + const keys = new Set(); + + // The family car waits in the factory yard right beside them, pointing at the gate. + const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading); + function reset() { + state = createState(); + payment = createPayment(); + // The family starts in the factory yard, facing the engineering office. + const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1); + player = { + x: spot.x, + z: spot.z, + heading: factory.start.heading, + speed: 0, + gait: 0, + }; + factory.reset(); + for (const t of factory.talks) t.done = false; + car = spawnCar(); + mode = 'walk'; + // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos). + cam = { + yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)), + pitch: 0.32, + distance: 5.5, + lastMouse: -10, + }; + waypoint = null; + route = null; + routeTimer = 0; + deliveryTimer = 0; + ended = false; + hints = { + closed: false, + car: false, + }; + Object.assign(guideState, { + station: null, + t: 0, + greeted: new Set(), + arrived: 0, + }); + ui.patch('payment', { + hidden: true, + }); + for (const s of stations) { + s.model.delivery.visible = s.model.card.visible = false; + s.model.flap.rotation.x = 0; + } + for (const id of ['shop', 'map-dialog', 'end']) ui.close(id); + keys.clear(); + refreshHUD(); + } + + // ---------- Input ---------- + const isTouch = matchMedia('(pointer: coarse)').matches; + const anyDialogOpen = () => + ui.read('shop').open || + ui.read('map-dialog').open || + ui.read('end').open || + !ui.read('briefing').hidden; + // Physical key codes, so the controls also work with a Persian keyboard layout. + const keyName = (e) => + e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase(); + listen(window, 'keydown', (e) => { + if (e.target instanceof HTMLInputElement) return; + const k = keyName(e); + if (k === 'm' && !e.repeat && ui.read('briefing').hidden) { + e.preventDefault(); + ui.read('map-dialog').open ? ui.close('map-dialog') : openMap(); + return; + } + if (anyDialogOpen()) return; + if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) + e.preventDefault(); + if (e.repeat) return; + keys.add(k); + if (k === 'f') toggleCar(); + if (k === 'e') interact(); + if (k === 'c') swipe(); + if (k === 'escape' && payment.phase !== 'idle') cancel(); + }); + listen(window, 'keyup', (e) => keys.delete(keyName(e))); + listen(window, 'blur', () => keys.clear()); + + // Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch. + listen(canvas, 'click', () => { + if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas) + canvas.requestPointerLock?.()?.catch?.(() => {}); + }); + listen(document, 'mousemove', (e) => { + if (document.pointerLockElement !== canvas) return; + look(e.movementX, e.movementY, 0.0024); + }); + let drag = null; + listen(canvas, 'pointerdown', (e) => { + if (document.pointerLockElement === canvas) return; + drag = { + id: e.pointerId, + x: e.clientX, + y: e.clientY, + }; + canvas.setPointerCapture(e.pointerId); + }); + listen(canvas, 'pointermove', (e) => { + if (!drag || drag.id !== e.pointerId) return; + look(e.clientX - drag.x, e.clientY - drag.y, 0.005); + drag.x = e.clientX; + drag.y = e.clientY; + }); + listen(canvas, 'pointerup', () => (drag = null)); + listen(canvas, 'pointercancel', () => (drag = null)); + listen( + canvas, + 'wheel', + (e) => { + cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * 0.6, 3, 12); + }, + { + passive: true, + }, + ); + function look(dx, dy, k) { + // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor. + if (real.on) { + if (arView.hasOrientation) return; + cam.yaw -= dx * k; + cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * 0.8, -1.2, 1.2); + cam.lastMouse = elapsed; + return; + } + cam.yaw -= dx * k; + cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * 0.8, -0.15, 1.15); + cam.lastMouse = elapsed; + } + + // Touch joystick: x steers / strafes, y walks / throttles. + const stick = { + x: 0, + y: 0, + id: null, + }; + if (isTouch) { + ui.patch('touch', { + hidden: false, + }); + const pad = $('#stick'), + knob = pad.querySelector('i'); + const move = (e) => { + const r = pad.getBoundingClientRect(), + dx = e.clientX - (r.left + r.width / 2), + dy = e.clientY - (r.top + r.height / 2); + const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), + a = Math.atan2(dy, dx); + stick.x = Math.cos(a) * len; + stick.y = -Math.sin(a) * len; + knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`; + }; + listen(pad, 'pointerdown', (e) => { + stick.id = e.pointerId; + pad.setPointerCapture(e.pointerId); + move(e); + }); + listen(pad, 'pointermove', (e) => e.pointerId === stick.id && move(e)); + const end = () => { + stick.id = null; + stick.x = stick.y = 0; + knob.style.transform = ''; + }; + listen(pad, 'pointerup', end); + listen(pad, 'pointercancel', end); + for (const k of ['shift', 'f', 'e']) + ui.on(`touch-${k}`, () => { + if (k === 'shift') { + if (keys.has('shift')) keys.delete('shift'); + else keys.add('shift'); + ui.patch('touch-shift', { + pressed: keys.has('shift'), + }); + } + if (k === 'f') toggleCar(); + if (k === 'e') payment.phase === 'ready' ? swipe() : interact(); + }); + } + const axis = (pos, neg) => + Number(pos.some((k) => keys.has(k))) - Number(neg.some((k) => keys.has(k))); + + // ---------- Subtitles ---------- + const SPEAKERS = { + robot: 'روبات', + father: 'پدر', + child: 'بچه', + }; + // Factory staff speak under their own names. + for (const t of factory.talks) if (t.label) SPEAKERS[t.speaker] = t.label; + let lines = [], + lineTimer = 0, + talking = false; + + // `face` picks the robot's expression portrait (public/robot/.webp). + function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) { + if (lines.some((l) => l.text === text)) return; + lines.push({ + speaker, + text, + face, + seconds, + }); + } + function updateSubtitles(dt) { + lineTimer -= dt; + if (lineTimer > 0) return; + const next = lines.shift(); + ui.patch('subtitle', { + hidden: !next, + }); + talking = next?.speaker === 'robot'; + if (!next) return; + ui.patch('subtitle-speaker', { + text: `${SPEAKERS[next.speaker]}:`, + }); + ui.patch('subtitle-speaker', { + className: ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff', + }); + ui.patch('subtitle-text', { + text: next.text, + }); + ui.patch('subtitle-face', { + hidden: next.speaker !== 'robot', + }); + if (next.speaker === 'robot') + ui.patch('subtitle-face', { + src: `./robot/${next.face}.webp`, + }); + lineTimer = next.seconds; + } + + // ---------- Sound ---------- + let audio = null, + soundOn = false, + engine = null; + function tone(freq, duration = 0.12, volume = 0.05, type = 'sine') { + if (!soundOn) return; + const o = audio.createOscillator(), + g = audio.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, audio.currentTime); + o.frequency.exponentialRampToValueAtTime(freq * 0.7, audio.currentTime + duration); + g.gain.setValueAtTime(volume, audio.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + duration); + o.connect(g).connect(audio.destination); + o.start(); + o.stop(audio.currentTime + duration); + } + ui.on('sound', async () => { + try { + audio ??= new AudioContext(); + await audio.resume(); + soundOn = !soundOn; + if (!engine) { + const o = audio.createOscillator(), + g = audio.createGain(); + o.type = 'sawtooth'; + g.gain.value = 0; + o.connect(g).connect(audio.destination); + o.start(); + engine = { + o, + g, + }; + } + ui.patch('sound', { + text: soundOn ? 'صدا: روشن' : 'صدا: خاموش', + }); + ui.patch('sound', { + pressed: String(soundOn), + }); + tone(700); + } catch { + ui.patch('sound', { + text: 'صدا در دسترس نیست', + }); + } + }); + + // ---------- Car ---------- + function toggleCar() { + if (payment.phase !== 'idle' || state.busy) return; + if (real.on) { + say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); + return; + } + if (mode === 'walk') { + if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return; + mode = 'drive'; + cam.distance = Math.max(cam.distance, 8); + tone(320, 0.2, 0.05, 'square'); + say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.'); + } else { + if (Math.abs(car.speed) > 3) { + say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.'); + return; + } + // Step out on the driver's (left) side, or wherever is free. + const left = { + x: -Math.sin(car.heading), + z: -Math.cos(car.heading), + }; + const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1); + Object.assign(player, { + x: spot.x, + z: spot.z, + heading: car.heading, + }); + car.speed = 0; + mode = 'walk'; + cam.distance = 5.5; + tone(260, 0.2, 0.05, 'square'); + } + refreshHUD(); + } + + // ---------- Machines, shop and payment ---------- + // In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres. + const reachRadius = () => (real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4); + const nearStation = () => + mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null; + const hoursText = (m) => + m.hours[1] - m.hours[0] >= 24 * 60 + ? 'شبانه‌روزی' + : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`; + let shopStation = null; + function interact() { + const station = nearStation(); + if (!station || state.busy || payment.phase !== 'idle' || ended) return; + if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) { + say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.'); + return; + } + if (!isOpen(station.def, state.minutes)) { + say( + guideOut() ? 'robot' : 'father', + `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, + 'serious', + ); + return; + } + shopStation = station; + renderShop(); + document.exitPointerLock?.(); + keys.clear(); + ui.open('shop'); + tone(750); + } + function renderShop() { + const def = shopStation.def; + ui.patch('shop-category', { + text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`, + }); + ui.patch('shop-name', { + text: `روبات مارکت · ${def.name}`, + }); + ui.patch('shop-address', { + text: def.address, + }); + ui.patch('shop-note', { + text: `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`, + }); + ui.patch('products', { + items: def.products.map((id) => ({ + id, + ...catalog[id], + price: priceAt(def, id), + blocked: purchaseBlocker(state, def, id), + })), + }); + } + function startPayment(productId) { + if (!beginPayment(state, payment, shopStation.def, productId)) return; + payment.station = shopStation; + payment.timer = 0; + ui.close('shop'); + const p = catalog[productId]; + ui.patch('payment', { + hidden: false, + }); + ui.patch('payment-title', { + text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`, + }); + ui.patch('payment-step', { + text: 'در حال نزدیک شدن به کارت‌خوان…', + }); + ui.patch('swipe', { + disabled: true, + }); + ui.patch('cancel-payment', { + hidden: false, + }); + say('child', 'بابا، کارت رو بده من بکشم!'); + } + function swipe() { + if (!swipePayment(state, payment)) return; + payment.timer = 0; + payment.station.model.card.visible = true; + ui.patch('swipe', { + disabled: true, + }); + ui.patch('cancel-payment', { + hidden: true, + }); + ui.patch('payment-step', { + text: 'پرداخت موفق ✓ محصول در حال تحویل است', + }); + tone(1100, 0.3); + refreshHUD(); + } + function cancel() { + if (!cancelPayment(state, payment)) return; + ui.patch('payment', { + hidden: true, + }); + } + ui.on('swipe', swipe); + // On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. + ui.on('prompt', () => { + if (payment.phase === 'ready') swipe(); + else if (nearStation()) interact(); + else toggleCar(); + }); + ui.on('cancel-payment', cancel); + ui.on('shop-close', () => ui.close('shop')); + function updatePayment(dt) { + if (payment.phase === 'idle') return; + payment.timer += dt; + const model = payment.station.model; + if (payment.phase === 'approaching' && payment.timer > 1) { + readyPayment(payment); + ui.patch('swipe', { + disabled: false, + }); + ui.patch('payment-step', { + text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C', + }); + } + if (payment.phase === 'approved') { + const t = Math.min(1, payment.timer); + model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66); + if (payment.timer > 1.2) { + model.card.visible = false; + ui.patch('payment', { + hidden: true, + }); + model.setDelivery(payment.productId); + deliveryTimer = 2.6; + payment.delivering = { + station: payment.station, + record: payment.record, + }; + Object.assign(payment, createPayment()); + } + } + } + function updateDelivery(dt) { + if (deliveryTimer <= 0) return; + deliveryTimer = Math.max(0, deliveryTimer - dt); + const { station, record } = payment.delivering, + model = station.model, + t = 2.6 - deliveryTimer; + const slot = record ? catalog[record.productId] : null; + model.delivery.visible = t < 2.4; + // Falls from a shelf, waits in the tray, then lifts toward the child's hands. + const shelf = new THREE.Vector3( + -0.5 + (record.price % 7) * 0.1, + 1.2 + (record.price % 5) * 0.15, + 0.45, + ); + if (t < 0.8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / 0.8); + else + model.delivery.position + .copy(model.slotPoint) + .add(new THREE.Vector3(0, Math.max(0, t - 1.6) * 0.6, Math.max(0, t - 1.6) * 0.8)); + model.flap.rotation.x = t > 0.7 && t < 2 ? -1 : 0; + if (deliveryTimer > 0) return; + model.flap.rotation.x = 0; + state.busy = false; + tone(980, 0.18); + reward(record, slot); + } + function reward(record, product) { + const awards = awardsFor(state.purchases.slice(0, -1), record), + rankBefore = rankFor(state.xp).index; + if (real.on) + awards.push({ + label: 'خرید در دنیای واقعی', + points: 200, + }); + const total = awards.reduce((s, a) => s + a.points, 0); + state.xp += total; + ui.patch('awards', { + items: awards, + total, + }); + clearTimeout(reward.timeout); + reward.timeout = setTimeout( + () => + ui.patch('awards', { + items: [], + }), + 3200, + ); + say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`); + const done = completeMissions(state); + for (const m of done) { + showBanner( + 'مأموریت انجام شد', + m.title, + `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`, + ); + tone(660, 0.5); + } + if (rankFor(state.xp).index > rankBefore) + setTimeout( + () => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاه‌های بیشتری پیدا کن'), + done.length ? 3400 : 0, + ); + const next = activeMission(state); + if (next) { + waypoint = null; + route = null; + setTimeout( + () => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), + 1500, + ); + } + refreshHUD(); + checkEnd(); + } + function showBanner(tag, title, sub) { + ui.patch('banner', { + tag, + title, + sub, + hidden: false, + }); + clearTimeout(showBanner.timeout); + showBanner.timeout = setTimeout( + () => + ui.patch('banner', { + hidden: true, + }), + 3200, + ); + } + function checkEnd() { + const allDone = state.missionsDone.length === MISSIONS.length; + if (ended || (!allDone && canAffordAnything(state, machines))) return; + ended = true; + document.exitPointerLock?.(); + ui.patch('end-tag', { + text: allDone ? 'عملیات موفق' : 'پایان بودجه', + }); + ui.patch('end-title', { + text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد', + }); + ui.patch('end-text', { + text: allDone + ? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.' + : 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:', + }); + const places = new Set(state.purchases.map((p) => p.machineId)).size; + ui.patch('end-stats', { + items: [ + ['امتیاز', `${fa(state.xp)} XP`], + ['درجه', rankFor(state.xp).name], + ['خریدها', fa(state.purchases.length)], + ['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`], + ['مأموریت‌ها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`], + ['باقی‌مانده', formatHetoman(state.balance)], + ], + }); + ui.patch('continue', { + hidden: !allDone, + }); + ui.open('end'); + } + ui.patch('quality', { + text: quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل', + }); + ui.on('quality', () => { + try { + localStorage.setItem('quality', quality === 'low' ? 'high' : 'low'); + } catch {} + location.replace(location.pathname); + }); + ui.on('again', reset); + ui.on('continue', () => ui.close('end')); + ui.on('restart', reset); + + // ---------- Objective, route and HUD ---------- + const objectiveStation = () => { + if (waypoint) return stationById(waypoint); + const mission = activeMission(state); + if (mission?.target) return stationById(mission.target); + // Open-ended missions point to the nearest place not bought from yet. + const visited = new Set(state.purchases.map((p) => p.machineId)); + return ( + activeStations() + .filter((s) => !visited.has(s.def.id)) + .sort( + (a, b) => + Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z), + )[0] ?? null + ); + }; + + // The gold route ribbon on the ground, rebuilt when the route changes. + const routeMaterial = new THREE.MeshBasicMaterial({ + color: '#f2c94c', + transparent: true, + opacity: 0.75, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -12, + polygonOffsetUnits: -24, + }); + let routeMesh = null; + function rebuildRouteMesh() { + if (routeMesh) { + scene.remove(routeMesh); + routeMesh.geometry.dispose(); + routeMesh = null; + } + if (!route) return; + const pts = [], + pos = [], + idx = []; + let total = 0; + for (const p of route.points) { + if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z); + if (total > 700) break; + pts.push(p); + } + for (let i = 0; i < pts.length; i++) { + const a = pts[Math.max(0, i - 1)], + b = pts[Math.min(pts.length - 1, i + 1)]; + const dx = b.x - a.x, + dz = b.z - a.z, + len = Math.hypot(dx, dz) || 1, + w = mode === 'drive' ? 1.1 : 0.55; + pos.push( + pts[i].x - (dz / len) * w, + 0.14, + pts[i].z + (dx / len) * w, + pts[i].x + (dz / len) * w, + 0.14, + pts[i].z - (dx / len) * w, + ); + if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1); + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setIndex(idx); + routeMesh = new THREE.Mesh(g, routeMaterial); + scene.add(routeMesh); + } + let routeTarget = null, + routeFrom = { + x: 0, + z: 0, + }, + routeMode = null; + function updateRoute(dt) { + routeTimer -= dt; + const target = objectiveStation(); + if (!target) { + if (route) { + route = null; + rebuildRouteMesh(); + } + return; + } + const pos = mode === 'drive' ? car : player; + const changed = target !== routeTarget || routeMode !== mode || !route; + if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10)) + return; + routeTimer = 1.2; + routeTarget = target; + routeMode = mode; + routeFrom = { + x: pos.x, + z: pos.z, + }; + // Close targets are reached directly; the street network would detour out of buildings. + const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z); + route = + direct < 60 + ? { + points: [ + { + x: pos.x, + z: pos.z, + }, + { + x: target.front.x, + z: target.front.z, + }, + ], + length: direct, + } + : routeBetween( + mode === 'drive' ? driveGraph : walkGraph, + pos.x, + pos.z, + target.front.x, + target.front.z, + ); + rebuildRouteMesh(); + } + const compass = $('#compass').getContext('2d'), + minimap = $('#minimap').getContext('2d'); + function drawCompass(headingDeg, markers) { + const w = 560, + h = 44; + compass.clearRect(0, 0, w, h); + compass.fillStyle = 'rgba(10,16,14,.45)'; + compass.fillRect(0, 10, w, 24); + compass.textAlign = 'center'; + compass.textBaseline = 'middle'; + const labels = { + 0: 'شمال', + 90: 'شرق', + 180: 'جنوب', + 270: 'غرب', + }; + for (let d = -100; d <= 100; d += 5) { + const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360, + x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6; + if (x < 10 || x > w - 10) continue; + compass.fillStyle = 'rgba(236,242,238,.8)'; + if (labels[deg] !== undefined) { + compass.font = 'bold 13px Vazirmatn, sans-serif'; + compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee'; + compass.fillText(labels[deg], x, 22); + } else if (deg % 15 === 0) { + compass.font = '10px Vazirmatn, sans-serif'; + compass.fillText(fa(deg), x, 22); + } else compass.fillRect(x - 0.5, 18, 1, 8); + } + for (const m of markers) { + const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6; + if (x < 10 || x > w - 10) continue; + compass.save(); + compass.translate(x, 6); + compass.rotate(Math.PI / 4); + compass.fillStyle = m.color; + compass.fillRect(-4, -4, 8, 8); + compass.restore(); + } + compass.fillStyle = '#ecf2ee'; + compass.fillRect(w / 2 - 1, 34, 2, 8); + compass.font = 'bold 11px Vazirmatn, sans-serif'; + compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6); + } + function refreshHUD() { + ui.patch('balance', { + text: fa(state.balance), + }); + const rank = rankFor(state.xp); + ui.patch('rank-name', { + text: rank.name, + }); + ui.patch('xp', { + text: rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`, + }); + ui.style('xp-bar', { + width: `${Math.round(rank.progress * 100)}%`, + }); + const active = activeMission(state); + ui.patch('mission-list', { + items: MISSIONS.map((m) => ({ + id: m.id, + title: m.title, + done: state.missionsDone.includes(m.id), + active: m === active, + })), + }); + ui.publishGame(state); + ui.patch('mode-label', { + text: mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده', + }); + ui.patch('speed-stat', { + hidden: mode !== 'drive', + }); + } + function updatePrompt() { + let text = ''; + const station = nearStation(); + if (!state.busy && payment.phase === 'idle') { + if (station) + text = isOpen(station.def, state.minutes) + ? `E · خرید از روبات مارکت · ${station.def.name}` + : `دستگاه بسته است · ${hoursText(station.def)}`; + else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) + text = 'F · سوار ماشین شو'; + else if ( + mode === 'drive' && + Math.abs(car.speed) < 1 && + stations.some((s) => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25) + ) + text = 'F · پیاده شو؛ دستگاه نزدیک است'; + } + ui.patch('prompt', { + text, + hidden: !text, + }); + } + + // ---------- Map dialog ---------- + const mapView = { + cx: 0, + cz: 0, + mpp: 2, + }, + mapCanvas = $('#fullmap'), + iranCanvas = $('#iran-map'); + let selected = null, + mapTab = 'city'; + const markers = () => { + const visited = new Set(state.purchases.map((p) => p.machineId)), + objective = objectiveStation(); + return activeStations().map((s) => ({ + id: s.def.id, + x: s.x, + z: s.z, + visited: visited.has(s.def.id), + open: isOpen(s.def, state.minutes), + objective: s === objective, + })); + }; + function sizeCanvas(c) { + const r = c.getBoundingClientRect(), + dpr = Math.min(devicePixelRatio, 2); + c.width = Math.max(1, r.width * dpr); + c.height = Math.max(1, r.height * dpr); + return dpr; + } + function renderMap() { + if (!ui.read('map-dialog').open) return; + if (mapTab === 'city') { + const dpr = sizeCanvas(mapCanvas); + const pos = mode === 'drive' ? car : player; + drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, { + dpr, + markers: markers(), + route: route?.points, + selected, + player: { + x: pos.x, + z: pos.z, + heading: mode === 'drive' ? car.heading : player.heading, + }, + }); + } else { + const dpr = sizeCanvas(iranCanvas), + c = iranCanvas.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + drawIran(c, iran, { + width: iranCanvas.width / dpr, + height: iranCanvas.height / dpr, + t: elapsed, + cities: CITIES, + }); + } + } + function renderMachineList() { + const pos = mode === 'drive' ? car : player; + const visited = new Set(state.purchases.map((p) => p.machineId)); + const items = activeStations().map((s) => ({ + id: s.def.id, + name: s.def.name, + icon: CATEGORIES[s.def.category].icon, + category: CATEGORIES[s.def.category].label, + visited: visited.has(s.def.id), + open: isOpen(s.def, state.minutes), + selected: s.def.id === selected, + distance: formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z)), + })); + ui.patch('machine-list', { + items: [ + ...items, + ...lockedPlaces.map((p) => ({ + ...p, + id: p.name, + locked: true, + })), + ], + }); + } + function selectMachine(id, center = false) { + selected = id; + const s = stationById(id), + def = s.def; + if (center) { + mapView.cx = s.x; + mapView.cz = s.z; + mapView.mpp = Math.min(mapView.mpp, 1.4); + } + ui.patch('machine-detail', { + hidden: false, + }); + ui.patch('detail-name', { + text: `${CATEGORIES[def.category].icon} ${def.name}`, + }); + ui.patch('detail-address', { + text: `📍 ${def.address}`, + }); + ui.patch('detail-meta', { + text: `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`, + }); + ui.patch('detail-products', { + text: def.products + .map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`) + .join(' · '), + }); + ui.patch('route-button', { + text: waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا', + }); + renderMachineList(); + renderMap(); + } + ui.on('route-button', () => { + if (!selected) return; + waypoint = selected; + route = null; + routeTimer = 0; + updateRoute(0); + const s = stationById(selected); + say( + 'father', + `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`, + ); + selectMachine(selected); + }); + function openMap() { + if (anyDialogOpen()) return; + document.exitPointerLock?.(); + keys.clear(); + const pos = mode === 'drive' ? car : player; + Object.assign(mapView, { + cx: pos.x, + cz: pos.z, + mpp: 2.2, + }); + ui.open('map-dialog'); + selected ??= objectiveStation()?.def.id ?? null; + if (selected) selectMachine(selected); + renderMachineList(); + renderMap(); + } + ui.on('minimap-button', openMap); + ui.on('map-close', () => ui.close('map-dialog')); + for (const tab of ['city', 'iran']) + ui.on(`tab-${tab}`, () => { + mapTab = tab; + ui.patch('tab-city', { + selected: tab === 'city', + }); + ui.patch('tab-iran', { + selected: tab === 'iran', + }); + ui.patch('fullmap', { hidden: tab !== 'city' }); + ui.patch('iran-map', { hidden: tab !== 'iran' }); + renderMap(); + }); + ui.on('select-machine', (id) => selectMachine(id, true)); + ui.on('select-product', startPayment); + let mapDrag = null; + listen(mapCanvas, 'pointerdown', (e) => { + mapDrag = { + x: e.clientX, + y: e.clientY, + moved: 0, + }; + mapCanvas.setPointerCapture(e.pointerId); + }); + listen(mapCanvas, 'pointermove', (e) => { + if (!mapDrag) return; + mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp; + mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp; + mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y); + mapDrag.x = e.clientX; + mapDrag.y = e.clientY; + renderMap(); + }); + listen(mapCanvas, 'pointerup', (e) => { + if (mapDrag && mapDrag.moved < 6) { + const r = mapCanvas.getBoundingClientRect(); + const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp, + wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp; + const hit = activeStations().find((s) => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp); + if (hit) selectMachine(hit.def.id); + } + mapDrag = null; + }); + listen( + mapCanvas, + 'wheel', + (e) => { + e.preventDefault(); + mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), 0.35, 30); + renderMap(); + }, + { + passive: false, + }, + ); + + // ---------- Briefing ---------- + const CITIES = [ + { + name: 'مشهد', + lat: 36.3, + lon: 59.6, + active: true, + }, + { + name: 'تهران', + lat: 35.69, + lon: 51.39, + }, + { + name: 'اصفهان', + lat: 32.65, + lon: 51.67, + }, + { + name: 'شیراز', + lat: 29.59, + lon: 52.58, + }, + { + name: 'تبریز', + lat: 38.08, + lon: 46.29, + }, + { + name: 'اهواز', + lat: 31.32, + lon: 48.67, + }, + { + name: 'کرمان', + lat: 30.28, + lon: 57.08, + }, + { + name: 'رشت', + lat: 37.28, + lon: 49.58, + }, + { + name: 'یزد', + lat: 31.9, + lon: 54.37, + }, + { + name: 'زاهدان', + lat: 29.5, + lon: 60.86, + }, + { + name: 'کرمانشاه', + lat: 34.31, + lon: 47.07, + }, + { + name: 'بندرعباس', + lat: 27.18, + lon: 56.27, + }, + ]; + const BRIEF = [ + `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`, + 'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست', + 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.', + 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.', + 'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.', + 'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.', + ].join('\n'); + let briefChars = 0; + function startMission() { + ui.patch('briefing', { + hidden: true, + }); + ui.patch('hud', { + hidden: false, + }); + say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟'); + say('father', 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!'); + say('child', 'اون وانت سفیده کیه داره میاد تو؟'); + say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.'); + if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {}); + } + ui.on('start-mission', startMission); + + // ---------- Real-world (GPS) mode ---------- + // The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where + // a machine stands lets the player buy there, with a bonus. + const real = { + on: false, + fix: null, + heading: null, + error: null, + waiting: null, + stop: null, + stabilizer: createStabilizer(), + }; + function setRealMode(on) { + if (on === real.on) return; + real.stop?.(); + Object.assign(real, { + on, + fix: null, + heading: null, + error: null, + waiting: null, + stop: null, + }); + for (const s of stations) if (s.def.test) s.model.group.visible = on; + ui.classState('gps-toggle', 'on', on); + ui.patch('gps-status', { + hidden: !on, + }); + ui.patch('ar-toggle', { + hidden: ui.patch('stable-toggle', { + hidden: !on, + }), + }); + // Real-world mode is first person: the player is where the phone is and sees what it faces. + ui.classState('body', 'real', on); + if (!on) { + setArMode(false); + arView.stopSensors(); + return; + } + arView.startSensors(); + cam.arPitch = 0; + cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); + if (mode === 'drive') toggleCar(); + mode = 'walk'; + real.stop = startTracking({ + onFix: (fix) => { + real.fix = fix; + real.error = null; + }, + onHeading: (heading) => (real.heading = heading), + onError: (message) => (real.error = message), + onStatus: (message) => (real.waiting = message), + stabilizer: (real.stabilizer = createStabilizer()), + }); + real.stabilizer.stable = stableMode; + say( + 'father', + 'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.', + ); + setArMode(true); + } + + // ---------- First-person real-world view ---------- + // The view stands at the GPS position at eye height and turns with the phone. With the camera + // picture on (AR) the city model is hidden and only the machines, their reach circles, the route + // and the robot are drawn over the real street; with it off, the 3D city is shown first person. + const arView = createArView($('#camera')); + let arMode = false, + stableMode = true; + const hiddenInAr = () => [ + sky, + city.group, + imagery.group, + factory.group, + traffic.group, + carModel.group, + parkGate, + ]; + async function setArMode(on) { + if (on && !arMode) { + const problem = await arView.start(); + if (problem) { + say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`); + return; + } + if (!real.on) { + arView.stop(); + return; + } + say( + 'robot', + 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', + 'excited', + ); + setTimeout(() => { + if (real.on && !arView.hasOrientation) + say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.'); + }, 3000); + } else if (!on && arMode) arView.stop(); + else return; + arMode = on; + for (const object of hiddenInAr()) object.visible = !on; + scene.fog = on ? null : fog; + ui.classState('body', 'ar', on); + ui.classState('ar-toggle', 'on', on); + } + ui.on('ar-toggle', () => setArMode(!arMode)); + ui.on('stable-toggle', () => { + stableMode = !stableMode; + real.stabilizer.stable = stableMode; + ui.classState('stable-toggle', 'on', stableMode); + ui.patch('stable-toggle', { + text: stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش', + }); + }); + + // The safety notice comes first; permissions are requested from the tap on its button (iOS needs that). + function askRealMode(thenStart) { + if (real.on) { + setRealMode(false); + return; + } + ui.open('real-intro'); + ui.on('real-go', async () => { + ui.close('real-intro'); + await requestCompass(); + setRealMode(true); + if (thenStart) startMission(); + }); + } + ui.on('real-mode', () => askRealMode(true)); + ui.on('gps-toggle', () => askRealMode(false)); + ui.on('real-cancel', () => ui.close('real-intro')); + function updateReal(dt) { + let uiStatus; + if (real.error) uiStatus = `⚠ ${real.error}`; + else if (!real.fix) uiStatus = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`; + else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) { + const nearest = Math.min( + ...activeStations().map((s) => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)), + ); + uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`; + } else + uiStatus = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`; + ui.patch('gps-status', { + text: uiStatus, + }); + if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) { + player.speed = 0; + return; + } + // Walk the avatar towards the (building-free) GPS point instead of teleporting it. + const target = resolveCircle(collision, real.fix.x, real.fix.z, 1); + const dx = target.x - player.x, + dz = target.z - player.z, + d = Math.hypot(dx, dz); + if (d > 150) + Object.assign(player, { + x: target.x, + z: target.z, + }); + const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0; + player.speed += (speed - player.speed) * Math.min(1, dt * 6); + if (d > 0.5) { + const step = Math.min(d, player.speed * dt); + player.x += (dx / d) * step; + player.z += (dz / d) * step; + } + const want = real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : player.heading); + // Stable mode also calms the compass, which jitters near cars and metal. + player.heading += + Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * + Math.min(1, dt * (stableMode ? 2 : 5)); + player.gait += dt * player.speed * 1.9; + } + + // ---------- Loop ---------- + const camPos = new THREE.Vector3(), + camTarget = new THREE.Vector3(), + lookAt = new THREE.Vector3(); + let elapsed = 0, + last = performance.now(), + hudTimer = 0, + streetTimer = 0; + // Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps. + const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : 0.25; + function resize() { + renderer.setSize(innerWidth, innerHeight); + camera.aspect = innerWidth / innerHeight; + camera.updateProjectionMatrix(); + renderMap(); + } + listen(window, 'resize', resize); + function updateWalk(dt) { + const forward = { + x: -Math.sin(cam.yaw), + z: -Math.cos(cam.yaw), + }, + right = { + x: Math.cos(cam.yaw), + z: -Math.sin(cam.yaw), + }; + const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, + ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x; + const frozen = state.busy || payment.phase !== 'idle'; + let dx = forward.x * iy + right.x * ix, + dz = forward.z * iy + right.z * ix; + const len = Math.hypot(dx, dz); + const run = keys.has('shift'); + const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len); + player.speed += (target - player.speed) * Math.min(1, dt * 8); + if (len > 0.1 && !frozen) { + dx /= len; + dz /= len; + const want = Math.atan2(-dz, dx); + player.heading += + Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * + Math.min(1, dt * 9); + } + // Face the machine while buying. + const station = payment.station ?? payment.delivering?.station; + if (frozen && station) { + const want = Math.atan2(-(station.z - player.z), station.x - player.x); + player.heading += + Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * + Math.min(1, dt * 6); + } + const mx = Math.cos(player.heading) * player.speed * dt, + mz = -Math.sin(player.heading) * player.speed * dt; + const p = resolveCircle(collision, player.x + mx, player.z + mz, 1); + const { minX, minZ, maxX, maxZ } = data.bounds; + player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15); + player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15); + player.gait += dt * player.speed * 1.9; + } + + // Collision log for ?debug runs; trimmed so it never grows without bound. + const debugHits = []; + function updateDrive(dt) { + if (debugHits.length > 200) debugHits.splice(0, 100); + const throttle = THREE.MathUtils.clamp( + axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, + -1, + 1, + ); + const steer = THREE.MathUtils.clamp( + axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, + -1, + 1, + ); + // At top speed the car covers almost 2 m per step, so move it in short slices that can't + // tunnel through a thin wall or fence. + const slices = Math.max(1, Math.ceil((Math.abs(car.speed) * dt) / 0.6)); + for (let i = 0; i < slices; i++) { + stepCar( + car, + { + throttle, + steer, + handbrake: keys.has(' '), + }, + dt / slices, + ); + // Two collision circles along the body keep the nose and tail out of buildings. + for (const along of [1.3, -1.3]) { + const cx = car.x + Math.cos(car.heading) * along, + cz = car.z - Math.sin(car.heading) * along; + const p = resolveCircle(collision, cx, cz, 1.05); + if (p.hit) { + debugHits.push({ + kind: 'building', + x: Math.round(cx), + z: Math.round(cz), + v: +car.speed.toFixed(1), + }); + car.x += p.x - cx; + car.z += p.z - cz; + if (Math.abs(car.speed) > 4) tone(90, 0.2, 0.08, 'square'); + car.speed *= 0.35; + } + } + } + // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed + // against one, it pulls away (respawns out of sight) so you can never get stuck behind it. + for (const npc of traffic.cars) { + const dx = car.x - npc.x, + dz = car.z - npc.z, + d = Math.hypot(dx, dz); + if (d >= 3.4 || d === 0) { + if (npc.contact) npc.contact = 0; + continue; + } + debugHits.push({ + kind: 'traffic', + x: Math.round(car.x), + z: Math.round(car.z), + v: +car.speed.toFixed(1), + npcDir: npc.dir, + }); + car.x = npc.x + (dx / d) * 3.4; + car.z = npc.z + (dz / d) * 3.4; + const towards = + (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0; + if (towards && Math.abs(car.speed) > 4) tone(110, 0.25, 0.08, 'square'); + if (towards) car.speed *= 0.4; + npc.contact = (npc.contact ?? 0) + dt; + if (npc.contact > 0.8) { + npc.contact = 0; + traffic.remove(npc); + } + } + const { minX, minZ, maxX, maxZ } = data.bounds; + car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15); + car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15); + Object.assign(player, { + x: car.x, + z: car.z, + heading: car.heading, + }); + // Chase camera swings back behind the car when the mouse is idle. + if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) { + const want = + Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0); + cam.yaw += + Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2); + cam.pitch += (0.28 - cam.pitch) * Math.min(1, dt * 1.5); + } + if (engine && soundOn) { + engine.o.frequency.setTargetAtTime( + 45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15, + audio.currentTime, + 0.1, + ); + engine.g.gain.setTargetAtTime(0.025, audio.currentTime, 0.2); + } + } + function updateCamera(dt) { + const station = payment.station; + if (station && payment.phase !== 'idle') { + // Close-up of the card reader during payment. + const reader = station.model.readerPoint.clone(); + station.model.group.localToWorld(reader); + const side = rotateLocal(1.2, 1.9, station.rot); + camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z); + lookAt.lerp(reader, 1 - Math.exp(-dt * 6)); + } else { + const driving = mode === 'drive', + height = driving ? 1.6 : 1.75; + const focus = new THREE.Vector3(player.x, height, player.z); + // Over-the-shoulder offset to the right, like a third-person shooter. + const shoulder = driving ? 0 : 0.75; + focus.x += Math.cos(cam.yaw) * shoulder; + focus.z += -Math.sin(cam.yaw) * shoulder; + let distance = cam.distance; + const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), + oz = Math.cos(cam.yaw) * Math.cos(cam.pitch); + const free = clearFraction( + collision, + focus.x, + focus.z, + focus.x + ox * distance, + focus.z + oz * distance, + 0.8, + ); + if (cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7); + camTarget.set( + focus.x + ox * distance, + Math.max(0.6, focus.y + Math.sin(cam.pitch) * distance), + focus.z + oz * distance, + ); + lookAt.lerp(focus, 1 - Math.exp(-dt * 14)); + } + camPos.lerp(camTarget, 1 - Math.exp(-dt * 12)); + camera.position.copy(camPos); + camera.lookAt(lookAt); + } + function updateHUD(dt) { + const pos = mode === 'drive' ? car : player; + ui.patch('clock', { + text: formatClock(state.minutes), + }); + if (mode === 'drive') + ui.patch('speed', { + text: fa(kmh(car)), + }); + // Where the camera actually looks: the orbit camera, or the phone itself in AR view. + const look3 = camera.getWorldDirection(new THREE.Vector3()), + flat = Math.hypot(look3.x, look3.z); + const forward = + flat > 0.05 + ? { + x: look3.x / flat, + z: look3.z / flat, + } + : { + x: -Math.sin(cam.yaw), + z: -Math.cos(cam.yaw), + }; + const headingDeg = bearing(forward.x, forward.z); + const objective = objectiveStation(); + drawCompass( + headingDeg, + objective + ? [ + { + bearing: bearing(objective.x - pos.x, objective.z - pos.z), + color: '#f2c94c', + }, + ] + : [], + ); + drawMinimap(minimap, mapAssets, { + size: 220, + x: pos.x, + z: pos.z, + heading: mode === 'drive' ? car.heading : player.heading, + camForward: forward, + radius: mode === 'drive' ? 650 : 200, + markers: markers(), + route: route?.points, + }); + // Objective marker above the machine, clamped to the screen edge when off-screen. + ui.patch('waypoint', { + hidden: !objective, + }); + if (objective) { + const d = Math.hypot(objective.x - pos.x, objective.z - pos.z), + p = new THREE.Vector3(objective.x, 3.4, objective.z).project(camera); + let sx = (p.x * 0.5 + 0.5) * innerWidth, + sy = (-p.y * 0.5 + 0.5) * innerHeight; + if (p.z > 1) { + sx = innerWidth - sx; + sy = innerHeight - 60; + } + ui.style('waypoint', { + left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`, + }); + ui.style('waypoint', { + top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`, + }); + ui.patch('waypoint-name', { + text: objective.def.name, + }); + ui.patch('waypoint-distance', { + text: formatDistance(d), + }); + ui.patch('waypoint', { + hidden: d < 6, + }); + for (const s of stations) { + const isObjective = s === objective; + s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9'); + s.model.beacon.material.opacity = + (isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40); + } + } + streetTimer -= dt; + if (streetTimer <= 0) { + streetTimer = 0.6; + const street = nearestSegment(segments, pos.x, pos.z, 35, (s) => s[5] >= 0); + const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130; + ui.patch('street', { + text: inFactory + ? `کارخانهٔ ${FACTORY.name}` + : street + ? `مشهد · ${data.names[street.name]}` + : 'حومهٔ مشهد', + }); + } + updatePrompt(); + } + let hints = { + closed: false, + car: false, + }; + + // People in the factory say their lines once, when the family walks up to them. + function updateTalks() { + if (mode !== 'walk' || real.on || lines.length > 2) return; + for (const t of factory.talks) { + if (t.done || (t.ready && !t.ready())) continue; + const p = t.pos(); + if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue; + t.done = true; + for (const text of t.lines) say(t.speaker, text); + return; + } + } + + // ---------- Robot guide ---------- + // When the family walks up to a machine the robot pops out of it, greets them and explains what to + // do; when they leave it goes back inside. It is never a walking companion. + const guideState = { + station: null, + t: 0, + greeted: new Set(), + arrived: 0, + }; + const guideOut = () => guideState.t >= 1; + const easeOutBack = (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2; + function greet(station) { + const def = station.def, + first = !guideState.greeted.has(def.id); + guideState.greeted.add(def.id); + if (def.id === 'factory' && first) { + say( + 'robot', + 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!', + 'excited', + ); + say( + 'robot', + `به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, + 'happy', + ); + say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink'); + say( + 'robot', + 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', + 'love', + ); + return; + } + if (!isOpen(def, state.minutes)) { + say( + 'robot', + `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, + 'serious', + ); + return; + } + if (first) { + say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited'); + say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy'); + } else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink'); + } + function updateGuide(dt) { + const near = + mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null; + // The robot only moves to another machine after it has gone back into the previous one. + if (near && !guideState.station) guideState.station = near; + const s = guideState.station, + wasOut = guideOut(); + if (!s) return; + guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1); + if (guideOut() && !wasOut) { + guideState.arrived = elapsed; + greet(s); + tone(990, 0.15); + tone(1320, 0.2); + } + if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy'); + if (!guideState.t) { + guideState.station = null; + guide.group.visible = false; + return; + } + // From inside the machine front out to its left side (the card reader is on the right). + const e = easeOutBack(guideState.t), + from = { + x: -0.2, + y: 0.9, + z: 0.2, + }, + to = { + x: -1.7, + y: 0, + z: 1.3, + }; + const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot); + guide.group.visible = true; + guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z); + guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e)); + // Face the family (the rig looks along its local +X). + guide.group.rotation.y = Math.atan2( + -(player.z - guide.group.position.z), + player.x - guide.group.position.x, + ); + if (!state.busy && payment.phase === 'idle') + s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0; + guide.update({ + dt, + time: elapsed, + gait: 0, + moving: false, + talking, + wave: elapsed - guideState.arrived < 2.5 || !guideOut(), + point: talking && guideOut(), + }); + } + function updateHints() { + const target = objectiveStation(); + if (target && !isOpen(target.def, state.minutes) && !hints.closed) { + hints.closed = true; + say( + 'robot', + `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`, + ); + } + if (route && route.length > 900 && mode === 'walk' && !hints.car) { + hints.car = true; + say('father', 'راه طولانیه؛ بهتره با ماشین بریم.'); + } + } + function frame() { + const now = performance.now(); + frameId = requestAnimationFrame(frame); + // The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are + // simulated in short substeps, so slow devices keep real-time speed without unstable physics. + const dt = THREE.MathUtils.clamp((now - last) / 1000, 0, MAX_FRAME); + last = now; + elapsed += dt; + const playing = ui.read('briefing').hidden; + const paused = + !playing || + ui.read('shop').open || + ui.read('map-dialog').open || + ui.read('end').open || + document.hidden; + const steps = Math.max(1, Math.ceil(dt / (1 / 30))), + step = dt / steps; + if (!playing) { + // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay. + const ic = $('#iran'), + dpr = sizeCanvas(ic), + c = ic.getContext('2d'); + c.setTransform(dpr, 0, 0, dpr, 0, 0); + drawIran(c, iran, { + width: ic.width / dpr, + height: ic.height / dpr, + t: elapsed, + cities: CITIES, + }); + briefChars = Math.min(BRIEF.length, briefChars + dt * 55); + ui.patch('brief-text', { + text: BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : ''), + }); + } + if (!paused) { + for (let i = 0; i < steps; i++) { + state.minutes += (step * 10) / 60; + if (real.on) updateReal(step); + else if (mode === 'drive') updateDrive(step); + else updateWalk(step); + updatePayment(step); + updateDelivery(step); + traffic.update( + step, + [ + { + x: player.x, + z: player.z, + }, + { + x: car.x, + z: car.z, + }, + ], + player, + ); + } + updateRoute(dt); + updateHints(); + updateTalks(); + updateGuide(dt); + if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd(); + } else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.1); + if (mode === 'walk' && engine && soundOn) + engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.2); + updateSubtitles(dt); + family.group.visible = mode === 'walk' && !real.on; + family.group.position.set(player.x, 0, player.z); + family.group.rotation.y = player.heading; + family.update({ + dt, + time: elapsed, + gait: player.gait, + moving: player.speed > 0.4, + shopping: state.busy || payment.phase !== 'idle', + delivery: deliveryTimer > 0, + }); + factory.update(elapsed, paused ? 0 : dt, camera.position); + carModel.group.position.set(car.x, 0, car.z); + carModel.group.rotation.y = car.heading; + for (const [i, wheel] of carModel.wheels.entries()) { + wheel.rotation.z -= (car.speed * dt) / 0.36; + if (i >= 2) wheel.rotation.y = -car.steer; + } + for (const s of stations) s.model.ring.material.opacity = 0.35 + Math.sin(elapsed * 3) * 0.25; + updateReachCircles(); + if (routeMesh) routeMaterial.opacity = 0.55 + Math.sin(elapsed * 4) * 0.15; + if (playing && real.on) { + // First person: the phone is the camera, at the player's GPS position and eye height. + camera.position.set(player.x, 1.6, player.z); + if (!arView.update(camera, dt, stableMode ? 5 : 10)) { + // No orientation sensor: face the compass or walking direction, or wherever the player drags. + if (real.heading != null && elapsed - cam.lastMouse > 2) { + const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); + cam.yaw += + Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3); + } + camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ'); + } + camPos.copy(camera.position); + } else if (playing) { + if (camera.fov !== 62) { + camera.fov = 62; + camera.updateProjectionMatrix(); + } + updateCamera(dt); + } else { + const a = elapsed * 0.08; + camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30); + camera.lookAt(player.x, 2, player.z); + camPos.copy(camera.position); + } + sun.position.set(player.x + 160, 230, player.z + 110); + sun.target.position.set(player.x, 0, player.z); + sky.position.copy(camera.position); + city.update(camera.position); + if (!arMode) imagery.update(camera.position.x, camera.position.z); + if (playing) { + hudTimer -= dt; + updateHUD(dt); + if (ui.read('map-dialog').open && hudTimer <= 0) { + hudTimer = 0.5; + renderMap(); + } + } + renderer.render(scene, camera); + } + + // ?debug exposes live game state in the console for development and automated checks. + if (new URLSearchParams(location.search).has('debug')) + window.game = { + get state() { + return state; + }, + get player() { + return player; + }, + get cam() { + return cam; + }, + factory, + get car() { + return car; + }, + get mode() { + return mode; + }, + get payment() { + return payment; + }, + get elapsed() { + return elapsed; + }, + get route() { + return route; + }, + setWaypoint(id) { + waypoint = id; + route = null; + routeTimer = 0; + }, + get arMode() { + return arMode; + }, + arView, + setArMode, + debugHits, + nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, (s) => s[4] <= 3), + stations, + guideState, + real, + keys, + traffic, + }; + reset(); + resize(); + + ui.patch('loading', { + hidden: true, + }); + frameId = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(frameId); + timers.forEach(window.clearTimeout); + real.stop?.(); + arView.stop(); + arView.stopSensors(); + imagery.dispose?.(); + audio?.close(); + const geometries = new Set(), + materials = new Set(), + textures = new Set(); + scene.traverse((object) => { + if (object.geometry) geometries.add(object.geometry); + const list = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of list) { + if (!material) continue; + materials.add(material); + for (const value of Object.values(material)) if (value?.isTexture) textures.add(value); + } + }); + geometries.forEach((item) => item.dispose()); + materials.forEach((item) => item.dispose()); + textures.forEach((item) => item.dispose()); + renderer.dispose(); + if (document.pointerLockElement === canvas) document.exitPointerLock?.(); + if (window.game) delete window.game; + ui.clearActions(); + }; +} diff --git a/src/ar.js b/src/game/device/ar.js similarity index 100% rename from src/ar.js rename to src/game/device/ar.js diff --git a/src/game/device/pwa.js b/src/game/device/pwa.js new file mode 100644 index 0000000..62a0a68 --- /dev/null +++ b/src/game/device/pwa.js @@ -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(); + }; +} diff --git a/src/realworld.js b/src/game/device/realworld.js similarity index 99% rename from src/realworld.js rename to src/game/device/realworld.js index bc04e49..050e22a 100644 --- a/src/realworld.js +++ b/src/game/device/realworld.js @@ -1,4 +1,4 @@ -import { toLocal } from './geo.js'; +import { toLocal } from '../../utils/geo.js'; // Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by // 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius. diff --git a/src/game-state.js b/src/game/economy/gameState.js similarity index 100% rename from src/game-state.js rename to src/game/economy/gameState.js diff --git a/src/payment.js b/src/game/economy/payment.js similarity index 95% rename from src/payment.js rename to src/game/economy/payment.js index 61128d5..6d62d7a 100644 --- a/src/payment.js +++ b/src/game/economy/payment.js @@ -1,4 +1,4 @@ -import { purchase, purchaseBlocker } from './game-state.js'; +import { purchase, purchaseBlocker } from './gameState.js'; // idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once). export function createPayment() { diff --git a/src/scoring.js b/src/game/economy/scoring.js similarity index 98% rename from src/scoring.js rename to src/game/economy/scoring.js index 44beb53..adbef0d 100644 --- a/src/scoring.js +++ b/src/game/economy/scoring.js @@ -1,4 +1,4 @@ -import { CATEGORIES } from './locations.js'; +import { CATEGORIES } from '../../constants/locations.js'; // The bonus for visiting every kind of place counts city locations, not the factory. const CITY_CATEGORIES = Object.keys(CATEGORIES).filter((c) => c !== 'factory' && c !== 'test'); diff --git a/src/game/interfaceBridge.js b/src/game/interfaceBridge.js new file mode 100644 index 0000000..e2e62f6 --- /dev/null +++ b/src/game/interfaceBridge.js @@ -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(); + }, + }; +} diff --git a/src/game/interfaceBridge.test.js b/src/game/interfaceBridge.test.js new file mode 100644 index 0000000..3b8ea1a --- /dev/null +++ b/src/game/interfaceBridge.test.js @@ -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); + }); +}); diff --git a/src/car-models.js b/src/game/models/carModels.js similarity index 100% rename from src/car-models.js rename to src/game/models/carModels.js diff --git a/src/characters.js b/src/game/models/characters.js similarity index 98% rename from src/characters.js rename to src/game/models/characters.js index 4439748..1719b78 100644 --- a/src/characters.js +++ b/src/game/models/characters.js @@ -196,12 +196,11 @@ export function createFamily({ robot: robotOnly = false } = {}) { 1.59 * scale, 0, ]); - const seam = shape( - torso, - new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale), - mat('#bda77f'), - [0, 1.2 * scale, 0.19 * scale], - ); + shape(torso, new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale), mat('#bda77f'), [ + 0, + 1.2 * scale, + 0.19 * scale, + ]); for (const side of [-1, 1]) { const collar = shape( torso, @@ -374,7 +373,7 @@ export function createFamily({ robot: robotOnly = false } = {}) { ); // With characters facing +X, father's left and child's right hands meet. const contact = new THREE.Vector3(0, 1.06, 0); - actors.forEach((actor, index) => + actors.forEach((actor) => actor.arms.forEach((arm, i) => { const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1; diff --git a/src/factory.js b/src/game/models/factory.js similarity index 99% rename from src/factory.js rename to src/game/models/factory.js index f567af3..2ef2540 100644 --- a/src/factory.js +++ b/src/game/models/factory.js @@ -1,9 +1,9 @@ import * as THREE from 'three'; import { createVendingMachine } from './vending.js'; -import { createCarModel } from './traffic.js'; -import { createPickup, createTruck } from './car-models.js'; +import { createCarModel } from '../systems/traffic.js'; +import { createPickup, createTruck } from './carModels.js'; import { createPerson, createCrew, nameTag } from './staff.js'; -import { FACTORY } from './locations.js'; +import { FACTORY } from '../../constants/locations.js'; import { textTexture } from './textures.js'; function stripes(c, w, h, base, line, every) { @@ -201,8 +201,7 @@ export function createFactory(scene) { const steel = mat('#59646b', { metalness: 0.5 }), teal = mat('#0b8f88', { metalness: 0.3, roughness: 0.4 }); const yellow = mat('#f2c94c', { roughness: 0.5 }), - darkSteel = mat('#3d4549', { metalness: 0.5 }), - silver = mat('#c4ccd0', { metalness: 0.8, roughness: 0.3 }); + darkSteel = mat('#3d4549', { metalness: 0.5 }); const lamp = new THREE.MeshBasicMaterial({ color: '#fffbe8' }); const animated = []; @@ -247,7 +246,6 @@ export function createFactory(scene) { c.fillStyle = '#f2c94c'; c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270); }); - const signMaterial = new THREE.MeshBasicMaterial({ map: nameSign, side: THREE.DoubleSide }); // Two faces, so the name also reads correctly from inside the yard. const frontSign = new THREE.MeshBasicMaterial({ map: nameSign }); for (const side of [1, -1]) { diff --git a/src/staff.js b/src/game/models/staff.js similarity index 100% rename from src/staff.js rename to src/game/models/staff.js diff --git a/src/textures.js b/src/game/models/textures.js similarity index 100% rename from src/textures.js rename to src/game/models/textures.js diff --git a/src/vending.js b/src/game/models/vending.js similarity index 99% rename from src/vending.js rename to src/game/models/vending.js index c72dacb..c6bf41c 100644 --- a/src/vending.js +++ b/src/game/models/vending.js @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import { catalog } from './game-state.js'; +import { catalog } from '../economy/gameState.js'; import { textTexture } from './textures.js'; const PRODUCT_COLORS = { diff --git a/src/traffic.js b/src/game/systems/traffic.js similarity index 100% rename from src/traffic.js rename to src/game/systems/traffic.js diff --git a/src/vehicle.js b/src/game/systems/vehicle.js similarity index 100% rename from src/vehicle.js rename to src/game/systems/vehicle.js diff --git a/src/buildings-data.js b/src/game/world/buildingsData.js similarity index 100% rename from src/buildings-data.js rename to src/game/world/buildingsData.js diff --git a/src/city.js b/src/game/world/city.js similarity index 100% rename from src/city.js rename to src/game/world/city.js diff --git a/src/collision.js b/src/game/world/collision.js similarity index 100% rename from src/collision.js rename to src/game/world/collision.js diff --git a/src/imagery.js b/src/game/world/imagery.js similarity index 88% rename from src/imagery.js rename to src/game/world/imagery.js index e1c38ba..dbeb107 100644 --- a/src/imagery.js +++ b/src/game/world/imagery.js @@ -1,6 +1,6 @@ -import { loadTileUrl } from './services/worldService.js'; +import { loadTileUrl } from '../../services/worldService.js'; import * as THREE from 'three'; -import { toLocal } from './geo.js'; +import { toLocal } from '../../utils/geo.js'; // Satellite ground: Esri World Imagery tiles (public/imagery, see scripts/fetch-imagery.mjs) laid on // the ground as textured quads. Detailed zoom-17 tiles load near the camera, zoom-15 tiles further @@ -41,6 +41,8 @@ export function createImagery( }), })); let loads = 0; + let disposed = false; + const controller = new AbortController(); async function show(level, tile) { if (tile.mesh || tile.loading || loads >= 6) return; @@ -48,7 +50,10 @@ export function createImagery( loads++; let tileUrl; try { - tileUrl = await loadTileUrl(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`); + tileUrl = await loadTileUrl( + `${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`, + controller.signal, + ); } catch { loads--; tile.loading = false; @@ -61,6 +66,10 @@ export function createImagery( URL.revokeObjectURL(tileUrl); loads--; tile.loading = false; + if (disposed) { + texture.dispose(); + return; + } texture.colorSpace = THREE.SRGBColorSpace; // Sharp at the low angles a driver sees the road from. texture.anisotropy = anisotropy; @@ -109,6 +118,11 @@ export function createImagery( let last = null; return { group, + dispose() { + disposed = true; + controller.abort(); + for (const level of levels) for (const tile of level.tiles) hide(tile); + }, available: levels.some((level) => level.tiles.length), // Loads tiles in range of (x, z), nearest first, and releases ones well out of range. update(x, z) { diff --git a/src/map-render.js b/src/game/world/mapRender.js similarity index 100% rename from src/map-render.js rename to src/game/world/mapRender.js diff --git a/src/routing.js b/src/game/world/routing.js similarity index 100% rename from src/routing.js rename to src/game/world/routing.js diff --git a/src/hooks/useGameEngine.js b/src/hooks/useGameEngine.js new file mode 100644 index 0000000..11171a3 --- /dev/null +++ b/src/hooks/useGameEngine.js @@ -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 }; +} diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 18f16e2..0000000 --- a/src/main.js +++ /dev/null @@ -1,1922 +0,0 @@ -import { loadWorldFiles } from './services/worldService.js'; -import * as THREE from 'three'; -import '@fontsource/vazirmatn/400.css'; -import '@fontsource/vazirmatn/600.css'; -import '@fontsource/vazirmatn/800.css'; -import './style.css'; -import { setupPwa } from './pwa.js'; -import { - startTracking, - requestCompass, - arrivalRadius, - insideWorld, - createStabilizer, - MAX_WALKING_SPEED, -} from './realworld.js'; -import { createArView } from './ar.js'; -import data from './data/mellat.json'; -import iran from './data/iran.json'; -import { createCity } from './city.js'; -import { createImagery } from './imagery.js'; -import { decodeBuildings } from './buildings-data.js'; -import { createCollisionWorld, addPolygon, resolveCircle, clearFraction } from './collision.js'; -import { - buildGraph, - routeBetween, - buildSegmentIndex, - nearestSegment, - DRIVABLE_MAX_TYPE, -} from './routing.js'; -import { machines, CATEGORIES, CITY_START, FACTORY, lockedPlaces } from './locations.js'; -import { - createState, - catalog, - priceAt, - isOpen, - purchaseBlocker, - formatHetoman, - formatClock, - fa, - canAffordAnything, -} from './game-state.js'; -import { - createPayment, - beginPayment, - readyPayment, - swipePayment, - cancelPayment, -} from './payment.js'; -import { awardsFor, rankFor, completeMissions, activeMission, MISSIONS } from './scoring.js'; -import { createFamily } from './characters.js'; -import { createVendingMachine } from './vending.js'; -import { createFactory } from './factory.js'; -import { createCar, stepCar, kmh } from './vehicle.js'; -import { createTraffic } from './traffic.js'; -import { createSportSedan } from './car-models.js'; -import { createMapAssets, drawMinimap, drawFullMap, drawIran } from './map-render.js'; -import { bearing, formatDistance, toLocal } from './geo.js'; - -const $ = (s) => document.querySelector(s); -const canvas = $('#world'); -let renderer; -try { - // alpha: in camera AR view the phone's camera picture shows through where nothing is drawn. - renderer = new THREE.WebGLRenderer({ - canvas, - antialias: true, - alpha: true, - powerPreference: 'high-performance', - }); -} catch { - $('#loading').textContent = - 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.'; - throw new Error('WebGL unavailable'); -} -// Low quality (no shadows, 1× pixels) for weak phones; chosen with ?quality=low or the toolbar button. -const quality = - new URLSearchParams(location.search).get('quality') ?? - (() => { - try { - return localStorage.getItem('quality'); - } catch { - return null; - } - })() ?? - (matchMedia('(pointer: coarse)').matches ? 'low' : 'high'); -renderer.setPixelRatio(quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75)); -renderer.shadowMap.enabled = quality !== 'low'; -renderer.shadowMap.type = THREE.PCFSoftShadowMap; -renderer.outputColorSpace = THREE.SRGBColorSpace; -renderer.toneMapping = THREE.ACESFilmicToneMapping; -renderer.toneMappingExposure = 1.05; - -const scene = new THREE.Scene(); -const fog = new THREE.Fog('#d8cdb4', 220, 1500); -scene.fog = fog; -const camera = new THREE.PerspectiveCamera(62, 1, 0.3, 7000); - -// Morning sky over Mashhad: a gradient dome, warm haze at the horizon. -const sky = new THREE.Mesh( - new THREE.SphereGeometry(5000, 32, 16), - new THREE.ShaderMaterial({ - side: THREE.BackSide, - depthWrite: false, - fog: false, - uniforms: { - top: { value: new THREE.Color('#6f9fd0') }, - horizon: { value: new THREE.Color('#e6dbc3') }, - }, - vertexShader: - 'varying vec3 vPos; void main(){ vPos = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }', - fragmentShader: - 'uniform vec3 top; uniform vec3 horizon; varying vec3 vPos; void main(){ float h = clamp(normalize(vPos).y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(horizon, top, h), 1.0); }', - }), -); -scene.add(sky); -scene.add(new THREE.HemisphereLight('#fff4dc', '#7d715d', 1.6)); -const sun = new THREE.DirectionalLight('#ffe9c2', 3.2); -sun.castShadow = true; -sun.shadow.mapSize.set(2048, 2048); -Object.assign(sun.shadow.camera, { - left: -75, - right: 75, - top: 75, - bottom: -75, - near: 10, - far: 700, -}); -sun.shadow.bias = -0.0004; -sun.shadow.normalBias = 0.4; -scene.add(sun, sun.target); - -// ---------- World data ---------- -// Microsoft's building footprints and the satellite tile list are downloaded files, not bundled code. -// The game still runs without them (OSM buildings and drawn roads). -const [buildingsFile, imageryIndex] = await loadWorldFiles(); -// Footprints reaching into the factory compound are left out: the game's own buildings stand there. -const inFactory = (x, z) => { - const dx = x - FACTORY.center.x, - dz = z - FACTORY.center.z; - const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z, - v = -dx * FACTORY.axis.z + dz * FACTORY.axis.x; - return u > -112 && u < 82 && v > -52 && v < 52; -}; -const extraBuildings = (buildingsFile ? decodeBuildings(buildingsFile) : []).filter((b) => { - for (let i = 1; i < b.length; i += 2) if (inFactory(b[i], b[i + 1])) return false; - return true; -}); -const collision = createCollisionWorld( - [...data.buildings, ...extraBuildings].map((b) => b.slice(1)), -); -const segments = buildSegmentIndex(data.roads); -const walkGraph = buildGraph(data.roads), - driveGraph = buildGraph(data.roads, { drivable: true }); -const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2]; - -const rotateLocal = (lx, lz, rot) => ({ - x: lx * Math.cos(rot) + lz * Math.sin(rot), - z: -lx * Math.sin(rot) + lz * Math.cos(rot), -}); - -// The game starts inside the vending-machine factory on the Mashhad–Chenaran road. -const factory = createFactory(scene); -factory.footprints.forEach((f) => addPolygon(collision, f)); - -// City machines stand beside their path point, on whichever side is free, facing the path; -// the factory's demo machine has a fixed place inside the production hall. -const stations = machines.map((def) => { - if (def.spot) { - // Test points: the given coordinate is where the player stands; the machine faces the factory. - const front = toLocal(def.spot.lat, def.spot.lon); - const dx = FACTORY.center.x - front.x, - dz = FACTORY.center.z - front.z, - len = Math.hypot(dx, dz) || 1; - return placeStation( - def, - { x: front.x - (dx / len) * 1.9, z: front.z - (dz / len) * 1.9 }, - Math.atan2(dx, dz), - ); - } - if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot); - const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? { dx: 1, dz: 0, type: 9 }; - const len = Math.hypot(seg.dx, seg.dz) || 1, - nx = -seg.dz / len, - nz = seg.dx / len; - const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4; - let pos = null; - for (const d of [off, -off, off + 1.5, -off - 1.5]) { - const x = def.anchor.x + nx * d, - z = def.anchor.z + nz * d; - if (!resolveCircle(collision, x, z, 1.4).hit) { - pos = { x, z }; - break; - } - } - pos ??= { x: def.anchor.x + nx * off, z: def.anchor.z + nz * off }; - return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z)); -}); - -function placeStation(def, pos, rot) { - const model = createVendingMachine(def); - model.group.position.set(pos.x, 0, pos.z); - model.group.rotation.y = rot; - scene.add(model.group); - addPolygon( - collision, - [ - [-1, -0.6], - [1, -0.6], - [1, 0.6], - [-1, 0.6], - ].flatMap(([lx, lz]) => { - const p = rotateLocal(lx, lz, rot); - return [pos.x + p.x, pos.z + p.z]; - }), - ); - const front = rotateLocal(0, 1.9, rot); - return { def, model, x: pos.x, z: pos.z, rot, front: { x: pos.x + front.x, z: pos.z + front.z } }; -} - -const stationById = (id) => stations.find((s) => s.def.id === id); -for (const s of stations) if (s.def.test) s.model.group.visible = false; - -// In real-world mode a circle on the ground marks how close counts as "at the machine". -for (const s of stations) { - s.reach = new THREE.Mesh( - new THREE.RingGeometry(0.96, 1, 96), - new THREE.MeshBasicMaterial({ - color: '#3ee0c9', - transparent: true, - opacity: 0.55, - depthWrite: false, - fog: false, - side: THREE.DoubleSide, - }), - ); - s.reach.rotation.x = -Math.PI / 2; - s.reach.position.set(s.front.x, 0.08, s.front.z); - s.reach.visible = false; - scene.add(s.reach); -} - -function updateReachCircles() { - const radius = reachRadius(), - objective = real.on ? objectiveStation() : null; - for (const s of stations) { - s.reach.visible = real.on; - if (!real.on) continue; - s.reach.scale.setScalar(radius); - s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9'); - } -} -// GPS test points only take part while real-world mode is on. -const activeStations = () => stations.filter((s) => !s.def.test || real.on); -// The closest active machine whose front is within `radius` of (x, z). -const closestStation = (x, z, radius) => - activeStations() - .map((s) => ({ s, d: Math.hypot(s.front.x - x, s.front.z - z) })) - .filter(({ d }) => d < radius) - .sort((a, b) => a.d - b.d)[0]?.s ?? null; - -const imagery = createImagery(scene, imageryIndex ?? {}, { - lowQuality: quality === 'low', - anisotropy: Math.min(16, renderer.capabilities.getMaxAnisotropy()), -}); -const city = createCity(scene, data, { - collision, - segments, - extraBuildings, - satellite: imagery.available, - lowQuality: quality === 'low', -}); -if (imagery.available) - $('#attribution').textContent += ` · تصویر ماهواره‌ای: ${imageryIndex.attribution}`; -const traffic = createTraffic(scene, data, 28); -const mapAssets = createMapAssets(data, [factory.label]); - -// Park gate sign at the first city machine. -const parkGate = new THREE.Group(); -{ - const start = stationById(CITY_START), - sign = document.createElement('canvas'); - sign.width = 512; - sign.height = 128; - const c = sign.getContext('2d'); - c.fillStyle = '#244d2e'; - c.fillRect(0, 0, 512, 128); - c.fillStyle = '#f4efd9'; - c.textAlign = 'center'; - c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif'; - c.fillText('بوستان ملت', 256, 86); - const texture = new THREE.CanvasTexture(sign); - texture.colorSpace = THREE.SRGBColorSpace; - const gate = parkGate, - post = new THREE.MeshStandardMaterial({ color: '#6b5a49' }); - for (const side of [-1, 1]) { - const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 4, 0.3), post); - p.position.set(side * 2.6, 2, 0); - p.castShadow = true; - gate.add(p); - } - const board = new THREE.Mesh( - new THREE.PlaneGeometry(5.6, 1.4), - new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide }), - ); - board.position.y = 4; - gate.add(board); - const at = rotateLocal(-4.5, 1.5, start.rot); - gate.position.set(start.x + at.x, 0, start.z + at.z); - gate.rotation.y = start.rot; - scene.add(gate); -} - -// ---------- Actors ---------- -const family = createFamily(); -scene.add(family.group); -// The Robot Market guide lives inside the machines and comes out when the family arrives. -const guide = createFamily({ robot: true }); -guide.group.visible = false; -scene.add(guide.group); -const carModel = createSportSedan('#1d3f78'); -scene.add(carModel.group); - -let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended; -const keys = new Set(); - -// The family car waits in the factory yard right beside them, pointing at the gate. -const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading); - -function reset() { - state = createState(); - payment = createPayment(); - // The family starts in the factory yard, facing the engineering office. - const spot = resolveCircle(collision, factory.start.x, factory.start.z, 1); - player = { x: spot.x, z: spot.z, heading: factory.start.heading, speed: 0, gait: 0 }; - factory.reset(); - for (const t of factory.talks) t.done = false; - car = spawnCar(); - mode = 'walk'; - // Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos). - cam = { - yaw: Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)), - pitch: 0.32, - distance: 5.5, - lastMouse: -10, - }; - waypoint = null; - route = null; - routeTimer = 0; - deliveryTimer = 0; - ended = false; - hints = { closed: false, car: false }; - Object.assign(guideState, { station: null, t: 0, greeted: new Set(), arrived: 0 }); - $('#payment').hidden = true; - for (const s of stations) { - s.model.delivery.visible = s.model.card.visible = false; - s.model.flap.rotation.x = 0; - } - for (const d of ['#shop', '#map-dialog', '#end']) if ($(d).open) $(d).close(); - keys.clear(); - refreshHUD(); -} - -// ---------- Input ---------- -const isTouch = matchMedia('(pointer: coarse)').matches; -const anyDialogOpen = () => - $('#shop').open || $('#map-dialog').open || $('#end').open || !$('#briefing').hidden; -// Physical key codes, so the controls also work with a Persian keyboard layout. -const keyName = (e) => - e.code.startsWith('Key') ? e.code.slice(3).toLowerCase() : e.key.toLowerCase(); -window.addEventListener('keydown', (e) => { - if (e.target instanceof HTMLInputElement) return; - const k = keyName(e); - if (k === 'm' && !e.repeat && $('#briefing').hidden) { - e.preventDefault(); - $('#map-dialog').open ? $('#map-dialog').close() : openMap(); - return; - } - if (anyDialogOpen()) return; - if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'tab'].includes(k)) - e.preventDefault(); - if (e.repeat) return; - keys.add(k); - if (k === 'f') toggleCar(); - if (k === 'e') interact(); - if (k === 'c') swipe(); - if (k === 'escape' && payment.phase !== 'idle') cancel(); -}); -window.addEventListener('keyup', (e) => keys.delete(keyName(e))); -window.addEventListener('blur', () => keys.clear()); - -// Pointer lock gives a shooter-style mouse look; dragging works as a fallback and on touch. -canvas.addEventListener('click', () => { - if (!isTouch && !anyDialogOpen() && document.pointerLockElement !== canvas) - canvas.requestPointerLock?.()?.catch?.(() => {}); -}); -document.addEventListener('mousemove', (e) => { - if (document.pointerLockElement !== canvas) return; - look(e.movementX, e.movementY, 0.0024); -}); -let drag = null; -canvas.addEventListener('pointerdown', (e) => { - if (document.pointerLockElement === canvas) return; - drag = { id: e.pointerId, x: e.clientX, y: e.clientY }; - canvas.setPointerCapture(e.pointerId); -}); -canvas.addEventListener('pointermove', (e) => { - if (!drag || drag.id !== e.pointerId) return; - look(e.clientX - drag.x, e.clientY - drag.y, 0.005); - drag.x = e.clientX; - drag.y = e.clientY; -}); -canvas.addEventListener('pointerup', () => (drag = null)); -canvas.addEventListener('pointercancel', () => (drag = null)); -canvas.addEventListener( - 'wheel', - (e) => { - cam.distance = THREE.MathUtils.clamp(cam.distance + Math.sign(e.deltaY) * 0.6, 3, 12); - }, - { passive: true }, -); - -function look(dx, dy, k) { - // In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor. - if (real.on) { - if (arView.hasOrientation) return; - cam.yaw -= dx * k; - cam.arPitch = THREE.MathUtils.clamp((cam.arPitch ?? 0) - dy * k * 0.8, -1.2, 1.2); - cam.lastMouse = elapsed; - return; - } - cam.yaw -= dx * k; - cam.pitch = THREE.MathUtils.clamp(cam.pitch + dy * k * 0.8, -0.15, 1.15); - cam.lastMouse = elapsed; -} - -// Touch joystick: x steers / strafes, y walks / throttles. -const stick = { x: 0, y: 0, id: null }; -if (isTouch) { - $('#touch').hidden = false; - const pad = $('#stick'), - knob = pad.querySelector('i'); - const move = (e) => { - const r = pad.getBoundingClientRect(), - dx = e.clientX - (r.left + r.width / 2), - dy = e.clientY - (r.top + r.height / 2); - const len = Math.min(1, Math.hypot(dx, dy) / (r.width / 2)), - a = Math.atan2(dy, dx); - stick.x = Math.cos(a) * len; - stick.y = -Math.sin(a) * len; - knob.style.transform = `translate(${stick.x * 40}px, ${-stick.y * 40}px)`; - }; - pad.addEventListener('pointerdown', (e) => { - stick.id = e.pointerId; - pad.setPointerCapture(e.pointerId); - move(e); - }); - pad.addEventListener('pointermove', (e) => e.pointerId === stick.id && move(e)); - const end = () => { - stick.id = null; - stick.x = stick.y = 0; - knob.style.transform = ''; - }; - pad.addEventListener('pointerup', end); - pad.addEventListener('pointercancel', end); - for (const b of document.querySelectorAll('.touch-buttons button')) - b.addEventListener('click', () => { - const k = b.dataset.key; - if (k === 'shift') - b.classList.toggle( - 'on', - keys.has('shift') ? (keys.delete('shift'), false) : (keys.add('shift'), true), - ); - if (k === 'f') toggleCar(); - if (k === 'e') payment.phase === 'ready' ? swipe() : interact(); - }); -} - -const axis = (pos, neg) => - Number(pos.some((k) => keys.has(k))) - Number(neg.some((k) => keys.has(k))); - -// ---------- Subtitles ---------- -const SPEAKERS = { robot: 'روبات', father: 'پدر', child: 'بچه' }; -// Factory staff speak under their own names. -for (const t of factory.talks) if (t.label) SPEAKERS[t.speaker] = t.label; -let lines = [], - lineTimer = 0, - talking = false; - -// `face` picks the robot's expression portrait (public/robot/.webp). -function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) { - if (lines.some((l) => l.text === text)) return; - lines.push({ speaker, text, face, seconds }); -} - -function updateSubtitles(dt) { - lineTimer -= dt; - if (lineTimer > 0) return; - const next = lines.shift(); - $('#subtitle').hidden = !next; - talking = next?.speaker === 'robot'; - if (!next) return; - $('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`; - $('#subtitle-speaker').className = ['robot', 'father', 'child'].includes(next.speaker) - ? next.speaker - : 'staff'; - $('#subtitle-text').textContent = next.text; - $('#subtitle-face').hidden = next.speaker !== 'robot'; - if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`; - lineTimer = next.seconds; -} - -// ---------- Sound ---------- -let audio = null, - soundOn = false, - engine = null; - -function tone(freq, duration = 0.12, volume = 0.05, type = 'sine') { - if (!soundOn) return; - const o = audio.createOscillator(), - g = audio.createGain(); - o.type = type; - o.frequency.setValueAtTime(freq, audio.currentTime); - o.frequency.exponentialRampToValueAtTime(freq * 0.7, audio.currentTime + duration); - g.gain.setValueAtTime(volume, audio.currentTime); - g.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + duration); - o.connect(g).connect(audio.destination); - o.start(); - o.stop(audio.currentTime + duration); -} - -$('#sound').onclick = async () => { - try { - audio ??= new AudioContext(); - await audio.resume(); - soundOn = !soundOn; - if (!engine) { - const o = audio.createOscillator(), - g = audio.createGain(); - o.type = 'sawtooth'; - g.gain.value = 0; - o.connect(g).connect(audio.destination); - o.start(); - engine = { o, g }; - } - $('#sound').textContent = soundOn ? 'صدا: روشن' : 'صدا: خاموش'; - $('#sound').setAttribute('aria-pressed', String(soundOn)); - tone(700); - } catch { - $('#sound').textContent = 'صدا در دسترس نیست'; - } -}; - -// ---------- Car ---------- -function toggleCar() { - if (payment.phase !== 'idle' || state.busy) return; - if (real.on) { - say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.'); - return; - } - if (mode === 'walk') { - if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return; - mode = 'drive'; - cam.distance = Math.max(cam.distance, 8); - tone(320, 0.2, 0.05, 'square'); - say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.'); - } else { - if (Math.abs(car.speed) > 3) { - say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.'); - return; - } - // Step out on the driver's (left) side, or wherever is free. - const left = { x: -Math.sin(car.heading), z: -Math.cos(car.heading) }; - const spot = resolveCircle(collision, car.x + left.x * 2.8, car.z + left.z * 2.8, 1); - Object.assign(player, { x: spot.x, z: spot.z, heading: car.heading }); - car.speed = 0; - mode = 'walk'; - cam.distance = 5.5; - tone(260, 0.2, 0.05, 'square'); - } - refreshHUD(); -} - -// ---------- Machines, shop and payment ---------- -// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres. -const reachRadius = () => (real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4); -const nearStation = () => - mode === 'walk' ? closestStation(player.x, player.z, reachRadius()) : null; -const hoursText = (m) => - m.hours[1] - m.hours[0] >= 24 * 60 - ? 'شبانه‌روزی' - : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`; -let shopStation = null; - -function interact() { - const station = nearStation(); - if (!station || state.busy || payment.phase !== 'idle' || ended) return; - if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) { - say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.'); - return; - } - if (!isOpen(station.def, state.minutes)) { - say( - guideOut() ? 'robot' : 'father', - `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, - 'serious', - ); - return; - } - shopStation = station; - renderShop(); - document.exitPointerLock?.(); - keys.clear(); - $('#shop').showModal(); - tone(750); -} - -function renderShop() { - const def = shopStation.def; - $('#shop-category').textContent = - `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${hoursText(def)}`; - $('#shop-name').textContent = `روبات مارکت · ${def.name}`; - $('#shop-address').textContent = def.address; - $('#shop-note').textContent = `${def.note} · موجودی شما: ${formatHetoman(state.balance)}`; - $('#products').replaceChildren( - ...def.products.map((id) => { - const p = catalog[id], - price = priceAt(def, id), - b = document.createElement('button'); - b.type = 'button'; - const blocker = purchaseBlocker(state, def, id); - b.disabled = Boolean(blocker); - b.innerHTML = `${p.icon}${p.name}${formatHetoman(price)}${price !== p.cost ? `${formatHetoman(p.cost)}` : ''}${p.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}${blocker === 'funds' ? ' · موجودی کافی نیست' : ''}`; - b.onclick = () => startPayment(id); - return b; - }), - ); -} - -function startPayment(productId) { - if (!beginPayment(state, payment, shopStation.def, productId)) return; - payment.station = shopStation; - payment.timer = 0; - $('#shop').close(); - const p = catalog[productId]; - $('#payment').hidden = false; - $('#payment-title').textContent = - `${p.icon} ${p.name} · ${formatHetoman(priceAt(shopStation.def, productId))}`; - $('#payment-step').textContent = 'در حال نزدیک شدن به کارت‌خوان…'; - $('#swipe').disabled = true; - $('#cancel-payment').hidden = false; - say('child', 'بابا، کارت رو بده من بکشم!'); -} - -function swipe() { - if (!swipePayment(state, payment)) return; - payment.timer = 0; - payment.station.model.card.visible = true; - $('#swipe').disabled = true; - $('#cancel-payment').hidden = true; - $('#payment-step').textContent = 'پرداخت موفق ✓ محصول در حال تحویل است'; - tone(1100, 0.3); - refreshHUD(); -} - -function cancel() { - if (!cancelPayment(state, payment)) return; - $('#payment').hidden = true; -} - -$('#swipe').onclick = swipe; -// On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. -$('#prompt').onclick = () => { - if (payment.phase === 'ready') swipe(); - else if (nearStation()) interact(); - else toggleCar(); -}; -$('#cancel-payment').onclick = cancel; -$('#shop-close').onclick = () => $('#shop').close(); - -function updatePayment(dt) { - if (payment.phase === 'idle') return; - payment.timer += dt; - const model = payment.station.model; - if (payment.phase === 'approaching' && payment.timer > 1) { - readyPayment(payment); - $('#swipe').disabled = false; - $('#payment-step').textContent = 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C'; - } - if (payment.phase === 'approved') { - const t = Math.min(1, payment.timer); - model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66); - if (payment.timer > 1.2) { - model.card.visible = false; - $('#payment').hidden = true; - model.setDelivery(payment.productId); - deliveryTimer = 2.6; - payment.delivering = { station: payment.station, record: payment.record }; - Object.assign(payment, createPayment()); - } - } -} - -function updateDelivery(dt) { - if (deliveryTimer <= 0) return; - deliveryTimer = Math.max(0, deliveryTimer - dt); - const { station, record } = payment.delivering, - model = station.model, - t = 2.6 - deliveryTimer; - const slot = record ? catalog[record.productId] : null; - model.delivery.visible = t < 2.4; - // Falls from a shelf, waits in the tray, then lifts toward the child's hands. - const shelf = new THREE.Vector3( - -0.5 + (record.price % 7) * 0.1, - 1.2 + (record.price % 5) * 0.15, - 0.45, - ); - if (t < 0.8) model.delivery.position.lerpVectors(shelf, model.slotPoint, t / 0.8); - else - model.delivery.position - .copy(model.slotPoint) - .add(new THREE.Vector3(0, Math.max(0, t - 1.6) * 0.6, Math.max(0, t - 1.6) * 0.8)); - model.flap.rotation.x = t > 0.7 && t < 2 ? -1 : 0; - if (deliveryTimer > 0) return; - model.flap.rotation.x = 0; - state.busy = false; - tone(980, 0.18); - reward(record, slot); -} - -function reward(record, product) { - const awards = awardsFor(state.purchases.slice(0, -1), record), - rankBefore = rankFor(state.xp).index; - if (real.on) awards.push({ label: 'خرید در دنیای واقعی', points: 200 }); - const total = awards.reduce((s, a) => s + a.points, 0); - state.xp += total; - $('#awards').replaceChildren( - Object.assign(document.createElement('div'), { - className: 'total', - textContent: `+${fa(total)}`, - }), - ...awards.map((a, i) => { - const line = document.createElement('div'); - line.className = 'line'; - line.style.animationDelay = `${i * 0.12}s`; - line.innerHTML = `${a.label}+${fa(a.points)}`; - return line; - }), - ); - clearTimeout(reward.timeout); - reward.timeout = setTimeout(() => $('#awards').replaceChildren(), 3200); - say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`); - const done = completeMissions(state); - for (const m of done) { - showBanner('مأموریت انجام شد', m.title, `+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`); - tone(660, 0.5); - } - if (rankFor(state.xp).index > rankBefore) - setTimeout( - () => showBanner('ارتقای درجه', rankFor(state.xp).name, 'دستگاه‌های بیشتری پیدا کن'), - done.length ? 3400 : 0, - ); - const next = activeMission(state); - if (next) { - waypoint = null; - route = null; - setTimeout( - () => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), - 1500, - ); - } - refreshHUD(); - checkEnd(); -} - -function showBanner(tag, title, sub) { - const b = $('#banner'); - b.innerHTML = `${tag}${title}${sub}`; - b.hidden = false; - b.style.animation = 'none'; - void b.offsetWidth; - b.style.animation = ''; - clearTimeout(showBanner.timeout); - showBanner.timeout = setTimeout(() => (b.hidden = true), 3200); -} - -function checkEnd() { - const allDone = state.missionsDone.length === MISSIONS.length; - if (ended || (!allDone && canAffordAnything(state, machines))) return; - ended = true; - document.exitPointerLock?.(); - $('#end-tag').textContent = allDone ? 'عملیات موفق' : 'پایان بودجه'; - $('#end-title').textContent = allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد'; - $('#end-text').textContent = allDone - ? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.' - : 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:'; - const places = new Set(state.purchases.map((p) => p.machineId)).size; - $('#end-stats').innerHTML = [ - ['امتیاز', `${fa(state.xp)} XP`], - ['درجه', rankFor(state.xp).name], - ['خریدها', fa(state.purchases.length)], - ['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`], - ['مأموریت‌ها', `${fa(state.missionsDone.length)} از ${fa(MISSIONS.length)}`], - ['باقی‌مانده', formatHetoman(state.balance)], - ] - .map(([k, v]) => `
  • ${k}${v}
  • `) - .join(''); - $('#continue').hidden = !allDone; - $('#end').showModal(); -} - -$('#quality').textContent = quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل'; -$('#quality').onclick = () => { - try { - localStorage.setItem('quality', quality === 'low' ? 'high' : 'low'); - } catch {} - location.replace(location.pathname); -}; -$('#again').onclick = reset; -$('#continue').onclick = () => $('#end').close(); -$('#restart').onclick = reset; - -// ---------- Objective, route and HUD ---------- -const objectiveStation = () => { - if (waypoint) return stationById(waypoint); - const mission = activeMission(state); - if (mission?.target) return stationById(mission.target); - // Open-ended missions point to the nearest place not bought from yet. - const visited = new Set(state.purchases.map((p) => p.machineId)); - return ( - activeStations() - .filter((s) => !visited.has(s.def.id)) - .sort( - (a, b) => - Math.hypot(a.x - player.x, a.z - player.z) - Math.hypot(b.x - player.x, b.z - player.z), - )[0] ?? null - ); -}; - -// The gold route ribbon on the ground, rebuilt when the route changes. -const routeMaterial = new THREE.MeshBasicMaterial({ - color: '#f2c94c', - transparent: true, - opacity: 0.75, - depthWrite: false, - polygonOffset: true, - polygonOffsetFactor: -12, - polygonOffsetUnits: -24, -}); -let routeMesh = null; - -function rebuildRouteMesh() { - if (routeMesh) { - scene.remove(routeMesh); - routeMesh.geometry.dispose(); - routeMesh = null; - } - if (!route) return; - const pts = [], - pos = [], - idx = []; - let total = 0; - for (const p of route.points) { - if (pts.length) total += Math.hypot(p.x - pts.at(-1).x, p.z - pts.at(-1).z); - if (total > 700) break; - pts.push(p); - } - for (let i = 0; i < pts.length; i++) { - const a = pts[Math.max(0, i - 1)], - b = pts[Math.min(pts.length - 1, i + 1)]; - const dx = b.x - a.x, - dz = b.z - a.z, - len = Math.hypot(dx, dz) || 1, - w = mode === 'drive' ? 1.1 : 0.55; - pos.push( - pts[i].x - (dz / len) * w, - 0.14, - pts[i].z + (dx / len) * w, - pts[i].x + (dz / len) * w, - 0.14, - pts[i].z - (dx / len) * w, - ); - if (i) idx.push((i - 1) * 2, i * 2, (i - 1) * 2 + 1, (i - 1) * 2 + 1, i * 2, i * 2 + 1); - } - const g = new THREE.BufferGeometry(); - g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); - g.setIndex(idx); - routeMesh = new THREE.Mesh(g, routeMaterial); - scene.add(routeMesh); -} - -let routeTarget = null, - routeFrom = { x: 0, z: 0 }, - routeMode = null; - -function updateRoute(dt) { - routeTimer -= dt; - const target = objectiveStation(); - if (!target) { - if (route) { - route = null; - rebuildRouteMesh(); - } - return; - } - const pos = mode === 'drive' ? car : player; - const changed = target !== routeTarget || routeMode !== mode || !route; - if (!changed && (routeTimer > 0 || Math.hypot(pos.x - routeFrom.x, pos.z - routeFrom.z) < 10)) - return; - routeTimer = 1.2; - routeTarget = target; - routeMode = mode; - routeFrom = { x: pos.x, z: pos.z }; - // Close targets are reached directly; the street network would detour out of buildings. - const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z); - route = - direct < 60 - ? { - points: [ - { x: pos.x, z: pos.z }, - { x: target.front.x, z: target.front.z }, - ], - length: direct, - } - : routeBetween( - mode === 'drive' ? driveGraph : walkGraph, - pos.x, - pos.z, - target.front.x, - target.front.z, - ); - rebuildRouteMesh(); -} - -const compass = $('#compass').getContext('2d'), - minimap = $('#minimap').getContext('2d'); - -function drawCompass(headingDeg, markers) { - const w = 560, - h = 44; - compass.clearRect(0, 0, w, h); - compass.fillStyle = 'rgba(10,16,14,.45)'; - compass.fillRect(0, 10, w, 24); - compass.textAlign = 'center'; - compass.textBaseline = 'middle'; - const labels = { 0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب' }; - for (let d = -100; d <= 100; d += 5) { - const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360, - x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6; - if (x < 10 || x > w - 10) continue; - compass.fillStyle = 'rgba(236,242,238,.8)'; - if (labels[deg] !== undefined) { - compass.font = 'bold 13px Vazirmatn, sans-serif'; - compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee'; - compass.fillText(labels[deg], x, 22); - } else if (deg % 15 === 0) { - compass.font = '10px Vazirmatn, sans-serif'; - compass.fillText(fa(deg), x, 22); - } else compass.fillRect(x - 0.5, 18, 1, 8); - } - for (const m of markers) { - const x = w / 2 + (((m.bearing - headingDeg + 540) % 360) - 180) * 2.6; - if (x < 10 || x > w - 10) continue; - compass.save(); - compass.translate(x, 6); - compass.rotate(Math.PI / 4); - compass.fillStyle = m.color; - compass.fillRect(-4, -4, 8, 8); - compass.restore(); - } - compass.fillStyle = '#ecf2ee'; - compass.fillRect(w / 2 - 1, 34, 2, 8); - compass.font = 'bold 11px Vazirmatn, sans-serif'; - compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6); -} - -function refreshHUD() { - $('#balance').textContent = fa(state.balance); - const rank = rankFor(state.xp); - $('#rank-name').textContent = rank.name; - $('#xp').textContent = rank.next ? `${fa(state.xp)} / ${fa(rank.next)} XP` : `${fa(state.xp)} XP`; - $('#xp-bar').style.width = `${Math.round(rank.progress * 100)}%`; - const active = activeMission(state); - $('#mission-list').innerHTML = MISSIONS.map((m) => { - const done = state.missionsDone.includes(m.id); - return `
  • ${m.title}
  • `; - }).join(''); - $('#mode-label').textContent = mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده'; - $('#speed-stat').hidden = mode !== 'drive'; -} - -function updatePrompt() { - let html = ''; - const station = nearStation(); - // The payment panel carries its own swipe button, so no prompt while paying or collecting. - if (state.busy || payment.phase !== 'idle') html = ''; - else if (station) - html = isOpen(station.def, state.minutes) - ? `Eخرید از روبات مارکت · ${station.def.name}` - : `دستگاه بسته است · ${hoursText(station.def)}`; - else if (mode === 'walk' && Math.hypot(car.x - player.x, car.z - player.z) < 4.5) - html = 'Fسوار ماشین شو'; - else if ( - mode === 'drive' && - Math.abs(car.speed) < 1 && - stations.some((s) => Math.hypot(s.front.x - car.x, s.front.z - car.z) < 25) - ) - html = 'Fپیاده شو؛ دستگاه نزدیک است'; - $('#prompt').hidden = !html; - if ($('#prompt').innerHTML !== html) $('#prompt').innerHTML = html; -} - -// ---------- Map dialog ---------- -const mapView = { cx: 0, cz: 0, mpp: 2 }, - mapCanvas = $('#fullmap'), - iranCanvas = $('#iran-map'); -let selected = null, - mapTab = 'city'; -const markers = () => { - const visited = new Set(state.purchases.map((p) => p.machineId)), - objective = objectiveStation(); - return activeStations().map((s) => ({ - id: s.def.id, - x: s.x, - z: s.z, - visited: visited.has(s.def.id), - open: isOpen(s.def, state.minutes), - objective: s === objective, - })); -}; - -function sizeCanvas(c) { - const r = c.getBoundingClientRect(), - dpr = Math.min(devicePixelRatio, 2); - c.width = Math.max(1, r.width * dpr); - c.height = Math.max(1, r.height * dpr); - return dpr; -} - -function renderMap() { - if (!$('#map-dialog').open) return; - if (mapTab === 'city') { - const dpr = sizeCanvas(mapCanvas); - const pos = mode === 'drive' ? car : player; - drawFullMap(mapCanvas.getContext('2d'), mapAssets, mapView, { - dpr, - markers: markers(), - route: route?.points, - selected, - player: { x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading }, - }); - } else { - const dpr = sizeCanvas(iranCanvas), - c = iranCanvas.getContext('2d'); - c.setTransform(dpr, 0, 0, dpr, 0, 0); - drawIran(c, iran, { - width: iranCanvas.width / dpr, - height: iranCanvas.height / dpr, - t: elapsed, - cities: CITIES, - }); - } -} - -function renderMachineList() { - const pos = mode === 'drive' ? car : player, - visited = new Set(state.purchases.map((p) => p.machineId)); - const items = activeStations().map((s) => { - const li = document.createElement('li'), - b = document.createElement('button'), - open = isOpen(s.def, state.minutes); - b.type = 'button'; - b.className = s.def.id === selected ? 'selected' : ''; - b.innerHTML = `${CATEGORIES[s.def.category].icon}${s.def.name}${visited.has(s.def.id) ? ' ✓' : ''}${CATEGORIES[s.def.category].label} · ${open ? 'باز' : 'بسته'}${formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z))}`; - b.onclick = () => selectMachine(s.def.id, true); - li.append(b); - return li; - }); - for (const place of lockedPlaces) { - const li = document.createElement('li'); - li.innerHTML = ``; - items.push(li); - } - $('#machine-list').replaceChildren(...items); -} - -function selectMachine(id, center = false) { - selected = id; - const s = stationById(id), - def = s.def; - if (center) { - mapView.cx = s.x; - mapView.cz = s.z; - mapView.mpp = Math.min(mapView.mpp, 1.4); - } - $('#machine-detail').hidden = false; - $('#detail-name').textContent = `${CATEGORIES[def.category].icon} ${def.name}`; - $('#detail-address').textContent = `📍 ${def.address}`; - $('#detail-meta').textContent = - `🕒 ${hoursText(def)} · ${isOpen(def, state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`; - $('#detail-products').textContent = def.products - .map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`) - .join(' · '); - $('#route-button').textContent = waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا'; - renderMachineList(); - renderMap(); -} - -$('#route-button').onclick = () => { - if (!selected) return; - waypoint = selected; - route = null; - routeTimer = 0; - updateRoute(0); - const s = stationById(selected); - say( - 'father', - `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`, - ); - selectMachine(selected); -}; - -function openMap() { - if (anyDialogOpen()) return; - document.exitPointerLock?.(); - keys.clear(); - const pos = mode === 'drive' ? car : player; - Object.assign(mapView, { cx: pos.x, cz: pos.z, mpp: 2.2 }); - $('#map-dialog').showModal(); - selected ??= objectiveStation()?.def.id ?? null; - if (selected) selectMachine(selected); - renderMachineList(); - renderMap(); -} - -$('#minimap-button').onclick = openMap; -$('#map-close').onclick = () => $('#map-dialog').close(); -for (const tab of document.querySelectorAll('.tabs button')) - tab.onclick = () => { - mapTab = tab.dataset.tab; - document - .querySelectorAll('.tabs button') - .forEach((t) => t.setAttribute('aria-selected', String(t === tab))); - mapCanvas.hidden = mapTab !== 'city'; - iranCanvas.hidden = mapTab !== 'iran'; - renderMap(); - }; -let mapDrag = null; -mapCanvas.addEventListener('pointerdown', (e) => { - mapDrag = { x: e.clientX, y: e.clientY, moved: 0 }; - mapCanvas.setPointerCapture(e.pointerId); -}); -mapCanvas.addEventListener('pointermove', (e) => { - if (!mapDrag) return; - mapView.cx -= (e.clientX - mapDrag.x) * mapView.mpp; - mapView.cz -= (e.clientY - mapDrag.y) * mapView.mpp; - mapDrag.moved += Math.abs(e.clientX - mapDrag.x) + Math.abs(e.clientY - mapDrag.y); - mapDrag.x = e.clientX; - mapDrag.y = e.clientY; - renderMap(); -}); -mapCanvas.addEventListener('pointerup', (e) => { - if (mapDrag && mapDrag.moved < 6) { - const r = mapCanvas.getBoundingClientRect(); - const wx = mapView.cx + (e.clientX - r.left - r.width / 2) * mapView.mpp, - wz = mapView.cz + (e.clientY - r.top - r.height / 2) * mapView.mpp; - const hit = activeStations().find((s) => Math.hypot(s.x - wx, s.z - wz) < 14 * mapView.mpp); - if (hit) selectMachine(hit.def.id); - } - mapDrag = null; -}); -mapCanvas.addEventListener( - 'wheel', - (e) => { - e.preventDefault(); - mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), 0.35, 30); - renderMap(); - }, - { passive: false }, -); - -// ---------- Briefing ---------- -const CITIES = [ - { name: 'مشهد', lat: 36.3, lon: 59.6, active: true }, - { name: 'تهران', lat: 35.69, lon: 51.39 }, - { name: 'اصفهان', lat: 32.65, lon: 51.67 }, - { name: 'شیراز', lat: 29.59, lon: 52.58 }, - { name: 'تبریز', lat: 38.08, lon: 46.29 }, - { name: 'اهواز', lat: 31.32, lon: 48.67 }, - { name: 'کرمان', lat: 30.28, lon: 57.08 }, - { name: 'رشت', lat: 37.28, lon: 49.58 }, - { name: 'یزد', lat: 31.9, lon: 54.37 }, - { name: 'زاهدان', lat: 29.5, lon: 60.86 }, - { name: 'کرمانشاه', lat: 34.31, lon: 47.07 }, - { name: 'بندرعباس', lat: 27.18, lon: 56.27 }, -]; -const BRIEF = [ - `نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`, - 'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست', - 'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.', - 'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.', - 'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.', - 'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.', -].join('\n'); -let briefChars = 0; - -function startMission() { - $('#briefing').hidden = true; - $('#hud').hidden = false; - say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟'); - say('father', 'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!'); - say('child', 'اون وانت سفیده کیه داره میاد تو؟'); - say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.'); - if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {}); -} - -$('#start-mission').onclick = startMission; - -// ---------- Real-world (GPS) mode ---------- -// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where -// a machine stands lets the player buy there, with a bonus. -const real = { - on: false, - fix: null, - heading: null, - error: null, - waiting: null, - stop: null, - stabilizer: createStabilizer(), -}; - -function setRealMode(on) { - if (on === real.on) return; - real.stop?.(); - Object.assign(real, { on, fix: null, heading: null, error: null, waiting: null, stop: null }); - for (const s of stations) if (s.def.test) s.model.group.visible = on; - $('#gps-toggle').classList.toggle('on', on); - $('#gps-status').hidden = !on; - $('#ar-toggle').hidden = $('#stable-toggle').hidden = !on; - // Real-world mode is first person: the player is where the phone is and sees what it faces. - document.body.classList.toggle('real', on); - if (!on) { - setArMode(false); - arView.stopSensors(); - return; - } - arView.startSensors(); - cam.arPitch = 0; - cam.yaw = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); - if (mode === 'drive') toggleCar(); - mode = 'walk'; - real.stop = startTracking({ - onFix: (fix) => { - real.fix = fix; - real.error = null; - }, - onHeading: (heading) => (real.heading = heading), - onError: (message) => (real.error = message), - onStatus: (message) => (real.waiting = message), - stabilizer: (real.stabilizer = createStabilizer()), - }); - real.stabilizer.stable = stableMode; - say( - 'father', - 'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.', - ); - setArMode(true); -} - -// ---------- First-person real-world view ---------- -// The view stands at the GPS position at eye height and turns with the phone. With the camera -// picture on (AR) the city model is hidden and only the machines, their reach circles, the route -// and the robot are drawn over the real street; with it off, the 3D city is shown first person. -const arView = createArView($('#camera')); -let arMode = false, - stableMode = true; -const hiddenInAr = () => [ - sky, - city.group, - imagery.group, - factory.group, - traffic.group, - carModel.group, - parkGate, -]; - -async function setArMode(on) { - if (on && !arMode) { - const problem = await arView.start(); - if (problem) { - say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`); - return; - } - if (!real.on) { - arView.stop(); - return; - } - say( - 'robot', - 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.', - 'excited', - ); - setTimeout(() => { - if (real.on && !arView.hasOrientation) - say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.'); - }, 3000); - } else if (!on && arMode) arView.stop(); - else return; - arMode = on; - for (const object of hiddenInAr()) object.visible = !on; - scene.fog = on ? null : fog; - document.body.classList.toggle('ar', on); - $('#ar-toggle').classList.toggle('on', on); -} - -$('#ar-toggle').onclick = () => setArMode(!arMode); -$('#stable-toggle').onclick = () => { - stableMode = !stableMode; - real.stabilizer.stable = stableMode; - $('#stable-toggle').classList.toggle('on', stableMode); - $('#stable-toggle').textContent = stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش'; -}; - -// The safety notice comes first; permissions are requested from the tap on its button (iOS needs that). -function askRealMode(thenStart) { - if (real.on) { - setRealMode(false); - return; - } - $('#real-intro').showModal(); - $('#real-go').onclick = async () => { - $('#real-intro').close(); - await requestCompass(); - setRealMode(true); - if (thenStart) startMission(); - }; -} - -$('#real-mode').onclick = () => askRealMode(true); -$('#gps-toggle').onclick = () => askRealMode(false); -$('#real-cancel').onclick = () => $('#real-intro').close(); - -function updateReal(dt) { - const status = $('#gps-status'); - if (real.error) status.textContent = `⚠ ${real.error}`; - else if (!real.fix) status.textContent = `📍 ${real.waiting ?? 'در حال یافتن موقعیت…'}`; - else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) { - const nearest = Math.min( - ...activeStations().map((s) => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)), - ); - status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`; - } else - status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`; - if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) { - player.speed = 0; - return; - } - // Walk the avatar towards the (building-free) GPS point instead of teleporting it. - const target = resolveCircle(collision, real.fix.x, real.fix.z, 1); - const dx = target.x - player.x, - dz = target.z - player.z, - d = Math.hypot(dx, dz); - if (d > 150) Object.assign(player, { x: target.x, z: target.z }); - const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0; - player.speed += (speed - player.speed) * Math.min(1, dt * 6); - if (d > 0.5) { - const step = Math.min(d, player.speed * dt); - player.x += (dx / d) * step; - player.z += (dz / d) * step; - } - const want = real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : player.heading); - // Stable mode also calms the compass, which jitters near cars and metal. - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * (stableMode ? 2 : 5)); - player.gait += dt * player.speed * 1.9; -} - -// ---------- Loop ---------- -const camPos = new THREE.Vector3(), - camTarget = new THREE.Vector3(), - lookAt = new THREE.Vector3(); -let elapsed = 0, - last = performance.now(), - hudTimer = 0, - streetTimer = 0; -// Debug runs (headless checks render ~1 fps) may simulate a whole second per frame, still in substeps. -const MAX_FRAME = new URLSearchParams(location.search).has('debug') ? 1 : 0.25; - -function resize() { - renderer.setSize(innerWidth, innerHeight); - camera.aspect = innerWidth / innerHeight; - camera.updateProjectionMatrix(); - renderMap(); -} - -window.addEventListener('resize', resize); - -function updateWalk(dt) { - const forward = { x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw) }, - right = { x: Math.cos(cam.yaw), z: -Math.sin(cam.yaw) }; - const iy = axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, - ix = axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x; - const frozen = state.busy || payment.phase !== 'idle'; - let dx = forward.x * iy + right.x * ix, - dz = forward.z * iy + right.z * ix; - const len = Math.hypot(dx, dz); - const run = keys.has('shift'); - const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len); - player.speed += (target - player.speed) * Math.min(1, dt * 8); - if (len > 0.1 && !frozen) { - dx /= len; - dz /= len; - const want = Math.atan2(-dz, dx); - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * 9); - } - // Face the machine while buying. - const station = payment.station ?? payment.delivering?.station; - if (frozen && station) { - const want = Math.atan2(-(station.z - player.z), station.x - player.x); - player.heading += - Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * - Math.min(1, dt * 6); - } - const mx = Math.cos(player.heading) * player.speed * dt, - mz = -Math.sin(player.heading) * player.speed * dt; - const p = resolveCircle(collision, player.x + mx, player.z + mz, 1); - const { minX, minZ, maxX, maxZ } = data.bounds; - player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15); - player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15); - player.gait += dt * player.speed * 1.9; -} - -// Collision log for ?debug runs; trimmed so it never grows without bound. -const debugHits = []; - -function updateDrive(dt) { - if (debugHits.length > 200) debugHits.splice(0, 100); - const throttle = THREE.MathUtils.clamp( - axis(['w', 'arrowup'], ['s', 'arrowdown']) + stick.y, - -1, - 1, - ); - const steer = THREE.MathUtils.clamp( - axis(['d', 'arrowright'], ['a', 'arrowleft']) + stick.x, - -1, - 1, - ); - // At top speed the car covers almost 2 m per step, so move it in short slices that can't - // tunnel through a thin wall or fence. - const slices = Math.max(1, Math.ceil((Math.abs(car.speed) * dt) / 0.6)); - for (let i = 0; i < slices; i++) { - stepCar(car, { throttle, steer, handbrake: keys.has(' ') }, dt / slices); - // Two collision circles along the body keep the nose and tail out of buildings. - for (const along of [1.3, -1.3]) { - const cx = car.x + Math.cos(car.heading) * along, - cz = car.z - Math.sin(car.heading) * along; - const p = resolveCircle(collision, cx, cz, 1.05); - if (p.hit) { - debugHits.push({ - kind: 'building', - x: Math.round(cx), - z: Math.round(cz), - v: +car.speed.toFixed(1), - }); - car.x += p.x - cx; - car.z += p.z - cz; - if (Math.abs(car.speed) > 4) tone(90, 0.2, 0.08, 'square'); - car.speed *= 0.35; - } - } - } - // Bumping a traffic car slows you down but never throws you backwards. If you stay pressed - // against one, it pulls away (respawns out of sight) so you can never get stuck behind it. - for (const npc of traffic.cars) { - const dx = car.x - npc.x, - dz = car.z - npc.z, - d = Math.hypot(dx, dz); - if (d >= 3.4 || d === 0) { - if (npc.contact) npc.contact = 0; - continue; - } - debugHits.push({ - kind: 'traffic', - x: Math.round(car.x), - z: Math.round(car.z), - v: +car.speed.toFixed(1), - npcDir: npc.dir, - }); - car.x = npc.x + (dx / d) * 3.4; - car.z = npc.z + (dz / d) * 3.4; - const towards = - (Math.cos(car.heading) * -dx - Math.sin(car.heading) * -dz) * Math.sign(car.speed) > 0; - if (towards && Math.abs(car.speed) > 4) tone(110, 0.25, 0.08, 'square'); - if (towards) car.speed *= 0.4; - npc.contact = (npc.contact ?? 0) + dt; - if (npc.contact > 0.8) { - npc.contact = 0; - traffic.remove(npc); - } - } - const { minX, minZ, maxX, maxZ } = data.bounds; - car.x = THREE.MathUtils.clamp(car.x, minX + 15, maxX - 15); - car.z = THREE.MathUtils.clamp(car.z, minZ + 15, maxZ - 15); - Object.assign(player, { x: car.x, z: car.z, heading: car.heading }); - // Chase camera swings back behind the car when the mouse is idle. - if (elapsed - cam.lastMouse > 1.4 && Math.abs(car.speed) > 1) { - const want = - Math.atan2(-Math.cos(car.heading), Math.sin(car.heading)) + (car.speed < 0 ? Math.PI : 0); - cam.yaw += - Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 2.2); - cam.pitch += (0.28 - cam.pitch) * Math.min(1, dt * 1.5); - } - if (engine && soundOn) { - engine.o.frequency.setTargetAtTime( - 45 + Math.abs(car.speed) * 5 + Math.max(0, throttle) * 15, - audio.currentTime, - 0.1, - ); - engine.g.gain.setTargetAtTime(0.025, audio.currentTime, 0.2); - } -} - -function updateCamera(dt) { - const station = payment.station; - if (station && payment.phase !== 'idle') { - // Close-up of the card reader during payment. - const reader = station.model.readerPoint.clone(); - station.model.group.localToWorld(reader); - const side = rotateLocal(1.2, 1.9, station.rot); - camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z); - lookAt.lerp(reader, 1 - Math.exp(-dt * 6)); - } else { - const driving = mode === 'drive', - height = driving ? 1.6 : 1.75; - const focus = new THREE.Vector3(player.x, height, player.z); - // Over-the-shoulder offset to the right, like a third-person shooter. - const shoulder = driving ? 0 : 0.75; - focus.x += Math.cos(cam.yaw) * shoulder; - focus.z += -Math.sin(cam.yaw) * shoulder; - let distance = cam.distance; - const ox = Math.sin(cam.yaw) * Math.cos(cam.pitch), - oz = Math.cos(cam.yaw) * Math.cos(cam.pitch); - const free = clearFraction( - collision, - focus.x, - focus.z, - focus.x + ox * distance, - focus.z + oz * distance, - 0.8, - ); - if (cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7); - camTarget.set( - focus.x + ox * distance, - Math.max(0.6, focus.y + Math.sin(cam.pitch) * distance), - focus.z + oz * distance, - ); - lookAt.lerp(focus, 1 - Math.exp(-dt * 14)); - } - camPos.lerp(camTarget, 1 - Math.exp(-dt * 12)); - camera.position.copy(camPos); - camera.lookAt(lookAt); -} - -function updateHUD(dt) { - const pos = mode === 'drive' ? car : player; - $('#clock').textContent = formatClock(state.minutes); - if (mode === 'drive') $('#speed').textContent = fa(kmh(car)); - // Where the camera actually looks: the orbit camera, or the phone itself in AR view. - const look3 = camera.getWorldDirection(new THREE.Vector3()), - flat = Math.hypot(look3.x, look3.z); - const forward = - flat > 0.05 - ? { x: look3.x / flat, z: look3.z / flat } - : { x: -Math.sin(cam.yaw), z: -Math.cos(cam.yaw) }; - const headingDeg = bearing(forward.x, forward.z); - const objective = objectiveStation(); - drawCompass( - headingDeg, - objective - ? [{ bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c' }] - : [], - ); - drawMinimap(minimap, mapAssets, { - size: 220, - x: pos.x, - z: pos.z, - heading: mode === 'drive' ? car.heading : player.heading, - camForward: forward, - radius: mode === 'drive' ? 650 : 200, - markers: markers(), - route: route?.points, - }); - // Objective marker above the machine, clamped to the screen edge when off-screen. - const wp = $('#waypoint'); - wp.hidden = !objective; - if (objective) { - const d = Math.hypot(objective.x - pos.x, objective.z - pos.z), - p = new THREE.Vector3(objective.x, 3.4, objective.z).project(camera); - let sx = (p.x * 0.5 + 0.5) * innerWidth, - sy = (-p.y * 0.5 + 0.5) * innerHeight; - if (p.z > 1) { - sx = innerWidth - sx; - sy = innerHeight - 60; - } - $('#waypoint').style.left = `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`; - $('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`; - $('#waypoint-name').textContent = objective.def.name; - $('#waypoint-distance').textContent = formatDistance(d); - wp.hidden = d < 6; - for (const s of stations) { - const isObjective = s === objective; - s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9'); - s.model.beacon.material.opacity = - (isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40); - } - } - streetTimer -= dt; - if (streetTimer <= 0) { - streetTimer = 0.6; - const street = nearestSegment(segments, pos.x, pos.z, 35, (s) => s[5] >= 0); - const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130; - $('#street').textContent = inFactory - ? `کارخانهٔ ${FACTORY.name}` - : street - ? `مشهد · ${data.names[street.name]}` - : 'حومهٔ مشهد'; - } - updatePrompt(); -} - -let hints = { closed: false, car: false }; - -// People in the factory say their lines once, when the family walks up to them. -function updateTalks() { - if (mode !== 'walk' || real.on || lines.length > 2) return; - for (const t of factory.talks) { - if (t.done || (t.ready && !t.ready())) continue; - const p = t.pos(); - if (Math.hypot(p.x - player.x, p.z - player.z) > t.radius) continue; - t.done = true; - for (const text of t.lines) say(t.speaker, text); - return; - } -} - -// ---------- Robot guide ---------- -// When the family walks up to a machine the robot pops out of it, greets them and explains what to -// do; when they leave it goes back inside. It is never a walking companion. -const guideState = { station: null, t: 0, greeted: new Set(), arrived: 0 }; -const guideOut = () => guideState.t >= 1; -const easeOutBack = (t) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2; - -function greet(station) { - const def = station.def, - first = !guideState.greeted.has(def.id); - guideState.greeted.add(def.id); - if (def.id === 'factory' && first) { - say( - 'robot', - 'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!', - 'excited', - ); - say( - 'robot', - `به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, - 'happy', - ); - say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink'); - say( - 'robot', - 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', - 'love', - ); - return; - } - if (!isOpen(def, state.minutes)) { - say( - 'robot', - `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, - 'serious', - ); - return; - } - if (first) { - say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited'); - say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy'); - } else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink'); -} - -function updateGuide(dt) { - const near = - mode === 'walk' ? closestStation(player.x, player.z, Math.max(9, reachRadius())) : null; - // The robot only moves to another machine after it has gone back into the previous one. - if (near && !guideState.station) guideState.station = near; - const s = guideState.station, - wasOut = guideOut(); - if (!s) return; - guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1); - if (guideOut() && !wasOut) { - guideState.arrived = elapsed; - greet(s); - tone(990, 0.15); - tone(1320, 0.2); - } - if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy'); - if (!guideState.t) { - guideState.station = null; - guide.group.visible = false; - return; - } - // From inside the machine front out to its left side (the card reader is on the right). - const e = easeOutBack(guideState.t), - from = { x: -0.2, y: 0.9, z: 0.2 }, - to = { x: -1.7, y: 0, z: 1.3 }; - const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot); - guide.group.visible = true; - guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z); - guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e)); - // Face the family (the rig looks along its local +X). - guide.group.rotation.y = Math.atan2( - -(player.z - guide.group.position.z), - player.x - guide.group.position.x, - ); - if (!state.busy && payment.phase === 'idle') s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0; - guide.update({ - dt, - time: elapsed, - gait: 0, - moving: false, - talking, - wave: elapsed - guideState.arrived < 2.5 || !guideOut(), - point: talking && guideOut(), - }); -} - -function updateHints() { - const target = objectiveStation(); - if (target && !isOpen(target.def, state.minutes) && !hints.closed) { - hints.closed = true; - say( - 'robot', - `${target.def.name} الان بسته است (${hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`, - ); - } - if (route && route.length > 900 && mode === 'walk' && !hints.car) { - hints.car = true; - say('father', 'راه طولانیه؛ بهتره با ماشین بریم.'); - } -} - -function frame() { - const now = performance.now(); - requestAnimationFrame(frame); - // The first rAF timestamp can predate `last`, so never let time run backwards. Long frames are - // simulated in short substeps, so slow devices keep real-time speed without unstable physics. - const dt = THREE.MathUtils.clamp((now - last) / 1000, 0, MAX_FRAME); - last = now; - elapsed += dt; - const playing = $('#briefing').hidden; - const paused = - !playing || $('#shop').open || $('#map-dialog').open || $('#end').open || document.hidden; - const steps = Math.max(1, Math.ceil(dt / (1 / 30))), - step = dt / steps; - if (!playing) { - // Briefing: animate Iran, type the orders, slowly orbit the start point behind the overlay. - const ic = $('#iran'), - dpr = sizeCanvas(ic), - c = ic.getContext('2d'); - c.setTransform(dpr, 0, 0, dpr, 0, 0); - drawIran(c, iran, { - width: ic.width / dpr, - height: ic.height / dpr, - t: elapsed, - cities: CITIES, - }); - briefChars = Math.min(BRIEF.length, briefChars + dt * 55); - $('#brief-text').textContent = - BRIEF.slice(0, Math.floor(briefChars)) + (briefChars < BRIEF.length ? '▌' : ''); - } - if (!paused) { - for (let i = 0; i < steps; i++) { - state.minutes += (step * 10) / 60; - if (real.on) updateReal(step); - else if (mode === 'drive') updateDrive(step); - else updateWalk(step); - updatePayment(step); - updateDelivery(step); - traffic.update( - step, - [ - { x: player.x, z: player.z }, - { x: car.x, z: car.z }, - ], - player, - ); - } - updateRoute(dt); - updateHints(); - updateTalks(); - updateGuide(dt); - if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd(); - } else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.1); - if (mode === 'walk' && engine && soundOn) - engine.g.gain.setTargetAtTime(0, audio.currentTime, 0.2); - updateSubtitles(dt); - - family.group.visible = mode === 'walk' && !real.on; - family.group.position.set(player.x, 0, player.z); - family.group.rotation.y = player.heading; - family.update({ - dt, - time: elapsed, - gait: player.gait, - moving: player.speed > 0.4, - shopping: state.busy || payment.phase !== 'idle', - delivery: deliveryTimer > 0, - }); - factory.update(elapsed, paused ? 0 : dt, camera.position); - carModel.group.position.set(car.x, 0, car.z); - carModel.group.rotation.y = car.heading; - for (const [i, wheel] of carModel.wheels.entries()) { - wheel.rotation.z -= (car.speed * dt) / 0.36; - if (i >= 2) wheel.rotation.y = -car.steer; - } - for (const s of stations) s.model.ring.material.opacity = 0.35 + Math.sin(elapsed * 3) * 0.25; - updateReachCircles(); - if (routeMesh) routeMaterial.opacity = 0.55 + Math.sin(elapsed * 4) * 0.15; - - if (playing && real.on) { - // First person: the phone is the camera, at the player's GPS position and eye height. - camera.position.set(player.x, 1.6, player.z); - if (!arView.update(camera, dt, stableMode ? 5 : 10)) { - // No orientation sensor: face the compass or walking direction, or wherever the player drags. - if (real.heading != null && elapsed - cam.lastMouse > 2) { - const want = Math.atan2(-Math.cos(player.heading), Math.sin(player.heading)); - cam.yaw += - Math.atan2(Math.sin(want - cam.yaw), Math.cos(want - cam.yaw)) * Math.min(1, dt * 3); - } - camera.rotation.set(cam.arPitch ?? 0, cam.yaw, 0, 'YXZ'); - } - camPos.copy(camera.position); - } else if (playing) { - if (camera.fov !== 62) { - camera.fov = 62; - camera.updateProjectionMatrix(); - } - updateCamera(dt); - } else { - const a = elapsed * 0.08; - camera.position.set(player.x + Math.cos(a) * 30, 18, player.z + Math.sin(a) * 30); - camera.lookAt(player.x, 2, player.z); - camPos.copy(camera.position); - } - sun.position.set(player.x + 160, 230, player.z + 110); - sun.target.position.set(player.x, 0, player.z); - sky.position.copy(camera.position); - city.update(camera.position); - if (!arMode) imagery.update(camera.position.x, camera.position.z); - if (playing) { - hudTimer -= dt; - updateHUD(dt); - if ($('#map-dialog').open && hudTimer <= 0) { - hudTimer = 0.5; - renderMap(); - } - } - renderer.render(scene, camera); -} - -// ?debug exposes live game state in the console for development and automated checks. -if (new URLSearchParams(location.search).has('debug')) - window.game = { - get state() { - return state; - }, - get player() { - return player; - }, - get cam() { - return cam; - }, - factory, - get car() { - return car; - }, - get mode() { - return mode; - }, - get payment() { - return payment; - }, - get elapsed() { - return elapsed; - }, - get route() { - return route; - }, - setWaypoint(id) { - waypoint = id; - route = null; - routeTimer = 0; - }, - get arMode() { - return arMode; - }, - arView, - setArMode, - debugHits, - nearestRoad: (x, z) => nearestSegment(segments, x, z, 40, (s) => s[4] <= 3), - stations, - guideState, - real, - keys, - traffic, - }; -reset(); -resize(); -setupPwa(); -$('#loading').remove(); -requestAnimationFrame(frame); diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..85746c5 --- /dev/null +++ b/src/main.jsx @@ -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( + + در حال آماده‌سازی بازی…

    }> + +
    +
    , +); diff --git a/src/pages/game/BriefingPanel.jsx b/src/pages/game/BriefingPanel.jsx new file mode 100644 index 0000000..7beaa58 --- /dev/null +++ b/src/pages/game/BriefingPanel.jsx @@ -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 ( + <> + +
    + +
    +
    + روبات راهنمای روبات مارکت + {'عملیات ۰۱ · خراسان رضوی'} +

    {'عملیات مشهد'}

    +

    {'روبات مارکت · شکار دستگاه‌های وندینگ'}

    + + actions.emit('start-mission')} + > + {'شروع عملیات'} + +
    + actions.emit('real-mode')} + > + {'📍 بازی در دنیای واقعی (GPS)'} + + +
    +

    + { + 'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M' + } +

    +
    +
    + + ); +} diff --git a/src/pages/game/EndDialog.jsx b/src/pages/game/EndDialog.jsx new file mode 100644 index 0000000..4c0465a --- /dev/null +++ b/src/pages/game/EndDialog.jsx @@ -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 ( + <> + actions.close('end')}> + + {'پایان عملیات'} + + + + + actions.emit(name, value)} + /> + +
    + actions.emit('again')} + > + {'عملیات دوباره'} + + actions.emit('continue')} + > + {'ادامهٔ گشت در شهر'} + +
    +
    + + ); +} diff --git a/src/pages/game/GameDialogs.jsx b/src/pages/game/GameDialogs.jsx new file mode 100644 index 0000000..1a52034 --- /dev/null +++ b/src/pages/game/GameDialogs.jsx @@ -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 ( + <> + + + + + + + + + ); +} diff --git a/src/pages/game/GameHud.jsx b/src/pages/game/GameHud.jsx new file mode 100644 index 0000000..75fb01a --- /dev/null +++ b/src/pages/game/GameHud.jsx @@ -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 ( + <> +