Compare commits

..
5 Commits
Author SHA1 Message Date
Hamed fbfa8d011d docs: document React architecture and enforce migration checks in CI
CI/CD / test (push) Successful in 1m15s
CI/CD / publish (push) Successful in 1m21s
CI/CD / deploy (push) Successful in 6s
2026-10-28 12:53:12 +03:30
Hamed 55d4123fe5 feat: add factory sample machines and robot pricing conversations 2026-10-28 12:53:07 +03:30
Hamed d0da416923 refactor: separate React views, Redux snapshots and Three.js runtime 2026-10-28 12:26:40 +03:30
Hamed 6bd9b17173 refactor: centralize world and imagery requests in Axios services 2026-10-28 12:14:08 +03:30
Hamed 08b6cd464b chore: establish formatting baseline and verification scripts 2026-10-28 12:11:36 +03:30
140 changed files with 19737 additions and 6964 deletions
+5 -1
View File
@@ -41,8 +41,12 @@ jobs:
- uses: actions/checkout@v4
- name: Install
run: npm ci --no-audit --no-fund
- name: Check formatting
run: npm run format:check
- name: Lint
run: npm run lint
- name: Test
run: node --test tests/*.test.js
run: npm test
- name: Build
run: npm run build
+5 -1
View File
@@ -27,8 +27,12 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- name: Check formatting
run: npm run format:check
- name: Lint
run: npm run lint
- name: Test game logic
run: node --test tests/*.test.js
run: npm test
- run: npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
src/data
public
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}
+1 -1
View File
@@ -6,7 +6,7 @@ ARG NPM_REGISTRY=https://registry.npmjs.org/
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" --fetch-retries=3 --fetch-timeout=60000
COPY . .
RUN node --test tests/*.test.js && npm run build
RUN npm run format:check && npm run lint && npm test && npm run build
# Stage 2: serve dist/ with Caddy, which also obtains and renews the HTTPS certificate.
FROM caddy:2.8.4-alpine
+41 -42
View File
@@ -1,11 +1,25 @@
# عملیات مشهد — روبات مارکت
بازی سه‌بعدی مرورگری (Three.js) با حال‌وهوای بازی‌های اکشن سوم‌شخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاه‌ها راه می‌افتند، در خیابان‌های واقعی مشهد می‌گردند، دستگاه‌های وندینگ روبات مارکت را پیدا می‌کنند و با هر خرید امتیاز می‌گیرند. روبات راهنمای روبات مارکت داخل دستگاه‌ها زندگی می‌کند: وقتی خانواده به یک دستگاه می‌رسند، از آن بیرون می‌آید، خوش‌آمد می‌گوید و راهنمایی‌شان می‌کند.
📚 [مستندات کامل بازی و راهنمای توسعه برای Agent](docs/README.md) — قوانین، سناریو، معماری، تاریخچه و بک‌لاگ بر اساس بررسی کد فعلی.
بازی سه‌بعدی مرورگری (React 18 + Three.js، JavaScript، Tailwind CSS) با حال‌وهوای بازی‌های اکشن سوم‌شخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاه‌ها راه می‌افتند، در خیابان‌های واقعی مشهد می‌گردند، دستگاه‌های وندینگ روبات مارکت را پیدا می‌کنند و با هر خرید امتیاز می‌گیرند. روبات راهنمای روبات مارکت داخل دستگاه‌ها زندگی می‌کند: وقتی خانواده به یک دستگاه می‌رسند، از آن بیرون می‌آید، خوش‌آمد می‌گوید و راهنمایی‌شان می‌کند.
بازی یک PWA است: روی گوشی نصب می‌شود و آفلاین هم اجرا می‌شود.
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
## نسخهٔ React و نمایشگاه کارخانه
رابط با React، وضعیت مشترک با Redux Toolkit و درخواست‌ها با Axios پیاده شده‌اند. موتور Three.js از رابط جداست. [معماری جدید و راهنمای Agent](docs/11-react-migration.md) مرجع ساختار فعلی است.
سه دستگاه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال در حیاط کارخانه قرار دارند. با Q نزدیک نمونه‌ها یا دکمهٔ «مدل‌های کارخانه» داخل محوطه، قیمت و امکانات را از روبات بپرسید. قیمت دستگاه‌ها به‌ترتیب ۴٬۶۸۰٬۰۰۰٬۰۰۰، ۵٬۴۸۰٬۰۰۰٬۰۰۰ و ۵٬۹۸۰٬۰۰۰٬۰۰۰ ریال است؛ این معرفی بودجهٔ هتومان را کم نمی‌کند. تصاویر/GIFهای کاتالوگ هنوز اضافه نشده‌اند؛ نمونه‌های سه‌بعدی موجودند و رابط برای نبود فایل پیام جایگزین دارد.
```sh
npm test
npm run lint
npm run format:check
```
## داستان و هدف
- **شروع:** سالن تولید کارخانهٔ **شرکت روبات مارکت** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه این بخش‌ها را دارد:
@@ -96,7 +110,7 @@
npm install
npm run dev
npm run build
node --test tests/*.test.js
npm test
```
- `?quality=low` یا دکمهٔ «گرافیک» سایه‌ها را خاموش می‌کند (پیش‌فرض گوشی‌ها).
@@ -132,29 +146,14 @@ docker compose logs -f
## CI/CD روی Gitea
مخزن اصلی `ssh://git@git.my-rm.com:2222/Hamed/vending-machine-game.git` با نام `origin` است. GitHub با نام `github` باقی مانده. گردش‌کار [.gitea/workflows/ci-cd.yml](.gitea/workflows/ci-cd.yml) با هر push خودش اجرا می‌شود:
گردش‌کار `.gitea/workflows/ci-cd.yml` تست و build را اجرا می‌کند. برای main تصویر Docker در Registry ساخته و منتشر می‌شود؛ سپس runner از Docker socket مشترک سرور و فایل `deploy/docker-compose.yml` برای pull و up استفاده می‌کند. انتشار فعلی SSH نیست.
- **هر push و pull request:** نصب، تست‌ها و build
- **push به `main`:** بعد از قبولی تست‌ها، کد از راه SSH به سرور فرستاده می‌شود و `docker compose up -d --build` اجرا می‌شود.
- Secrets لازم: `REGISTRY_USERNAME` و `REGISTRY_TOKEN` با دسترسی read/write به package.
- Variables اختیاری: `NPM_REGISTRY` برای mirror و `HEALTH_URL` برای بررسی پاسخ سایت.
- شبکهٔ خارجی `frontend_proxy` و Nginx Proxy Manager برای HTTPS در این روش لازم‌اند؛ خود کانتینر روی پورت ۸۰ داخلی است.
- Compose ریشه روش مستقل دیگری است که Caddy مستقیماً TLS می‌دهد؛ با Compose مخصوص deploy اشتباه نشود.
**راه‌اندازی یک‌باره:**
1. در سرور، Docker و Docker Compose نصب باشد و کاربر SSH بتواند `docker` اجرا کند (عضو گروه `docker` یا root).
2. یک کلید SSH مخصوص انتشار بسازید و کلید عمومی را به `~/.ssh/authorized_keys` سرور اضافه کنید:
```sh
ssh-keygen -t ed25519 -N '' -f deploy_key -C gitea-deploy
ssh-copy-id -i deploy_key.pub user@server
```
3. در Gitea، بخش Settings → Actions → Secrets مخزن، این‌ها را تعریف کنید:
- `DEPLOY_HOST`: آدرس سرور
- `DEPLOY_SSH_KEY`: محتوای فایل `deploy_key`
- در صورت نیاز: `DEPLOY_USER` (پیش‌فرض root)، `DEPLOY_PORT` (پیش‌فرض ۲۲) و `DEPLOY_KNOWN_HOSTS` (خروجی `ssh-keyscan server`)
4. اختیاری، در بخش Variables:
- `DEPLOY_PATH`: پوشهٔ روی سرور
- `SITE_ADDRESS`: دامنه‌ها
- `HEALTH_URL`: مثلاً `https://robotmarkets.ir/`، تا بعد از انتشار بالا آمدن سایت چک شود
- `NPM_REGISTRY`: اگر runner یا سرور به `registry.npmjs.org` دسترسی ندارد (معمول در سرورهای ایران)، آدرس یک mirror در دسترس npm را اینجا بگذارید. همین مقدار برای build روی سرور هم استفاده می‌شود.
تا وقتی `DEPLOY_HOST` تعریف نشده، مرحلهٔ انتشار رد می‌شود و فقط تست و build اجرا می‌شوند. هر مرحله محدودیت زمانی دارد (تست ۱۵ دقیقه، انتشار ۳۰ دقیقه) تا runner هیچ‌وقت بی‌پایان معطل نماند.
پیش از push نام remote و branch مقصد را بررسی کنید. [جزئیات انتشار و تفاوت روش‌ها](docs/06-development-and-deployment.md).
## ساخت دوبارهٔ دادهٔ نقشه
@@ -206,29 +205,29 @@ node scripts/fetch-imagery.mjs
## ساختار
- `src/main.js`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها) در تکه‌های ۲ کیلومتری که دورها پنهان می‌شوند
- `src/map-render.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشی‌های کش‌شده و نمای کلی) و نقشهٔ ایران
- `src/routing.js`: گراف خیابان‌ها، مسیریابی A* و پیدا کردن نام خیابان
- `src/collision.js`: برخورد با ساختمان‌ها برای شخصیت‌ها، ماشین و دوربین
- `src/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/game-state.js`، `src/payment.js`، `src/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `src/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاه‌ها بیرون می‌آید
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، به‌همراه دفترها، کامیون و ورود مدیر
- `src/staff.js`: آدم‌های کارخانه، یعنی کارمندهای مفصل (هودی، کت‌وشلوار، مانتو و روسری) با برچسب اسم، و ۴۰ کارگر به‌صورت instanced
- `src/car-models.js`: ماشین خانواده (سدان اسپرت)، وانت مدیر و کامیون
- `src/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام به‌روزرسانی
- `src/realworld.js`: GPS، قطب‌نما، نرم‌سازی و حالت پایدار موقعیت، و شعاع رسیدن
- `src/imagery.js`: زمین ماهواره‌ای که کاشی‌هایش با نزدیک شدن بارگذاری و با دور شدن آزاد می‌شوند
- `src/buildings-data.js`: خواندن فایل ساختمان‌های Microsoft
- `src/ar.js`: نمای دوربین (AR)، شامل تصویر دوربین پشت گوشی، جهت گوشی و زاویهٔ دید
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/game/world/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها) در تکه‌های ۲ کیلومتری که دورها پنهان می‌شوند
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشی‌های کش‌شده و نمای کلی) و نقشهٔ ایران
- `src/game/world/routing.js`: گراف خیابان‌ها، مسیریابی A* و پیدا کردن نام خیابان
- `src/game/world/collision.js`: برخورد با ساختمان‌ها برای شخصیت‌ها، ماشین و دوربین
- `src/constants/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاه‌ها بیرون می‌آید
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، به‌همراه دفترها، کامیون و ورود مدیر
- `src/game/models/staff.js`: آدم‌های کارخانه، یعنی کارمندهای مفصل (هودی، کت‌وشلوار، مانتو و روسری) با برچسب اسم، و ۴۰ کارگر به‌صورت instanced
- `src/game/models/carModels.js`: ماشین خانواده (سدان اسپرت)، وانت مدیر و کامیون
- `src/game/device/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام به‌روزرسانی
- `src/game/device/realworld.js`: GPS، قطب‌نما، نرم‌سازی و حالت پایدار موقعیت، و شعاع رسیدن
- `src/game/world/imagery.js`: زمین ماهواره‌ای که کاشی‌هایش با نزدیک شدن بارگذاری و با دور شدن آزاد می‌شوند
- `src/game/world/buildingsData.js`: خواندن فایل ساختمان‌های Microsoft
- `src/game/device/ar.js`: نمای دوربین (AR)، شامل تصویر دوربین پشت گوشی، جهت گوشی و زاویهٔ دید
- `src/game/models/vending.js`، `src/game/systems/vehicle.js`، `src/game/systems/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
- `src/utils/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
## محدودیت‌ها و گام‌های بعدی
- مدل‌ها هندسی و سبک‌اند و کیفیت گرافیکی بازی‌های AAA را ندارند.
- خیابان‌ها یک‌طرفه و چراغ راهنمایی ندارند، و ترافیک فقط روی بلوارهای اصلی است.
- داده و ترافیک جهت یک‌طرفه دارند، اما مسیریابی هنوز دوطرفه است و چراغ راهنمایی شبیه‌سازی نمی‌شود؛ ترافیک روی خیابان‌های اصلی است.
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محله‌ها و شهرهای دیگر.
نقشه: © مشارکت‌کنندگان OpenStreetMap (ODbL). ساختمان‌ها: © Microsoft (ODbL). تصویر ماهواره‌ای: Esri, Maxar, Earthstar Geographics, and the GIS User Community. مرز استان‌ها: Natural Earth.
+35
View File
@@ -0,0 +1,35 @@
# محصول، سناریو و دامنهٔ فعلی
## هویت
نام فعلی: «عملیات مشهد — روبات مارکت». بازی مرورگری فارسی، راست‌به‌چپ، سه‌بعدی، بدون سلاح و خشونت است. هدف اصلی کشف دستگاه‌های فرضی وندینگ در جغرافیای واقعی مشهد، خرید نمایشی و پیشرفت در مأموریت‌هاست. مختصات و نقشه واقعی‌اند؛ وجود یک دستگاه در بازی به معنای وجود دستگاه واقعی در آن نشانی نیست.
## سناریوی قابل اجرا
1. صفحهٔ briefing نقشهٔ ایران و متن مأموریت را نشان می‌دهد. فقط مشهد فعال است؛ شهرهای دیگر نمایش قفل دارند.
2. پس از شروع، پدر و کودک در حیاط کارخانهٔ شرکت روبات مارکت، کنار ماشین خانواده و رو به دفتر مهندسی هستند. README فعلی شروع را «سالن تولید» توصیف می‌کند؛ محل spawn واقعی در `factory.start` حیاط است.
3. خانواده می‌تواند سالن تولید، دفاتر و کارکنان را ببیند. گفت‌وگوهای آغازین به معرفی کارخانه و ورود مدیر اشاره می‌کنند.
4. اولین هدف، خرید از دستگاه دفتر فنی مهندسی است. روبات از کنار دستگاه بیرون می‌آید، خوش‌آمد می‌گوید و کنترل E و ماشین F را معرفی می‌کند.
5. خانواده با خودرو از کارخانه به شهر می‌رود؛ گراف فعلی مسیر کارخانه تا بوستان ملت را حدود ۱۸ تا ۲۳ کیلومتر محاسبه می‌کند (تست محدودهٔ طول مسیر).
6. اهداف شهری شامل بوستان ملت، مدرسه، دانشگاه فردوسی، مترو و مشهد مال هستند. خریدهای دیگر و کشف آزاد نیز ممکن‌اند.
7. تکمیل همهٔ هفت مأموریت، یا نبود هیچ خرید قابل پرداخت مطابق منطق فعلی پایان بودجه، پنجرهٔ پایان را باز می‌کند. پس از تکمیل همهٔ مأموریت‌ها دکمهٔ ادامه وجود دارد؛ «شروع دوباره» عملیات تازه می‌سازد.
ترتیب نمایشی مأموریت‌ها خطی است، اما شرایط تکمیل همهٔ مأموریت‌ها پس از هر خرید بررسی می‌شوند؛ انجام زودتر یک هدف ممنوع نیست. الزام «رفتن با ماشین» در متن بعضی مأموریت‌هاست و به‌عنوان شرط در رکورد خرید ذخیره/بررسی نمی‌شود.
## شخصیت‌ها
- پدر و کودک انسانی تیم قابل کنترل‌اند؛ یک player مشترک، جهت و انیمیشن آن‌ها را هدایت می‌کند.
- روبات فیروزه‌ای/سفید Robot Market راهنمای دستگاه‌هاست. با نزدیک‌شدن ظاهر، با دورشدن جمع و در سفر همراه خانواده نمی‌شود. مدل هندسی در `characters.js` و صورت‌های تصویری در `public/robot` هستند.
- روبات می‌تواند دست تکان بدهد، اشاره کند و همراه زیرنویس حرف بزند. صورت زیرنویس بر اساس نام فایل تصویری انتخاب می‌شود.
- کارکنان دفتر: حامد (React، هودی مشکی)، رایان (Go، هودی آبی)، علیرضا (C++، هودی مشکی)، رضا (C++، کت‌وشلوار)، محمد؛ چهار همکار حسابداری و آقای محمدی مدیر.
- نام React، Go و C++ در گفت‌وگو و مانیتور کارکنان بخشی از داستان کارخانه است؛ بازی با این فناوری‌ها نوشته نشده است.
## کارخانه
پنج بخش تولید: خم‌کاری، برش لیزر، رنگ‌کاری، یخچال‌سازی، مونتاژ؛ ۴۰ کارگر instanced، ابزار و قطعات متحرک، دستگاه‌های آماده، بسته‌بندی و بارگیری. کامیون هشت بسته را بار می‌زند، خارج می‌شود، مدتی غایب می‌ماند، بازمی‌گردد و دنده‌عقب به سکو می‌رسد. این چرخه تزئینی است؛ امتیاز و موجودی کالا را تغییر نمی‌دهد.
مدیر با وانت سفید به سبک KMC T8 وارد می‌شود، پارک می‌کند، تا دفتر راه می‌رود و می‌نشیند؛ گفت‌وگوی او بعد از نشستن فعال می‌شود. گفت‌وگوی هر محدوده/کارمند معمولاً یک بار در هر عملیات و با نزدیک‌شدن فعال می‌شود. برای متن دقیق، `factory.talks` و `greet`/`startMission` در `main.js` مرجع‌اند.
## خارج از دامنهٔ فعلی
پرداخت بانکی، سرور حساب کاربری، ذخیرهٔ پیشرفت، leaderboard آنلاین، شهر قابل بازی غیر از مشهد، مأموریت مبارزه، چرخهٔ روز/شب و شبیه‌سازی کامل قوانین ترافیک پیاده‌سازی نشده‌اند. پارک کوهسنگی و حرم/مرکز شهر در فهرست مناطق آینده‌اند، نه مأموریت فعال.
+71
View File
@@ -0,0 +1,71 @@
# قوانین بازی، اقتصاد و قراردادهای رفتاری
## حالت، زمان و ورودی
- حالت جابه‌جایی `walk` یا `drive` است. GPS حالت مستقل `real.on` است و حرکت را به موقعیت گوشی می‌سپارد.
- شروع: ۵۰۰ هتومان، صفر خرید، XP صفر، مأموریت انجام‌شدهٔ خالی، ساعت ۰۸:۳۰.
- هر ثانیهٔ واقعی فعال = ده ثانیهٔ بازی؛ `state.minutes += dt * 10 / 60`.
- briefing، فروشگاه، نقشه، پنجرهٔ پایان و مخفی‌بودن document شبیه‌سازی را متوقف می‌کنند. زیرنویس‌ها به‌تنهایی حرکت را نمی‌بندند. پنل پرداخت شبیه‌سازی را متوقف نمی‌کند، اما `busy` حرکت پیاده را محدود می‌کند.
- WASD و جهت‌ها: حرکت نسبی به دوربین/کنترل ماشین؛ Shift: دویدن؛ Space: ترمز دستی؛ F: سوار/پیاده؛ E: فروشگاه؛ C: کشیدن کارت؛ Escape: انصراف پیش از تأیید؛ M: نقشه.
- کنترل حروف با `KeyboardEvent.code` است تا کیبورد فارسی هم کار کند. چرخ ماوس فاصلهٔ دوربین را بین ۳ و ۱۲ تغییر می‌دهد. روی دسکتاپ Pointer Lock و در fallback/موبایل drag وجود دارد.
- موبایل joystick و دکمه‌های F، E و toggle دویدن دارد. E موبایل در مرحلهٔ ready کارت می‌کشد.
- سرعت هدف پیاده ۳٫۴ و دویدن ۶٫۵ متر/ثانیه با نرم‌سازی است. برخورد دایره‌ای شخصیت شعاع ۱ متر دارد. مرز جهان با فاصلهٔ ۱۵ متر محدود می‌شود.
## ماشین
سوارشدن در فاصلهٔ حداکثر ۴٫۵ متر و خارج پرداخت/مشغولی؛ پیاده‌شدن در قدرمطلق سرعت حداکثر ۳ متر/ثانیه. UI پیشنهاد پیاده‌شدن را با شرط محدودتر سرعت زیر ۱ و دستگاه نزدیک نشان می‌دهد. سمت خروج راننده بررسی برخورد می‌شود.
ثابت‌های `CAR`: سقف جلو ۵۵٫۶ متر/ثانیه (حدود ۲۰۰ km/h)، عقب ۶، شتاب ۹٫۵، ترمز ۱۴، drag برابر ۱٫۶، فرمان حداکثر ۰٫۵۵ رادیان، wheelBase برابر ۲٫۷. فرمان در سرعت بالا محدودتر می‌شود. برخورد دیوار دو دایرهٔ جلو/عقب و حرکت زیرگام دارد. تصادف ترافیک سرعت را کم می‌کند و NPC پس از تماس طولانی حذف/بازپدید می‌شود. جریمهٔ نقدی، خسارت قابل تعمیر و سوخت وجود ندارد.
## پول و محصول
۱۰ هتومان = ۱۰۰٬۰۰۰ ریال؛ محاسبات با هتومان صحیح و نمایش با رقم فارسی است. قیمت هر دستگاه `Math.round(baseCost * priceFactor)` است؛ مثال آب کارخانه `round(15 × .5) = 8`.
| محصول | نوع | قیمت پایه هتومان |
|---|---|---:|
| آب‌میوه | drink | ۳۵ |
| آب معدنی | drink | ۱۵ |
| شیر کاکائو | drink | ۴۰ |
| چای سرد | drink | ۴۵ |
| لیموناد | drink | ۴۰ |
| بیسکویت | snack | ۲۵ |
| شکلات | snack | ۳۰ |
| آجیل | snack | ۶۵ |
| کیک | snack | ۳۵ |
خرید نامحدود تا حد بودجه است؛ موجودی انبار، مصرف محصول، فروش دوباره و رزرو پول برای مأموریت نداریم. کانال‌های عددی ۲۱ تا ۶۰ متعلق به نسخهٔ قدیمی‌اند؛ فروشگاه فعلی کارت محصول با productId دارد.
`purchaseBlocker` به ترتیب دستگاه/محصول ناشناخته، busy، بسته‌بودن و کمبود پول را کنترل می‌کند. بازبودن شامل ساعت شروع و فاقد ساعت پایان است: `start <= minutes % 1440 < end`. بازهٔ عبور از نیمه‌شب پشتیبانی نشده است.
## پرداخت و تحویل
`idle → approaching → ready → approved → delivery → idle`
- بازکردن فروشگاه: پیاده، دستگاه نزدیک (۳٫۴ متر در حالت مجازی)، عملیات تمام‌نشده و بدون busy/پرداخت فعال.
- انتخاب محصول و `beginPayment`: بدون برداشت؛ busy روشن و دوربین نزدیک کارت‌خوان.
- بعد از بیش از ۱ ثانیه آماده؛ تنها C/دکمهٔ کشیدن کارت خرید را ثبت و مبلغ را یک بار کسر می‌کند.
- انصراف در approaching یا ready بدون تغییر پول/خرید؛ پس از approved مجاز نیست.
- پس از بیش از ۱٫۲ ثانیهٔ approved، تحویل ۲٫۶ ثانیه‌ای آغاز می‌شود. busy تا پایان تحویل باقی می‌ماند.
- رکورد خرید شامل machineId، category، productId، type، price و minutes است. XP و جایزهٔ مأموریت در پایان تحویل اعمال می‌شوند، نه لحظهٔ انتخاب.
- همه‌چیز نمایشی و داخل مرورگر است؛ اتصال درگاه یا تراکنش واقعی وجود ندارد.
## امتیاز و درجه
هر خرید ۱۰۰ XP؛ اولین خرید از هر machineId، ۲۵۰؛ اولین ترکیب drink و snack در همان دستگاه، ۱۵۰؛ تکمیل پنج دستهٔ شهری park/university/school/metro/mall، ۵۰۰. factory و test در پاداش پنج دسته و explorer شمرده نمی‌شوند. خرید در real.on بعد از تحویل ۲۰۰ XP اضافه می‌دهد؛ کشف/ترکیب برای دستگاه آزمایشی هم ممکن است.
| درجه | حداقل XP |
|---|---:|
| تازه‌وارد | ۰ |
| کاوشگر | ۶۰۰ |
| راه‌بلد | ۱۴۰۰ |
| شکارچی دستگاه | ۲۶۰۰ |
| استاد نقشه | ۴۲۰۰ |
| افسانهٔ مشهد | ۶۵۰۰ |
مأموریت‌ها تنها یک بار با missionsDone جایزه می‌دهند؛ مشخصات کامل در [مرجع](10-game-reference.md). activeMission اولین مأموریت انجام‌نشده است، ولی completeMissions همهٔ شرط‌ها را بررسی می‌کند.
## پایان و reset
`checkEnd` در پایان خرید و تقریباً هر نیم‌ثانیه بررسی می‌شود: همهٔ مأموریت‌ها کامل یا `canAffordAnything` منفی. تابع بودجه دستگاه ارزان قابل خرید فعلی/با پایان ساعت دیرتر در همان روز را می‌شمارد، نه امکان کسب پول در آینده؛ محدودیت‌های این رفتار در بازبینی آمده است.
reset پول، خرید، XP، ساعت، payment، spawn، ماشین، مأموریت، مقصد و greeting روبات را تازه می‌کند. reset فعلی پاک‌سازی کامل همهٔ timerها، گفت‌وگوها، GPS و انیمیشن کامیون نیست؛ Agent نباید آن را بدون تست کامل فرض کند.
+54
View File
@@ -0,0 +1,54 @@
# معماری و فناوری
## پشتهٔ فعلی
JavaScript ES Modules، Three.js/WebGL، Vite، HTML و CSS فارسی RTL و Canvas 2D برای نقشه، تابلو، تکسچر و portrait. فونت Vazirmatn با Fontsource داخل bundle می‌آید. نسخهٔ lock فعلی: Three.js `0.180.0`، Vite `6.4.4`، Fontsource `5.2.5`؛ در package.json نسخه‌ها بازهٔ caret دارند. Node 22 در CI/Docker استفاده می‌شود؛ target خروجی ES2022 برای top-level await است. TypeScript، React، موتور فیزیک بیرونی و framework رابط در بازی وجود ندارند.
## نقشهٔ ماژول‌ها
| فایل | مسئولیت |
|---|---|
| main.js | bootstrap، DOM، player/mode/camera، حلقه، اتصال پرداخت و جایزه، GPS/AR، UI و debug |
| game-state.js | catalog، پول و ساعت، خرید، blockers و امکان ادامه با بودجه |
| payment.js | ماشین حالت تراکنش و جلوگیری از برداشت دوباره |
| scoring.js | XP، درجه، مأموریت و جایزهٔ مأموریت |
| locations.js | مشخصات کارخانه، دستگاه‌ها، test points و مناطق قفل |
| characters.js | createFamily معمولی (پدر/کودک) و robot-only، مفصل و انیمیشن |
| factory.js | ساختمان/دکور/کارخانه، کارکنان، موقعیت spawn، talks، مدیر و کامیون |
| staff.js | مدل کارکنان مفصلی و crew به شکل InstancedMesh |
| car-models.js | سدان خانواده، pickup مدیر و کامیون |
| vehicle.js | state و فیزیک آرکید ماشین |
| traffic.js | ماشین‌های NPC، spawn و حرکت روی شبکهٔ اصلی و one-way داده |
| city.js | مدل شهر، زمین fallback، ساختمان، فضای سبز و chunk visibility |
| collision.js | spatial grid چندضلعی‌ها، resolveCircle و clearFraction دوربین |
| routing.js | گراف پیاده/سواره، A*، نزدیک‌ترین node/segment و طول مسیر |
| geo.js | تبدیل lat/lon و مختصات محلی، bearing و فاصلهٔ فارسی |
| map-render.js | minimap، fullmap و نقشهٔ استان‌های ایران با Canvas |
| vending.js | مدل دستگاه، slot، card، flap، beacon و محصول تحویلی |
| imagery.js | کاشی ماهواره‌ای، تبدیل Web Mercator، بارگذاری/آزادسازی texture |
| buildings-data.js | decode فایل باینری RMB2 |
| realworld.js | smoothFix، stabilizer، geolocation/compass و شعاع رسیدن |
| ar.js | rear camera، حسگر جهت، quaternion و تطبیق FOV |
| pwa.js | نصب، ثبت SW، اعلان نسخه و reload |
| textures.js | ساخت CanvasTexture مشترک |
| style.css / index.html | ساختار DOM، HUD، modalها و ظاهر desktop/mobile |
## جریان داده
`locations/catalog + map JSON + public binary/index → ساخت world/graph/collision → reset → requestAnimationFrame`.
حلقهٔ اصلی dt را عادی به ۰٫۲۵ ثانیه محدود و با گام‌های حداکثر ۱/۳۰ شبیه‌سازی می‌کند؛ debug حداکثر ۱ ثانیه دارد. در حالت فعال: ساعت، حرکت، payment، delivery، traffic؛ سپس route، hints، talks و guide. پس از آن انیمیشن‌ها، دوربین، HUD و render اجرا می‌شوند. توقف شبیه‌سازی الزاماً همهٔ انیمیشن‌های بصری را متوقف نمی‌کند.
`main.js` وضعیت مرکزی را در متغیرهای ماژولی نگه می‌دارد؛ event bus یا store جدا نداریم. `state` اقتصادی از `player/car/cam/real` جداست. `payment` در اتصال UI فیلدهای اضافی station/timer/delivering می‌گیرد که در createPayment تعریف نشده‌اند. Object.assign به createPayment فیلدهای اضافی را حذف نمی‌کند؛ هنگام refactor به این وابستگی توجه شود.
## قرارداد توسعه
- محاسبهٔ قیمت فقط از priceAt و catalog؛ UI نسخهٔ دوم قیمت نسازد.
- برداشت فقط در swipe موفق؛ XP/مأموریت پس از تحویل؛ busy تا پایان تحویل.
- دستگاه با id پایدار معرفی شود؛ مأموریت‌ها و خریدهای ذخیره‌شوندهٔ آینده به id وابسته‌اند.
- مبنای geo تغییر نکند مگر همهٔ JSON/binary/anchorها و تست‌ها با هم مهاجرت کنند.
- تغییر mode باید مسیر، دوربین، ورودی و HUD را در نظر بگیرد؛ تغییر real/AR باید lifecycle stream/sensor را هم رعایت کند.
- geometry، material و texture پویا هنگام حذف آزاد شوند؛ مدل تازه نباید هر frame asset بسازد.
- main.js حدود ۱۴۴۳ و factory.js حدود ۶۹۸ خط‌اند؛ جداسازی تدریجی ممکن است، ولی بازنویسی سراسری برای یک تسک کوچک ضرورت ندارد.
backend اختصاصی بازی وجود ندارد. Gitea Registry و Caddy زیرساخت build/میزبانی‌اند، نه API بازی. GPS و تصویر دوربین در این کد به سرور بازی ارسال نمی‌شوند؛ سرویس مکان‌یابی مرورگر و دانلود assetها همچنان محیط بیرونی خود را دارند.
+44
View File
@@ -0,0 +1,44 @@
# جهان، نقشه، داده‌ها و دارایی‌ها
## مختصات و قرارداد فایل
مبدأ `ORIGIN = {lat:36.315, lon:59.539}`، هر واحد یک متر، +X شرق و -Z شمال. تبدیل صفحهٔ مماس محلی با latitude/longitude انجام می‌شود؛ terrain ارتفاع جغرافیایی واقعی ندارد. جهت heading ماشین با `(cos(h), -sin(h))` در صفحهٔ XZ است. bearing صفر شمال و ۹۰ شرق است.
محدودهٔ فعلی: X از -۱۲۲۲۰ تا ۳۱۵۹؛ Z از -۱۶۴۵۱ تا ۲۰۷۸. `scripts/build-map-data.mjs` REGION را از lat 36.298 تا 36.462 و lon 59.405 تا 59.572 می‌گیرد.
`src/data/mellat.json`: origin، attribution، bounds، roadTypes، areaKinds، names، roads، buildings، areas، oneway، labels. snapshot: ۶۱۹۶ road، ۳۹۸۶ ساختمان OSM، ۲۶۸ area، ۲۹۱۰ شاخص road یک‌طرفه و ۹۵ label. این شمارش‌ها تعداد رکورد فایل‌اند، نه شمارش همهٔ خیابان‌های واقعی.
- road: `[typeIndex, nameIndex, x0, z0, x1, z1, ...]`
- building در runtime: `[height, x0, z0, ...]`
- جدول names برای lookup نام road استفاده می‌شود.
- دادهٔ استان‌ها در `src/data/iran.json` است.
## گراف و برخورد
buildGraph گراف پیاده با همهٔ مسیرها، و سواره با road type ≤ 7 می‌سازد. نزدیک‌ترین node از بزرگ‌ترین مؤلفهٔ متصل انتخاب می‌شود؛ A* با هزینهٔ طول کار می‌کند. connector ابتدا/انتهای مسیر به node نزدیک، خط مستقیم است. گراف فعلی دوطرفه است؛ oneway در routing مصرف نمی‌شود، هرچند traffic از آن استفاده می‌کند.
خط طلایی روی زمین فقط تا حدود ۷۰۰ متر از polyline ساخته می‌شود. نقشهٔ کامل و minimap مسیر کامل را می‌گیرند. هدف معمولاً مأموریت فعال است؛ waypoint دستی مقدم است و پس از خرید/اعلام مأموریت بعدی می‌تواند پاک شود.
برخورد بر مبنای footprintهای OSM + Microsoft + factory است؛ spatial cell برابر ۲۴ متر. resolveCircle برای player، خودرو و نقاط spawn استفاده می‌شود. clearFraction مانع ردشدن خط دید دوربین از دیوار می‌شود. ساختمان‌های Microsoft داخل محوطهٔ کارخانه کنار گذاشته می‌شوند.
## دارایی‌ها و بهینه‌سازی
- `public/data/buildings.bin`: قالب RMB2؛ header شامل count، minX، minZ، quantization step؛ هر polygon تعداد رأس/ارتفاع نیم‌متری و مختصات delta دارد. در snapshot بررسی‌شده ۱۴۱٬۴۹۷ ساختمان و ۳٬۴۷۸٬۹۱۰ بایت دارد. ارتفاع‌ها تخمینی‌اند. decoder فعلی برای فایل کوتاه/خراب اعتبارسنجی کامل ندارد.
- city بخش‌های ۲۰۰۰ متری و ساختمان‌ها بلوک‌های ۵۰۰ متری دارد؛ ساخت/visibility وابسته به فاصله است. worker و props با instancing کاهش draw call دارند.
- `public/imagery/index.json`: ۳۴۰ tile زوم۱۵ و ۶۶۰ tile زوم۱۷ (مجموع ۱۰۰۰ JPG). imagery هم‌زمان حداکثر ۶ درخواست می‌فرستد؛ tileهای دور geometry/material/texture آزاد می‌کنند.
- زوم۱۷: شعاع ۴۵۰ متر در low و ۷۵۰ در high؛ زوم۱۵: ۱۸۰۰ و ۲۸۰۰. محدودهٔ حذف ۱٫۲ برابر range است.
- با index ماهواره‌ای موجود، زمین/جادهٔ procedural شهر جایگزین می‌شود. نبود دانلود bin/index fallback دارد؛ index موجود با JPGهای غایب لزوماً به fallback بدون تصویر تبدیل نمی‌شود.
- portraitهای روبات: front، happy، wink، surprised، laugh، love، curious، serious، excited؛ نام فایل با face زیرنویس تطبیق دارد.
- آیکون‌های PWA در `public/icons` و manifest در `public/manifest.webmanifest` هستند.
## بازتولید داده
```sh
node scripts/build-map-data.mjs --osm city.json zone-factory.json corridor.json major.json --poi poi.json --provinces ne_10m_admin_1_states_provinces.geojson
node scripts/build-buildings.mjs <iran-quadkey.csv.gz>
node scripts/fetch-imagery.mjs
```
این‌ها عملیات جدا از npm build هستند. raw exportها ورودی بیرونی‌اند؛ خودکار دانلود/بازنویسی نکنید مگر تسک نیاز داشته باشد. پس از تغییر REGION یا مبدأ باید anchors، factory، graph، binary، tile index و تست دسترس‌پذیری با هم بررسی شوند. `npm run build` فقط فایل‌های موجود را بسته‌بندی می‌کند.
Attribution در کد/رابط وجود دارد: OpenStreetMap، Microsoft، Esri/Maxar/Earthstar و Natural Earth. متن مجوز و شرایط استفادهٔ بیرونی در این بازبینی حقوقی بررسی نشده است؛ attribution فعلی در تغییر دارایی‌ها حفظ شود و متن README دربارهٔ منبع تصاویر مرجع پروژه باقی بماند.
+33
View File
@@ -0,0 +1,33 @@
# GPS، دوربین، حسگر و PWA
## حالت دنیای واقعی
کاربر از briefing یا نوار پایین real mode را می‌زند؛ modal معرفی/ایمنی و سپس اجازهٔ قطب‌نما و مکان‌یابی درخواست می‌شود. HTTPS یا localhost لازم است. joystick و صفحه‌کلید جای player را تغییر نمی‌دهند؛ updateReal موقعیت را از گوشی می‌گیرد. ماشین مجازی در این حالت قابل استفاده نیست. خانواده پنهان و دوربین در ارتفاع ۱٫۶ متر قرار می‌گیرد.
مکان‌یابی ابتدا getCurrentPosition تقریبی (۱۵ ثانیه timeout، maximumAge ۶۰ ثانیه) و سپس watchPosition دقیق (۳۰ ثانیه timeout و maximumAge ۳ ثانیه) دارد. timeout اولیهٔ watch فقط وضعیت جست‌وجو است؛ کد۱ permission، کد۲ unavailable و کد۳ timeout.
smoothFix وزن را با واریانس accuracy دو fix می‌سازد؛ پس از فاصلهٔ زمانی بیش از ۳۰ ثانیه fix تازه جایگزین می‌شود. accuracy کف ۳ متر دارد. stabilizer پرش خارج از دسترس را رد می‌کند، پس از سه رد پیاپی نمونهٔ بعدی امکان پذیرش دارد؛ تا drift بیش از max(3, accuracy/2) یا speed گزارش‌شده بیش از .7 نباشد مکان نمایشی ثابت می‌ماند.
updateReal نقطه را از برخورد ساختمان بیرون می‌آورد؛ اختلاف بالای ۱۵۰ متر teleport، در فاصله‌های کمتر حرکت نرم تا حداکثر ۸ m/s دارد. فقط fix داخل bounds اعمال می‌شود؛ خارج bounds پیام نزدیک‌ترین دستگاه نمایش داده می‌شود.
شعاع تعامل `min(50, max(20, accuracy × 2.5))` متر است. نزدیک‌ترین دستگاه فعال در این شعاع انتخاب می‌شود؛ سه test point فقط در real فعال‌اند. شرط سرعت بیش از ۶ m/s در interact جلوی بازکردن فروشگاه را می‌گیرد. این شرط در swipePayment تکرار نشده؛ محدودیت تست/بک‌لاگ محسوب می‌شود.
## AR
getUserMedia با rear/environment، video مطلوب ۱۲۸۰×۷۲۰ و بدون audio؛ تصویر video پشت WebGL alpha قرار می‌گیرد. city، imagery، factory، traffic، car، sky و parkGate در AR پنهان‌اند؛ دستگاه و روبات و مسیر روی تصویر می‌مانند. این AR بر پایهٔ GPS/قطب‌نماست، نه WebXR، تشخیص سطح یا occlusion واقعی. انتظار دقت سانتی‌متری درست نیست.
زاویهٔ گوشی به quaternion با deviceorientationabsolute یا fallback deviceorientation تبدیل می‌شود؛ iOS compassHeading می‌تواند offset شمال را اصلاح کند. FOV از فرض زاویهٔ ضلع بلند ۶۵ درجه و نسبت video/screen محاسبه می‌شود. stable mode چرخش را آرام‌تر می‌کند. بدون حسگر drag و جهت حرکت/compass fallback هستند.
خاموش‌کردن AR trackهای دوربین را stop می‌کند ولی حسگر برای نمای اول‌شخص ادامه دارد؛ خاموش‌کردن real باید tracking، AR و sensors را متوقف کند. reset معمول بازی فعلاً معادل خاموش‌کردن real نیست.
## PWA و ذخیره
setupPwa در build service worker نسبی `./sw.js` ثبت می‌کند؛ dev server ثبت نمی‌کند. نصب با beforeinstallprompt و راهنمای Add to Home Screen برای iOS فراهم است. manifest و icon محلی هستند.
Vite plugin فهرست assetهای build و public را precache می‌کند؛ JPGهای imagery مستثنا هستند و فقط پس از مشاهده cache می‌شوند. بنابراین «آفلاین» برای همهٔ نقاط ماهواره‌ای پس از اولین اجرا تضمین نشده است. Navigation network-first با fallback صفحهٔ cache؛ assetهای دیگر cache-first؛ imagery cache جدا دارد. نسخهٔ تازه با skip-waiting و controllerchange reload می‌شود.
localStorage فعلی فقط کیفیت گرافیک را نگه می‌دارد. پول، خرید، XP، مأموریت و موقعیت player ذخیرهٔ ماندگار ندارند. نصب PWA با ذخیرهٔ پیشرفت یکی نیست. حالت low پیش‌فرض ورودی coarse است: سایه خاموش و pixelRatio ۱؛ high تا ۱٫۷۵. query `?quality=low` و کلید گرافیک انتخاب را تغییر می‌دهند.
## آزمون دستی ضروری
مجوز ردشده/داده‌شده، قطع GPS، fix خارج جهان، نمونهٔ قدیمی و jump، نزدیک‌ترین test point، سرعت بالا حین پرداخت، دو بار روشن/خاموش، screen rotation، توقف camera track، بازگشت از background، Android/iOS، آفلاین مسیر بازدیدشده/جدید و update از نسخهٔ نصب‌شده. این‌ها در تست Node به‌طور انتهابه‌انتها پوشش ندارند.
+55
View File
@@ -0,0 +1,55 @@
# اجرا، تست، انتشار و نگهداری
## محیط توسعه
```sh
npm ci
npm run dev
node --test tests/*.test.js
npm run build
npm run preview
```
در محیط این مخزن طبق `/home/hamed/.codex/RTK.md` فرمان‌های shell با `rtk` پیشوند اجرا می‌شوند؛ مثال `rtk npm run build`. خروجی dist تولیدی و در gitignore است. Node 22 با CI هماهنگ است. query `?debug` شیء window.game را برای بررسی state/player/car/cam/payment/route و ابزارهای موجود در انتهای main در دسترس می‌گذارد؛ این دسترسی فقط ابزار توسعه است.
## پوشش تست فعلی
| فایل | تعریف تست | پوشش |
|---|---:|---|
| payment.test.js | ۳ | برداشت یک بار، مرحلهٔ ready، انصراف/بسته/نامعتبر/پول، قیمت و ساعت |
| scoring.test.js | ۲ | کشف/ترکیب/پنج دسته، درجه و جایزهٔ مأموریت |
| realworld.test.js | ۴ | smoothFix، arrival/bounds، stabilizer، quaternion و FOV |
| world.test.js | ۷ | geo، collision، مسیر کارخانه/دستگاه، خودرو، one-way ترافیک، binary و tile |
در تاریخ مبنا همهٔ این فایل‌ها موفق اجرا شدند. runner خروجی را به چهار فایل تجمیع کرده؛ عدد چهار در خلاصهٔ خروجی تعداد فایل‌هاست. تست browser E2E، اجرای فیزیکی گوشی، CI واقعی، build Docker و سایت زنده در این بازبینی اجرا نشده‌اند.
## GitHub Pages
`.github/workflows/pages.yml`: push به main یا اجرای دستی؛ checkout، Node22/npm ci، تست، build، configure-pages، upload فقط dist و deploy-pages با مجوز pages/id-token. در Settings → Pages، Source باید GitHub Actions باشد. `base:'./'` برای مسیر مخزن است. لینک ثبت‌شدهٔ پروژه: https://hamedabdollahzade.github.io/vending-machine-game/ (دسترس‌پذیری فعلی بررسی نشده).
## Docker مستقل
Dockerfile: build با node:22-alpine و npm ci + تست + build؛ runtime caddy:2.8.4-alpine و dist در /srv. Compose ریشه از سورس build، پورت‌های ۸۰/۴۴۳ و volume گواهی دارد؛ SITE_ADDRESS پیش‌فرض robotmarkets.ir و www.robotmarkets.ir، NPM_REGISTRY قابل تنظیم. در این مدل Caddy مسئول TLS است.
```sh
docker compose up -d --build
```
Caddy assets hashed را immutable یک‌ساله؛ HTML/SW/manifest را no-cache؛ mapdata را یک هفته cache می‌کند. zstd/gzip و headerهای geolocation/camera/sensor و nosniff تنظیم‌اند. try_files ناشناخته را به index.html می‌فرستد؛ برای فایل دادهٔ گمشده باید این رفتار در QA در نظر گرفته شود.
## Gitea فعلی؛ تفاوت مهم با README قدیمی
گردش‌کار `.gitea/workflows/ci-cd.yml` فعلی از SSH deployment استفاده نمی‌کند. توضیح قدیمی DEPLOY_HOST/DEPLOY_SSH_KEY در README برای این workflow معتبر نیست.
1. test job روی Node22: npm ci، تست و build؛ timeout پانزده دقیقه.
2. publish برای main و غیر PR: Docker build، push تصویر با tag زمان/run/SHA به git.my-rm.com، سپس تلاش link package به مخزن. ناموفق بودن link مانع انتشار تصویر نمی‌شود. timeout سی دقیقه.
3. deploy با image خروجی publish و Docker daemon مشترک runner/server: compose pull و up از `deploy/docker-compose.yml`، نمایش ps و image prune. timeout پانزده دقیقه.
4. HEALTH_URL اختیاری برای پاسخ HTTP تست می‌شود؛ این تست صحت بازی یا CSS/WebGL را تضمین نمی‌کند.
نیازها: secretهای REGISTRY_USERNAME و REGISTRY_TOKEN با مجوز package read/write؛ variable اختیاری NPM_REGISTRY و HEALTH_URL؛ runner دارای Docker CLI/Compose و دسترسی socket daemon میزبان. مقدار secretها در مستندات نوشته نشود.
Compose مخصوص deploy تصویر آمادهٔ IMAGE را می‌گیرد، container_name برابر vending-machine-game و شبکهٔ خارجی frontend_proxy دارد. پورت میزبان publish نمی‌کند؛ SITE_ADDRESS=:80 و TLS با Nginx Proxy Manager بیرون container است. پس آن را با compose ریشه که Caddy TLS دارد یکی ندانید.
## کنترل تغییر
قبل از کار git status و remote را بررسی کنید؛ origin را GitHub فرض نکنید (README از origin در Gitea و remote github یاد می‌کند). build محلی به معنی انتشار نیست. برای هر تسک، تست مرتبط + build و بررسی diff لازم است؛ تغییر داده/زیرساخت نیازمند بررسی خاص خود است. این مستندات خودشان دستور push یا deploy نیستند.
+36
View File
@@ -0,0 +1,36 @@
# برنامه‌های اجراشده و تاریخچهٔ تغییر جهت
این جدول از git log محلی استخراج شده؛ تاریخ دقیق و جزئیات هر مرحله را با `git show <sha>` بررسی کنید. پیام‌های «update» نقشهٔ محصول قابل استناد ندارند و از آن‌ها قابلیت حدس زده نشده است.
| مرحله | شاهد گیت | نتیجه/وضعیت کنونی |
|---|---|---|
| پایهٔ اولیه | f7c6aee | شروع مخزن |
| سفر جنگل و پیک‌نیک | 5857b11 | داستان و مراحل تعاملی؛ سناریوی تاریخی، با شهر جایگزین شده |
| Pages و لینک | 6d4c4b7 | workflow ساخت dist و README؛ workflow هنوز هست |
| روبات جای کودک | 8a49dc0 | در آن مرحله child تعویض شد؛ در نسخهٔ فعلی کودک انسانی برگشته و روبات نقش راهنمای دستگاه دارد |
| تبدیل به شهر مشهد | 95a2ae8 | جابه‌جایی آزاد سوم‌شخص، دستگاه شهری، خرید/امتیاز و نقشه |
| کارخانه/PWA/GPS | 0939dac | شروع کارخانه، نصب و حالت real |
| گیر ماشین بزرگراه | 2f74788 | اصلاح حرکت/برخورد در مسیر چناران |
| Docker/HTTPS | 47b2b72 | build چندمرحله و Caddy/Compose مستقل |
| Gitea CI/CD | 22aa11b | گردش‌کار اولیه |
| محدودیت registry | 9832f30 | registry قابل تنظیم و timeout/retry نصب |
| تصویر registry | 894e3e2 | انتشار image به‌جای build سورس روی سرور |
| نام کارخانه/GPS | 8cc0e8c | Robot Market، status موقعیت و سه نقطهٔ آزمایشی |
| Docker socket deploy | c1141ad | جایگزینی deploy SSH با daemon مشترک runner |
| تحمل خطای package link | 9564880 | خطای link مانع job نمی‌شود |
| AR و stable GPS | 6ce5600 | rear camera، نرم‌سازی و شعاع تعامل گسترده |
| real اول‌شخص/UI موبایل | 5cea802 | دوربین گوشی و اعلان‌های وسط صفحه |
| ماهواره/ساختمان | 9b289f7 | Esri tile و footprint Microsoft، lazy rendering |
| کارخانه مفصل و خودرو سریع | 751af76 | کارکنان و بخش‌ها، مدیر و کامیون، سقف سرعت حدود ۲۰۰ km/h |
## چیزهایی که نباید به‌عنوان برنامهٔ انجام‌شده ثبت شوند
ذخیرهٔ پیشرفت، leaderboard آنلاین، مأموریت ایمنی، چراغ قرمز، تاکسی/قطار شهری، شهر دیگر و چرخهٔ روز/شب صرفاً پیشنهادند. وجود نام فناوری در مانیتور کارکنان به معنای backend واقعی Go یا frontend React نیست. نمایش مقصد قفل‌شده به معنای منطقهٔ قابل بازی نیست.
## تصمیم‌های بالفعل که Agent باید حفظ کند
- تجربهٔ فعلی خانوادهٔ انسانی + روبات در دستگاه است؛ رجوع به پیام تاریخی جایگزینی روبات بدون بررسی کد، نقش‌ها را اشتباه می‌کند.
- اقتصاد، زمان و امتیاز در ماژول جدا؛ پرداخت نمایشی و برداشت پس از swipe.
- map و data محلی‌اند؛ runtime برای راه‌اندازی به سرویس route آنلاین متکی نیست.
- اسکریپت‌های بازتولید داده بخشی از build روزمره نیستند.
- مسیر deploy جاری Gitea image/socket است؛ SSH فقط شرح قدیمی README است.
+43
View File
@@ -0,0 +1,43 @@
# بازبینی کد و بک‌لاگ پیشنهادی
تاریخ: ۲۰۲۶/۱۰/۲۸، مبنا 751af76. بررسی: منبع همهٔ حوزه‌های اصلی، دادهٔ محلی، تست‌ها، build و فایل‌های انتشار. اجرای تعاملی WebGL/گوشی یا محیط production انجام نشده است. موارد زیر اصلاح‌شده محسوب نمی‌شوند. اولویت‌ها پیشنهاد مهندسی‌اند، نه تسک مصوب.
## یافته‌های قابل پیگیری
| شناسه | اولویت | شاهد و اثر | معیار پذیرش پیشنهادی |
|---|---|---|---|
| R01 | بالا | `swipePayment` شکست خرید را به idle برمی‌گرداند، ولی `swipe()` در main روی false فوراً return می‌کند. اگر دستگاه بین آماده‌شدن و کشیدن کارت بسته شود، پنل پرداخت ممکن است باز و بدون مسیر بازیابی بماند؛ cancel در idle false است. | بازتولید عبور ساعت پایان بین begin/ready/swipe؛ بدون برداشت، پیام خطا و پنل قابل بستن/تلاش مجدد، busy خاموش؛ تست اتصال UI |
| R02 | بالا | `canAffordAnything(state,machines)` در `checkEnd` test pointهای .5 قیمت را حتی در حالت غیرواقعی می‌شمارد. در UI و منطق بودجه تعریف «دستگاه فعال» یکسان نیست. با این حال factory نیز ارزان و شبانه‌روزی است، پس صرف حذف test در همین داده‌ها الزاماً نتیجهٔ تابع پایان را عوض نمی‌کند. دسترسی جغرافیایی نیز در تابع منظور نیست. | تعریف دستگاه‌های قابل احتساب بر اساس mode و دسترسی؛ تست حذف نقاط test در حالت عادی و مرز پول/ساعت |
| R03 | متوسط | `buildGraph` همهٔ یال‌ها را دوطرفه می‌سازد؛ oneway فقط در traffic است. خط مسیریابی ماشین ممکن است خلاف جهت راهنمایی کند. | گراف رانندگی جهت‌دار، پیاده طبق سیاست مطلوب؛ تست مسیر مجاز/ممنوع در دو جهت و مسیر کارخانه |
| R04 | متوسط | reset متغیرهای lines/lineTimer، timeoutهای reward/banner و stick را پاک نمی‌کند؛ real tracking/AR را خاموش نمی‌کند؛ factory.reset فقط resetArrival است و چرخهٔ کامیون ادامه دارد. | ابتدا تعریف سیاست reset؛ سپس reset وسط دیالوگ/پاداش/joystick/GPS/بارگیری بدون پیام یا ورودی عملیات قبلی؛ تست lifecycle |
| R05 | متوسط | ساعت بازی حین پرداخت ادامه دارد؛ checkEnd هر نیم‌ثانیه خریدها را می‌بیند ولی پاداش مأموریت بعد از تحویل اعمال می‌شود. اگر آخرین خرید پول را تمام کند، پایان بودجه می‌تواند پیش از جایزهٔ نجات‌دهنده نمایش داده شود. | تست خرید آخر با جایزهٔ نقدی، درحال‌تحویل پایان ندهد؛ پس از تسویهٔ پاداش وضعیت نهایی محاسبه شود |
| R06 | متوسط | شرط سرعت GPS فقط interact است. پس از بازشدن shop/آغاز payment سرعت و fix در swipe مجدداً اعتبارسنجی نمی‌شوند؛ paused بودن shop نیز updateReal را متوقف می‌کند. | سیاست اعتبار fix/سرعت/رسیدن در تأیید خرید تعریف شود؛ سناریؤ تغییر سرعت یا مکان وسط پرداخت بدون برداشت نامعتبر |
| R07 | متوسط | SW نسخه را از نام فایل‌ها می‌سازد و assets بدون hash مثل buildings.bin یا portrait در cache-first می‌مانند. تغییر فقط محتوای public با نام ثابت لزوماً cache جدید نمی‌سازد؛ cache imagery هم نسخه ندارد. | build با تغییر یک asset هم‌نام؛ نسخهٔ نصب‌شده فایل تازه بگیرد و tile قدیمی طبق سیاست مشخص invalidate شود |
| R08 | متوسط | main دانلود فایل optional را catch می‌کند، ولی decodeBuildings خارج catch است. پاسخ موفق با bin خراب یا HTML fallback می‌تواند bootstrap را قطع کند. | header/length/count معتبر یا خطای کنترل‌شده و fallback OSM؛ تست فایل کوتاه، magic غلط، truncated vertex |
| R09 | متوسط | وقتی imagery index موجود است city از satellite mode استفاده می‌کند، اما tile غایب/failed فقط failed علامت می‌خورد و دوباره تلاش نمی‌شود. آفلاین در منطقهٔ دیده‌نشده احتمالاً زمین کامل ندارد. | آزمون index موجود+JPG 404/offline؛ fallback خوانا و سیاست retry روشن |
| R10 | متوسط | `canAffordAnything` پس از بسته‌شدن همهٔ دستگاه‌های مقرون‌به‌صرفه به فردا نگاه نمی‌کند، ولی ساعت نمایش و isOpen روزانه modulo دارند. مأموریت مدرسه بدون پنجرهٔ شکست/انتظار ممکن است معطل بماند. | تصمیم محصول دربارهٔ روز دوم، شکست/تکرار مأموریت و انتظار؛ تست ۱۳:۳۰، ۲۳:۰۰ و ۰۰:۰۰ |
| R11 | پایین | `places` پایان از همهٔ خریدها و denominator از کل machines است؛ testها حتی وقتی غیرفعال‌اند در کل مکان‌ها می‌آیند. متن «با ماشین» فقط متن است و نه شرط مأموریت. | شمارش UI با دستگاه فعال و سیاست مأموریت مطابقت داشته باشد؛ پوشش test/real و خرید پیاده |
| R12 | متوسط | bundle JS حدود ۱٫۷۹ مگابایت و main بزرگ؛ world JSON و Three.js در payload شروع‌اند. تست فعلی FPS/زمان شروع یا مصرف حافظه را نمی‌سنجد. | baseline روی گوشی ضعیف قبل refactor؛ هدف عددی زمان شروع/FPS با توافق صاحب محصول، بدون حذف قابلیت |
R01 و R05 با import مستقیم توابع فعلی در Node نیز بررسی شدند:
- R01: مدرسه در minutes=809.98 وارد پرداخت/ready شد؛ در minutes=810، swipe برابر false، phase برابر idle، busy برابر false و balance همچنان 500 بود. مسیر main پس از false پنل را نمی‌بندد؛ اثر بصری پنل نیازمند بازتولید مرورگر است.
- R05: خرید آب کارخانه با balance=8، پس از swipe balance=0 و busy=true و canAffordAnything=false شد؛ completeMissions سپس balance=20 و canAffordAnything=true ساخت. این اختلاف پنجرهٔ checkEnd پیش از delivery را تأیید می‌کند؛ ترتیب نمایش modal نیازمند تست یکپارچه است.
- R02: در ۲۳:۰۰ با balance=8، با همهٔ تعاریف و بدون testها هر دو canAffordAnything=true بودند؛ کارخانه دلیل باقی‌ماندن نتیجه است. این مورد ناهماهنگی دامنه/سیاست است، نه ادعای اثبات‌شدهٔ تغییر خروجی پایان صرفاً به‌خاطر testها.
passing بودن تست‌های فعلی رد سناریوهای بالا نیست.
## ناهماهنگی اسناد قدیمی
- README هنوز Gitea deploy را SSH و DEPLOY_HOST توضیح می‌دهد؛ workflow فعلی registry + Docker socket است.
- README می‌گوید خیابان‌ها یک‌طرفه ندارند؛ داده و traffic one-way دارند اما routing ندارد. به‌جای حکم کلی این تفکیک لازم است.
- README شروع را سالن تولید نوشته؛ spawn واقعی حیاط کنار ماشین است.
- آرشیو مکالمهٔ قدیمی از روبات جای کودک و جنگل می‌گوید؛ وضعیت فعلی خانواده انسانی و روبات دستگاه است.
## پیشنهادهای محصول؛ نیازمند تصمیم
ذخیرهٔ پیشرفت محلی با schemaVersion و migration؛ برنامهٔ آزمون کامل موبایل؛ قواعد واقعی‌تر ترافیک/چراغ؛ پیشرفت مناطق و شهرها؛ مأموریت کارخانه/نقش کارکنان؛ تجربهٔ GPS با اعتبارسنجی روشن؛ accessibility و کاهش حرکت/مصرف باتری. backend و leaderboard تصمیم معماری/محصول جدا می‌خواهند؛ آن‌ها را صرفاً برای رفع یک UI bug اضافه نکنید.
## ترتیب اجرایی پیشنهادی
ابتدا صحت تراکنش و reset (R01/R04/R05)، سپس منطق mode/پایان (R02/R06/R10/R11)، سپس قابلیت اطمینان offline/data (R07/R08/R09)، سپس route direction (R03) و اندازه‌گیری کارایی (R12). هر تغییر بهتر است تسک مستقل با تست مربوط باشد.
+48
View File
@@ -0,0 +1,48 @@
# قرارداد تحویل تسک به Agent
## متن شروع پیشنهادی
> ابتدا docs/README.md و سندهای مرتبط با این تسک را بخوان. سپس کد فعلی و git status را بررسی کن؛ مستندات snapshot هستند و ممکن است پس از commit مبنا تغییر کرده باشند. رفتار فعلی، تصمیم محصول و پیشنهادهای آینده را یکی نگیر. فقط محدودهٔ این تسک را پیاده کن و مدارک تست، محدودیت و مستندات به‌روز را تحویل بده.
## قالب آمادهٔ تسک
```text
عنوان:
هدف کاربر و مسئله:
مراجع docs و شناسهٔ بک‌لاگ:
رفتار فعلی و مراحل بازتولید:
رفتار مطلوب (تصمیم صاحب بازی):
دامنهٔ تغییر / فایل‌های احتمالی:
خارج از دامنه:
قواعدی که باید حفظ شوند:
معیار پذیرش قابل مشاهده:
سناریوهای خطا و مرزها:
تست خودکار مرتبط:
تست دستی desktop/mobile/GPS/offline در صورت نیاز:
نیاز به asset یا دادهٔ بیرونی:
خروجی مورد انتظار (کد، سند، نتیجهٔ تست، تصویر در صورت نیاز):
مجوز commit/push/deploy و remote مقصد، اگر خواسته شده:
```
## چک‌لیست Agent
1. دستورهای AGENTS/RTK قابل اعمال و وضعیت تغییرات کاربر را بخوان؛ remote و branch را فرض نکن.
2. منبع حقیقت تسک را مشخص کن: اقتصاد game-state/scoring؛ مکان locations؛ نمایش main/style؛ حسگر realworld/ar؛ انتشار workflow/compose.
3. قبل از تغییر، تست مربوط و مسیر وابستگی را بفهم. برای bug ابتدا failing regression معنادار بساز؛ برای متن صرف تست تقلیدی نساز.
4. تغییر را با دامنهٔ کوچک انجام بده. محاسبهٔ قیمت یا شرط مأموریت را در UI دوباره تعریف نکن.
5. مقدار پول قبل/پس، busy، تراکنش یکتا، mode، timer و رفتار لغو را در تسک‌های مرتبط حفظ کن.
6. تست مرتبط و build را اجرا کن؛ برای GPU/permissions/offline از تست Node نتیجهٔ بصری استنتاج نکن.
7. سند مرتبط را هم اصلاح کن؛ قابلیت آینده را «انجام‌شده» علامت نزن مگر معیار پذیرش محقق شده باشد.
8. نتیجه را با فایل‌ها، شواهد، ریسک/محدودیت و عملیات واقعاً انجام‌شده گزارش کن.
## نمونهٔ تسک مستقل
عنوان: بازیابی UI پس از بسته‌شدن دستگاه در مرحلهٔ پرداخت (R01).
معیار پذیرش: در ۱۳:۲۹:۵۹ از مدرسه پرداخت را شروع کن، قبل swipe ساعت از ۱۳:۳۰ بگذرد؛ پول/خرید تغییر نکنند، پیام بسته‌بودن نمایش داده شود، پنل گیر نکند، کنترل حرکت برگردد و کاربر دستگاه دیگری انتخاب کند. لغو عادی و خرید موفق/برداشت یک‌باره همچنان پاس باشند. تغییر به حفظ ساعت پرداخت تنها با تصمیم صریح محصول انجام شود.
این نمونه پیشنهاد است و اجرای آن با ساخت این سند مجاز/انجام‌شده تلقی نمی‌شود.
## دستورهای مخزن و توصیف فناوری
در پایان این بررسی یک فایل AGENTS.md با دستورهای عمومی React/Next.js در workspace دیده شد. آن فایل از تغییرات این نوبت نیست. Agent توسعه باید نسخهٔ حاضر آن را پیش از کار بخواند و دستورهای قابل اعمال را رعایت کند؛ توصیف فناوری در این اسناد گزارش وضعیت واقعی کد است، نه جایگزین AGENTS یا مجوز مهاجرت به React/Redux/Tailwind. انتخاب زبان/UI برای توسعهٔ بعدی و هر تعارض عملی باید در همان تسک روشن شود. برای این تسک مستندسازی، زبان برنامه و کتابخانهٔ رابط تغییر نکرده‌اند.
+210
View File
@@ -0,0 +1,210 @@
# مرجع دقیق داده‌های قابل بازی
این جدول از exportهای کد در snapshot 751af76 استخراج شده است؛ با تغییر locations/game-state/scoring باید به‌روزرسانی شود.
## دستگاه‌ها
### دستگاه دفتر فنی مهندسی — factory
- دسته: کارخانه (factory)
- نشانی: بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان، ساختمان اداری، دفتر فنی مهندسی
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
- موقعیت تعریف‌شده: factory.demo در factory.js
- دستگاه عادی
- توضیح: دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 8 |
| آب‌میوه | juice | drink | 18 |
| چای سرد | tea | drink | 23 |
| بیسکویت | biscuit | snack | 13 |
| شکلات | chocolate | snack | 15 |
### بوستان ملت — mellat-park
- دسته: پارک (park)
- نشانی: بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 1
- موقعیت تعریف‌شده: anchor X=-487.6, Z=-326.4
- دستگاه عادی
- توضیح: نوشیدنی‌های خنک برای پیاده‌روی در پارک
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 15 |
| آب‌میوه | juice | drink | 35 |
| لیموناد | lemonade | drink | 40 |
| چای سرد | tea | drink | 45 |
| بیسکویت | biscuit | snack | 25 |
| شکلات | chocolate | snack | 30 |
| کیک | cake | snack | 35 |
| آجیل | nuts | snack | 65 |
### مجموعه آموزشی راحیل — school-rahil
- دسته: مدرسه (school)
- نشانی: بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه
- ساعت: 07:30 تا 13:30؛ ضریب قیمت 0.9
- موقعیت تعریف‌شده: anchor X=-554.6, Z=-569.7
- دستگاه عادی
- توضیح: فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 14 |
| شیر کاکائو | milk | drink | 36 |
| آب‌میوه | juice | drink | 32 |
| بیسکویت | biscuit | snack | 23 |
| کیک | cake | snack | 32 |
### دانشگاه فردوسی مشهد — ferdowsi-university
- دسته: دانشگاه (university)
- نشانی: میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
- موقعیت تعریف‌شده: anchor X=-936, Z=645
- دستگاه عادی
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزان‌تر
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 12 |
| چای سرد | tea | drink | 36 |
| شیر کاکائو | milk | drink | 32 |
| بیسکویت | biscuit | snack | 20 |
| شکلات | chocolate | snack | 24 |
| آجیل | nuts | snack | 52 |
| کیک | cake | snack | 28 |
### دانشکدهٔ ادبیات دکتر شریعتی — literature-faculty
- دسته: دانشگاه (university)
- نشانی: پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
- موقعیت تعریف‌شده: anchor X=-417.1, Z=966.3
- دستگاه عادی
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزان‌تر
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 12 |
| چای سرد | tea | drink | 36 |
| لیموناد | lemonade | drink | 32 |
| بیسکویت | biscuit | snack | 20 |
| شکلات | chocolate | snack | 24 |
### دانشگاه علوم پزشکی مشهد — medical-university
- دسته: دانشگاه (university)
- نشانی: میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد
- ساعت: 07:00 تا 23:00؛ ضریب قیمت 0.8
- موقعیت تعریف‌شده: anchor X=-272.1, Z=150.6
- دستگاه عادی
- توضیح: تخفیف دانشجویی: ۲۰٪ ارزان‌تر
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 12 |
| آب‌میوه | juice | drink | 28 |
| شیر کاکائو | milk | drink | 32 |
| آجیل | nuts | snack | 52 |
| بیسکویت | biscuit | snack | 20 |
### ایستگاه مترو آزادی — azadi-metro
- دسته: ایستگاه مترو (metro)
- نشانی: بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی
- ساعت: 06:00 تا 22:00؛ ضریب قیمت 1
- موقعیت تعریف‌شده: anchor X=494.1, Z=280.3
- دستگاه عادی
- توضیح: دستگاه کنار گیت‌های مترو
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 15 |
| چای سرد | tea | drink | 45 |
| آب‌میوه | juice | drink | 35 |
| شکلات | chocolate | snack | 30 |
| بیسکویت | biscuit | snack | 25 |
### مشهد مال — mashhad-mall
- دسته: مرکز خرید (mall)
- نشانی: خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال
- ساعت: 10:00 تا 23:00؛ ضریب قیمت 1.1
- موقعیت تعریف‌شده: anchor X=1940.6, Z=-367.8
- دستگاه عادی
- توضیح: بیشترین تنوع محصول؛ قیمت کمی بالاتر
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 17 |
| آب‌میوه | juice | drink | 39 |
| شیر کاکائو | milk | drink | 44 |
| چای سرد | tea | drink | 50 |
| لیموناد | lemonade | drink | 44 |
| شکلات | chocolate | snack | 33 |
| کیک | cake | snack | 39 |
| آجیل | nuts | snack | 72 |
| بیسکویت | biscuit | snack | 28 |
### نقطهٔ آزمایشی ۱ — test-1
- دسته: نقطهٔ آزمایشی GPS (test)
- نشانی: کنار کارخانهٔ روبات مارکت · 36.450276, 59.423431
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
- موقعیت تعریف‌شده: GPS 36.450276, 59.423431
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 8 |
| بیسکویت | biscuit | snack | 13 |
### نقطهٔ آزمایشی ۲ — test-2
- دسته: نقطهٔ آزمایشی GPS (test)
- نشانی: کنار کارخانهٔ روبات مارکت · 36.450172, 59.423209
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
- موقعیت تعریف‌شده: GPS 36.450172, 59.423209
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 8 |
| بیسکویت | biscuit | snack | 13 |
### نقطهٔ آزمایشی ۳ — test-3
- دسته: نقطهٔ آزمایشی GPS (test)
- نشانی: کنار کارخانهٔ روبات مارکت · 36.449976, 59.422866
- ساعت: 00:00 تا 24:00؛ ضریب قیمت 0.5
- موقعیت تعریف‌شده: GPS 36.449976, 59.422866
- فقط حالت دنیای واقعی؛ نقطهٔ آزمایشی
- توضیح: برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید
| محصول | productId | نوع | قیمت نهایی هتومان |
|---|---|---|---:|
| آب معدنی | water | drink | 8 |
| بیسکویت | biscuit | snack | 13 |
## مأموریت‌ها
| ترتیب | id | عنوان | target | XP | هتومان | شرط تکمیل واقعی |
|---:|---|---|---|---:|---:|---|
| 1 | factory | در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن | factory | 100 | 20 | وجود خرید factory |
| 2 | first | با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده | mellat-park | 150 | 30 | وجود خرید mellat-park |
| 3 | school | قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن | school-rahil | 200 | 40 | وجود خرید school-rahil؛ ساعت خرید از اعتبارسنجی خرید می‌آید |
| 4 | campus | به دانشگاه فردوسی برو و یک خوراکی بخر | ferdowsi-university | 200 | 40 | خرید snack از ferdowsi-university |
| 5 | metro | از دستگاه ایستگاه مترو آزادی خرید کن | azadi-metro | 150 | 30 | وجود خرید azadi-metro |
| 6 | mall | با ماشین تا مشهد مال برو و خرید کن | mashhad-mall | 250 | 50 | وجود خرید mashhad-mall |
| 7 | explorer | از ۵ مکان متفاوت شهر خرید کن | پویا/نزدیک‌ترین بازدیدنشده | 400 | 80 | حداقل ۵ machineId متفاوت در پنج دستهٔ شهری؛ factory/test حذف |
«پنج مکان» با «پنج دسته» متفاوت است: explorer مکان متمایز می‌شمارد؛ پاداش allCategories دسته متمایز. خریدهای از پیش انجام‌شده می‌توانند مأموریت غیر فعال را هم تکمیل کنند. رسیدن جغرافیایی یا سوارشدن ماشین در شرط مأموریت ذخیره نمی‌شود.
## نقاط ثابت
مرکز کارخانه X=-10308.4, Z=-14964.2 و محور (0.7071067811865476, 0.7071067811865476)؛ مکان دقیق ماشین و خانواده از factory.start/car محاسبه می‌شود.
+42
View File
@@ -0,0 +1,42 @@
# وضعیت پس از انتقال به React
این سند بر اسناد snapshot پیش از ریفکتور اولویت دارد. زبان JavaScript و رابط React 18 + Vite است؛ Ant Design و TypeScript اضافه نشده‌اند. Tailwind CSS استایل رابط را تولید می‌کند. Three.js و نسخهٔ قبلی Vite ارتقا داده نشده‌اند.
## معماری
- `src/main.jsx` و `routes/AppRoutes.jsx`: Provider و lazy/Suspense برای صفحهٔ بازی.
- `pages/game/`: briefing، HUD، نقشه، خرید/پرداخت، زیرنویس و نمایشگاه با JSX؛ بدون innerHTML و بدون ارسال مستقیم HTTP از کامپوننت.
- `components/shared/GameNode.jsx`: نمایش props و هماهنگی modal بومی؛ از store یا services وابستگی ندارد.
- `hooks/useGameEngine.js`: بارگذاری موتور و cleanup در unmount.
- `game/createGame.js`: راه‌اندازی مرتب زیرسیستم‌ها از runtimeBindings؛ توقف مشترک با AbortSignal.
- `game/runtime/`: چرخهٔ عمر، ورودی/حرکت، خرید، نقشه، HUD، برخوردهای داستانی، GPS/AR و render loop جدا هستند. context فقط برای state زندهٔ شبیه‌سازی و اشیای غیرقابل سریال‌سازی است؛ داخل Redux renderer/Scene/Texture نمی‌گذاریم.
- `game/models/`: ساخت هندسهٔ کارخانه، خانواده، کارکنان، خودرو، دستگاه و نمونه‌های نمایشگاه. factory/city/family اکنون مراحل مستقل ساخت و helperهای نام‌دار دارند.
- `game/economy/`: gameState، payment و scoring با قرارداد اقتصادی قبلی.
- `constants/`: مکان‌ها و showroomCatalog؛ `utils/`: geo و showroomAnswers؛ `types/`: JSDoc قراردادها.
- `services/apiClient.js`: تنها Axios instance، timeout و error normalization. worldService دادهٔ optional و tileها را می‌گیرد؛ imageryDownloadService اسکریپت آماده‌سازی تصویر را پوشش می‌دهد.
## مرز Redux و موتور
slice `game` تصویر قابل سریال‌سازی پیشرفت اقتصادی را برای بخش‌های مشترک رابط دارد. موتور تنها نویسندهٔ قواعد شبیه‌سازی است؛ رابط فرمان می‌فرستد و مستقلاً پول/XP تغییر نمی‌دهد. `interface` snapshot نمایش را نگه می‌دارد. bridge وضعیت pause را هم‌زمان برای موتور در دسترس می‌گذارد و انتشار React را به ده بار در ثانیه محدود می‌کند؛ WebGL مستقل render می‌کند. callbackها در registry محلی bridge‌اند و در Redux قرار ندارند. وضعیت موقت preview تصویر داخل کامپوننت است.
## استایل
`styles/game.css` ورودی Tailwind است. `styles/gameTheme.js` لایهٔ پایه و tokenهای ظاهر قبلی را برای جلوگیری از تغییر ناخواستهٔ UI نگه می‌دارد و از plugin تنظیمات Tailwind تولید می‌شود. کامپوننت‌های جدید نمایشگاه از utilityهای Tailwind استفاده می‌کنند. inline style تنها برای مختصات پویا/پیشرفت به کار می‌رود. ادامهٔ طراحی نباید لایهٔ CSS مستقل موازی بسازد.
## نمایشگاه
سه مدل rm-35/rm-48/rm-60 در حیاط، نزدیک نقطهٔ شروع و خودرو هستند. نمایش سه‌بعدی نمونه‌ها به‌ترتیب ۳۵، ۴۸ و ۶۰ slot دارد. با نزدیک‌شدن، نشان تعامل Q ظاهر می‌شود؛ در محوطه، دکمهٔ «مدل‌های کارخانه» نیز کاتالوگ را باز می‌کند. روبات قیمت و امکانات را از یک کاتالوگ مشترک پاسخ می‌دهد.
قیمت‌ها به ریال‌اند: ۴٬۶۸۰٬۰۰۰٬۰۰۰؛ ۵٬۴۸۰٬۰۰۰٬۰۰۰؛ ۵٬۹۸۰٬۰۰۰٬۰۰۰. این‌ها قیمت دستگاه کامل‌اند و به اقتصاد خوراکی/هتومان، XP، موجودی و مأموریت وصل نیستند. پاسخ‌ها محلی و تعیین‌شده‌اند؛ backend یا مدل زبانی بیرونی ندارند.
مسیر فایل‌های `images/machine-*-base.webp`، `images/machine-*-white.webp` و `gifs/machine-*.gif` ثبت شده، اما فایل اصلی هنوز تحویل نشده است. UI نبود تصویر را به‌وضوح نشان می‌دهد و مدل سه‌بعدی مستقل وجود دارد. مسیرهای ریشه‌ای کاتالوگ هنگام نمایش نسبت به پوشهٔ انتشار حل می‌شوند تا GitHub Pages هم کار کند.
## آزمون و انتشار
`npm test` شامل تست Node قبلی و Vitest/React Testing Library است. CIهای GitHub/Gitea و Dockerfile نیز همین فرمان را اجرا می‌کنند. `npm run lint`، `npm run format:check` و `npm run build` بررسی‌های دیگرند. نسخهٔ Vitest تازه انتخاب شده فاقد هشدارهای audit مرحلهٔ نصب است؛ نسخه‌های قبلی بازی ارتقا نیافتند.
Service Worker همچنان در Vite تولید می‌شود. با تأیید کاربر، `fetch` بومی داخل آن استثنای قاعدهٔ Axios است؛ interception و cache آفلاین آن حفظ شده‌اند. assetهای جدید build در precache می‌آیند؛ tileهای ماهواره‌ای فقط پس از مشاهده cache می‌شوند. صف ارسال آنلاین جدید اضافه نشده است.
## محدودیت بررسی
اجرای مرورگر محلی برای briefing، شروع، HUD، نقشه و پرسیدن قیمت نمایشگاه انجام شده است. تست حسگر واقعی، GPS میدانی، camera permission روی گوشی و انتشار روی سرور در این ریفکتور انجام نشده‌اند. یافته‌های رفتاری قدیمی docs/08 الزاماً اصلاح نشده‌اند؛ آن‌ها تسک‌های جدا هستند. بلوک داده/هندسه و بعضی الگوریتم‌های قدیمی هنوز می‌توانند طولانی‌تر از هدف تقریبی ۳۰ خط باشند؛ ادعای انطباق صددرصد تمام توابع و nesting وجود ندارد. برای تغییر بعدی هر ماژول، قواعد AGENTS روی کد تازه الزامی است.
+39
View File
@@ -0,0 +1,39 @@
# مرجع توسعهٔ بازی روبات مارکت
تاریخ بررسی: ۲۰۲۶/۱۰/۲۸ — مبنا: commit `751af76` در checkout محلی. این مجموعه از کد فعلی، تست‌ها، فایل‌های انتشار و تاریخچهٔ گیت استخراج شده است. وضعیت سرویس‌های آنلاین و اجرای بصری روی گوشی در این بررسی تأیید نشده‌اند.
## وضعیت جدید React
ابتدا [معماری و قابلیت‌های پس از انتقال به React](11-react-migration.md) را بخوانید. اسناد زیر snapshot پیش از ریفکتورند؛ قواعد اقتصادی حفظ شده‌اند، اما مسیر فایل‌ها، فناوری UI و فرمان تست تغییر کرده‌اند.
## مسیر مطالعه برای Agent
۱. [وضعیت و دامنه](01-product-and-scenario.md)
۲. [قوانین و اقتصاد](02-game-rules.md)
۳. [معماری و فناوری](03-architecture.md)
۴. [جهان، نقشه و داده](04-world-and-data.md)
۵. [GPS، AR و آفلاین](05-device-and-offline.md)
۶. [اجرا، تست و انتشار](06-development-and-deployment.md)
۷. [تاریخچهٔ کارهای انجام‌شده](07-implemented-plans.md)
۸. [بازبینی کد و بک‌لاگ پیشنهادی](08-review-and-backlog.md)
۹. [راهنمای تحویل تسک به Agent](09-agent-task-guide.md)
۱۰. [فهرست دقیق دستگاه‌ها و مأموریت‌ها](10-game-reference.md)
## نحوهٔ استفاده
- «پیاده‌سازی‌شده» یعنی در کد وجود دارد؛ تضمین صحت بصری یا آماده‌بودن محیط production نیست.
- «یافتهٔ کد» از بررسی استاتیک به دست آمده است؛ موارد نیازمند بازتولید صریحاً مشخص شده‌اند.
- «پیشنهاد» تصمیم محصول یا تسک مصوب محسوب نمی‌شود. صاحب بازی باید اولویت و رفتار مطلوب را تعیین کند.
- کد مرجع رفتار فعلی است. تغییر اقتصاد، سناریو، داده یا انتشار باید همراه به‌روزرسانی سند مربوط انجام شود.
- سناریوی جنگل و پیک‌نیک در تاریخچه قرار دارد؛ شرح نسخهٔ فعال نیست. روبات فعلی راهنمای دستگاه است و جای کودک راه نمی‌رود.
- مستندات هیچ مجوزی برای push، deploy، تغییر سرور، دانلود مجدد داده یا استفاده از secret ایجاد نمی‌کنند؛ محدودهٔ تسک کاربر تعیین‌کننده است.
## نتیجهٔ بررسی این نوبت
چهار فایل تست فعلی با ۱۶ تعریف تست بررسی و اجرای آن‌ها موفق شد. `npm run build` موفق بود: ۳۳ ماژول، JavaScript حدود ۱٬۷۹۴٫۳۵ کیلوبایت (gzip حدود ۵۷۶٫۶۱)، CSS حدود ۱۹٫۷۰ کیلوبایت. هشدار chunk بزرگ‌تر از ۵۰۰ کیلوبایت باقی است. این اعداد اندازهٔ bundle هستند، نه اندازهٔ کل public یا اندازه‌گیری FPS.
در این نوبت فقط مستندات اضافه/اصلاح می‌شوند؛ یافته‌های گزارش‌شده به‌صورت ضمنی اصلاح نشده‌اند. جزئیات محدودیت آزمون و کارهای پیشنهادی در سند بازبینی آمده است.
## محدودهٔ فایل‌های این تحویل
خروجی این کار پوشهٔ docs و لینک آن در README ریشه است. هنگام پایان بررسی، AGENTS.md و دو فایل src/data/iran.json و src/data/mellat.json نیز در وضعیت تغییرکردهٔ workspace دیده شدند؛ آن‌ها توسط این کار ایجاد/ویرایش نشده‌اند و در تحویل مستندسازی منظور نیستند. مبنای کد برنامه 751af76 است؛ شمارش داده‌ها از فایل‌های حاضر هنگام بررسی گرفته شده و باید پس از هر بازتولید داده تازه شود.
+28
View File
@@ -0,0 +1,28 @@
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' },
},
];
+20 -172
View File
@@ -1,176 +1,24 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0d1412">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="theme-color" content="#0d1412" />
<title>عملیات مشهد | روبات مارکت</title>
<meta name="description" content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ را در مشهد پیدا کن و امتیاز بگیر.">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="روبات مارکت">
<link rel="manifest" href="./manifest.webmanifest">
<link rel="icon" type="image/png" href="./icons/favicon-64.png">
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
</head>
<body>
<video id="camera" playsinline muted autoplay hidden></video>
<canvas id="world"></canvas>
<div id="loading">در حال ساختن شهر مشهد…</div>
<!-- Mission briefing over the map of Iran -->
<section id="briefing">
<div class="brief-map"><canvas id="iran"></canvas></div>
<div class="brief-panel">
<img class="brief-robot" src="./robot/front.webp" alt="روبات راهنمای روبات مارکت" width="136" height="223">
<small class="tag">عملیات ۰۱ · خراسان رضوی</small>
<h1>عملیات مشهد</h1>
<p class="sub">روبات مارکت · شکار دستگاه‌های وندینگ</p>
<pre id="brief-text" aria-live="polite"></pre>
<button id="start-mission" type="button">شروع عملیات</button>
<div class="brief-actions">
<button id="real-mode" type="button" class="ghost">📍 بازی در دنیای واقعی (GPS)</button>
<button id="install" type="button" class="ghost" hidden>⬇ نصب بازی روی گوشی</button>
</div>
<p class="keys">حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M</p>
</div>
</section>
<!-- In-game HUD -->
<div id="hud" hidden>
<canvas id="compass" width="560" height="44"></canvas>
<button id="minimap-button" type="button" aria-label="باز کردن نقشهٔ شهر (M)">
<canvas id="minimap" width="220" height="220"></canvas>
<span class="map-hint">نقشه · M</span>
</button>
<div id="location"><span id="street">مشهد</span><small id="mode-label">پیاده</small><small id="gps-status" hidden></small></div>
<aside id="objectives">
<h2>مأموریت‌ها</h2>
<ol id="mission-list"></ol>
</aside>
<div id="waypoint" hidden><i></i><b id="waypoint-name"></b><span id="waypoint-distance"></span></div>
<div id="prompt" hidden></div>
<div id="subtitle" hidden><img id="subtitle-face" alt="" hidden><b id="subtitle-speaker"></b><span id="subtitle-text"></span></div>
<div id="awards" aria-live="polite"></div>
<div id="banner" hidden></div>
<div id="status">
<div class="stat"><small>بودجه</small><strong id="balance">۵۰۰</strong><span>هتومان</span></div>
<div class="stat"><small>ساعت</small><strong id="clock">۰۸:۳۰</strong></div>
<div class="stat" id="speed-stat" hidden><small>سرعت</small><strong id="speed">۰</strong><span>km/h</span></div>
</div>
<div id="rank"><b id="rank-name">تازه‌وارد</b><span id="xp">۰ XP</span><div class="bar"><i id="xp-bar"></i></div></div>
<div id="toolbar">
<button id="gps-toggle" type="button">📍 دنیای واقعی</button>
<button id="ar-toggle" type="button" hidden>📷 دوربین</button>
<button id="stable-toggle" type="button" class="on" hidden>🎯 پایدار: روشن</button>
<button id="sound" type="button" aria-pressed="false">صدا: خاموش</button>
<button id="quality" type="button">گرافیک</button>
<button id="restart" type="button">شروع دوباره</button>
</div>
<div id="touch" hidden>
<div id="stick"><i></i></div>
<div class="touch-buttons">
<button type="button" data-key="f">F</button>
<button type="button" data-key="e">E</button>
<button type="button" data-key="shift">دویدن</button>
</div>
</div>
<div id="crosshair"></div>
<small id="attribution">نقشه: © مشارکت‌کنندگان OpenStreetMap</small>
</div>
<!-- Payment at the card reader -->
<section id="payment" hidden>
<small>کارت‌خوان روبات مارکت</small>
<h2 id="payment-title"></h2>
<p id="payment-step"></p>
<div class="actions">
<button id="swipe" type="button" disabled>کشیدن کارت · C</button>
<button id="cancel-payment" type="button" class="ghost">انصراف</button>
</div>
</section>
<dialog id="map-dialog">
<header>
<div class="tabs" role="tablist">
<button type="button" role="tab" data-tab="city" aria-selected="true">مشهد</button>
<button type="button" role="tab" data-tab="iran" aria-selected="false">ایران</button>
</div>
<button type="button" class="close" id="map-close" aria-label="بستن">✕</button>
</header>
<div class="map-body">
<div class="map-view">
<canvas id="fullmap"></canvas>
<canvas id="iran-map" hidden></canvas>
<p class="map-help">کشیدن: جابه‌جایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب</p>
</div>
<aside class="map-side">
<h3>دستگاه‌های روبات مارکت</h3>
<div id="machine-detail" hidden>
<h4 id="detail-name"></h4>
<p id="detail-address"></p>
<p id="detail-meta"></p>
<p id="detail-products"></p>
<button id="route-button" type="button">مسیریابی تا اینجا</button>
</div>
<ul id="machine-list"></ul>
</aside>
</div>
</dialog>
<dialog id="shop">
<header>
<div>
<small id="shop-category"></small>
<h2 id="shop-name"></h2>
<p id="shop-address"></p>
</div>
<button type="button" class="close" id="shop-close" aria-label="بستن">✕</button>
</header>
<p id="shop-note"></p>
<div id="products"></div>
<p class="rate">۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.</p>
</dialog>
<dialog id="real-intro">
<img src="./robot/serious.webp" alt="" width="72" height="68">
<h2>بازی در دنیای واقعی</h2>
<p>با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.</p>
<ul>
<li>هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.</li>
<li>بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.</li>
<li>هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.</li>
<li>موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.</li>
</ul>
<div class="actions">
<button type="button" id="real-go">قبول دارم، شروع کن</button>
<button type="button" id="real-cancel" class="ghost">انصراف</button>
</div>
</dialog>
<dialog id="ios-install">
<h2>نصب روی آیفون</h2>
<ol>
<li>در Safari دکمهٔ <b>Share</b> (مربع با فلش رو به بالا) را بزنید.</li>
<li>گزینهٔ <b>Add to Home Screen</b> را انتخاب کنید.</li>
<li>روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.</li>
</ol>
<button type="button" id="ios-close">متوجه شدم</button>
</dialog>
<div id="update-toast" hidden>نسخهٔ جدید بازی آماده است <button type="button" id="update-reload">به‌روزرسانی</button></div>
<dialog id="end">
<small id="end-tag">پایان عملیات</small>
<h2 id="end-title"></h2>
<p id="end-text"></p>
<ul id="end-stats"></ul>
<div class="actions">
<button id="again" type="button">عملیات دوباره</button>
<button id="continue" type="button" class="ghost">ادامهٔ گشت در شهر</button>
</div>
</dialog>
<script type="module" src="/src/main.js"></script>
</body>
<meta
name="description"
content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ را در مشهد پیدا کن و امتیاز بگیر."
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="روبات مارکت" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/png" href="./icons/favicon-64.png" />
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+4247 -4
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -5,7 +5,15 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
"preview": "vite preview --host 0.0.0.0",
"format": "prettier --write \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" \"*.{js,html}\" \"scripts/*.mjs\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,css,md}\" \"tests/**/*.js\" \"*.{js,html}\" \"scripts/*.mjs\"",
"predev": "npm run format",
"prebuild": "npm run format",
"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": "",
@@ -13,9 +21,29 @@
"type": "module",
"dependencies": {
"@fontsource/vazirmatn": "^5.2.5",
"@reduxjs/toolkit": "^2.13.0",
"axios": "^1.21.0",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.3.1",
"three": "^0.180.0"
},
"devDependencies": {
"vite": "^6.4.3"
"@eslint/js": "^9.39.5",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.6.2",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.32",
"prettier": "^3.9.10",
"tailwindcss": "^3.4.19",
"vite": "^6.4.3",
"vitest": "^4.1.12"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+119 -95
View File
@@ -14,37 +14,41 @@
// vertex count u8, height in 0.5 m u8, first vertex (x u16, z u16) in `step` metres from that origin,
// then each further vertex as (dx i16, dz i16) from the one before. Buildings are sorted west to
// east in 100 m strips, so neighbours sit next to each other and the small deltas gzip well.
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 { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { createGunzip } from 'node:zlib';
import { createInterface } from 'node:readline';
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) {
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
process.exit(1);
console.error('usage: node scripts/build-buildings.mjs <global-buildings .csv.gz>');
process.exit(1);
}
const STEP = .5;
const STEP = 0.5;
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
const {minX, minZ, maxX, maxZ} = data.bounds;
const osm = createCollisionWorld(data.buildings.map(b => b.slice(1)));
const areasOf = kind => data.areas.filter(a => data.areaKinds[a[0]] === kind).map(a => a.slice(1));
const industrial = createCollisionWorld(areasOf('industrial')), parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
const { minX, minZ, maxX, maxZ } = data.bounds;
const osm = createCollisionWorld(data.buildings.map((b) => b.slice(1)));
const areasOf = (kind) =>
data.areas.filter((a) => data.areaKinds[a[0]] === kind).map((a) => a.slice(1));
const industrial = createCollisionWorld(areasOf('industrial')),
parks = createCollisionWorld([...areasOf('park'), ...areasOf('forest')]);
// The factory compound in its own frame (u along the driveway, v across), with a margin.
const normal = {x: -FACTORY.axis.z, z: FACTORY.axis.x};
const normal = { x: -FACTORY.axis.z, z: FACTORY.axis.x };
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 * normal.x + dz * normal.z;
return u > -115 && u < 85 && v > -55 && v < 55;
const dx = x - FACTORY.center.x,
dz = z - FACTORY.center.z;
const u = dx * FACTORY.axis.x + dz * FACTORY.axis.z,
v = dx * normal.x + dz * normal.z;
return u > -115 && u < 85 && v > -55 && v < 55;
};
const hash = n => {
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
const hash = (n) => {
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
return ((n ^ (n >>> 16)) >>> 0) / 4294967296;
};
// Outside the Payambar-e Azam ring (west of ~59.47°E or north of ~36.40°N) the region is industrial
@@ -52,99 +56,119 @@ const hash = n => {
const outskirts = (x, z) => x < -6000 || z < -9400;
// Storeys of 3.2 m, picked from the footprint area; warehouses are one tall storey.
export function estimateHeight(area, {inIndustrial, inPark, inOutskirts}, seed) {
const r = hash(seed), pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
if (inPark) return 3.2;
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
if (inOutskirts) return pick(1, 2) * 3.2;
if (inIndustrial && area >= 150) return 7 + r * 3;
if (area < 40) return 3.2;
if (area < 120) return pick(2, 3) * 3.2;
if (area < 300) return pick(3, 4) * 3.2;
if (area < 800) return pick(4, 6) * 3.2;
return pick(5, 8) * 3.2;
export function estimateHeight(area, { inIndustrial, inPark, inOutskirts }, seed) {
const r = hash(seed),
pick = (lo, hi) => lo + Math.floor(r * (hi - lo + 1));
if (inPark) return 3.2;
if ((inIndustrial || inOutskirts) && area >= 250) return 6 + r * 3;
if (inOutskirts) return pick(1, 2) * 3.2;
if (inIndustrial && area >= 150) return 7 + r * 3;
if (area < 40) return 3.2;
if (area < 120) return pick(2, 3) * 3.2;
if (area < 300) return pick(3, 4) * 3.2;
if (area < 800) return pick(4, 6) * 3.2;
return pick(5, 8) * 3.2;
}
const polygonArea = pts => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
const polygonArea = (pts) => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
};
const out = [];
let read = 0, outside = 0, duplicate = 0, factory = 0, tiny = 0;
const lines = createInterface({input: createReadStream(source).pipe(createGunzip())});
let read = 0,
outside = 0,
duplicate = 0,
factory = 0,
tiny = 0;
const lines = createInterface({ input: createReadStream(source).pipe(createGunzip()) });
for await (const line of lines) {
if (!line.trim()) continue;
read++;
const ring = JSON.parse(line).geometry.coordinates[0];
const pts = [];
for (const [lon, lat] of ring.slice(0, -1)) {
const p = toLocal(lat, lon);
pts.push(p.x, p.z);
}
let cx = 0, cz = 0;
for (let i = 0; i < pts.length; i += 2) {
cx += pts[i];
cz += pts[i + 1];
}
cx /= pts.length / 2;
cz /= pts.length / 2;
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
outside++;
continue;
}
if (pts.length / 2 > 255 || pts.length < 6) continue;
const area = polygonArea(pts);
if (area < 12) {
tiny++;
continue;
}
if (blocked(osm, cx, cz)) {
duplicate++;
continue;
}
if (inFactory(cx, cz)) {
factory++;
continue;
}
const height = estimateHeight(area, {inIndustrial: blocked(industrial, cx, cz), inPark: blocked(parks, cx, cz), inOutskirts: outskirts(cx, cz)}, Math.round(cx * 7 + cz * 13));
out.push({height, pts});
if (!line.trim()) continue;
read++;
const ring = JSON.parse(line).geometry.coordinates[0];
const pts = [];
for (const [lon, lat] of ring.slice(0, -1)) {
const p = toLocal(lat, lon);
pts.push(p.x, p.z);
}
let cx = 0,
cz = 0;
for (let i = 0; i < pts.length; i += 2) {
cx += pts[i];
cz += pts[i + 1];
}
cx /= pts.length / 2;
cz /= pts.length / 2;
if (cx < minX || cx > maxX || cz < minZ || cz > maxZ) {
outside++;
continue;
}
if (pts.length / 2 > 255 || pts.length < 6) continue;
const area = polygonArea(pts);
if (area < 12) {
tiny++;
continue;
}
if (blocked(osm, cx, cz)) {
duplicate++;
continue;
}
if (inFactory(cx, cz)) {
factory++;
continue;
}
const height = estimateHeight(
area,
{
inIndustrial: blocked(industrial, cx, cz),
inPark: blocked(parks, cx, cz),
inOutskirts: outskirts(cx, cz),
},
Math.round(cx * 7 + cz * 13),
);
out.push({ height, pts });
}
let bytes = 20;
for (const b of out) bytes += 2 + b.pts.length * 2;
const buffer = new ArrayBuffer(bytes), view = new DataView(buffer);
const buffer = new ArrayBuffer(bytes),
view = new DataView(buffer);
[0x52, 0x4d, 0x42, 0x32].forEach((c, i) => view.setUint8(i, c));
out.sort((a, b) => Math.floor(a.pts[1] / 100) - Math.floor(b.pts[1] / 100) || a.pts[0] - b.pts[0]);
view.setUint32(4, out.length, true);
// Coordinates are stored from a point 200 m outside the region, so edge buildings never go negative.
const originX = minX - 200, originZ = minZ - 200;
const originX = minX - 200,
originZ = minZ - 200;
view.setFloat32(8, originX, true);
view.setFloat32(12, originZ, true);
view.setFloat32(16, STEP, true);
let o = 20;
for (const b of out) {
view.setUint8(o++, b.pts.length / 2);
view.setUint8(o++, Math.min(255, Math.round(b.height / .5)));
let px = 0, pz = 0;
for (let i = 0; i < b.pts.length; i += 2) {
const qx = Math.round((b.pts[i] - originX) / STEP), qz = Math.round((b.pts[i + 1] - originZ) / STEP);
if (i === 0) {
view.setUint16(o, qx, true);
view.setUint16(o + 2, qz, true);
} else {
view.setInt16(o, qx - px, true);
view.setInt16(o + 2, qz - pz, true);
}
px = qx;
pz = qz;
o += 4;
view.setUint8(o++, b.pts.length / 2);
view.setUint8(o++, Math.min(255, Math.round(b.height / 0.5)));
let px = 0,
pz = 0;
for (let i = 0; i < b.pts.length; i += 2) {
const qx = Math.round((b.pts[i] - originX) / STEP),
qz = Math.round((b.pts[i + 1] - originZ) / STEP);
if (i === 0) {
view.setUint16(o, qx, true);
view.setUint16(o + 2, qz, true);
} else {
view.setInt16(o, qx - px, true);
view.setInt16(o + 2, qz - pz, true);
}
px = qx;
pz = qz;
o += 4;
}
}
mkdirSync(new URL('../public/data/', import.meta.url), {recursive: true});
mkdirSync(new URL('../public/data/', import.meta.url), { recursive: true });
writeFileSync(new URL('../public/data/buildings.bin', import.meta.url), new Uint8Array(buffer));
console.log(`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`);
console.log(
`read ${read}, kept ${out.length}, outside ${outside}, OSM duplicates ${duplicate}, factory ${factory}, tiny ${tiny}, ${(bytes / 1e6).toFixed(1)} MB`,
);
+248 -141
View File
@@ -14,194 +14,301 @@
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
// 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 { readFileSync, writeFileSync } from 'node:fs';
import { ORIGIN, toLocal } from '../src/utils/geo.js';
const args = {osm: []};
const args = { osm: [] };
for (let i = 2, key = null; i < process.argv.length; i++) {
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
else if (key === 'osm') args.osm.push(process.argv[i]);
else args[key] = process.argv[i];
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
else if (key === 'osm') args.osm.push(process.argv[i]);
else args[key] = process.argv[i];
}
if (!args.osm.length || !args.poi || !args.provinces) {
console.error('usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>');
process.exit(1);
console.error(
'usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>',
);
process.exit(1);
}
// Points of interest are kept only inside the detailed city zone.
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
const BBOX = { minLat: 36.3, maxLat: 36.33, minLon: 59.512, maxLon: 59.566 };
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
const REGION = {minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572};
const REGION = { minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572 };
const merged = new Map();
for (const file of args.osm) for (const e of JSON.parse(readFileSync(file, 'utf8')).elements) merged.set(`${e.type}/${e.id}`, e);
for (const file of args.osm)
for (const e of JSON.parse(readFileSync(file, 'utf8')).elements)
merged.set(`${e.type}/${e.id}`, e);
const city = [...merged.values()];
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
const provincesPath = args.provinces;
const r1 = v => Math.round(v * 10) / 10;
const flat = geometry => geometry.flatMap(({lat, lon}) => {
const {x, z} = toLocal(lat, lon);
const r1 = (v) => Math.round(v * 10) / 10;
const flat = (geometry) =>
geometry.flatMap(({ lat, lon }) => {
const { x, z } = toLocal(lat, lon);
return [r1(x), r1(z)];
});
});
// Closed OSM rings repeat their first node; the game stores each vertex once.
const ring = geometry => {
const pts = flat(geometry);
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
return pts;
const ring = (geometry) => {
const pts = flat(geometry);
if (pts.length > 4 && pts[0] === pts.at(-2) && pts[1] === pts.at(-1)) pts.length -= 2;
return pts;
};
const hash = n => {
n = (n ^ 61) ^ (n >>> 16);
n = Math.imul(n, 9);
n ^= n >>> 4;
n = Math.imul(n, 0x27d4eb2d);
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
const hash = (n) => {
n = n ^ 61 ^ (n >>> 16);
n = Math.imul(n, 9);
n ^= n >>> 4;
n = Math.imul(n, 0x27d4eb2d);
return ((n ^ (n >>> 15)) >>> 0) / 4294967296;
};
const roadTypes = ['trunk', 'primary', 'secondary', 'tertiary', 'unclassified', 'residential', 'living_street', 'service', 'pedestrian', 'footway', 'cycleway', 'path', 'track', 'steps'];
const names = [], nameIndex = new Map();
const nameId = name => {
if (!name) return -1;
if (!nameIndex.has(name)) {
nameIndex.set(name, names.length);
names.push(name);
}
return nameIndex.get(name);
const roadTypes = [
'trunk',
'primary',
'secondary',
'tertiary',
'unclassified',
'residential',
'living_street',
'service',
'pedestrian',
'footway',
'cycleway',
'path',
'track',
'steps',
];
const names = [],
nameIndex = new Map();
const nameId = (name) => {
if (!name) return -1;
if (!nameIndex.has(name)) {
nameIndex.set(name, names.length);
names.push(name);
}
return nameIndex.get(name);
};
const roads = [], buildings = [], areas = [], labels = [], onewayRoads = [];
const roads = [],
buildings = [],
areas = [],
labels = [],
onewayRoads = [];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
: t.landuse === 'grass' ? 'grass'
: t.landuse === 'forest' || t.natural === 'wood' ? 'forest'
: t.leisure === 'pitch' ? 'pitch'
: t.natural === 'water' ? 'water'
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus'
: t.landuse === 'industrial' ? 'industrial'
: t.landuse === 'farmland' ? 'farmland' : null;
const areaKind = (t) =>
t.leisure === 'park' ||
t.leisure === 'garden' ||
t.landuse === 'park' ||
t.landuse === 'recreation_ground'
? 'park'
: t.landuse === 'grass'
? 'grass'
: t.landuse === 'forest' || t.natural === 'wood'
? 'forest'
: t.leisure === 'pitch'
? 'pitch'
: t.natural === 'water'
? 'water'
: ['school', 'university', 'college'].includes(t.amenity)
? 'campus'
: t.landuse === 'industrial'
? 'industrial'
: t.landuse === 'farmland'
? 'farmland'
: null;
// Joins a relation's outer member ways end to end into closed rings.
function joinRings(members) {
const parts = members.filter(m => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1).map(m => m.geometry.slice());
const rings = [], same = (a, b) => a.lat === b.lat && a.lon === b.lon;
while (parts.length) {
let current = parts.shift();
for (let grown = true; grown && !same(current[0], current.at(-1));) {
grown = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
else continue;
parts.splice(i, 1);
grown = true;
break;
}
}
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
const parts = members
.filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry?.length > 1)
.map((m) => m.geometry.slice());
const rings = [],
same = (a, b) => a.lat === b.lat && a.lon === b.lon;
while (parts.length) {
let current = parts.shift();
for (let grown = true; grown && !same(current[0], current.at(-1));) {
grown = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (same(current.at(-1), p[0])) current = current.concat(p.slice(1));
else if (same(current.at(-1), p.at(-1))) current = current.concat(p.slice(0, -1).reverse());
else continue;
parts.splice(i, 1);
grown = true;
break;
}
}
return rings;
if (same(current[0], current.at(-1)) && current.length > 3) rings.push(current);
}
return rings;
}
for (const e of city) {
const t = e.tags ?? {};
// Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// One-way roads are stored pointing in their direction of travel; dual carriageways
// (each side its own way) are one-way, as are motorways and roundabouts by default.
const oneway = ['yes', 'true', '1', '-1'].includes(t.oneway) || (t.oneway !== 'no' && (t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
// Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = [];
for (const point of [...geometry, null]) {
if (point && inRegion(point)) run.push(point);
else {
if (run.length > 1) {
if (oneway) onewayRoads.push(roads.length);
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
}
run = [];
}
const t = e.tags ?? {};
// Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// One-way roads are stored pointing in their direction of travel; dual carriageways
// (each side its own way) are one-way, as are motorways and roundabouts by default.
const oneway =
['yes', 'true', '1', '-1'].includes(t.oneway) ||
(t.oneway !== 'no' &&
(t.highway.startsWith('motorway') || ['roundabout', 'circular'].includes(t.junction)));
const geometry = t.oneway === '-1' ? [...e.geometry].reverse() : e.geometry;
// Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({ lat, lon }) =>
lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = [];
for (const point of [...geometry, null]) {
if (point && inRegion(point)) run.push(point);
else {
if (run.length > 1) {
if (oneway) onewayRoads.push(roads.length);
roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
}
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
const levels = Number.parseFloat(t['building:levels']), height = Number.parseFloat(t.height);
const h = Number.isFinite(height) ? height : Number.isFinite(levels) ? levels * 3.2 : (2 + Math.floor(hash(e.id) * 4)) * 3.2;
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
} else if (e.type === 'relation' && areaKind(t)) {
for (const r of joinRings(e.members ?? [])) areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
}
if (t.name && (t.amenity === 'school' || t.amenity === 'university' || t.leisure === 'park' || t.landuse === 'industrial')) {
const c = e.type === 'node' ? e : e.bounds ? {lat: (e.bounds.minlat + e.bounds.maxlat) / 2, lon: (e.bounds.minlon + e.bounds.maxlon) / 2} : null;
if (c) labels.push({name: t.name, kind: t.landuse === 'industrial' ? 'industrial' : t.amenity ?? 'park', ...roundPoint(toLocal(c.lat, c.lon))});
run = [];
}
}
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
const levels = Number.parseFloat(t['building:levels']),
height = Number.parseFloat(t.height);
const h = Number.isFinite(height)
? height
: Number.isFinite(levels)
? levels * 3.2
: (2 + Math.floor(hash(e.id) * 4)) * 3.2;
buildings.push([r1(Math.min(h, 80)), ...ring(e.geometry)]);
} else if (e.type === 'way' && areaKind(t) && e.geometry?.length > 3) {
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(e.geometry)]);
} else if (e.type === 'relation' && areaKind(t)) {
for (const r of joinRings(e.members ?? []))
areas.push([areaKinds.indexOf(areaKind(t)), ...ring(r)]);
}
if (
t.name &&
(t.amenity === 'school' ||
t.amenity === 'university' ||
t.leisure === 'park' ||
t.landuse === 'industrial')
) {
const c =
e.type === 'node'
? e
: e.bounds
? {
lat: (e.bounds.minlat + e.bounds.maxlat) / 2,
lon: (e.bounds.minlon + e.bounds.maxlon) / 2,
}
: null;
if (c)
labels.push({
name: t.name,
kind: t.landuse === 'industrial' ? 'industrial' : (t.amenity ?? 'park'),
...roundPoint(toLocal(c.lat, c.lon)),
});
}
}
for (const e of poi) {
const t = e.tags ?? {}, c = e.center ?? e;
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon) continue;
if (t.railway === 'station' || t.shop === 'mall') labels.push({name: t.name, kind: t.railway ? 'metro' : 'mall', ...roundPoint(toLocal(c.lat, c.lon))});
const t = e.tags ?? {},
c = e.center ?? e;
if (c.lat < BBOX.minLat || c.lat > BBOX.maxLat || c.lon < BBOX.minLon || c.lon > BBOX.maxLon)
continue;
if (t.railway === 'station' || t.shop === 'mall')
labels.push({
name: t.name,
kind: t.railway ? 'metro' : 'mall',
...roundPoint(toLocal(c.lat, c.lon)),
});
}
function roundPoint({x, z}) {
return {x: r1(x), z: r1(z)};
function roundPoint({ x, z }) {
return { x: r1(x), z: r1(z) };
}
// Areas draw largest first, so small pitches and ponds stay visible on top of parks.
const polygonArea = pts => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
const polygonArea = (pts) => {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return Math.abs(a / 2);
};
areas.sort((a, b) => polygonArea(b.slice(1)) - polygonArea(a.slice(1)));
// The world spans every road we kept, plus a margin.
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
for (const road of roads) for (let i = 2; i < road.length; i += 2) {
let minX = Infinity,
minZ = Infinity,
maxX = -Infinity,
maxZ = -Infinity;
for (const road of roads)
for (let i = 2; i < road.length; i += 2) {
minX = Math.min(minX, road[i]);
maxX = Math.max(maxX, road[i]);
minZ = Math.min(minZ, road[i + 1]);
maxZ = Math.max(maxZ, road[i + 1]);
}
const min = {x: Math.floor(minX - 200), z: Math.floor(minZ - 200)}, max = {x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200)};
}
const min = { x: Math.floor(minX - 200), z: Math.floor(minZ - 200) },
max = { x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200) };
const city_ = {
origin: ORIGIN,
attribution: '© OpenStreetMap contributors',
bounds: {minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z},
roadTypes, areaKinds, names, roads, buildings, areas,
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
oneway: onewayRoads,
labels: [...new Map(labels.map(l => [l.name, l])).values()]
origin: ORIGIN,
attribution: '© OpenStreetMap contributors',
bounds: { minX: min.x, minZ: min.z, maxX: max.x, maxZ: max.z },
roadTypes,
areaKinds,
names,
roads,
buildings,
areas,
// Indices into `roads` of one-way roads, whose points run in the direction of travel.
oneway: onewayRoads,
labels: [...new Map(labels.map((l) => [l.name, l])).values()],
};
writeFileSync(new URL('../src/data/mellat.json', import.meta.url), JSON.stringify(city_));
// Douglas–Peucker keeps the province shapes recognisable at a fraction of the size.
function simplify(points, tolerance) {
if (points.length < 3) return points;
const [ax, ay] = points[0], [bx, by] = points.at(-1);
let index = 0, distance = 0;
for (let i = 1; i < points.length - 1; i++) {
const [px, py] = points[i], dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);
// A closed ring starts and ends on the same point, so measure from that point instead.
const d = len ? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len : Math.hypot(px - ax, py - ay);
if (d > distance) {
index = i;
distance = d;
}
if (points.length < 3) return points;
const [ax, ay] = points[0],
[bx, by] = points.at(-1);
let index = 0,
distance = 0;
for (let i = 1; i < points.length - 1; i++) {
const [px, py] = points[i],
dx = bx - ax,
dy = by - ay,
len = Math.hypot(dx, dy);
// A closed ring starts and ends on the same point, so measure from that point instead.
const d = len
? Math.abs(dy * px - dx * py + bx * ay - by * ax) / len
: Math.hypot(px - ax, py - ay);
if (d > distance) {
index = i;
distance = d;
}
if (distance <= tolerance) return [points[0], points.at(-1)];
return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)];
}
if (distance <= tolerance) return [points[0], points.at(-1)];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8')).features
.filter(f => f.properties.adm0_a3 === 'IRN')
.map(f => {
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
return {
id: f.properties.name,
name: f.properties.name_fa.replace(/^استان\s+/, ''),
rings: polygons.map(p => simplify(p[0], .02).flatMap(([lon, lat]) => [Math.round(lon * 1000) / 1000, Math.round(lat * 1000) / 1000])).filter(r => r.length >= 8)
};
});
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({provinces}));
console.log(`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`);
const provinces = JSON.parse(readFileSync(provincesPath, 'utf8'))
.features.filter((f) => f.properties.adm0_a3 === 'IRN')
.map((f) => {
const polygons =
f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
return {
id: f.properties.name,
name: f.properties.name_fa.replace(/^استان\s+/, ''),
rings: polygons
.map((p) =>
simplify(p[0], 0.02).flatMap(([lon, lat]) => [
Math.round(lon * 1000) / 1000,
Math.round(lat * 1000) / 1000,
]),
)
.filter((r) => r.length >= 8),
};
});
writeFileSync(new URL('../src/data/iran.json', import.meta.url), JSON.stringify({ provinces }));
console.log(
`roads ${roads.length}, buildings ${buildings.length}, areas ${areas.length}, labels ${city_.labels.length}, provinces ${provinces.length}`,
);
+65 -46
View File
@@ -1,3 +1,4 @@
import { downloadImagery } from '../src/services/imageryDownloadService.js';
// Downloads Esri World Imagery tiles for the game region into public/imagery/<z>/<x>/<y>.jpg and
// writes public/imagery/index.json listing them.
//
@@ -9,69 +10,87 @@
//
// 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 { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
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 URL_TEMPLATE =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const OUT = new URL('../public/imagery/', import.meta.url);
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url), 'utf8'));
export const tileX = (lon, z) => Math.floor((lon + 180) / 360 * 2 ** z);
export const tileY = (lat, z) => Math.floor((1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * 2 ** z);
export const tileX = (lon, z) => Math.floor(((lon + 180) / 360) * 2 ** z);
export const tileY = (lat, z) =>
Math.floor(((1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2) * 2 ** z);
const tiles = {15: new Set(), 17: new Set()};
const tiles = { 15: new Set(), 17: new Set() };
const addBox = (z, lat0, lon0, lat1, lon1) => {
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
for (let x = tileX(lon0, z); x <= tileX(lon1, z); x++)
for (let y = tileY(lat1, z); y <= tileY(lat0, z); y++) tiles[z].add(`${x}/${y}`);
};
const {minX, minZ, maxX, maxZ} = data.bounds;
const nw = toLatLon(minX, minZ), se = toLatLon(maxX, maxZ);
const { minX, minZ, maxX, maxZ } = data.bounds;
const nw = toLatLon(minX, minZ),
se = toLatLon(maxX, maxZ);
addBox(15, se.lat, nw.lon, nw.lat, se.lon);
addBox(17, 36.300, 59.512, 36.330, 59.566);
addBox(17, 36.3, 59.512, 36.33, 59.566);
addBox(17, 36.443, 59.414, 36.458, 59.434);
// The drive along the real roads, widened by 300 m.
const drive = buildGraph(data.roads, {drivable: true}), park = machineById('mellat-park').anchor;
const drive = buildGraph(data.roads, { drivable: true }),
park = machineById('mellat-park').anchor;
const route = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, park.x, park.z);
for (let i = 1; i < route.points.length; i++) {
const a = route.points[i - 1], b = route.points[i], steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
for (let s = 0; s <= steps; s++) {
const x = a.x + (b.x - a.x) * s / steps, z = a.z + (b.z - a.z) * s / steps;
const p0 = toLatLon(x - 300, z + 300), p1 = toLatLon(x + 300, z - 300);
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
}
const a = route.points[i - 1],
b = route.points[i],
steps = Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / 100);
for (let s = 0; s <= steps; s++) {
const x = a.x + ((b.x - a.x) * s) / steps,
z = a.z + ((b.z - a.z) * s) / steps;
const p0 = toLatLon(x - 300, z + 300),
p1 = toLatLon(x + 300, z - 300);
addBox(17, p0.lat, p0.lon, p1.lat, p1.lon);
}
}
const queue = Object.entries(tiles).flatMap(([z, set]) => [...set].map(key => ({z: Number(z), key})));
const queue = Object.entries(tiles).flatMap(([z, set]) =>
[...set].map((key) => ({ z: Number(z), key })),
);
console.log(`tiles: zoom 15 ${tiles[15].size}, zoom 17 ${tiles[17].size}`);
let done = 0, fetched = 0, bytes = 0;
let done = 0,
fetched = 0,
bytes = 0;
async function worker() {
while (queue.length) {
const {z, key} = queue.pop(), [x, y] = key.split('/');
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
if (!existsSync(file)) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x), {headers: {'User-Agent': 'robot-market-game/1.0'}});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = new Uint8Array(await res.arrayBuffer());
mkdirSync(new URL(`${z}/${x}/`, OUT), {recursive: true});
writeFileSync(file, body);
fetched++;
bytes += body.length;
break;
} catch (error) {
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
while (queue.length) {
const { z, key } = queue.pop(),
[x, y] = key.split('/');
const file = new URL(`${z}/${x}/${y}.jpg`, OUT);
if (!existsSync(file)) {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const body = new Uint8Array(
await downloadImagery(
URL_TEMPLATE.replace('{z}', z).replace('{y}', y).replace('{x}', x),
),
);
mkdirSync(new URL(`${z}/${x}/`, OUT), { recursive: true });
writeFileSync(file, body);
fetched++;
bytes += body.length;
break;
} catch (error) {
if (attempt === 3) console.warn(`failed ${z}/${x}/${y}: ${error.message}`);
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
if (++done % 100 === 0) console.log(`${done} tiles…`);
}
}
if (++done % 100 === 0) console.log(`${done} tiles…`);
}
}
await Promise.all(Array.from({length: 6}, worker));
const index = {attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community'};
for (const [z, set] of Object.entries(tiles)) index[z] = [...set].filter(key => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
await Promise.all(Array.from({ length: 6 }, worker));
const index = { attribution: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community' };
for (const [z, set] of Object.entries(tiles))
index[z] = [...set].filter((key) => existsSync(new URL(`${z}/${key}.jpg`, OUT))).sort();
writeFileSync(new URL('index.json', OUT), JSON.stringify(index));
console.log(`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`);
console.log(
`downloaded ${fetched} new tiles (${(bytes / 1e6).toFixed(1)} MB); index lists ${index[15].length} + ${index[17].length}`,
);
-115
View File
@@ -1,115 +0,0 @@
import * as THREE from 'three';
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
// robot appear in their real direction over the camera picture.
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
const CAMERA_LONG_FOV = 65;
const zee = new THREE.Vector3(0, 0, 1), euler = new THREE.Euler(), q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
// `alpha` must be measured from north (absolute), counter-clockwise.
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
const r = Math.PI / 180;
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
target.setFromEuler(euler);
target.multiply(q1);
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
return target;
}
// Vertical field of view of what is visible once the video is scaled to cover the screen.
export function coverFov(videoAspect, screenAspect) {
const long = Math.tan(CAMERA_LONG_FOV * Math.PI / 360);
// The video's own vertical and horizontal half-angle tangents.
const vTan = videoAspect >= 1 ? long / videoAspect : long, hTan = videoAspect >= 1 ? long : long * videoAspect;
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
return Math.atan(visibleV) * 360 / Math.PI;
}
export function createArView(video) {
let stream = null, orientation = null, compassOffset = null, absoluteSeen = false;
const target = new THREE.Quaternion();
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
const onAbsolute = event => {
if (event.alpha == null) return;
absoluteSeen = true;
orientation = {alpha: event.alpha, beta: event.beta, gamma: event.gamma};
};
const onOrientation = event => {
if (event.alpha == null || absoluteSeen) return;
let alpha = event.alpha;
if (event.webkitCompassHeading != null) {
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
compassOffset = (360 - event.webkitCompassHeading) - event.alpha;
}
if (!event.absolute && compassOffset != null) alpha += compassOffset;
orientation = {alpha, beta: event.beta, gamma: event.gamma};
};
let sensing = false;
return {
// Camera picture on (AR) or off (the 3D world fills the screen).
get active() {
return Boolean(stream);
},
get hasOrientation() {
return Boolean(orientation);
},
// Phone orientation, used by the first-person view with or without the camera picture.
startSensors() {
if (sensing) return;
sensing = true;
window.addEventListener('deviceorientationabsolute', onAbsolute);
window.addEventListener('deviceorientation', onOrientation);
},
stopSensors() {
sensing = false;
orientation = null;
absoluteSeen = false;
window.removeEventListener('deviceorientationabsolute', onAbsolute);
window.removeEventListener('deviceorientation', onOrientation);
},
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
async start() {
if (stream) return null;
if (!navigator.mediaDevices?.getUserMedia) return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).';
try {
stream = await navigator.mediaDevices.getUserMedia({video: {facingMode: {ideal: 'environment'}, width: {ideal: 1280}, height: {ideal: 720}}, audio: false});
} catch (error) {
stream = null;
return error?.name === 'NotAllowedError' ? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.' : 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟';
}
video.srcObject = stream;
video.hidden = false;
await video.play().catch(() => {});
this.startSensors();
return null;
},
// Closes the camera picture; the sensors keep running for the first-person view.
stop() {
stream?.getTracks().forEach(track => track.stop());
stream = null;
video.srcObject = null;
video.hidden = true;
},
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
update(camera, dt, smoothing = 8) {
const fov = stream ? coverFov(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3, innerWidth / innerHeight) : 62;
if (Math.abs(camera.fov - fov) > .1) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
if (!orientation) return false;
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
return true;
}
};
}
-25
View File
@@ -1,25 +0,0 @@
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
export function decodeBuildings(buffer) {
const view = new DataView(buffer);
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
const count = view.getUint32(4, true), minX = view.getFloat32(8, true), minZ = view.getFloat32(12, true), step = view.getFloat32(16, true);
const buildings = new Array(count);
let o = 20;
for (let b = 0; b < count; b++) {
const n = view.getUint8(o), height = view.getUint8(o + 1) * .5;
o += 2;
const building = new Array(1 + n * 2);
building[0] = height;
let qx = 0, qz = 0;
for (let i = 0; i < n; i++, o += 4) {
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
building[1 + i * 2] = minX + qx * step;
building[2 + i * 2] = minZ + qz * step;
}
buildings[b] = building;
}
return buildings;
}
-250
View File
@@ -1,250 +0,0 @@
import * as THREE from 'three';
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .45, ...extra});
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
function extrudeProfile(points, width, material, bevel = .1) {
const shape = new THREE.Shape();
points.forEach(([x, y], i) => i ? shape.lineTo(x, y) : shape.moveTo(x, y));
const geometry = new THREE.ExtrudeGeometry(shape, {depth: width - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel * .8, bevelSegments: 3, curveSegments: 6});
geometry.translate(0, 0, -(width - bevel * 2) / 2);
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
}
// Lower-body outline with wheel arches cut around the wheel centres.
function bodyOutline(front, rear, arches, radius, top) {
const shape = new THREE.Shape();
shape.moveTo(rear[0][0], rear[0][1]);
const bottom = .3;
// Underside from the rear towards the front, arching over each wheel.
for (const ax of [...arches].sort((a, b) => a - b)) {
shape.lineTo(ax - radius, bottom);
shape.absarc(ax, .36, radius, Math.PI + .14, -.14, true);
}
for (const [x, y] of front) shape.lineTo(x, y);
for (const [x, y] of top) shape.lineTo(x, y);
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
return shape;
}
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
const tyre = mat('#151719', {roughness: .9}), rim = mat(rimColor, {metalness: .85, roughness: .25}), brake = mat('#3a3d40', {metalness: .6});
const wheels = [];
for (const [x, z] of positions) {
const wheel = new THREE.Group();
wheel.position.set(x, radius, z);
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
t.rotation.x = Math.PI / 2;
t.castShadow = true;
wheel.add(t);
const side = Math.sign(z);
const face = new THREE.Mesh(new THREE.CylinderGeometry(radius * .68, radius * .68, .02, 24), brake);
face.rotation.x = Math.PI / 2;
face.position.z = side * tyreWidth * .44;
wheel.add(face);
for (let i = 0; i < spokes; i++) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * .16, .04), rim);
spoke.position.z = side * tyreWidth * .5;
spoke.rotation.z = i * Math.PI / spokes;
wheel.add(spoke);
}
const hub = new THREE.Mesh(new THREE.CylinderGeometry(radius * .2, radius * .2, .05, 12), rim);
hub.rotation.x = Math.PI / 2;
hub.position.z = side * tyreWidth * .52;
wheel.add(hub);
group.add(wheel);
wheels.push(wheel);
}
return wheels;
}
/**
* Sports saloon in the style of a German executive car: long bonnet, twin kidney grille,
* ring-shaped "angel eye" headlights, kinked rear side window and five-spoke rims. 4.7 m long.
* No manufacturer badges.
*/
export function createSportSedan(color = '#1d3f78') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .7, roughness: .28}), glass = mat('#0f1a22', {metalness: .7, roughness: .08});
const black = mat('#0b0c0d', {roughness: .6}), chrome = mat('#d8dcdf', {metalness: .95, roughness: .15});
const W = 1.86;
const outline = bodyOutline(
[[2.24, .3], [2.36, .42], [2.38, .62], [2.28, .78]],
[[-2.3, .3], [-2.36, .55], [-2.3, .86], [-2.12, .97]],
[-1.42, 1.43], .41,
[[1.9, .86], [.95, 1.0], [-1.2, 1.0], [-1.6, .99]]
);
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {depth: W - .2, bevelEnabled: true, bevelThickness: .1, bevelSize: .08, bevelSegments: 3, curveSegments: 10});
bodyGeometry.translate(0, 0, -(W - .2) / 2);
const body = new THREE.Mesh(bodyGeometry, paint);
body.castShadow = body.receiveShadow = true;
group.add(body);
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
const cabin = extrudeProfile([[.95, .98], [.05, 1.39], [-.9, 1.41], [-1.62, 1.0], [-1.5, .98]], 1.5, glass, .08);
group.add(cabin);
const roof = extrudeProfile([[.0, 1.37], [-.88, 1.39], [-1.0, 1.32], [.12, 1.32]], 1.36, paint, .06);
roof.position.y = .045;
group.add(roof);
const pillar = new THREE.ShapeGeometry(new THREE.Shape([new THREE.Vector2(-1.05, 1.0), new THREE.Vector2(-1.55, 1.0), new THREE.Vector2(-1.08, 1.33), new THREE.Vector2(-.96, 1.33), new THREE.Vector2(-1.18, 1.12)]));
const pillarPaint = paint.clone();
pillarPaint.side = THREE.DoubleSide;
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
for (const side of [-1, 1]) {
const z = side * .72;
const b = new THREE.Mesh(new THREE.BoxGeometry(.09, .4, .04), black);
b.position.set(-.35, 1.18, z);
group.add(b);
const c = new THREE.Mesh(pillar, pillarPaint);
c.position.z = z + side * .035;
group.add(c);
// Door handles, mirror and the chrome window line.
for (const x of [.15, -.75]) {
const handle = new THREE.Mesh(new THREE.BoxGeometry(.18, .04, .03), chrome);
handle.position.set(x, .9, side * .94);
group.add(handle);
}
const mirror = new THREE.Mesh(new THREE.BoxGeometry(.18, .12, .2), paint);
mirror.position.set(.78, 1.05, side * 1.0);
group.add(mirror);
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, .025, .02), chrome);
trim.position.set(-.3, .985, side * .76);
group.add(trim);
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, .08, .04), black);
skirt.position.set(0, .34, side * .93);
group.add(skirt);
}
// Twin kidney grille with chrome surrounds.
for (const side of [-1, 1]) {
const surround = new THREE.Mesh(new THREE.BoxGeometry(.06, .2, .3), chrome);
surround.position.set(2.46, .66, side * .18);
group.add(surround);
const grille = new THREE.Mesh(new THREE.BoxGeometry(.07, .16, .25), black);
grille.position.set(2.47, .66, side * .18);
group.add(grille);
for (let i = 0; i < 5; i++) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(.075, .15, .012), chrome);
bar.position.set(2.47, .66, side * .18 + (i - 2) * .05);
group.add(bar);
}
}
// Headlights with two light rings each, and slim L-shaped tail lights.
const ring = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.6});
const tail = mat('#9e1410', {emissive: '#e0261c', emissiveIntensity: .9});
for (const side of [-1, 1]) {
const housing = new THREE.Mesh(new THREE.BoxGeometry(.12, .14, .44), black);
housing.position.set(2.42, .72, side * .6);
group.add(housing);
for (const dz of [-.1, .1]) {
const eye = new THREE.Mesh(new THREE.TorusGeometry(.05, .012, 6, 18), ring);
eye.rotation.y = Math.PI / 2;
eye.position.set(2.48, .72, side * .6 + dz);
group.add(eye);
}
const tl = new THREE.Mesh(new THREE.BoxGeometry(.06, .1, .42), tail);
tl.position.set(-2.45, .82, side * .62);
group.add(tl);
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(.3, .1, .05), tail);
tlSide.position.set(-2.2, .82, side * .9);
group.add(tlSide);
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(.05, .05, .12, 12), chrome);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-2.44, .36, side * .45);
group.add(exhaust);
}
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(.05, .12, 1.2), black);
lowerGrille.position.set(2.45, .42, 0);
group.add(lowerGrille);
const plate = new THREE.Mesh(new THREE.BoxGeometry(.03, .11, .5), mat('#f4f4f0'));
plate.position.set(2.47, .5, 0);
group.add(plate);
const wheels = addWheels(group, [[-1.42, -.82], [-1.42, .82], [1.43, -.82], [1.43, .82]], .36, .26, '#b8bcc0', 5);
return {group, wheels};
}
/**
* Double-cab pickup in the style of the KMC T8: tall square bonnet, wide chrome grille, a cargo
* bed behind the cab. 5.3 m long.
*/
export function createPickup(color = '#e8e8e4') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .5, roughness: .32}), glass = mat('#13202a', {metalness: .6, roughness: .1});
const black = mat('#1a1c1e', {roughness: .7}), chrome = mat('#d0d4d7', {metalness: .9, roughness: .2});
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
group.add(extrudeProfile([[2.62, .5], [2.66, .95], [2.5, 1.18], [1.3, 1.24], [1.3, .5]], 1.9, paint, .1));
group.add(extrudeProfile([[1.3, .5], [1.3, 1.24], [.7, 1.86], [-.95, 1.88], [-1.0, .5]], 1.88, paint, .1));
group.add(extrudeProfile([[1.24, 1.26], [.68, 1.82], [-.9, 1.84], [-.94, 1.26]], 1.92, glass, .02));
// Cargo bed: floor, sides and tailgate.
box(1.62, .1, 1.8, black, -1.85, .72, 0);
for (const side of [-1, 1]) box(1.66, .5, .07, paint, -1.85, .95, side * .9);
box(.07, .5, 1.86, paint, -2.66, .95, 0);
box(1.66, .46, 1.9, paint, -1.85, .5, 0);
box(.3, .06, 1.86, black, -1.02, 1.26, 0);
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
box(.06, .34, 1.1, black, 2.67, .92, 0);
for (let i = 0; i < 3; i++) box(.07, .04, 1.12, chrome, 2.68, .82 + i * .1, 0);
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1.2});
for (const side of [-1, 1]) {
box(.08, .14, .32, lamp, 2.62, 1.02, side * .74);
box(.06, .26, .1, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .7}), -2.7, 1.0, side * .86);
box(.2, .14, .2, black, 1.2, 1.36, side * 1.02);
box(1.0, .05, .06, chrome, -.1, 1.25, side * .97);
for (const x of [.6, -.4]) box(.16, .04, .03, chrome, x, 1.12, side * .97);
}
box(.16, .2, 2.0, black, 2.7, .55, 0);
box(.16, .2, 2.0, black, -2.72, .55, 0);
box(.04, .12, .5, mat('#f4f4f0'), 2.79, .6, 0);
const wheels = addWheels(group, [[-1.7, -.86], [-1.7, .86], [1.7, -.86], [1.7, .86]], .4, .3, '#a9adb1', 6);
for (const x of [-1.7, 1.7]) for (const side of [-1, 1]) box(1.05, .1, .2, black, x, .86, side * .96);
return {group, wheels};
}
/**
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
*/
export function createTruck(color = '#e9eef0') {
const group = new THREE.Group();
const paint = mat(color, {metalness: .35, roughness: .4}), glass = mat('#14212b', {metalness: .6, roughness: .1});
const black = mat('#1b1d1f', {roughness: .7}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
const box = (w, h, d, m, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
};
// Cab over the front axle.
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
box(.05, .9, 2.0, glass, 3.91, 2.35, 0);
for (const side of [-1, 1]) box(1.0, .7, .04, glass, 3.1, 2.4, side * 1.16);
box(.08, .3, 2.2, teal, 3.92, 1.35, 0);
box(.25, .35, 2.36, black, 3.95, .85, 0);
const lamp = mat('#eaf6ff', {emissive: '#cfe9ff', emissiveIntensity: 1});
for (const side of [-1, 1]) {
box(.06, .16, .3, lamp, 3.98, 1.1, side * .9);
box(.3, .3, .08, black, 3.4, 2.7, side * 1.25);
}
// Chassis and the flatbed with low side rails.
box(7.6, .3, 1.0, black, 0, .75, 0);
box(5.9, .14, 2.4, mat('#6b5a45', {roughness: .9}), -1.05, 1.0, 0);
for (const side of [-1, 1]) box(5.9, .5, .06, teal, -1.05, 1.32, side * 1.19);
box(.06, .5, 2.4, teal, -4.0, 1.32, 0);
box(.1, 1.6, 2.4, black, 1.9, 1.85, 0);
for (const side of [-1, 1]) box(.1, .22, .26, mat('#9e1410', {emissive: '#c4241b', emissiveIntensity: .6}), -4.02, .82, side * .95);
const bed = new THREE.Group();
bed.position.set(-1.05, 1.07, 0);
group.add(bed);
const wheels = addWheels(group, [[-2.9, -1.0], [-2.9, 1.0], [2.9, -1.0], [2.9, 1.0]], .5, .38, '#c2c6c9', 8);
return {group, wheels, bed};
}
-226
View File
@@ -1,226 +0,0 @@
import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the group.
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
export function createFamily({robot: robotOnly = false} = {}) {
const group = new THREE.Group();
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
function shape(parent, geometry, material, position) {
const m = new THREE.Mesh(geometry, material);
m.position.set(...position);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
function sphere(parent, r, material, pos, scale = [1, 1, 1]) {
const m = shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
m.scale.set(...scale);
return m;
}
function segment(parent, a, b, r, material) {
const mesh = shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
return {mesh, a, b};
}
function updateSegment(bone, a, b) {
const delta = b.clone().sub(a);
bone.mesh.position.copy(a).add(b).multiplyScalar(.5);
bone.mesh.scale.y = delta.length();
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
}
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
function makeRobot(z) {
const scale = .69, root = new THREE.Group(), torso = new THREE.Group();
root.name = 'robot-market-mascot';
root.position.z = z;
group.add(root);
root.add(torso);
const teal = new THREE.MeshStandardMaterial({color: '#079f98', metalness: .4, roughness: .28});
const white = new THREE.MeshStandardMaterial({color: '#edf6f4', metalness: .22, roughness: .32});
const joint = new THREE.MeshStandardMaterial({color: '#102b30', metalness: .5, roughness: .35});
const glow = new THREE.MeshStandardMaterial({color: '#c6f7ff', emissive: '#23bcff', emissiveIntensity: 2.5, roughness: .2});
const visor = new THREE.MeshStandardMaterial({color: '#020d16', metalness: .5, roughness: .16});
sphere(torso, .25, teal, [0, .76, 0], [1, .65, .75]);
sphere(torso, .31, white, [0, .97, 0], [1, 1.05, .72]);
for (const side of [-1, 1]) sphere(torso, .17, teal, [side * .24, 1.03, -.04], [.55, 1, 1]);
shape(torso, new THREE.CylinderGeometry(.09, .09, .12, 12), joint, [0, 1.27, 0]);
const head = new THREE.Group();
head.position.y = 1.61;
torso.add(head);
sphere(head, .43, teal, [0, 0, 0], [1.12, .91, .88]);
sphere(head, .40, white, [0, -.015, .12], [1.12, .86, .77]);
sphere(head, .35, visor, [0, -.015, .255], [1.13, .83, .48]);
const eyes = [];
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
const line = shape(parent, new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI), material, pos);
line.rotation.z = rotation;
return line;
}
for (const side of [-1, 1]) {
eyes.push(arc(head, .065, .016, [side * .16, .025, .423]));
const ear = shape(head, new THREE.CylinderGeometry(.14, .14, .085, 24), joint, [side * .47, 0, 0]);
ear.rotation.z = Math.PI / 2;
const ring = shape(head, new THREE.TorusGeometry(.105, .012, 8, 24), glow, [side * .518, 0, 0]);
ring.rotation.y = Math.PI / 2;
sphere(head, .12, teal, [side * .49, 0, 0], [.4, 1, 1]);
}
arc(head, .075, .016, [0, -.085, .425], Math.PI);
sphere(head, .10, teal, [0, .38, 0], [1, .55, 1]);
shape(head, new THREE.CylinderGeometry(.017, .022, .18, 10), teal, [0, .49, 0]);
sphere(head, .055, teal, [0, .60, 0]);
// Draw the chest mark as a texture so it remains attached to the 3D body.
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256;
const c = canvas.getContext('2d');
if (c) {
c.strokeStyle = '#102b30'; c.fillStyle = '#102b30';
c.lineWidth = 12; c.lineJoin = 'round';
c.beginPath(); c.moveTo(65, 40); c.lineTo(82, 40); c.lineTo(99, 110); c.lineTo(170, 110); c.lineTo(186, 58); c.lineTo(89, 58); c.stroke();
for (const x of [111, 162]) {c.beginPath(); c.arc(x, 130, 9, 0, Math.PI * 2); c.fill();}
c.textAlign = 'center'; c.font = 'bold 34px sans-serif';
c.fillText('ROBOT', 128, 181); c.fillText('MARKET', 128, 218);
shape(torso, new THREE.PlaneGeometry(.31, .31), new THREE.MeshBasicMaterial({map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false}), [0, .99, .224]);
}
}
const legs = [-1, 1].map(side => {
const hip = new THREE.Group(); hip.position.set(side * .15, .65, 0); root.add(hip);
sphere(hip, .09, joint, [0, 0, 0]);
shape(hip, new THREE.CapsuleGeometry(.095, .12, 4, 12), white, [0, -.12, 0]);
const knee = new THREE.Group(); knee.position.y = -.25; hip.add(knee);
sphere(knee, .105, teal, [0, 0, .035]);
shape(knee, new THREE.CapsuleGeometry(.10, .12, 4, 12), white, [0, -.13, 0]);
const foot = sphere(knee, .13, teal, [0, -.29, .07], [1, .65, 1.6]);
sphere(knee, .13, joint, [0, -.34, .07], [1.06, .22, 1.65]);
return {hip, knee, foot};
});
const arms = [-1, 1].map(() => ({
upper: segment(group, null, null, .09, teal),
lower: segment(group, null, null, .085, white),
hand: sphere(group, .09, joint, [0, 0, 0], [1, 1.1, 1])
}));
return {root, torso, head, legs, arms, scale, z, eyes};
}
function makePerson(isChild, z) {
const scale = isChild ? .69 : 1.05, root = new THREE.Group();
root.position.z = z;
group.add(root);
const torso = new THREE.Group();
root.add(torso);
const cloth = mat(isChild ? '#d98150' : '#e4c695'), pants = mat(isChild ? '#527463' : '#496474');
// Tapered jacket, hips and neck replace the single rounded toy-like body.
shape(torso, new THREE.CylinderGeometry(.255 * scale, .22 * scale, .62 * scale, 16), cloth, [0, 1.2 * scale, 0]).scale.z = .72;
sphere(torso, .23, pants, [0, .88 * scale, 0], [scale, .55 * scale, .72 * scale]);
shape(torso, new THREE.CylinderGeometry(.075 * scale, .09 * scale, .18 * scale, 12), skin, [0, 1.59 * scale, 0]);
const seam = shape(torso, new THREE.BoxGeometry(.014 * scale, .5 * scale, .014 * scale), mat('#bda77f'), [0, 1.2 * scale, .19 * scale]);
for (const side of [-1, 1]) {
const collar = shape(torso, new THREE.BoxGeometry(.12 * scale, .09 * scale, .025 * scale), cloth, [side * .07 * scale, 1.49 * scale, .15 * scale]);
collar.rotation.z = side * .4;
}
const head = new THREE.Group();
head.position.y = 1.77 * scale;
torso.add(head);
head.scale.setScalar(isChild ? .9 : .77);
const eyes = [];
sphere(head, .27 * scale, skin, [0, 0, 0]);
sphere(head, .275 * scale, hair, [0, .11 * scale, -.035 * scale], [1, .65, 1]);
sphere(head, .058 * scale, skin, [0, -.02 * scale, .25 * scale]);
for (const side of [-1, 1]) {
sphere(head, .06 * scale, skin, [side * .26 * scale, 0, 0]);
sphere(head, .037 * scale, mat('#f6f1e7'), [side * .09 * scale, .03 * scale, .241 * scale], [1, .65, .5]);
const eye = sphere(head, .022 * scale, shoes, [side * .09 * scale, .03 * scale, .258 * scale], [.8, 1, .45]);
eyes.push(eye);
const brow = shape(head, new THREE.BoxGeometry(.075 * scale, .015 * scale, .015 * scale), hair, [side * .09 * scale, .087 * scale, .237 * scale]);
brow.rotation.z = side * -.08;
sphere(head, .04 * scale, mat('#d79379'), [side * .15 * scale, -.055 * scale, .215 * scale]);
}
const smile = shape(head, new THREE.TorusGeometry(.068 * scale, .012 * scale, 6, 14, Math.PI), shoes, [0, -.075 * scale, .252 * scale]);
smile.rotation.z = Math.PI;
if (isChild) {
sphere(head, .29 * scale, mat('#e8b857'), [0, .18 * scale, 0], [1, .5, 1]);
shape(head, new THREE.BoxGeometry(.55 * scale, .04 * scale, .34 * scale), mat('#e8b857'), [0, .13 * scale, .12 * scale]);
} else {
sphere(torso, .22, mat('#a77950'), [0, 1.2 * scale, -.24 * scale], [1, 1.2, .5]);
}
const legs = [-1, 1].map(side => {
const hip = new THREE.Group();
hip.position.set(side * .15 * scale, .83 * scale, 0);
root.add(hip);
shape(hip, new THREE.CapsuleGeometry(.09 * scale, .23 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
const knee = new THREE.Group();
knee.position.y = -.34 * scale;
hip.add(knee);
shape(knee, new THREE.CapsuleGeometry(.08 * scale, .25 * scale, 4, 10), pants, [0, -.16 * scale, 0]);
const foot = shape(knee, new THREE.SphereGeometry(.12 * scale, 12, 8), shoes, [0, -.32 * scale, .065 * scale]);
foot.scale.set(.85, .65, 1.5);
return {hip, knee, foot};
});
const arms = [-1, 1].map(() => ({
upper: segment(group, null, null, .072 * scale, cloth),
lower: segment(group, null, null, .061 * scale, skin),
hand: sphere(group, .075 * scale, skin, [0, 0, 0])
}));
return {root, torso, head, legs, arms, scale, z, eyes};
}
const father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45);
const robot = robotOnly ? makeRobot(0) : null;
const actors = robotOnly ? [robot] : [father, child];
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
// `wave` raises the robot's right hand, `point` stretches it forward.
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, wave = false, point = false, dt = 1 / 60}) {
actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
// The robot hovers; it bobs faster while it is speaking.
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
actor.root.position.z = finished ? 0 : actor.z;
actor.root.rotation.y = facing;
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
actor.torso.position.y = bob;
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
const blinking = Math.sin(time * 1.3 + index) > .993;
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
actor.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI;
const stride = isRobot ? .08 : .34;
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0;
leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
});
});
group.updateMatrixWorld(true);
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, (actor === robot ? 1.12 : 1.43) * actor.scale, 0));
// 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) => actor.arms.forEach((arm, i) => {
const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1;
const a = shoulder(actor, side);
let target;
if (inside && !shopping && !finished) target = contact.clone();
else if (actor === child && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12));
else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62));
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
updateSegment(arm.upper, a, elbow);
updateSegment(arm.lower, elbow, target);
arm.hand.position.copy(target);
}));
}
update({time: 0, gait: 0, moving: false});
return {group, update};
}
-469
View File
@@ -1,469 +0,0 @@
import * as THREE from 'three';
import {nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
import {blocked, insidePolygon} from './collision.js';
// Road widths in metres per OSM highway type. Dual carriageways are two separate one-way ways.
const WIDTH = {trunk: 11, primary: 10, secondary: 9, tertiary: 8, unclassified: 6.5, residential: 6, living_street: 5, service: 4, pedestrian: 4, footway: 2.4, cycleway: 2, path: 1.6, track: 3, steps: 2.2};
const BUCKET = {trunk: 'major', primary: 'major', secondary: 'major', tertiary: 'minor', unclassified: 'minor', residential: 'minor', living_street: 'minor', service: 'minor', pedestrian: 'paved', footway: 'paved', cycleway: 'paved', steps: 'paved', path: 'dirt', track: 'dirt'};
// Ground layers: height above the ground plane and polygon offset, so overlaps never flicker.
const LAYER = {industrial: [.006, 1], farmland: [.008, 1], campus: [.01, 1], park: [.02, 2], grass: [.025, 3], forest: [.025, 3], pitch: [.03, 4], water: [.035, 5], sidewalk: [.04, 6], dirt: [.045, 7], paved: [.05, 8], minor: [.06, 9], major: [.07, 10]};
const AREA_COLOR = {campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e'};
const BUILDING_COLORS = ['#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a'].map(c => new THREE.Color(c));
function canvasTexture(width, height, draw, repeat = true) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
draw(canvas.getContext('2d'), width, height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
if (repeat) texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = 8;
return texture;
}
function noise(c, w, h, base, spread, count) {
c.fillStyle = base;
c.fillRect(0, 0, w, h);
for (let i = 0; i < count; i++) {
const v = Math.random() * spread - spread / 2;
c.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
c.fillRect(Math.random() * w, Math.random() * h, 2, 2);
}
}
const textures = () => ({
major: canvasTexture(128, 256, (c, w, h) => {
noise(c, w, h, '#4a4d50', .12, 1800);
c.fillStyle = '#e8e4d6';
c.fillRect(6, 0, 4, h);
c.fillRect(w - 10, 0, 4, h);
c.fillRect(w / 2 - 2, 0, 4, h * .45);
}),
minor: canvasTexture(128, 256, (c, w, h) => noise(c, w, h, '#58595a', .12, 1600)),
paved: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#c4b597', .1, 900);
c.strokeStyle = 'rgba(90,70,50,.25)';
for (let i = 0; i <= 4; i++) {
c.beginPath();
c.moveTo(0, i * 32);
c.lineTo(w, i * 32);
c.moveTo(i * 32 + (i % 2) * 16, 0);
c.lineTo(i * 32 + (i % 2) * 16, h);
c.stroke();
}
}),
sidewalk: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#b9b1a1', .1, 900);
c.fillStyle = '#d9d4c8';
c.fillRect(0, 0, 10, h);
c.fillRect(w - 10, 0, 10, h);
}),
dirt: canvasTexture(64, 64, (c, w, h) => noise(c, w, h, '#b39d78', .15, 500)),
walls: canvasTexture(128, 128, (c, w, h) => {
noise(c, w, h, '#f1ede4', .06, 600);
const g = c.createLinearGradient(0, 30, 0, 96);
g.addColorStop(0, '#7c95a3');
g.addColorStop(1, '#3b4b55');
c.fillStyle = '#8e8679';
c.fillRect(28, 26, 72, 70);
c.fillStyle = g;
c.fillRect(32, 30, 64, 62);
c.fillStyle = 'rgba(255,255,255,.18)';
c.fillRect(34, 32, 18, 58);
c.fillStyle = '#8e8679';
c.fillRect(62, 30, 4, 62);
c.fillStyle = 'rgba(0,0,0,.12)';
c.fillRect(0, 118, w, 10);
})
});
// Pushes a strip of quads along a polyline into a geometry buffer.
function ribbon(out, pts, width, y, repeat) {
const n = pts.length / 2;
if (n < 2) return;
const base = out.pos.length / 3, half = width / 2;
let along = 0;
for (let i = 0; i < n; i++) {
const x = pts[i * 2], z = pts[i * 2 + 1];
if (i) along += Math.hypot(x - pts[i * 2 - 2], z - pts[i * 2 - 1]);
const seg = (a, b) => {
const dx = pts[b * 2] - pts[a * 2], dz = pts[b * 2 + 1] - pts[a * 2 + 1], len = Math.hypot(dx, dz) || 1;
return [-dz / len, dx / len];
};
const n1 = seg(Math.max(0, i - 1), Math.max(1, i)), n2 = seg(Math.min(n - 2, i), Math.min(n - 1, i + 1));
let nx = n1[0] + n2[0], nz = n1[1] + n2[1];
const len = Math.hypot(nx, nz) || 1;
nx /= len;
nz /= len;
const miter = half / Math.max(.45, nx * n1[0] + nz * n1[1]);
out.pos.push(x + nx * miter, y, z + nz * miter, x - nx * miter, y, z - nz * miter);
out.uv.push(0, along / repeat, 1, along / repeat);
if (i) {
const a = base + (i - 1) * 2;
// Wound so the strip faces up (+y) and survives back-face culling.
out.idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
}
}
function disc(out, x, z, radius, y) {
const base = out.pos.length / 3;
out.pos.push(x, y, z);
out.uv.push(.25, 0);
for (let i = 0; i <= 10; i++) {
const a = i / 10 * Math.PI * 2;
out.pos.push(x + Math.cos(a) * radius, y, z + Math.sin(a) * radius);
out.uv.push(.25, 0);
if (i) out.idx.push(base, base + i + 1, base + i);
}
}
function polygonTriangles(pts) {
const contour = [];
for (let i = 0; i < pts.length; i += 2) contour.push(new THREE.Vector2(pts[i], pts[i + 1]));
try {
return THREE.ShapeUtils.triangulateShape(contour, []);
} catch {
return [];
}
}
// Flat polygon facing up; triangles are flipped where needed so normals point to the sky.
function flatPolygon(out, pts, y, uvScale = 0, color = null) {
const base = out.pos.length / 3;
for (let i = 0; i < pts.length; i += 2) {
out.pos.push(pts[i], y, pts[i + 1]);
out.uv?.push(pts[i] * uvScale, pts[i + 1] * uvScale);
if (color) out.col.push(color.r, color.g, color.b);
}
for (const [a, b, c] of polygonTriangles(pts)) {
const cross = (pts[b * 2 + 1] - pts[a * 2 + 1]) * (pts[c * 2] - pts[a * 2]) - (pts[b * 2] - pts[a * 2]) * (pts[c * 2 + 1] - pts[a * 2 + 1]);
out.idx.push(base + a, ...(cross >= 0 ? [base + b, base + c] : [base + c, base + b]));
}
}
function toMesh(out, material, normals = 'up') {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(out.pos, 3));
if (out.uv?.length) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
if (out.col?.length) geometry.setAttribute('color', new THREE.Float32BufferAttribute(out.col, 3));
geometry.setIndex(out.idx);
if (normals === 'up') geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(out.pos.length).map((_, i) => i % 3 === 1 ? 1 : 0), 3));
else geometry.computeVertexNormals();
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(geometry, material);
mesh.receiveShadow = true;
return mesh;
}
const groundMaterial = (options, layer) => new THREE.MeshStandardMaterial({
roughness: .95, ...options,
polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2
});
function seeded(seed) {
return () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
}
function signedArea(pts) {
let a = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length;
a += pts[i] * pts[j + 1] - pts[j] * pts[i + 1];
}
return a / 2;
}
// The world is split into 2 km chunks (buildings: 500 m blocks); meshes out of view range are hidden.
const CHUNK = 2000, BLOCK = 500;
// With satellite imagery the photo shows the real roads and land, so the drawn roads and areas are
// left out; `extraBuildings` are footprints beyond OSM (Microsoft's, see buildings-data.js).
export function createCity(scene, data, {collision, segments, extraBuildings = [], satellite = false, lowQuality = false}) {
const VIEW_RANGE = lowQuality ? 1500 : 2400;
const tex = textures(), group = new THREE.Group();
scene.add(group);
const random = seeded(1405);
const chunked = [], buckets = new Map();
const bucket = (name, x, z, make) => {
const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
if (!buckets.has(key)) buckets.set(key, {name, out: make()});
return buckets.get(key).out;
};
const addChunked = mesh => {
mesh.geometry.computeBoundingSphere();
chunked.push(mesh);
group.add(mesh);
return mesh;
};
// Open ground that follows the camera, so the horizon never shows an edge.
const ground = new THREE.Mesh(new THREE.PlaneGeometry(14000, 14000), groundMaterial({color: '#c6baa0'}, 0));
ground.rotation.x = -Math.PI / 2;
ground.position.y = -.02;
ground.receiveShadow = true;
group.add(ground);
for (const area of satellite ? [] : data.areas) {
const kind = data.areaKinds[area[0]];
flatPolygon(bucket(`area:${kind}`, area[1], area[2], () => ({pos: [], idx: []})), area.slice(1), LAYER[kind][0]);
}
const areaMaterials = {};
for (const kind of data.areaKinds) areaMaterials[kind] = groundMaterial({color: AREA_COLOR[kind], roughness: kind === 'water' ? .25 : .95, metalness: kind === 'water' ? .2 : 0}, LAYER[kind][1]);
const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({pos: [], uv: [], idx: []}));
for (const r of satellite ? [] : data.roads) {
const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type];
ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
if (r[0] <= DRIVABLE_MAX_TYPE) {
if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
}
}
const roadMaterials = {};
for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({map: tex[kind]}, LAYER[kind][1]);
// Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs. There are
// ~145,000 of them (OSM plus Microsoft footprints), so each 500 m block is built only when the
// camera first comes near it, one block per frame.
const wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85});
const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9});
const blocks = new Map();
for (const building of [...data.buildings, ...extraBuildings]) {
const key = `${Math.floor(building[1] / BLOCK)},${Math.floor(building[2] / BLOCK)}`;
if (!blocks.has(key)) {
const [bx, bz] = key.split(',').map(Number);
blocks.set(key, {x: (bx + .5) * BLOCK, z: (bz + .5) * BLOCK, buildings: [], meshes: null});
}
blocks.get(key).buildings.push(building);
}
const pendingBlocks = [...blocks.values()];
function buildBlock(block) {
const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []};
for (const building of block.buildings) {
const height = building[0];
let pts = building.slice(1);
if (signedArea(pts) > 0) {
const reversed = [];
for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]);
pts = reversed;
}
// Colour picked from the position, so a block looks the same every time it is built.
const shade = Math.abs(Math.sin(pts[0] * 12.9898 + pts[1] * 78.233) * 43758.5453) % 1;
const color = BUILDING_COLORS[Math.floor(shade * BUILDING_COLORS.length)].clone().multiplyScalar(.9 + (shade * 7 % 1) * .15);
let along = 0;
for (let i = 0; i < pts.length; i += 2) {
const j = (i + 2) % pts.length, ax = pts[i], az = pts[i + 1], bx = pts[j], bz = pts[j + 1];
const len = Math.hypot(bx - ax, bz - az), base = walls.pos.length / 3;
walls.pos.push(ax, 0, az, bx, 0, bz, bx, height, bz, ax, height, az);
walls.uv.push(along / 3.6, 0, (along + len) / 3.6, 0, (along + len) / 3.6, height / 3.2, along / 3.6, height / 3.2);
for (let k = 0; k < 4; k++) walls.col.push(color.r, color.g, color.b);
walls.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
along += len;
}
flatPolygon(roofs, pts, height, 0, color.clone().multiplyScalar(.78));
}
block.meshes = [toMesh(walls, wallMaterial, 'compute'), toMesh(roofs, roofMaterial)];
for (const mesh of block.meshes) {
mesh.castShadow = true;
addChunked(mesh);
}
}
// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks, on the OSM buildings.
const tanks = [], units = [];
for (const building of data.buildings) {
const height = building[0], pts = building.slice(1);
let cx = 0, cz = 0;
for (let i = 0; i < pts.length; i += 2) {
cx += pts[i];
cz += pts[i + 1];
}
cx /= pts.length / 2;
cz /= pts.length / 2;
if (insidePolygon(pts, cx, cz) && random() < .6) {
(random() < .5 ? tanks : units).push([cx + (random() - .5) * 2, height, random() * Math.PI, cz + (random() - .5) * 2, 0]);
}
}
for (const {name, out} of buckets.values()) {
const [kind, sub] = name.split(':');
addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub]) : toMesh(out, roadMaterials[sub]));
}
const dummy = new THREE.Object3D();
// One instanced mesh per chunk, so small props are culled with the rest of their chunk.
// Each item is an array whose first entry is x and second-to-last entry is z.
const instanced = (geometry, material, items, place) => {
const byChunk = new Map();
items.forEach((item, i) => {
const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`;
if (!byChunk.has(key)) byChunk.set(key, []);
byChunk.get(key).push([item, i]);
});
const meshes = [];
for (const list of byChunk.values()) {
const mesh = new THREE.InstancedMesh(geometry, material, list.length);
list.forEach(([item], i) => {
place(item);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.castShadow = mesh.receiveShadow = true;
mesh.computeBoundingSphere();
mesh.userData.items = list.map(([, i]) => i);
chunked.push(mesh);
group.add(mesh);
meshes.push(mesh);
}
return meshes;
};
instanced(new THREE.CylinderGeometry(.55, .55, 1.1, 10), new THREE.MeshStandardMaterial({color: '#d8d8d2', metalness: .4, roughness: .4}), tanks, ([x, y, , z]) => {
dummy.position.set(x, y + .55, z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
});
instanced(new THREE.BoxGeometry(1, .8, .9), new THREE.MeshStandardMaterial({color: '#a9aaa4', roughness: .6}), units, ([x, y, r, z]) => {
dummy.position.set(x, y + .4, z);
dummy.rotation.set(0, r, 0);
dummy.scale.setScalar(1);
});
// Trees fill parks and line the boulevards, kept off paths and out of buildings.
const trees = [];
const freeSpot = (x, z) => !blocked(collision, x, z) && !nearestSegment(segments, x, z, 3);
for (const area of data.areas) {
const kind = data.areaKinds[area[0]];
if (!['park', 'forest', 'grass'].includes(kind)) continue;
const pts = area.slice(1), step = kind === 'grass' ? 16 : 10;
let ax = Infinity, az = Infinity, bx = -Infinity, bz = -Infinity;
for (let i = 0; i < pts.length; i += 2) {
ax = Math.min(ax, pts[i]);
bx = Math.max(bx, pts[i]);
az = Math.min(az, pts[i + 1]);
bz = Math.max(bz, pts[i + 1]);
}
for (let x = ax; x < bx; x += step) for (let z = az; z < bz; z += step) {
const tx = x + (random() - .5) * step * .8, tz = z + (random() - .5) * step * .8;
if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, .8 + random() * .6, tz, 0]);
}
}
for (const road of data.roads) {
const type = data.roadTypes[road[0]];
if (road[0] > 3) continue;
const offset = WIDTH[type] / 2 + 2.6;
for (let i = 4; i < road.length; i += 2) {
const ax = road[i - 2], az = road[i - 1], dx = road[i] - ax, dz = road[i + 1] - az, len = Math.hypot(dx, dz);
for (let d = random() * 12; d < len; d += 12) for (const side of [-1, 1]) {
const tx = ax + dx / len * d - dz / len * offset * side, tz = az + dz / len * d + dx / len * offset * side;
if (!blocked(collision, tx, tz) && !nearestSegment(segments, tx, tz, WIDTH[type] / 2 + 1.5, s => s[4] <= DRIVABLE_MAX_TYPE)) trees.push([tx, .7 + random() * .4, tz, 0]);
}
}
}
instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, s, z]) => {
dummy.position.set(x, 1.3 * s, z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(s);
});
const crowns = instanced(new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .85, flatShading: true}), trees, ([x, s, z]) => {
dummy.position.set(x, 3.4 * s, z);
dummy.rotation.set(0, x * 13 % 6, 0);
dummy.scale.set(s, s * (1 + (x * 7 % 1) * .3), s);
});
const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map(c => new THREE.Color(c));
for (const mesh of crowns) mesh.userData.items.forEach((tree, i) => mesh.setColorAt(i, crownColors[tree % crownColors.length]));
// Metro station entrances with the Mashhad Urban Railway "M" sign.
const signTexture = canvasTexture(128, 128, (c, w, h) => {
c.fillStyle = '#d2232a';
c.beginPath();
c.arc(w / 2, h / 2, 60, 0, Math.PI * 2);
c.fill();
c.fillStyle = '#fff';
c.font = 'bold 84px sans-serif';
c.textAlign = 'center';
c.fillText('M', w / 2, h / 2 + 30);
}, false);
const canopy = new THREE.MeshStandardMaterial({color: '#59646b', metalness: .5, roughness: .4});
const glass = new THREE.MeshStandardMaterial({color: '#9fc4cf', transparent: true, opacity: .45, roughness: .1});
for (const label of data.labels.filter(l => l.kind === 'metro')) {
const entrance = new THREE.Group();
entrance.position.set(label.x, 0, label.z);
const road = nearestSegment(segments, label.x, label.z, 40, s => s[4] <= DRIVABLE_MAX_TYPE);
if (road) entrance.rotation.y = Math.atan2(road.dx, road.dz);
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
entrance.add(mesh);
};
box(4.4, .2, 7, canopy, 0, 3, 0);
box(.15, 3, 7, glass, -2.1, 1.5, 0);
box(.15, 3, 7, glass, 2.1, 1.5, 0);
box(.25, 4.2, .25, canopy, 2.6, 2.1, 3.6);
const sign = new THREE.Mesh(new THREE.CircleGeometry(.6, 24), new THREE.MeshBasicMaterial({map: signTexture}));
sign.position.set(2.6, 4.4, 3.75);
entrance.add(sign);
const back = sign.clone();
back.rotation.y = Math.PI;
back.position.z = 3.45;
entrance.add(back);
group.add(entrance);
}
// A continuous ridge line around the horizon: the Binalood range high to the south-west,
// the lower Hezar Masjed hills to the north-east. Hazy colours stand in for distance fog.
const ridgePos = [], ridgeCol = [], ridgeIdx = [], segmentsAround = 240;
const foot = new THREE.Color('#cfc5b0'), crest = new THREE.Color('#a39a8a');
for (let i = 0; i <= segmentsAround; i++) {
const a = i / segmentsAround * Math.PI * 2, x = Math.cos(a), z = Math.sin(a);
// South-west is −x (west) and +z (south); peaks grow with how far this direction points there.
const southWest = Math.max(0, (-x + z) / Math.SQRT2), rough = Math.sin(a * 7) * .5 + Math.sin(a * 17 + 1) * .3 + Math.sin(a * 41) * .15;
const height = 90 + southWest ** 1.5 * 420 + rough * (60 + southWest * 120), r = 4300;
ridgePos.push(x * r, -20, z * r, x * r, height, z * r);
ridgeCol.push(foot.r, foot.g, foot.b, crest.r, crest.g, crest.b);
if (i) {
const b = (i - 1) * 2;
ridgeIdx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2);
}
}
const ridgeGeometry = new THREE.BufferGeometry();
ridgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(ridgePos, 3));
ridgeGeometry.setAttribute('color', new THREE.Float32BufferAttribute(ridgeCol, 3));
ridgeGeometry.setIndex(ridgeIdx);
const ridge = new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide}));
group.add(ridge);
let lastCheck = null;
return {
group,
// Keeps the ground and horizon around the camera and shows only chunks within range.
update(camera) {
ground.position.set(camera.x, -.02, camera.z);
ridge.position.set(camera.x, 0, camera.z);
// Build the nearest unbuilt block in range, one per frame to avoid stutter.
let next = null, nextDistance = VIEW_RANGE + BLOCK;
for (const block of pendingBlocks) {
const d = Math.hypot(block.x - camera.x, block.z - camera.z);
if (d < nextDistance) {
next = block;
nextDistance = d;
}
}
if (next) {
pendingBlocks.splice(pendingBlocks.indexOf(next), 1);
buildBlock(next);
lastCheck = null;
}
if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return;
lastCheck = {x: camera.x, z: camera.z};
for (const mesh of chunked) {
const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
mesh.visible = Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius < VIEW_RANGE;
}
}
};
}
-84
View File
@@ -1,84 +0,0 @@
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
const CELL = 24;
const key = (cx, cz) => cx * 100003 + cz;
export function createCollisionWorld(polygons) {
const world = {polygons: [], grid: new Map()};
polygons.forEach(p => addPolygon(world, p));
return world;
}
export function addPolygon(world, pts) {
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i]);
maxX = Math.max(maxX, pts[i]);
minZ = Math.min(minZ, pts[i + 1]);
maxZ = Math.max(maxZ, pts[i + 1]);
}
const index = world.polygons.push({pts, minX, minZ, maxX, maxZ}) - 1;
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
const k = key(cx, cz);
if (!world.grid.has(k)) world.grid.set(k, []);
world.grid.get(k).push(index);
}
}
function nearby(world, x, z, r) {
const found = new Set();
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
return [...found].map(i => world.polygons[i]).filter(p => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
}
export function insidePolygon(pts, x, z) {
let inside = false;
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const xi = pts[i], zi = pts[i + 1], xj = pts[j], zj = pts[j + 1];
if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside;
}
return inside;
}
function closestOnEdges(pts, x, z) {
let best = {d: Infinity, x: 0, z: 0};
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const ax = pts[j], az = pts[j + 1], dx = pts[i] - ax, dz = pts[i + 1] - az;
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
const px = ax + dx * t, pz = az + dz * t, d = Math.hypot(x - px, z - pz);
if (d < best.d) best = {d, x: px, z: pz};
}
return best;
}
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
export function resolveCircle(world, x, z, r) {
let hit = false;
for (let pass = 0; pass < 3; pass++) {
let moved = false;
for (const p of nearby(world, x, z, r)) {
const inside = insidePolygon(p.pts, x, z), c = closestOnEdges(p.pts, x, z);
if (!inside && c.d >= r) continue;
const len = c.d || 1e-6, nx = (x - c.x) / len * (inside ? -1 : 1), nz = (z - c.z) / len * (inside ? -1 : 1);
x = c.x + nx * r;
z = c.z + nz * r;
moved = hit = true;
}
if (!moved) break;
}
return {x, z, hit};
}
export const blocked = (world, x, z) => nearby(world, x, z, 0).some(p => insidePolygon(p.pts, x, z));
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
export function clearFraction(world, ax, az, bx, bz, step = 1) {
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
}
return 1;
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react';
import clsx from 'clsx';
/** @typedef {{as?:string, model?:object, children?:import('react').ReactNode, onAction?:function, onClose?:function}} GameNodeProps */
/** Renders an accessible view-model node without exposing the game engine to DOM mutations.
* @param {GameNodeProps} props - Plain presentation properties and callbacks.
* @returns {import('react').ReactElement} Native element.
*/
export function GameNode({ as: Tag = 'div', model = {}, children, onAction, onClose, ...props }) {
const ref = useRef(null);
useEffect(() => {
if (Tag !== 'dialog' || !ref.current) return;
if (model.open && !ref.current.open) ref.current.showModal();
if (!model.open && ref.current.open) ref.current.close();
}, [Tag, model.open]);
const activeClasses = Object.entries(model.classes ?? {})
.filter(([, active]) => active)
.map(([name]) => name);
const selected = model.selected ?? props['aria-selected'];
const hidden = model.hidden ?? props.hidden;
return (
<Tag
{...props}
ref={ref}
hidden={hidden}
disabled={model.disabled ?? props.disabled}
className={clsx(model.className ?? props.className, activeClasses, {
on: model.pressed === true,
})}
style={model.style}
src={model.src ?? props.src}
aria-pressed={model.pressed ?? props['aria-pressed']}
aria-selected={selected}
onClick={onAction}
onCancel={onClose}
onClose={Tag === 'dialog' ? onClose : undefined}
>
{model.text ?? children}
</Tag>
);
}
+211
View File
@@ -0,0 +1,211 @@
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
export const CATEGORIES = {
factory: {
label: 'کارخانه',
icon: '🏭',
},
park: {
label: 'پارک',
icon: '🌳',
},
university: {
label: 'دانشگاه',
icon: '🎓',
},
school: {
label: 'مدرسه',
icon: '🏫',
},
metro: {
label: 'ایستگاه مترو',
icon: 'Ⓜ',
},
mall: {
label: 'مرکز خرید',
icon: '🛍',
},
test: {
label: 'نقطهٔ آزمایشی GPS',
icon: '🧪',
},
};
const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = {
name: 'شرکت روبات مارکت',
tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند',
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: {
x: -10308.4,
z: -14964.2,
},
axis: {
x: Math.SQRT1_2,
z: Math.SQRT1_2,
},
};
export const machines = [
{
id: 'factory',
name: 'دستگاه دفتر فنی مهندسی',
category: 'factory',
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
anchor: null,
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه',
},
{
id: 'mellat-park',
name: 'بوستان ملت',
category: 'park',
address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
anchor: {
x: -487.6,
z: -326.4,
},
hours: ALL_DAY,
priceFactor: 1,
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک',
},
{
id: 'school-rahil',
name: 'مجموعه آموزشی راحیل',
category: 'school',
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
anchor: {
x: -554.6,
z: -569.7,
},
hours: [7 * 60 + 30, 13 * 60 + 30],
priceFactor: 0.9,
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است',
},
{
id: 'ferdowsi-university',
name: 'دانشگاه فردوسی مشهد',
category: 'university',
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی',
anchor: {
x: -936,
z: 645,
},
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
},
{
id: 'literature-faculty',
name: 'دانشکدهٔ ادبیات دکتر شریعتی',
category: 'university',
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
anchor: {
x: -417.1,
z: 966.3,
},
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
},
{
id: 'medical-university',
name: 'دانشگاه علوم پزشکی مشهد',
category: 'university',
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
anchor: {
x: -272.1,
z: 150.6,
},
hours: ALL_DAY,
priceFactor: 0.8,
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر',
},
{
id: 'azadi-metro',
name: 'ایستگاه مترو آزادی',
category: 'metro',
address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی',
anchor: {
x: 494.1,
z: 280.3,
},
hours: [6 * 60, 22 * 60],
priceFactor: 1,
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
note: 'دستگاه کنار گیت‌های مترو',
},
{
id: 'mashhad-mall',
name: 'مشهد مال',
category: 'mall',
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
anchor: {
x: 1940.6,
z: -367.8,
},
hours: [10 * 60, 23 * 60],
priceFactor: 1.1,
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر',
},
];
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
// They exist only while real-world mode is on; `spot` is where the player stands to buy.
/**
* Runs the test point operation for this subsystem.
* @param {*} n - n.
* @param {*} lat - lat.
* @param {*} lon - lon.
* @returns {*} Operation result, when applicable.
*/
const testPoint = (n, lat, lon) => ({
id: `test-${n}`,
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
category: 'test',
test: true,
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
anchor: null,
spot: {
lat,
lon,
},
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'biscuit'],
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
});
machines.push(
testPoint(1, 36.450276, 59.423431),
testPoint(2, 36.450172, 59.423209),
testPoint(3, 36.449976, 59.422866),
);
// Shown on the map as the next area to unlock.
export const lockedPlaces = [
{
name: 'پارک کوهسنگی',
note: 'خارج از محدودهٔ فعلی · به‌زودی',
},
{
name: 'حرم و مرکز شهر',
note: 'خارج از محدودهٔ فعلی · به‌زودی',
},
];
export const START = {
machineId: 'factory',
};
export const CITY_START = 'mellat-park';
/**
* Runs the machine by id operation for this subsystem.
* @param {*} id - id.
* @returns {*} Operation result, when applicable.
*/
export const machineById = (id) => machines.find((m) => m.id === id) ?? null;
+47
View File
@@ -0,0 +1,47 @@
/** Display-only machine prices in rial, independent of the consumable economy. */
export const SHOWROOM_CATALOG = [
{
id: 'rm-35',
category: 'warm-vending',
name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال',
channels: 35,
priceLabel: '4,680,000,000 ریال',
priceNumeric: 4680000000,
description:
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
image: '/images/machine-35-base.webp',
imageAlt: '/images/machine-35-white.webp',
gif: '/gifs/machine-35.gif',
},
{
id: 'rm-48',
category: 'warm-vending',
name: 'روبات مارکت ۴۸ کانال',
shortLabel: '۴۸ کانال',
channels: 48,
priceLabel: '5,480,000,000 ریال',
priceNumeric: 5480000000,
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.',
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
image: '/images/machine-48-base.webp',
imageAlt: '/images/machine-48-white.webp',
gif: '/gifs/machine-48.gif',
},
{
id: 'rm-60',
category: 'warm-vending',
name: 'روبات مارکت ۶۰ کانال',
shortLabel: '۶۰ کانال',
channels: 60,
priceLabel: '5,980,000,000 ریال',
priceNumeric: 5980000000,
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.',
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
image: '/images/machine-60-base.webp',
imageAlt: '/images/machine-60-white.webp',
gif: '/gifs/machine-60.gif',
},
];
export const SHOWROOM_REACH = 4;
-698
View File
@@ -1,698 +0,0 @@
import * as THREE from 'three';
import {createVendingMachine} from './vending.js';
import {createCarModel} from './traffic.js';
import {createPickup, createTruck} from './car-models.js';
import {createPerson, createCrew, nameTag} from './staff.js';
import {FACTORY} from './locations.js';
import {textTexture} from './textures.js';
function stripes(c, w, h, base, line, every) {
c.fillStyle = base;
c.fillRect(0, 0, w, h);
c.fillStyle = line;
for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
}
// Persian sign: white text on the company's dark teal.
const sign = (text, sub = '') => textTexture(512, 160, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillRect(0, h - 12, w, 12);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(text, w / 2, sub ? 72 : 100);
if (sub) {
c.fillStyle = '#9fe9da';
c.font = '36px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, w / 2, 128);
}
});
// Monitor contents: a few lines of code (or rows) on a dark editor background.
const screen = (lines, background = '#14181f') => textTexture(512, 300, (c, w, h) => {
c.fillStyle = background;
c.fillRect(0, 0, w, h);
c.fillStyle = '#232a35';
c.fillRect(0, 0, w, 26);
c.font = '21px monospace';
lines.forEach(([text, color], i) => {
c.fillStyle = color;
c.fillText(text, 16, 58 + i * 29);
});
});
const CODE = {
react: screen([['export function Shelf({items}) {', '#c792ea'], [' const [slot, setSlot] = useState();', '#82aaff'], [' return (', '#eeffff'], [' <Grid cols={4}>', '#f07178'], [' {items.map(i => <Item {...i} />)}', '#c3e88d'], [' </Grid>', '#f07178'], [' );', '#eeffff'], ['}', '#c792ea']]),
go: screen([['func (s *Server) Pay(w http.Res', '#82aaff'], [' tx, err := s.bank.Charge(ctx, req)', '#eeffff'], [' if err != nil {', '#c792ea'], [' http.Error(w, err.Error(), 402)', '#f07178'], [' return', '#c792ea'], [' }', '#c792ea'], [' s.events <- Sale{tx.ID, req.Slot}', '#c3e88d'], ['}', '#82aaff']]),
cpp: screen([['void Motor::dispense(int slot) {', '#82aaff'], [' auto& coil = coils_[slot];', '#eeffff'], [' coil.rotate(360_deg);', '#c3e88d'], [' while (!sensor_.dropped())', '#c792ea'], [' std::this_thread::yield();', '#eeffff'], [' fridge_.hold(4.0_C);', '#f78c6c'], [' log("slot", slot, "ok");', '#c3e88d'], ['}', '#82aaff']]),
terminal: screen([['$ go test ./...', '#c3e88d'], ['ok payments 0.42s', '#eeffff'], ['$ cmake --build build', '#c3e88d'], ['[100%] Built target firmware', '#eeffff'], ['$ npm run build', '#c3e88d'], ['✓ built in 2.1s', '#eeffff'], ['$ git push origin main', '#c3e88d'], ['', '#fff']], '#0c0f12'),
sheet: screen([['فاکتور دستگاه مبلغ', '#1d3b2f'], ['۱۰۴۲ بوستان ملت ۴۸۰', '#333'], ['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'], ['۱۰۴۴ مترو آزادی ۲۹۰', '#333'], ['۱۰۴۵ مشهد مال ۶۱۰', '#333'], ['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'], ['جمع ۱۸۸۰', '#0b8f88']], '#f4f7f2'),
dashboard: screen([['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'], ['تولید هفته: ۴۶ دستگاه', '#f2c94c'], ['ارسال شده: ۳۸', '#eeffff'], ['', '#fff'], ['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'], ['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'], ['▇▇▇▇▇▇▇ رنگ', '#82aaff']], '#101820')
};
/**
* The Robot Market factory compound: production hall with five work areas and forty workers, an
* office building (engineering, accounting and the manager's room), a loading truck, the manager's
* arrival in his pickup, fenced yard and gate. Layout uses a local frame: u runs along the driveway
* towards the gate, v across it. Returns world-space collision footprints, key positions and the
* people the family can talk to.
*/
export function createFactory(scene) {
const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
const rot = Math.atan2(-axis.z, axis.x);
const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
// A heading in the local frame (u, v direction) as a world-space car heading.
const worldHeading = (du, dv) => {
const x = axis.x * du + normal.x * dv, z = axis.z * du + normal.z * dv;
return Math.atan2(-z, x);
};
const group = new THREE.Group();
group.position.set(center.x, 0, center.z);
group.rotation.y = rot;
scene.add(group);
const footprints = [];
const solid = (u0, u1, v0, v1) => footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
// Axis-aligned box in the local frame; solid ones also block movement.
const box = (a0, a1, b0, b1, y0, y1, material, isSolid = true, parent = group) => {
const u0 = Math.min(a0, a1), u1 = Math.max(a0, a1), v0 = Math.min(b0, b1), v1 = Math.max(b0, b1);
const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
if (isSolid) solid(u0, u1, v0, v1);
return mesh;
};
const cylinder = (r, h, material, u, y, v, parent = group) => {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 16), material);
mesh.position.set(u, y, v);
mesh.castShadow = true;
parent.add(mesh);
return mesh;
};
// A sign plane; `facing` is the local direction its front faces: 1 = +v, -1 = −v.
const panel = (texture, w, h, u, y, v, facing = 1) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({map: texture, side: THREE.DoubleSide}));
mesh.position.set(u, y, v);
if (facing < 0) mesh.rotation.y = Math.PI;
group.add(mesh);
return mesh;
};
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
const floorMat = (color, extra = {}) => mat(color, {polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32, ...extra});
const flat = (u0, u1, v0, v1, y, material) => {
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, v1 - v0), material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set((u0 + u1) / 2, y, (v0 + v1) / 2);
mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
const steel = mat('#59646b', {metalness: .5}), teal = mat('#0b8f88', {metalness: .3, roughness: .4});
const yellow = mat('#f2c94c', {roughness: .5}), darkSteel = mat('#3d4549', {metalness: .5}), silver = mat('#c4ccd0', {metalness: .8, roughness: .3});
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
const animated = [];
// ---------- Yard, fence, gate ----------
flat(-110, 80, -50, 50, .08, floorMat('#8f8c86', {roughness: .95, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
const fence = mat('#d9d3c4');
box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
box(79.7, 80.3, -50, -9, 0, 2.4, fence);
box(79.7, 80.3, 9, 50, 0, 2.4, fence);
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
// Painted parking bays in front of the office.
const paint = floorMat('#f1efe6', {roughness: .9, polygonOffsetFactor: -18, polygonOffsetUnits: -36});
for (let u = 38; u <= 70; u += 4) flat(u - .06, u + .06, -21.5, -16.5, .09, paint);
const nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillRect(0, h - 18, w, 18);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.name, w / 2, 120);
c.fillStyle = '#9fe9da';
c.font = '44px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.tagline, w / 2, 210);
c.font = 'bold 34px sans-serif';
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]) {
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), frontSign);
gateSign.position.set(82 + side * .02, 6.6, -20);
gateSign.rotation.y = side * Math.PI / 2;
group.add(gateSign);
}
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, steel, false);
// ---------- Production hall ----------
// Corrugated walls, a wide door onto the yard (v = V0) and a loading dock door at the east end.
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
corrugated.repeat.set(18, 2);
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
const H = 10, U0 = -104, U1 = 0, V0 = 10, V1 = 42, D0 = -36, D1 = -20, DOCK0 = 27, DOCK1 = 35;
box(U0, U1, V1, V1 + .4, 0, H, wall);
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
box(U1, U1 + .4, V0 - .4, DOCK0, 0, H, wall);
box(U1, U1 + .4, DOCK1, V1 + .4, 0, H, wall);
box(U1, U1 + .4, DOCK0, DOCK1, 5, H, wall, false);
box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
box(D0, D1, V0 - .4, V0, 7, H, wall, false);
box(U0 - .4, U1 + .4, V0 - .5, V0 - .3, H - 1.6, H - .6, teal, false);
const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
// The roof stays out of the shadow map, so daylight still reaches the hall floor.
roof.castShadow = false;
flat(U0, U1, V0, V1, .1, floorMat('#b9c4bd', {roughness: .35}));
// Safety walkway along the door side.
flat(U0 + 1, U1 - 1, 18.6, 19, .11, floorMat('#f2c94c', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
for (let u = U0 + 8; u < U1; u += 12) for (const v of [18, 32]) box(u - 3, u + 3, v - 1, v + 1, H - .3, H - .1, lamp, false).castShadow = false;
panel(nameSign, 16, 5, (D0 + D1) / 2, H + 3, V0 - .6, -1);
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, steel, false);
// Work-area signs hang above the walkway, readable from the door.
const zone = (text, sub, u) => {
panel(sign(text, sub), 5.4, 1.7, u, 6.6, 21, -1);
for (const du of [-2.2, 2.2]) box(u + du - .03, u + du + .03, 20.97, 21.03, 7.45, H, steel, false);
};
zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93);
zone('برش لیزر', 'برش ورق و قطعات', -71);
zone('رنگ‌کاری', 'کابین رنگ پودری', -52);
zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31);
zone('مونتاژ', 'خط مونتاژ نهایی', -13);
zone('بسته‌بندی', 'ارسال به شهر', -3);
// Bending: two press brakes with a moving ram, sheet stacks behind.
const sheet = mat('#d4dadd', {metalness: .85, roughness: .25});
for (const u of [-98, -88]) {
box(u - 1.8, u + 1.8, 29.4, 30.6, 0, 1.0, mat('#4a6b80', {metalness: .4}));
for (const du of [-1.8, 1.5]) box(u + du, u + du + .3, 29.2, 30.8, 0, 2.9, mat('#4a6b80', {metalness: .4}), false);
const ram = box(u - 1.5, u + 1.5, 29.6, 30.4, 1.9, 2.5, yellow, false);
animated.push(t => ram.position.y = 2.2 + Math.max(0, Math.sin(t * 1.4 + u)) * -.6);
box(u - 1.4, u + 1.4, 30.6, 31.4, 1.0, 1.03, sheet, false);
}
for (const u of [-101, -95, -90, -85]) {
box(u - 1, u + 1, 38.4, 39.6, 0, .15, mat('#8a6a45'));
for (let i = 0; i < 6; i++) box(u - .95, u + .95, 38.45, 39.55, .15 + i * .05, .19 + i * .05, sheet, false);
}
// Laser cutting: a bed with a moving gantry and glowing cutting head, plus a shear.
box(-78, -66, 26, 32, 0, .9, darkSteel);
const slats = textTexture(256, 128, (c, w, h) => stripes(c, w, h, '#2b3135', '#6d767c', 12));
flat(-77.8, -66.2, 26.2, 31.8, .91, new THREE.MeshStandardMaterial({map: slats, metalness: .6, roughness: .4}));
const gantry = new THREE.Group();
group.add(gantry);
box(-.3, .3, 25.4, 32.6, 1.6, 1.9, yellow, false, gantry);
for (const v of [25.5, 32.5]) box(-.25, .25, v - .2, v + .2, .9, 1.9, yellow, false, gantry);
const head = box(-.2, .2, -.2, .2, 1.1, 1.6, darkSteel, false, gantry);
const spark = new THREE.Mesh(new THREE.SphereGeometry(.09, 8, 6), new THREE.MeshBasicMaterial({color: '#ff5a3c'}));
gantry.add(spark);
animated.push(t => {
gantry.position.x = -72 + Math.sin(t * .45) * 5;
head.position.z = spark.position.z = 29 + Math.sin(t * 1.3) * 2.5;
spark.position.y = .95;
spark.scale.setScalar(.7 + Math.random() * .6);
});
box(-65, -64, 33.2, 33.8, 0, 1.4, mat('#2e3a40'));
box(-73.5, -70.5, 37.9, 39.1, 0, 1.6, mat('#4a6b80', {metalness: .4}));
// Powder painting booth: see-through panels, an overhead rail carrying panels through it.
const glass = mat('#9fd3e6', {transparent: true, opacity: .28, depthWrite: false, roughness: .1});
box(-58, -46, 36.8, 37, 0, 4.2, glass);
box(-58, -57.8, 25, 37, 0, 4.2, glass);
box(-46.2, -46, 25, 37, 0, 4.2, glass);
box(-58, -46, 25, 37, 4.2, 4.3, glass, false);
flat(-57.8, -46.2, 25.2, 36.8, .12, floorMat('#5d7f86', {polygonOffsetFactor: -18, polygonOffsetUnits: -36}));
box(-61, -43, 30.9, 31.1, 3.7, 3.8, steel, false);
const hung = [];
for (let i = 0; i < 5; i++) {
const piece = box(-.6, .6, 30.97, 31.03, 1.4, 3.2, i % 2 ? teal : mat('#eef4f2'), false);
hung.push(piece);
}
animated.push(t => hung.forEach((piece, i) => piece.position.x = -60 + ((t * .5 + i * 3.4) % 17)));
// Refrigeration: work tables with insulated cabinets, compressors and copper coils.
const copper = mat('#c27a45', {metalness: .9, roughness: .3}), insulated = mat('#f2f4f3', {roughness: .5});
for (const u of [-37, -31, -25]) {
box(u - 2, u + 2, 29.2, 30.8, 0, .9, steel);
box(u - 1.6, u - .2, 29.4, 30.6, .9, 2.2, insulated, false);
cylinder(.28, .55, mat('#16191c', {metalness: .5}), u + .7, 1.18, 30);
const coil = new THREE.Mesh(new THREE.TorusGeometry(.35, .04, 6, 20), copper);
coil.position.set(u + 1.4, 1.3, 30);
coil.rotation.y = Math.PI / 2;
group.add(coil);
}
for (let i = 0; i < 4; i++) box(-35 + i * 2.1, -33.2 + i * 2.1, 38.6, 40.4, 0, 1.9, insulated);
// Assembly: conveyor carrying machine frames between robot arms, finished machines at the end.
box(-20, -6, 32.4, 34.6, 0, .9, darkSteel);
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
for (let u = -19.4; u < -6; u += 1.2) {
const roller = cylinder(.12, 2.2, rollers, u, .95, 33.5);
roller.rotation.x = Math.PI / 2;
}
const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
for (let i = 0; i < 4; i++) {
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
frame.position.set(-19 + i * 3.5, 2.2, 33.5);
frame.castShadow = true;
group.add(frame);
frames.push(frame);
}
const arms = [];
for (const u of [-16, -10]) {
box(u - .6, u + .6, 36, 37.2, 0, 1.2, yellow);
const arm = new THREE.Group();
arm.position.set(u, 1.2, 36.6);
group.add(arm);
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), yellow);
upper.position.y = 1.1;
upper.castShadow = true;
const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
fore.position.set(0, 2.1, -1.1);
fore.castShadow = true;
arm.add(upper, fore);
arms.push(arm);
}
animated.push(t => {
arms.forEach((arm, i) => arm.rotation.y = Math.sin(t * .8 + i * 2) * .9);
frames.forEach((frame, i) => frame.position.x = -19.5 + ((t * .6 + i * 3.5) % 14));
});
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
finished.beacon.visible = finished.ring.visible = false;
const u = -19.2 + i * 4.2;
finished.group.position.set(u, 0, 40.6);
finished.group.rotation.y = Math.PI;
group.add(finished.group);
solid(u - 1, u + 1, 40, 41.2);
}
// Packing: wrapped machines and a roller conveyor out through the dock door to the truck.
const carton = textTexture(256, 512, (c, w, h) => {
c.fillStyle = '#c49a64';
c.fillRect(0, 0, w, h);
c.fillStyle = 'rgba(255,255,255,.18)';
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
c.fillStyle = '#0b8f88';
c.fillRect(0, h * .42, w, 34);
c.fillStyle = '#1d2a2a';
c.textAlign = 'center';
c.font = 'bold 34px sans-serif';
c.fillText('ROBOT', w / 2, h * .3);
c.fillText('MARKET', w / 2, h * .3 + 40);
c.font = '60px sans-serif';
c.fillText('⇧', w / 2, h * .75);
});
const cartonMat = mat('#ffffff', {map: carton, roughness: .85});
const cartonGeometry = new THREE.BoxGeometry(.95, 1.9, 1.05);
const packaged = () => {
const mesh = new THREE.Mesh(cartonGeometry, cartonMat);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
};
for (const v of [37, 38.4, 39.8]) {
const p = packaged();
p.position.set(-3.2, .95, v);
group.add(p);
solid(-3.7, -2.7, v - .55, v + .55);
}
box(-6, 1.6, 30.4, 31.6, 0, 1.0, darkSteel);
for (let u = -5.6; u < 1.6; u += .8) cylinder(.08, 1.1, rollers, u, 1.04, 31).rotation.x = Math.PI / 2;
// ---------- Production crew: 40 workers ----------
const overall = '#2f4e7a', vest = '#e8742c', paintSuit = '#eef1f2';
const W = (x, z, facing, kind = 'work', extra = {}) => ({x, z, facing, kind, suit: overall, ...extra});
const walk = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'walk', {to: {x: tx, z: tz}, ...extra});
const carry = (x, z, tx, tz, extra = {}) => W(x, z, 0, 'carry', {to: {x: tx, z: tz}, ...extra});
const crew = [
// Bending: two at each press, four carrying sheets.
W(-98.8, 32.3, Math.PI), W(-97.2, 32.3, Math.PI), W(-88.8, 32.3, Math.PI), W(-87.2, 32.3, Math.PI, 'work', {suit: vest}),
carry(-101, 37.3, -99.5, 33.6), carry(-95, 37.3, -96.2, 33.6, {suit: vest}), carry(-90, 37.3, -90.5, 33.6), carry(-85, 37.3, -86.2, 33.6),
// Laser cutting: operators at the bed and control panel, two at the shear, three moving parts.
W(-64.5, 34.6, Math.PI), W(-68, 33.4, Math.PI), W(-75, 33.4, Math.PI, 'work', {suit: vest}),
W(-73, 37.1, 0), W(-71, 37.1, 0),
walk(-79, 35.3, -62, 35.3), carry(-68, 40.4, -62, 40.4), walk(-80, 37, -76, 40.4, {suit: vest}),
// Painting: four spraying inside the booth in white suits, three outside.
W(-55, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-51.5, 29.4, 0, 'work', {suit: paintSuit, hat: '#ffffff'}),
W(-53, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}), W(-49, 32.6, Math.PI, 'work', {suit: paintSuit, hat: '#ffffff'}),
walk(-60, 23, -44, 23), carry(-59, 39.4, -45, 39.4), walk(-44.5, 26, -44.5, 37, {suit: vest}),
// Refrigeration: two at each table, three moving cabinets and coils.
W(-38, 28.4, 0), W(-36, 28.4, 0), W(-32, 28.4, 0, 'work', {suit: vest}), W(-30, 28.4, 0), W(-26, 28.4, 0), W(-24, 28.4, 0),
walk(-39, 34.4, -22, 34.4), carry(-24, 37, -38, 37), walk(-31, 32.4, -31, 36),
// Assembly and packing.
W(-18, 31, 0), W(-14.4, 31, 0), W(-11, 31, 0, 'work', {suit: vest}), W(-7.6, 31, 0),
W(-17.1, 38.4, 0), W(-12.9, 38.4, 0),
carry(-20, 24, -4, 24), carry(-3, 22.5, -3, 29)
];
const workers = createCrew(group, crew);
for (const w of crew) if (w.kind === 'work') solid(w.x - .3, w.x + .3, w.z - .3, w.z + .3);
// ---------- Office building: engineering, accounting, management ----------
// One storey, rooms side by side with their own doors onto the yard (front at v = -24).
const windows = textTexture(128, 128, (c, w, h) => {
c.fillStyle = '#e9e4d8';
c.fillRect(0, 0, w, h);
c.fillStyle = '#4c6672';
c.fillRect(14, 26, 100, 64);
c.fillStyle = 'rgba(255,255,255,.25)';
c.fillRect(18, 30, 28, 56);
});
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
const facade = mat('#ffffff', {map: windows}), plaster = mat('#ece8df', {roughness: .9});
const OH = 3.8, O0 = 6, O1 = 50, F = -24, B = -46;
const doors = [[14, 17], [31, 34], [43.5, 46.5]];
let from = O0;
for (const [d0, d1] of doors) {
box(from, d0, F - .3, F, 0, OH, plaster);
box(d0, d1, F - .3, F, 2.7, OH, plaster, false);
from = d1;
}
box(from, O1, F - .3, F, 0, OH, plaster);
box(O0, O1, B - .3, B, 0, OH, plaster);
box(O0 - .3, O0, B - .3, F, 0, OH, plaster);
box(O1, O1 + .3, B - .3, F, 0, OH, plaster);
box(25.85, 26.15, B, F - .3, 0, OH, plaster);
box(39.85, 40.15, B, F - .3, 0, OH, plaster);
const officeRoof = box(O0 - .5, O1 + .5, B - .8, F + .5, OH, OH + .25, mat('#bdb5a5'), false);
officeRoof.castShadow = false;
box(O0 - .5, O1 + .5, F + .4, F + .5, OH - .5, OH, teal, false);
// Windows on the front wall between the doors, and on the end walls.
for (const [u0, u1] of [[7, 13.2], [18, 30.2], [35, 42.6], [47.4, 49.4]]) {
const pane = new THREE.Mesh(new THREE.PlaneGeometry(u1 - u0, 1.4), facade);
pane.position.set((u0 + u1) / 2, 1.9, F + .02);
facade.map.repeat.set(1, 1);
group.add(pane);
}
panel(nameSign, 22, 3.4, 28, OH + 2, F + .1);
for (const u of [18, 38]) box(u - .15, u + .15, F - .1, F + .05, OH, OH + 3.7, steel, false);
flat(O0, 26, B, F, .1, floorMat('#a9b4bb'));
flat(26, 40, B, F, .1, floorMat('#c8bba4'));
flat(40, O1, B, F, .1, floorMat('#7b5a44', {roughness: .5}));
for (const [u0, u1] of [[8, 24], [28, 38], [42, 48]]) box(u0, u1, -36, -34, OH - .08, OH - .02, lamp, false).castShadow = false;
panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, F + .05);
panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, F + .05);
panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, F + .05);
const wood = mat('#c8a477', {roughness: .6}), black = mat('#1b1d20', {roughness: .5}), fabric = mat('#2c3238');
// Desk facing `facing` (the side the person sits on) with one or two monitors.
function desk(u, v, sitSide, screens, width = 1.8) {
box(u - width / 2, u + width / 2, v - .45, v + .45, .72, .77, wood);
for (const du of [-1, 1]) box(u + du * (width / 2 - .05) - .03, u + du * (width / 2 - .05) + .03, v - .4, v + .4, 0, .72, black, false);
screens.forEach((texture, i) => {
const du = screens.length > 1 ? (i - .5) * .78 : 0;
const mats = [black, black, black, black, black, black];
mats[sitSide > 0 ? 4 : 5] = new THREE.MeshBasicMaterial({map: texture});
const monitor = new THREE.Mesh(new THREE.BoxGeometry(.72, .44, .04), mats);
monitor.position.set(u + du, 1.12, v - sitSide * .2);
monitor.rotation.y = (i - .5) * -.25 * sitSide * (screens.length > 1 ? 1 : 0);
group.add(monitor);
box(u + du - .03, u + du + .03, v - sitSide * .22 - .03, v - sitSide * .22 + .03, .77, .9, black, false);
});
box(u - .25, u + .25, v + sitSide * .12, v + sitSide * .28, .77, .79, mat('#30353b'), false);
}
// Office chair behind a seated person; the back is on the far side from the desk.
function chair(u, v, sitSide) {
box(u - .26, u + .26, v - .26, v + .26, .4, .47, fabric, false);
box(u - .25, u + .25, v + sitSide * .26, v + sitSide * .32, .47, 1.1, fabric, false);
cylinder(.035, .4, black, u, .2, v);
box(u - .3, u + .3, v - .03, v + .03, .02, .06, black, false);
box(u - .03, u + .03, v - .3, v + .3, .02, .06, black, false);
solid(u - .35, u + .35, v - .35, v + .35);
}
const people = [], talks = [];
// A seated person at a desk; `sitSide` is +1 when they sit on the +v side facing −v.
function seat(person, u, v, sitSide, tag) {
person.root.position.set(u, 0, v);
person.root.rotation.y = sitSide > 0 ? Math.PI : 0;
group.add(person.root);
if (tag) {
tag.position.set(0, 1.78, 0);
person.root.add(tag);
}
chair(u, v, sitSide);
return person;
}
// Engineering: five developers, a whiteboard, and the office's vending machine by the door.
const developers = [
{name: 'حامد', role: 'React', u: 11, v: -31.6, look: {outfit: 'hoodie', top: '#151517', bottom: '#2d3d57'}, code: CODE.react, speaker: 'hamed',
lines: ['سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاه‌ها رو با React می‌سازم.', 'هر چی روی صفحهٔ دستگاه می‌بینین، از همین مانیتور اومده!']},
{name: 'رایان', role: 'Go', u: 16, v: -31.6, look: {outfit: 'hoodie', top: '#2f6fd6', bottom: '#33363b', skin: '#e9bf9c'}, code: CODE.go, speaker: 'ryan',
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.']},
{name: 'علیرضا', role: 'C++', u: 21, v: -31.6, look: {outfit: 'hoodie', top: '#1a1a1c', bottom: '#3b4a5e', hair: '#1a1410'}, code: CODE.cpp, speaker: 'alireza',
lines: ['سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم می‌کنم.']},
{name: 'رضا', role: 'C++', u: 13.5, v: -38.6, look: {outfit: 'suit', top: '#2b2f3a', bottom: '#2b2f3a', tie: '#7a1f2b', shoes: '#111'}, code: CODE.cpp, speaker: 'reza',
lines: ['سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.', 'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!']},
{name: 'محمد', role: '', u: 18.5, v: -38.6, look: {outfit: 'hoodie', top: '#6f767d', bottom: '#2a2f36', skin: '#d9a985'}, code: CODE.terminal, speaker: 'mohammad',
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!']}
];
for (const dev of developers) {
desk(dev.u, dev.v - 1.0, 1, [dev.code, CODE.terminal]);
const person = seat(createPerson(dev.look), dev.u, dev.v, 1, nameTag(dev.name, dev.role));
people.push({person, pose: 'type'});
talks.push({speaker: dev.speaker, label: dev.name, lines: dev.lines, radius: 2.6, pos: () => toWorld(dev.u, dev.v)});
}
const board = textTexture(512, 256, (c, w, h) => {
c.fillStyle = '#fbfbf8';
c.fillRect(0, 0, w, h);
c.strokeStyle = '#9aa4a8';
c.lineWidth = 8;
c.strokeRect(0, 0, w, h);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#1d4f91';
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
c.font = '28px Vazirmatn, Tahoma, sans-serif';
c.fillStyle = '#333';
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
});
panel(board, 4, 2, 16, 1.9, B + .05);
const officeMachine = {u: 6.8, v: -28.6};
// Accounting: four colleagues at desks, filing cabinets along the wall.
const accountants = [
{u: 29.5, v: -31.6, look: {top: '#2d3550', scarf: '#c25b7a'}},
{u: 36.5, v: -31.6, look: {top: '#1f1f22', scarf: '#3e6e8e', skin: '#edc2a0'}},
{u: 29.5, v: -38.6, look: {top: '#8a6e55', scarf: '#e3d3b9'}},
{u: 36.5, v: -38.6, look: {top: '#4a5a48', scarf: '#7a3b52', skin: '#d9a985'}}
];
for (const a of accountants) {
desk(a.u, a.v - 1.0, 1, [CODE.sheet]);
people.push({person: seat(createPerson({outfit: 'manteau', bottom: '#24262b', shoes: '#111', ...a.look}), a.u, a.v, 1), pose: 'type'});
}
for (let i = 0; i < 4; i++) box(26.2, 26.9, -44.5 + i * 1, -43.6 + i * 1, 0, 1.4, mat('#9aa3a8', {metalness: .5}));
talks.push({speaker: 'accountant', label: 'واحد حسابداری', radius: 4, pos: () => toWorld(33, -29),
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!']});
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
desk(45, -39.7, -1, [CODE.dashboard], 2.4);
chair(45, -40.7, -1);
for (const u of [43.8, 46.2]) {
box(u - .28, u + .28, -37.1, -36.5, .42, .5, mat('#5a3a2a'), false);
box(u - .28, u + .28, -36.5, -36.4, .5, 1.0, mat('#5a3a2a'), false);
solid(u - .3, u + .3, -37.1, -36.4);
}
box(41, 49, -45.9, -45.4, 0, 2.6, mat('#6b4a33'));
for (let i = 0; i < 4; i++) for (let j = 0; j < 9; j++) box(41.3 + j * .85, 41.3 + j * .85 + .5, -45.4, -45.2, .3 + i * .6, .78 + i * .6, mat(['#7e1f1d', '#2f4a6b', '#d6c9a5', '#3e6b48'][(i + j) % 4]), false);
box(49, 49.5, -27, -26.5, 0, .5, mat('#7a5a3c'), true);
const leaves = new THREE.Mesh(new THREE.SphereGeometry(.45, 10, 8), mat('#3e7a3e'));
leaves.position.set(49.25, 1.0, -26.75);
group.add(leaves);
const nameplate = panel(sign('محمدی', 'مدیر شرکت'), .6, .19, 45, .87, -39.2, 1);
nameplate.rotation.x = -.3;
// ---------- Staff car park ----------
for (let i = 0; i < 4; i++) {
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
const u = 56 + i * 4;
parked.group.position.set(u, 0, -19);
parked.group.rotation.y = Math.PI / 2;
group.add(parked.group);
solid(u - 1.1, u + 1.1, -21.3, -16.7);
}
// ---------- The manager arrives in his pickup ----------
// Mr Mohammadi drives in through the gate when the game starts, parks by the office, walks to
// his room and sits at his desk.
const pickup = createPickup('#f1f1ee');
group.add(pickup.group);
const manager = createPerson({outfit: 'suit', top: '#3a3f47', bottom: '#3a3f47', hair: '#8d8a86', glasses: true, shoes: '#111'});
const managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
managerTag.position.y = 2.15;
manager.root.add(managerTag);
group.add(manager.root);
const DRIVE_IN = [[98, -3], [80, -3], [62, -5], [51, -9], [46, -16]];
const WALK_IN = [[47.4, -16.9], [45, -21], [45, -25.6], [48.5, -29], [48.5, -40.7], [45, -40.7]];
{
// The parked pickup blocks its bay.
const [ax, az] = DRIVE_IN.at(-2), [bx, bz] = DRIVE_IN.at(-1), len = Math.hypot(bx - ax, bz - az);
const du = (bx - ax) / len, dv = (bz - az) / len;
footprints.push([[2.7, 1], [2.7, -1], [-2.7, -1], [-2.7, 1]].flatMap(([a, b]) => Object.values(toWorld(bx + du * a - dv * b, bz + dv * a + du * b))));
}
const arrival = {stage: 'drive', s: 0, heading: Math.PI, sat: false};
talks.push({speaker: 'mohammadi', label: 'آقای محمدی', radius: 3.4, pos: () => toWorld(manager.root.position.x, manager.root.position.z), ready: () => arrival.stage === 'seated',
lines: ['سلام، خوش اومدین! من محمدی‌ام، مدیر روبات مارکت.', 'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.', 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!']});
// Position and direction a distance `s` along a polyline of [u, v] points.
const along = (path, s) => {
for (let i = 1; i < path.length; i++) {
const [ax, az] = path[i - 1], [bx, bz] = path[i], len = Math.hypot(bx - ax, bz - az);
if (s <= len || i === path.length - 1) {
const t = Math.min(1, s / len);
return {u: ax + (bx - ax) * t, v: az + (bz - az) * t, du: (bx - ax) / len, dv: (bz - az) / len, done: s >= len && i === path.length - 1};
}
s -= len;
}
};
const turn = (from, to, k) => from + Math.atan2(Math.sin(to - from), Math.cos(to - from)) * k;
const placeVehicle = (vehicle, state, p, dt, reverse = false) => {
const want = reverse ? Math.atan2(p.dv, -p.du) : Math.atan2(-p.dv, p.du);
state.heading = dt ? turn(state.heading, want, Math.min(1, dt * 3)) : want;
vehicle.group.position.set(p.u, 0, p.v);
vehicle.group.rotation.y = state.heading;
};
function updateArrival(dt, time) {
if (arrival.stage === 'drive') {
arrival.s += dt * 7;
const p = along(DRIVE_IN, arrival.s);
placeVehicle(pickup, arrival, p, dt);
pickup.wheels.forEach(w => w.rotation.z -= dt * 7 / .4);
manager.root.visible = false;
if (p.done) Object.assign(arrival, {stage: 'walk', s: 0});
} else if (arrival.stage === 'walk') {
arrival.s += dt * 1.3;
const p = along(WALK_IN, arrival.s);
manager.root.visible = true;
manager.root.position.set(p.u, 0, p.v);
manager.root.rotation.y = Math.atan2(p.du, p.dv);
manager.pose('walk', time);
if (p.done) arrival.stage = 'seated';
} else if (!arrival.sat) {
arrival.sat = true;
manager.root.position.set(45, 0, -40.7);
manager.root.rotation.y = 0;
manager.pose('sit');
}
}
function resetArrival() {
Object.assign(arrival, {stage: 'drive', s: 0, sat: false});
placeVehicle(pickup, arrival, along(DRIVE_IN, 0), 0);
manager.root.visible = false;
}
resetArrival();
// ---------- Delivery truck ----------
// Packed machines ride the conveyor onto the truck at the dock; when it is full it drives out
// of the gate to the city, and comes back empty a while later, reversing up to the dock.
const truck = createTruck();
group.add(truck.group);
const DOCK = [5.5, 31];
const DEPART = [DOCK, [14, 31], [30, 22], [50, 8], [66, 1], [80, 0], [100, 0]];
const RETURN = [[100, 0], [80, 0], [60, 6], [40, 14], [27, 18]];
const BACK_IN = [[27, 18], [16, 28.5], DOCK];
const SLOTS = [2.1, .95, -.2, -1.35].flatMap(x => [[x, -.6], [x, .6]]);
const loaded = [];
const moving = packaged();
group.add(moving);
const delivery = {stage: 'load', t: 0, s: 0, heading: 0};
const truckAtDock = () => {
truck.group.visible = true;
truck.group.position.set(DOCK[0], 0, DOCK[1]);
truck.group.rotation.y = delivery.heading = 0;
};
truckAtDock();
function updateTruck(dt) {
const d = delivery;
d.t += dt;
moving.visible = d.stage === 'load';
if (d.stage === 'load') {
// Each box: two seconds along the conveyor, one second lifted onto its slot.
const [sx, sz] = SLOTS[loaded.length], bed = truck.bed.position;
const slot = {u: DOCK[0] + bed.x + sx, y: bed.y + .95, v: DOCK[1] + sz};
if (d.t < 2) moving.position.set(-6 + (1.6 + 6) * d.t / 2, 1.05 + .95, 31);
else {
const k = Math.min(1, d.t - 2);
moving.position.set(1.6 + (slot.u - 1.6) * k, 2.0 + (slot.y - 2.0) * k + Math.sin(k * Math.PI) * .5, 31 + (slot.v - 31) * k);
}
if (d.t >= 3) {
const box = packaged();
box.position.set(sx, .95, sz);
truck.bed.add(box);
loaded.push(box);
d.t = 0;
if (loaded.length === SLOTS.length) d.stage = 'full';
}
} else if (d.stage === 'full' && d.t > 2) Object.assign(d, {stage: 'depart', s: 0});
else if (d.stage === 'depart' || d.stage === 'return' || d.stage === 'back') {
const speed = d.stage === 'back' ? 2.5 : 6;
d.s += dt * speed;
const path = {depart: DEPART, return: RETURN, back: BACK_IN}[d.stage];
const p = along(path, d.s);
placeVehicle(truck, d, p, dt, d.stage === 'back');
truck.wheels.forEach(w => w.rotation.z -= dt * speed / .5 * (d.stage === 'back' ? -1 : 1));
if (p.done) {
if (d.stage === 'depart') {
Object.assign(d, {stage: 'away', t: 0});
truck.group.visible = false;
loaded.splice(0).forEach(box => truck.bed.remove(box));
} else if (d.stage === 'return') Object.assign(d, {stage: 'back', s: 0});
else {
truckAtDock();
Object.assign(d, {stage: 'load', t: 0});
}
}
} else if (d.stage === 'away' && d.t > 18) {
truck.group.visible = true;
Object.assign(d, {stage: 'return', s: 0, heading: Math.PI});
}
}
talks.push({speaker: 'child', radius: 9, pos: () => toWorld(4, 31), once: true,
lines: ['بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!']});
// The family comments on each work area the first time they walk into it.
for (const [u, text, speaker] of [
[-93, 'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.', 'father'],
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.', 'child'],
[-52, 'این کابین رنگه؛ بدنه‌ها اینجا رنگ فیروزه‌ای روبات مارکت می‌گیرن.', 'father'],
[-31, 'یخچال‌سازی! برای همینه که نوشیدنی‌های دستگاه همیشه خنکن.', 'child'],
[-13, 'خط مونتاژ: روبات‌ها و کارگرها قطعه‌ها رو کنار هم می‌ذارن و دستگاه کامل می‌شه.', 'father']
]) talks.push({speaker, radius: 8, pos: () => toWorld(u, 25), once: true, lines: [text]});
// ---------- Key positions ----------
// The family starts in the yard between the hall and the office, the car parked right beside them.
const demo = toWorld(officeMachine.u, officeMachine.v), facing = {x: axis.x, z: axis.z};
const car = toWorld(1.5, -7);
const start = toWorld(-1, -3.6), door = toWorld(15.5, -24);
return {
group,
footprints,
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
car: {x: car.x, z: car.z, heading: worldHeading(1, 0)},
start: {x: start.x, z: start.z, heading: Math.atan2(-(door.z - start.z), door.x - start.x)},
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
talks,
reset: resetArrival,
// `dt` is 0 while the game is paused; distant viewers skip the people and machines.
update(time, dt, viewer) {
const near = !viewer || Math.hypot(viewer.x - center.x, viewer.z - center.z) < 260;
updateArrival(dt, time);
updateTruck(dt);
workers.group.visible = near;
if (!near) return;
for (const step of animated) step(time);
workers.update(time);
people.forEach(({person, pose}, i) => person.pose(pose, time, i * 1.7));
}
};
}
-56
View File
@@ -1,56 +0,0 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30;
export const fa = n => n.toLocaleString('fa-IR');
export const formatHetoman = value => `${fa(value)} هتومان`;
export const hetomanToRial = value => value * 10000;
export const formatClock = minutes => {
const m = Math.floor(minutes) % (24 * 60);
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
};
export const catalog = {
juice: {id: 'juice', name: 'آب‌میوه', icon: '🧃', cost: 35, type: 'drink'},
water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
biscuit: {id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack'},
chocolate: {id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack'},
nuts: {id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack'},
cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack'}
};
export const priceAt = (machine, productId) => Math.round(catalog[productId].cost * machine.priceFactor);
export function isOpen(machine, minutes) {
const m = minutes % (24 * 60);
return m >= machine.hours[0] && m < machine.hours[1];
}
export function createState() {
return {balance: INITIAL_BALANCE, purchases: [], xp: 0, missionsDone: [], minutes: START_MINUTES, busy: false};
}
// Returns why a purchase cannot happen, or null when it can.
export function purchaseBlocker(state, machine, productId) {
if (!machine || !machine.products.includes(productId)) return 'unknown';
if (state.busy) return 'busy';
if (!isOpen(machine, state.minutes)) return 'closed';
if (state.balance < priceAt(machine, productId)) return 'funds';
return null;
}
export function purchase(state, machine, productId) {
if (purchaseBlocker(state, machine, productId)) return null;
const product = catalog[productId], price = priceAt(machine, productId);
const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes};
state.balance -= price;
state.purchases.push(record);
return record;
}
// The run ends once no open or later-opening machine sells anything the player can afford.
export function canAffordAnything(state, machines) {
return machines.some(m => m.products.some(id => priceAt(m, id) <= state.balance) && (state.minutes % 1440) < m.hours[1]);
}
+23
View File
@@ -0,0 +1,23 @@
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup.
* @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount context.
* @returns {Promise<function>} Disposes input, timers, sensors and graphics.
*/
export async function createGame({ root, ui, signal }) {
const context = {
root,
ui,
signal,
};
bindRuntime(context);
try {
for (const initialize of RUNTIME_INITIALIZERS) {
if (signal.aborted) break;
await initialize(context);
}
} catch (error) {
context.renderer?.dispose();
throw error;
}
return context.dispose ?? (() => context.renderer?.dispose());
}
+178
View File
@@ -0,0 +1,178 @@
import * as THREE from 'three';
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
// robot appear in their real direction over the camera picture.
// The rear camera's field of view across its long side; typical phone main cameras are 60–70°.
const CAMERA_LONG_FOV = 65;
const zee = new THREE.Vector3(0, 0, 1),
euler = new THREE.Euler(),
q0 = new THREE.Quaternion();
const q1 = new THREE.Quaternion(-Math.SQRT1_2, 0, 0, Math.SQRT1_2); // camera looks out of the back, not the top
// Device orientation (degrees) to a camera quaternion in a frame where −z is north and +y is up.
// `alpha` must be measured from north (absolute), counter-clockwise.
/**
* Runs the orientation quaternion operation for this subsystem.
* @param {*} target - target.
* @param {*} alpha - alpha.
* @param {*} beta - beta.
* @param {*} gamma - gamma.
* @param {*} screenAngle - screen Angle.
* @returns {*} Operation result, when applicable.
*/
export function orientationQuaternion(target, alpha, beta, gamma, screenAngle) {
const r = Math.PI / 180;
euler.set(beta * r, alpha * r, -gamma * r, 'YXZ');
target.setFromEuler(euler);
target.multiply(q1);
target.multiply(q0.setFromAxisAngle(zee, -screenAngle * r));
return target;
}
// Vertical field of view of what is visible once the video is scaled to cover the screen.
/**
* Runs the cover fov operation for this subsystem.
* @param {*} videoAspect - video Aspect.
* @param {*} screenAspect - screen Aspect.
* @returns {*} Operation result, when applicable.
*/
export function coverFov(videoAspect, screenAspect) {
const long = Math.tan((CAMERA_LONG_FOV * Math.PI) / 360);
// The video's own vertical and horizontal half-angle tangents.
const vTan = videoAspect >= 1 ? long / videoAspect : long,
hTan = videoAspect >= 1 ? long : long * videoAspect;
// "cover" fills the screen: if the screen is wider than the video, the full width shows.
const visibleV = screenAspect > videoAspect ? hTan / screenAspect : vTan;
return (Math.atan(visibleV) * 360) / Math.PI;
}
/**
* Creates ar view resources for the game.
* @param {*} video - video.
* @returns {*} Operation result, when applicable.
*/
export function createArView(video) {
let stream = null,
orientation = null,
compassOffset = null,
absoluteSeen = false;
const target = new THREE.Quaternion();
// Android sends north-referenced readings as `deviceorientationabsolute`; iOS and some browsers
// only send `deviceorientation`, which is used until (or unless) absolute readings arrive.
/**
* Runs the on absolute operation for this subsystem.
* @param {*} event - event.
* @returns {*} Operation result, when applicable.
*/
const onAbsolute = (event) => {
if (event.alpha == null) return;
absoluteSeen = true;
orientation = {
alpha: event.alpha,
beta: event.beta,
gamma: event.gamma,
};
};
/**
* Runs the on orientation operation for this subsystem.
* @param {*} event - event.
* @returns {*} Operation result, when applicable.
*/
const onOrientation = (event) => {
if (event.alpha == null || absoluteSeen) return;
let alpha = event.alpha;
if (event.webkitCompassHeading != null) {
// iOS: alpha is relative to the start pose; the compass heading pins it to north.
compassOffset = 360 - event.webkitCompassHeading - event.alpha;
}
if (!event.absolute && compassOffset != null) alpha += compassOffset;
orientation = {
alpha,
beta: event.beta,
gamma: event.gamma,
};
};
let sensing = false;
return {
// Camera picture on (AR) or off (the 3D world fills the screen).
get active() {
return Boolean(stream);
},
get hasOrientation() {
return Boolean(orientation);
},
// Phone orientation, used by the first-person view with or without the camera picture.
startSensors() {
if (sensing) return;
sensing = true;
window.addEventListener('deviceorientationabsolute', onAbsolute);
window.addEventListener('deviceorientation', onOrientation);
},
stopSensors() {
sensing = false;
orientation = null;
absoluteSeen = false;
window.removeEventListener('deviceorientationabsolute', onAbsolute);
window.removeEventListener('deviceorientation', onOrientation);
},
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
async start() {
if (stream) return null;
if (!navigator.mediaDevices?.getUserMedia)
return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).';
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: {
ideal: 'environment',
},
width: {
ideal: 1280,
},
height: {
ideal: 720,
},
},
audio: false,
});
} catch (error) {
stream = null;
return error?.name === 'NotAllowedError'
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟';
}
video.srcObject = stream;
video.hidden = false;
await video.play().catch(() => {});
this.startSensors();
return null;
},
// Closes the camera picture; the sensors keep running for the first-person view.
stop() {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
video.srcObject = null;
video.hidden = true;
},
// Points the 3D camera like the phone. With the camera picture on, the field of view matches it.
// `smoothing` (per second) trades lag for steadiness. Returns false without orientation data.
update(camera, dt, smoothing = 8) {
const fov = stream
? coverFov(
video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 4 / 3,
innerWidth / innerHeight,
)
: 62;
if (Math.abs(camera.fov - fov) > 0.1) {
camera.fov = fov;
camera.updateProjectionMatrix();
}
if (!orientation) return false;
const angle = screen.orientation?.angle ?? window.orientation ?? 0;
orientationQuaternion(target, orientation.alpha, orientation.beta, orientation.gamma, angle);
camera.quaternion.slerp(target, 1 - Math.exp(-dt * smoothing));
return true;
},
};
}
+105
View File
@@ -0,0 +1,105 @@
/** 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();
};
}
+221
View File
@@ -0,0 +1,221 @@
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.
export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, so buying pauses.
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
/**
* Runs the smooth fix operation for this subsystem.
* @param {*} previous - previous.
* @param {*} fix - fix.
* @returns {*} Operation result, when applicable.
*/
export function smoothFix(previous, fix) {
if (!previous || fix.time - previous.time > 30000)
return {
...fix,
};
const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
const dt = Math.max(0.001, (fix.time - previous.time) / 1000);
const x = previous.x + (fix.x - previous.x) * weight,
z = previous.z + (fix.z - previous.z) * weight;
// The blended estimate is a little better than either input, but never better than 3 m.
const accuracy = Math.max(
3,
Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) +
Math.hypot(x - previous.x, z - previous.z) * 0.2,
);
return {
x,
z,
accuracy,
time: fix.time,
speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt,
};
}
// Stable mode on top of smoothFix, against phone GPS jitter:
// - a fix further from the estimate than both errors plus a brisk walk allows is a jump and is
// dropped (after three in a row it is accepted: the phone really moved, e.g. got in a car);
// - the shown position stays put until the estimate drifts beyond about half the GPS error,
// or the phone reports walking speed. Without stable mode every smoothed fix is shown.
/**
* Creates stabilizer resources for the game.
* @param {*} options - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function createStabilizer({ maxSpeed = 3 } = {}) {
let estimate = null,
shown = null,
rejected = 0;
return {
stable: true,
push(fix) {
if (estimate && this.stable && fix.time - estimate.time < 30000) {
const dt = Math.max(0.001, (fix.time - estimate.time) / 1000);
const reach = estimate.accuracy + fix.accuracy + maxSpeed * dt * 1.5;
if (
Math.hypot(fix.x - estimate.x, fix.z - estimate.z) > reach &&
fix.accuracy > estimate.accuracy / 2 &&
rejected < 3
) {
rejected++;
return (
shown && {
...shown,
}
);
}
}
rejected = 0;
estimate = smoothFix(estimate, fix);
const drift = shown ? Math.hypot(estimate.x - shown.x, estimate.z - shown.z) : Infinity;
if (!this.stable || drift > Math.max(3, estimate.accuracy / 2) || (fix.speed ?? 0) > 0.7)
shown = {
...estimate,
};
else
shown = {
...shown,
accuracy: estimate.accuracy,
time: estimate.time,
speed: fix.speed ?? 0,
};
return {
...shown,
};
},
};
}
// How close counts as "at the machine": 2.5× the GPS error, between 20 m (good outdoor fix) and 50 m,
// so reaching the area around a machine is enough.
/**
* Runs the arrival radius operation for this subsystem.
* @param {*} accuracy - accuracy.
* @returns {*} Operation result, when applicable.
*/
export const arrivalRadius = (accuracy) => Math.min(50, Math.max(20, accuracy * 2.5));
/**
* Runs the inside world operation for this subsystem.
* @param {*} bounds - bounds.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
export const insideWorld = (bounds, x, z) =>
x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
// Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}),
// onHeading(radians in the game's heading convention), onError(message) for a real problem and
// onStatus(message) while it is still searching. `stabilizer` filters the fixes (see above).
/**
* Runs the start tracking operation for this subsystem.
* @param {*} options - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function startTracking({
onFix,
onHeading,
onError,
onStatus = () => {},
stabilizer = createStabilizer(),
}) {
if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
return () => {};
}
if (!window.isSecureContext) {
onError('موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.');
return () => {};
}
let fixes = 0;
/**
* Runs the handle operation for this subsystem.
* @param {*} position - position.
* @returns {*} Operation result, when applicable.
*/
const handle = (position) => {
const { latitude: lat, longitude: lon, accuracy, speed } = position.coords,
local = toLocal(lat, lon);
const shown = stabilizer.push({
...local,
accuracy: Math.max(3, accuracy),
speed: Number.isFinite(speed) ? speed : null,
time: position.timestamp,
});
fixes++;
if (shown)
onFix({
...shown,
lat,
lon,
});
};
/**
* Runs the fail operation for this subsystem.
* @param {*} error - error.
* @returns {*} Operation result, when applicable.
*/
const fail = (error) => {
if (error.code === error.PERMISSION_DENIED)
onError(
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
);
else if (error.code === error.POSITION_UNAVAILABLE)
onError(
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)',
);
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
else if (!fixes)
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)');
};
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
navigator.geolocation.getCurrentPosition(handle, () => {}, {
enableHighAccuracy: false,
maximumAge: 60000,
timeout: 15000,
});
const watch = navigator.geolocation.watchPosition(handle, fail, {
enableHighAccuracy: true,
maximumAge: 3000,
timeout: 30000,
});
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
/**
* Runs the orientation operation for this subsystem.
* @param {*} event - event.
* @returns {*} Operation result, when applicable.
*/
const orientation = (event) => {
const compass =
event.webkitCompassHeading ??
(event.absolute && event.alpha != null ? 360 - event.alpha : null);
// Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
if (compass != null) onHeading(Math.PI / 2 - (compass * Math.PI) / 180);
};
const eventName =
'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(eventName, orientation);
return () => {
navigator.geolocation.clearWatch(watch);
window.removeEventListener(eventName, orientation);
};
}
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
/**
* Runs the request compass operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
export async function requestCompass() {
try {
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission)
await DeviceOrientationEvent.requestPermission();
} catch {
// Without the compass the family faces the direction of travel instead.
}
}
+181
View File
@@ -0,0 +1,181 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30;
/**
* Runs the fa operation for this subsystem.
* @param {*} n - n.
* @returns {*} Operation result, when applicable.
*/
export const fa = (n) => n.toLocaleString('fa-IR');
/**
* Runs the format hetoman operation for this subsystem.
* @param {*} value - value.
* @returns {*} Operation result, when applicable.
*/
export const formatHetoman = (value) => `${fa(value)} هتومان`;
/**
* Runs the hetoman to rial operation for this subsystem.
* @param {*} value - value.
* @returns {*} Operation result, when applicable.
*/
export const hetomanToRial = (value) => value * 10000;
/**
* Runs the format clock operation for this subsystem.
* @param {*} minutes - minutes.
* @returns {*} Operation result, when applicable.
*/
export const formatClock = (minutes) => {
const m = Math.floor(minutes) % (24 * 60);
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
};
export const catalog = {
juice: {
id: 'juice',
name: 'آب‌میوه',
icon: '🧃',
cost: 35,
type: 'drink',
},
water: {
id: 'water',
name: 'آب معدنی',
icon: '💧',
cost: 15,
type: 'drink',
},
milk: {
id: 'milk',
name: 'شیر کاکائو',
icon: '🥛',
cost: 40,
type: 'drink',
},
tea: {
id: 'tea',
name: 'چای سرد',
icon: '🧋',
cost: 45,
type: 'drink',
},
lemonade: {
id: 'lemonade',
name: 'لیموناد',
icon: '🍋',
cost: 40,
type: 'drink',
},
biscuit: {
id: 'biscuit',
name: 'بیسکویت',
icon: '🍪',
cost: 25,
type: 'snack',
},
chocolate: {
id: 'chocolate',
name: 'شکلات',
icon: '🍫',
cost: 30,
type: 'snack',
},
nuts: {
id: 'nuts',
name: 'آجیل',
icon: '🥜',
cost: 65,
type: 'snack',
},
cake: {
id: 'cake',
name: 'کیک',
icon: '🧁',
cost: 35,
type: 'snack',
},
};
/**
* Runs the price at operation for this subsystem.
* @param {*} machine - machine.
* @param {*} productId - product Id.
* @returns {*} Operation result, when applicable.
*/
export const priceAt = (machine, productId) =>
Math.round(catalog[productId].cost * machine.priceFactor);
/**
* Runs the is open operation for this subsystem.
* @param {*} machine - machine.
* @param {*} minutes - minutes.
* @returns {*} Operation result, when applicable.
*/
export function isOpen(machine, minutes) {
const m = minutes % (24 * 60);
return m >= machine.hours[0] && m < machine.hours[1];
}
/**
* Creates state resources for the game.
* @returns {*} Operation result, when applicable.
*/
export function createState() {
return {
balance: INITIAL_BALANCE,
purchases: [],
xp: 0,
missionsDone: [],
minutes: START_MINUTES,
busy: false,
};
}
// Returns why a purchase cannot happen, or null when it can.
/**
* Runs the purchase blocker operation for this subsystem.
* @param {*} state - state.
* @param {*} machine - machine.
* @param {*} productId - product Id.
* @returns {*} Operation result, when applicable.
*/
export function purchaseBlocker(state, machine, productId) {
if (!machine || !machine.products.includes(productId)) return 'unknown';
if (state.busy) return 'busy';
if (!isOpen(machine, state.minutes)) return 'closed';
if (state.balance < priceAt(machine, productId)) return 'funds';
return null;
}
/**
* Runs the purchase operation for this subsystem.
* @param {*} state - state.
* @param {*} machine - machine.
* @param {*} productId - product Id.
* @returns {*} Operation result, when applicable.
*/
export function purchase(state, machine, productId) {
if (purchaseBlocker(state, machine, productId)) return null;
const product = catalog[productId],
price = priceAt(machine, productId);
const record = {
machineId: machine.id,
category: machine.category,
productId,
type: product.type,
price,
minutes: state.minutes,
};
state.balance -= price;
state.purchases.push(record);
return record;
}
// The run ends once no open or later-opening machine sells anything the player can afford.
/**
* Runs the can afford anything operation for this subsystem.
* @param {*} state - state.
* @param {*} machines - machines.
* @returns {*} Operation result, when applicable.
*/
export function canAffordAnything(state, machines) {
return machines.some(
(m) =>
m.products.some((id) => priceAt(m, id) <= state.balance) && state.minutes % 1440 < m.hours[1],
);
}
+76
View File
@@ -0,0 +1,76 @@
import { purchase, purchaseBlocker } from './gameState.js';
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
/**
* Creates payment resources for the game.
* @returns {*} Operation result, when applicable.
*/
export function createPayment() {
return {
phase: 'idle',
machine: null,
productId: null,
record: null,
};
}
/**
* Runs the begin payment operation for this subsystem.
* @param {*} state - state.
* @param {*} payment - payment.
* @param {*} machine - machine.
* @param {*} productId - product Id.
* @returns {*} Operation result, when applicable.
*/
export function beginPayment(state, payment, machine, productId) {
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
Object.assign(payment, {
phase: 'approaching',
machine,
productId,
record: null,
});
state.busy = true;
return true;
}
/**
* Runs the ready payment operation for this subsystem.
* @param {*} payment - payment.
* @returns {*} Operation result, when applicable.
*/
export function readyPayment(payment) {
if (payment.phase !== 'approaching') return false;
payment.phase = 'ready';
return true;
}
/**
* Runs the swipe payment operation for this subsystem.
* @param {*} state - state.
* @param {*} payment - payment.
* @returns {*} Operation result, when applicable.
*/
export function swipePayment(state, payment) {
if (payment.phase !== 'ready') return false;
state.busy = false;
const record = purchase(state, payment.machine, payment.productId);
if (!record) {
Object.assign(payment, createPayment());
return false;
}
state.busy = true;
payment.record = record;
payment.phase = 'approved';
return true;
}
/**
* Runs the cancel payment operation for this subsystem.
* @param {*} state - state.
* @param {*} payment - payment.
* @returns {*} Operation result, when applicable.
*/
export function cancelPayment(state, payment) {
if (!['approaching', 'ready'].includes(payment.phase)) return false;
Object.assign(payment, createPayment());
state.busy = false;
return true;
}
+184
View File
@@ -0,0 +1,184 @@
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');
export const POINTS = {
purchase: 100,
discovery: 250,
combo: 150,
allCategories: 500,
};
export const RANKS = [
{
name: 'تازه‌وارد',
xp: 0,
},
{
name: 'کاوشگر',
xp: 600,
},
{
name: 'راه‌بلد',
xp: 1400,
},
{
name: 'شکارچی دستگاه',
xp: 2600,
},
{
name: 'استاد نقشه',
xp: 4200,
},
{
name: 'افسانهٔ مشهد',
xp: 6500,
},
];
// Awards for `record`, given the purchases that happened before it.
/**
* Runs the awards for operation for this subsystem.
* @param {*} previous - previous.
* @param {*} record - record.
* @returns {*} Operation result, when applicable.
*/
export function awardsFor(previous, record) {
const awards = [
{
label: 'خرید موفق',
points: POINTS.purchase,
},
];
const here = previous.filter((p) => p.machineId === record.machineId);
if (!here.length)
awards.push({
label: 'کشف مکان جدید',
points: POINTS.discovery,
});
// Paid once per machine: the first time it has sold both a drink and a snack.
const typesHere = new Set(here.map((p) => p.type));
if (typesHere.size === 1 && !typesHere.has(record.type))
awards.push({
label: 'ترکیب نوشیدنی و خوراکی',
points: POINTS.combo,
});
const categories = new Set(
previous.map((p) => p.category).filter((c) => CITY_CATEGORIES.includes(c)),
);
if (
CITY_CATEGORIES.includes(record.category) &&
!categories.has(record.category) &&
categories.size === CITY_CATEGORIES.length - 1
)
awards.push({
label: 'همهٔ انواع مکان',
points: POINTS.allCategories,
});
return awards;
}
/**
* Runs the rank for operation for this subsystem.
* @param {*} xp - xp.
* @returns {*} Operation result, when applicable.
*/
export function rankFor(xp) {
let index = 0;
while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
const next = RANKS[index + 1] ?? null;
return {
index,
name: RANKS[index].name,
min: RANKS[index].xp,
next: next?.xp ?? null,
progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1,
};
}
/**
* Runs the places visited operation for this subsystem.
* @param {*} purchases - purchases.
* @returns {*} Operation result, when applicable.
*/
const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).size;
export const MISSIONS = [
{
id: 'factory',
title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
target: 'factory',
xp: 100,
hetoman: 20,
done: (ps) => ps.some((p) => p.machineId === 'factory'),
},
{
id: 'first',
title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
target: 'mellat-park',
xp: 150,
hetoman: 30,
done: (ps) => ps.some((p) => p.machineId === 'mellat-park'),
},
{
id: 'school',
title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
target: 'school-rahil',
xp: 200,
hetoman: 40,
done: (ps) => ps.some((p) => p.machineId === 'school-rahil'),
},
{
id: 'campus',
title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
target: 'ferdowsi-university',
xp: 200,
hetoman: 40,
done: (ps) => ps.some((p) => p.machineId === 'ferdowsi-university' && p.type === 'snack'),
},
{
id: 'metro',
title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
target: 'azadi-metro',
xp: 150,
hetoman: 30,
done: (ps) => ps.some((p) => p.machineId === 'azadi-metro'),
},
{
id: 'mall',
title: 'با ماشین تا مشهد مال برو و خرید کن',
target: 'mashhad-mall',
xp: 250,
hetoman: 50,
done: (ps) => ps.some((p) => p.machineId === 'mashhad-mall'),
},
{
id: 'explorer',
title: 'از ۵ مکان متفاوت شهر خرید کن',
target: null,
xp: 400,
hetoman: 80,
done: (ps) => placesVisited(ps.filter((p) => CITY_CATEGORIES.includes(p.category))) >= 5,
},
];
// Marks and returns missions that the current purchases complete for the first time.
/**
* Runs the complete missions operation for this subsystem.
* @param {*} state - state.
* @returns {*} Operation result, when applicable.
*/
export function completeMissions(state) {
const fresh = MISSIONS.filter(
(m) => !state.missionsDone.includes(m.id) && m.done(state.purchases),
);
for (const m of fresh) {
state.missionsDone.push(m.id);
state.xp += m.xp;
state.balance += m.hetoman;
}
return fresh;
}
/**
* Runs the active mission operation for this subsystem.
* @param {*} state - state.
* @returns {*} Operation result, when applicable.
*/
export const activeMission = (state) =>
MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null;
+76
View File
@@ -0,0 +1,76 @@
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 {
activate() {
disposed = false;
},
patch,
read: (id) => nodes[id] ?? {},
open: (id) =>
patch(id, {
open: true,
}),
close: (id) =>
patch(id, {
open: false,
}),
on: (id, handler) => actions.set(id, handler),
emit: (id, value) => actions.get(id)?.(value),
style: (id, style) =>
patch(id, {
style: {
...nodes[id]?.style,
...style,
},
}),
classState: (id, name, on) =>
patch(id, {
classes: {
...nodes[id]?.classes,
[name]: on,
},
}),
publishGame: (state) => store.dispatch(publishProgress(structuredClone(state))),
clearActions: () => actions.clear(),
dispose() {
disposed = true;
clearTimeout(timer);
actions.clear();
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createAppStore } from '../store/index.js';
import { createInterfaceBridge } from './interfaceBridge.js';
afterEach(() => vi.useRealTimers());
describe('renderer to React bridge', () => {
it('batches UI changes, retains synchronous pause state and serializes economic progress', () => {
vi.useFakeTimers();
const store = createAppStore(),
ui = createInterfaceBridge(store);
ui.open('shop');
expect(ui.read('shop').open).toBe(true);
expect(store.getState().interface.nodes.shop).toBeUndefined();
vi.advanceTimersByTime(100);
expect(store.getState().interface.nodes.shop.open).toBe(true);
ui.publishGame({ balance: 465, purchases: [], missionsDone: [], xp: 350, minutes: 510 });
expect(store.getState().game.balance).toBe(465);
ui.dispose();
});
it('runs registered commands and does not publish stale UI after disposal', () => {
vi.useFakeTimers();
const store = createAppStore(),
ui = createInterfaceBridge(store),
handler = vi.fn();
ui.on('select-product', handler);
ui.emit('select-product', 'water');
expect(handler).toHaveBeenCalledWith('water');
ui.patch('balance', { text: 'test' });
ui.dispose();
vi.runAllTimers();
expect(store.getState().interface.nodes.balance).toBeUndefined();
ui.emit('select-product', 'water');
expect(handler).toHaveBeenCalledTimes(1);
});
});
+553
View File
@@ -0,0 +1,553 @@
import * as THREE from 'three';
// Detailed vehicles for the family and the factory. All face local +X and sit on y = 0, like
// createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
/**
* Runs the mat operation for this subsystem.
* @param {*} color - color.
* @param {*} extra - extra.
* @returns {*} Operation result, when applicable.
*/
const mat = (color, extra = {}) =>
new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
...extra,
});
// A side profile (x along the car, y up) extruded across its width, with rounded edges.
/**
* Runs the extrude profile operation for this subsystem.
* @param {*} points - points.
* @param {*} width - width.
* @param {*} material - material.
* @param {*} bevel - bevel.
* @returns {*} Operation result, when applicable.
*/
function extrudeProfile(points, width, material, bevel = 0.1) {
const shape = new THREE.Shape();
points.forEach(([x, y], i) => (i ? shape.lineTo(x, y) : shape.moveTo(x, y)));
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: width - bevel * 2,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel * 0.8,
bevelSegments: 3,
curveSegments: 6,
});
geometry.translate(0, 0, -(width - bevel * 2) / 2);
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
}
// Lower-body outline with wheel arches cut around the wheel centres.
/**
* Runs the body outline operation for this subsystem.
* @param {*} front - front.
* @param {*} rear - rear.
* @param {*} arches - arches.
* @param {*} radius - radius.
* @param {*} top - top.
* @returns {*} Operation result, when applicable.
*/
function bodyOutline(front, rear, arches, radius, top) {
const shape = new THREE.Shape();
shape.moveTo(rear[0][0], rear[0][1]);
const bottom = 0.3;
// Underside from the rear towards the front, arching over each wheel.
for (const ax of [...arches].sort((a, b) => a - b)) {
shape.lineTo(ax - radius, bottom);
shape.absarc(ax, 0.36, radius, Math.PI + 0.14, -0.14, true);
}
for (const [x, y] of front) shape.lineTo(x, y);
for (const [x, y] of top) shape.lineTo(x, y);
for (const [x, y] of rear.slice(1).reverse()) shape.lineTo(x, y);
return shape;
}
/**
* Runs the add wheels operation for this subsystem.
* @param {*} group - group.
* @param {*} positions - positions.
* @param {*} radius - radius.
* @param {*} tyreWidth - tyre Width.
* @param {*} rimColor - rim Color.
* @param {*} spokes - spokes.
* @returns {*} Operation result, when applicable.
*/
function addWheels(group, positions, radius, tyreWidth, rimColor = '#c9cdd1', spokes = 5) {
const tyre = mat('#151719', {
roughness: 0.9,
}),
rim = mat(rimColor, {
metalness: 0.85,
roughness: 0.25,
}),
brake = mat('#3a3d40', {
metalness: 0.6,
});
const wheels = [];
for (const [x, z] of positions) {
const wheel = new THREE.Group();
wheel.position.set(x, radius, z);
const t = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, tyreWidth, 24), tyre);
t.rotation.x = Math.PI / 2;
t.castShadow = true;
wheel.add(t);
const side = Math.sign(z);
const face = new THREE.Mesh(
new THREE.CylinderGeometry(radius * 0.68, radius * 0.68, 0.02, 24),
brake,
);
face.rotation.x = Math.PI / 2;
face.position.z = side * tyreWidth * 0.44;
wheel.add(face);
for (let i = 0; i < spokes; i++) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.25, radius * 0.16, 0.04), rim);
spoke.position.z = side * tyreWidth * 0.5;
spoke.rotation.z = (i * Math.PI) / spokes;
wheel.add(spoke);
}
const hub = new THREE.Mesh(
new THREE.CylinderGeometry(radius * 0.2, radius * 0.2, 0.05, 12),
rim,
);
hub.rotation.x = Math.PI / 2;
hub.position.z = side * tyreWidth * 0.52;
wheel.add(hub);
group.add(wheel);
wheels.push(wheel);
}
return wheels;
}
/**
* Sports saloon in the style of a German executive car: long bonnet, twin kidney grille,
* ring-shaped "angel eye" headlights, kinked rear side window and five-spoke rims. 4.7 m long.
* No manufacturer badges.
*/
export function createSportSedan(color = '#1d3f78') {
const group = new THREE.Group();
const paint = mat(color, {
metalness: 0.7,
roughness: 0.28,
}),
glass = mat('#0f1a22', {
metalness: 0.7,
roughness: 0.08,
});
const black = mat('#0b0c0d', {
roughness: 0.6,
}),
chrome = mat('#d8dcdf', {
metalness: 0.95,
roughness: 0.15,
});
const W = 1.86;
const outline = bodyOutline(
[
[2.24, 0.3],
[2.36, 0.42],
[2.38, 0.62],
[2.28, 0.78],
],
[
[-2.3, 0.3],
[-2.36, 0.55],
[-2.3, 0.86],
[-2.12, 0.97],
],
[-1.42, 1.43],
0.41,
[
[1.9, 0.86],
[0.95, 1.0],
[-1.2, 1.0],
[-1.6, 0.99],
],
);
const bodyGeometry = new THREE.ExtrudeGeometry(outline, {
depth: W - 0.2,
bevelEnabled: true,
bevelThickness: 0.1,
bevelSize: 0.08,
bevelSegments: 3,
curveSegments: 10,
});
bodyGeometry.translate(0, 0, -(W - 0.2) / 2);
const body = new THREE.Mesh(bodyGeometry, paint);
body.castShadow = body.receiveShadow = true;
group.add(body);
// Greenhouse: windscreen, roof and the fast rear window, narrower than the body.
const cabin = extrudeProfile(
[
[0.95, 0.98],
[0.05, 1.39],
[-0.9, 1.41],
[-1.62, 1.0],
[-1.5, 0.98],
],
1.5,
glass,
0.08,
);
group.add(cabin);
const roof = extrudeProfile(
[
[0.0, 1.37],
[-0.88, 1.39],
[-1.0, 1.32],
[0.12, 1.32],
],
1.36,
paint,
0.06,
);
roof.position.y = 0.045;
group.add(roof);
const pillar = new THREE.ShapeGeometry(
new THREE.Shape([
new THREE.Vector2(-1.05, 1.0),
new THREE.Vector2(-1.55, 1.0),
new THREE.Vector2(-1.08, 1.33),
new THREE.Vector2(-0.96, 1.33),
new THREE.Vector2(-1.18, 1.12),
]),
);
const pillarPaint = paint.clone();
pillarPaint.side = THREE.DoubleSide;
// Pillars and the kinked C-pillar ("Hofmeister" kink) on both sides.
for (const side of [-1, 1]) {
const z = side * 0.72;
const b = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.4, 0.04), black);
b.position.set(-0.35, 1.18, z);
group.add(b);
const c = new THREE.Mesh(pillar, pillarPaint);
c.position.z = z + side * 0.035;
group.add(c);
// Door handles, mirror and the chrome window line.
for (const x of [0.15, -0.75]) {
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.04, 0.03), chrome);
handle.position.set(x, 0.9, side * 0.94);
group.add(handle);
}
const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.12, 0.2), paint);
mirror.position.set(0.78, 1.05, side * 1.0);
group.add(mirror);
const trim = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.025, 0.02), chrome);
trim.position.set(-0.3, 0.985, side * 0.76);
group.add(trim);
const skirt = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.08, 0.04), black);
skirt.position.set(0, 0.34, side * 0.93);
group.add(skirt);
}
// Twin kidney grille with chrome surrounds.
for (const side of [-1, 1]) {
const surround = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.2, 0.3), chrome);
surround.position.set(2.46, 0.66, side * 0.18);
group.add(surround);
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.16, 0.25), black);
grille.position.set(2.47, 0.66, side * 0.18);
group.add(grille);
for (let i = 0; i < 5; i++) {
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.075, 0.15, 0.012), chrome);
bar.position.set(2.47, 0.66, side * 0.18 + (i - 2) * 0.05);
group.add(bar);
}
}
// Headlights with two light rings each, and slim L-shaped tail lights.
const ring = mat('#eaf6ff', {
emissive: '#cfe9ff',
emissiveIntensity: 1.6,
});
const tail = mat('#9e1410', {
emissive: '#e0261c',
emissiveIntensity: 0.9,
});
for (const side of [-1, 1]) {
const housing = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.14, 0.44), black);
housing.position.set(2.42, 0.72, side * 0.6);
group.add(housing);
for (const dz of [-0.1, 0.1]) {
const eye = new THREE.Mesh(new THREE.TorusGeometry(0.05, 0.012, 6, 18), ring);
eye.rotation.y = Math.PI / 2;
eye.position.set(2.48, 0.72, side * 0.6 + dz);
group.add(eye);
}
const tl = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.1, 0.42), tail);
tl.position.set(-2.45, 0.82, side * 0.62);
group.add(tl);
const tlSide = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.1, 0.05), tail);
tlSide.position.set(-2.2, 0.82, side * 0.9);
group.add(tlSide);
const exhaust = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.12, 12), chrome);
exhaust.rotation.z = Math.PI / 2;
exhaust.position.set(-2.44, 0.36, side * 0.45);
group.add(exhaust);
}
const lowerGrille = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.12, 1.2), black);
lowerGrille.position.set(2.45, 0.42, 0);
group.add(lowerGrille);
const plate = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.11, 0.5), mat('#f4f4f0'));
plate.position.set(2.47, 0.5, 0);
group.add(plate);
const wheels = addWheels(
group,
[
[-1.42, -0.82],
[-1.42, 0.82],
[1.43, -0.82],
[1.43, 0.82],
],
0.36,
0.26,
'#b8bcc0',
5,
);
return {
group,
wheels,
};
}
/**
* Double-cab pickup in the style of the KMC T8: tall square bonnet, wide chrome grille, a cargo
* bed behind the cab. 5.3 m long.
*/
export function createPickup(color = '#e8e8e4') {
const group = new THREE.Group();
const paint = mat(color, {
metalness: 0.5,
roughness: 0.32,
}),
glass = mat('#13202a', {
metalness: 0.6,
roughness: 0.1,
});
const black = mat('#1a1c1e', {
roughness: 0.7,
}),
chrome = mat('#d0d4d7', {
metalness: 0.9,
roughness: 0.2,
});
/**
* Runs the box operation for this subsystem.
* @param {*} w - w.
* @param {*} h - h.
* @param {*} d - d.
* @param {*} m - m.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
group.add(
extrudeProfile(
[
[2.62, 0.5],
[2.66, 0.95],
[2.5, 1.18],
[1.3, 1.24],
[1.3, 0.5],
],
1.9,
paint,
0.1,
),
);
group.add(
extrudeProfile(
[
[1.3, 0.5],
[1.3, 1.24],
[0.7, 1.86],
[-0.95, 1.88],
[-1.0, 0.5],
],
1.88,
paint,
0.1,
),
);
group.add(
extrudeProfile(
[
[1.24, 1.26],
[0.68, 1.82],
[-0.9, 1.84],
[-0.94, 1.26],
],
1.92,
glass,
0.02,
),
);
// Cargo bed: floor, sides and tailgate.
box(1.62, 0.1, 1.8, black, -1.85, 0.72, 0);
for (const side of [-1, 1]) box(1.66, 0.5, 0.07, paint, -1.85, 0.95, side * 0.9);
box(0.07, 0.5, 1.86, paint, -2.66, 0.95, 0);
box(1.66, 0.46, 1.9, paint, -1.85, 0.5, 0);
box(0.3, 0.06, 1.86, black, -1.02, 1.26, 0);
// Front: wide grille with horizontal chrome bars, headlights, bumper and skid plate.
box(0.06, 0.34, 1.1, black, 2.67, 0.92, 0);
for (let i = 0; i < 3; i++) box(0.07, 0.04, 1.12, chrome, 2.68, 0.82 + i * 0.1, 0);
const lamp = mat('#eaf6ff', {
emissive: '#cfe9ff',
emissiveIntensity: 1.2,
});
for (const side of [-1, 1]) {
box(0.08, 0.14, 0.32, lamp, 2.62, 1.02, side * 0.74);
box(
0.06,
0.26,
0.1,
mat('#9e1410', {
emissive: '#c4241b',
emissiveIntensity: 0.7,
}),
-2.7,
1.0,
side * 0.86,
);
box(0.2, 0.14, 0.2, black, 1.2, 1.36, side * 1.02);
box(1.0, 0.05, 0.06, chrome, -0.1, 1.25, side * 0.97);
for (const x of [0.6, -0.4]) box(0.16, 0.04, 0.03, chrome, x, 1.12, side * 0.97);
}
box(0.16, 0.2, 2.0, black, 2.7, 0.55, 0);
box(0.16, 0.2, 2.0, black, -2.72, 0.55, 0);
box(0.04, 0.12, 0.5, mat('#f4f4f0'), 2.79, 0.6, 0);
const wheels = addWheels(
group,
[
[-1.7, -0.86],
[-1.7, 0.86],
[1.7, -0.86],
[1.7, 0.86],
],
0.4,
0.3,
'#a9adb1',
6,
);
for (const x of [-1.7, 1.7])
for (const side of [-1, 1]) box(1.05, 0.1, 0.2, black, x, 0.86, side * 0.96);
return {
group,
wheels,
};
}
/**
* Flatbed delivery lorry with side rails. `bed` is a group on the load floor (local +X towards the
* cab, origin at the bed's centre) where cargo is placed. 8 m long.
*/
export function createTruck(color = '#e9eef0') {
const group = new THREE.Group();
const paint = mat(color, {
metalness: 0.35,
roughness: 0.4,
}),
glass = mat('#14212b', {
metalness: 0.6,
roughness: 0.1,
});
const black = mat('#1b1d1f', {
roughness: 0.7,
}),
teal = mat('#0b8f88', {
metalness: 0.3,
roughness: 0.4,
});
/**
* Runs the box operation for this subsystem.
* @param {*} w - w.
* @param {*} h - h.
* @param {*} d - d.
* @param {*} m - m.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @param {*} parent - parent.
* @returns {*} Operation result, when applicable.
*/
const box = (w, h, d, m, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
};
// Cab over the front axle.
box(1.9, 2.1, 2.3, paint, 2.95, 1.95, 0);
box(0.05, 0.9, 2.0, glass, 3.91, 2.35, 0);
for (const side of [-1, 1]) box(1.0, 0.7, 0.04, glass, 3.1, 2.4, side * 1.16);
box(0.08, 0.3, 2.2, teal, 3.92, 1.35, 0);
box(0.25, 0.35, 2.36, black, 3.95, 0.85, 0);
const lamp = mat('#eaf6ff', {
emissive: '#cfe9ff',
emissiveIntensity: 1,
});
for (const side of [-1, 1]) {
box(0.06, 0.16, 0.3, lamp, 3.98, 1.1, side * 0.9);
box(0.3, 0.3, 0.08, black, 3.4, 2.7, side * 1.25);
}
// Chassis and the flatbed with low side rails.
box(7.6, 0.3, 1.0, black, 0, 0.75, 0);
box(
5.9,
0.14,
2.4,
mat('#6b5a45', {
roughness: 0.9,
}),
-1.05,
1.0,
0,
);
for (const side of [-1, 1]) box(5.9, 0.5, 0.06, teal, -1.05, 1.32, side * 1.19);
box(0.06, 0.5, 2.4, teal, -4.0, 1.32, 0);
box(0.1, 1.6, 2.4, black, 1.9, 1.85, 0);
for (const side of [-1, 1])
box(
0.1,
0.22,
0.26,
mat('#9e1410', {
emissive: '#c4241b',
emissiveIntensity: 0.6,
}),
-4.02,
0.82,
side * 0.95,
);
const bed = new THREE.Group();
bed.position.set(-1.05, 1.07, 0);
group.add(bed);
const wheels = addWheels(
group,
[
[-2.9, -1.0],
[-2.9, 1.0],
[2.9, -1.0],
[2.9, 1.0],
],
0.5,
0.38,
'#c2c6c9',
8,
);
return {
group,
wheels,
bed,
};
}
+559
View File
@@ -0,0 +1,559 @@
import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the group.
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
/**
* Creates family resources for the game.
* @param {*} options - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function createFamily({ robot: robotOnly = false } = {}) {
const build = {
robotOnly,
};
for (const [name, method] of Object.entries(FAMILY_METHODS))
build[name] = (...args) => method(build, ...args);
for (const initialize of FAMILY_STEPS) initialize(build);
return build.result;
}
/**
* Runs the family shape operation for this subsystem.
* @param {*} build - build.
* @param {*} parent - parent.
* @param {*} geometry - geometry.
* @param {*} material - material.
* @param {*} position - position.
* @returns {*} Operation result, when applicable.
*/
function familyShape(build, parent, geometry, material, position) {
const m = new THREE.Mesh(geometry, material);
m.position.set(...position);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
/**
* Runs the family sphere operation for this subsystem.
* @param {*} build - build.
* @param {*} parent - parent.
* @param {*} r - r.
* @param {*} material - material.
* @param {*} pos - pos.
* @param {*} scale - scale.
* @returns {*} Operation result, when applicable.
*/
function familySphere(build, parent, r, material, pos, scale = [1, 1, 1]) {
const m = build.shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
m.scale.set(...scale);
return m;
}
/**
* Runs the family segment operation for this subsystem.
* @param {*} build - build.
* @param {*} parent - parent.
* @param {*} a - a.
* @param {*} b - b.
* @param {*} r - r.
* @param {*} material - material.
* @returns {*} Operation result, when applicable.
*/
function familySegment(build, parent, a, b, r, material) {
const mesh = build.shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
return {
mesh,
a,
b,
};
}
/**
* Runs the family update segment operation for this subsystem.
* @param {*} build - build.
* @param {*} bone - bone.
* @param {*} a - a.
* @param {*} b - b.
* @returns {*} Operation result, when applicable.
*/
function familyUpdateSegment(build, bone, a, b) {
const delta = b.clone().sub(a);
bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
bone.mesh.scale.y = delta.length();
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
}
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
/**
* Runs the family make robot operation for this subsystem.
* @param {*} build - build.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
function familyMakeRobot(build, z) {
const scale = 0.69,
root = new THREE.Group(),
torso = new THREE.Group();
root.name = 'robot-market-mascot';
root.position.z = z;
build.group.add(root);
root.add(torso);
const teal = new THREE.MeshStandardMaterial({
color: '#079f98',
metalness: 0.4,
roughness: 0.28,
});
const white = new THREE.MeshStandardMaterial({
color: '#edf6f4',
metalness: 0.22,
roughness: 0.32,
});
const joint = new THREE.MeshStandardMaterial({
color: '#102b30',
metalness: 0.5,
roughness: 0.35,
});
const glow = new THREE.MeshStandardMaterial({
color: '#c6f7ff',
emissive: '#23bcff',
emissiveIntensity: 2.5,
roughness: 0.2,
});
const visor = new THREE.MeshStandardMaterial({
color: '#020d16',
metalness: 0.5,
roughness: 0.16,
});
build.sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
build.sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
for (const side of [-1, 1])
build.sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
build.shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
const head = new THREE.Group();
head.position.y = 1.61;
torso.add(head);
build.sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
build.sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
build.sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
const eyes = [];
/**
* Runs the arc operation for this subsystem.
* @param {*} parent - parent.
* @param {*} radius - radius.
* @param {*} thickness - thickness.
* @param {*} pos - pos.
* @param {*} rotation - rotation.
* @param {*} material - material.
* @returns {*} Operation result, when applicable.
*/
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
const line = build.shape(
parent,
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
material,
pos,
);
line.rotation.z = rotation;
return line;
}
for (const side of [-1, 1]) {
eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
const ear = build.shape(head, new THREE.CylinderGeometry(0.14, 0.14, 0.085, 24), joint, [
side * 0.47,
0,
0,
]);
ear.rotation.z = Math.PI / 2;
const ring = build.shape(head, new THREE.TorusGeometry(0.105, 0.012, 8, 24), glow, [
side * 0.518,
0,
0,
]);
ring.rotation.y = Math.PI / 2;
build.sphere(head, 0.12, teal, [side * 0.49, 0, 0], [0.4, 1, 1]);
}
arc(head, 0.075, 0.016, [0, -0.085, 0.425], Math.PI);
build.sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]);
build.shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]);
build.sphere(head, 0.055, teal, [0, 0.6, 0]);
// Draw the chest mark as a texture so it remains attached to the 3D body.
if (typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const c = canvas.getContext('2d');
if (c) {
c.strokeStyle = '#102b30';
c.fillStyle = '#102b30';
c.lineWidth = 12;
c.lineJoin = 'round';
c.beginPath();
c.moveTo(65, 40);
c.lineTo(82, 40);
c.lineTo(99, 110);
c.lineTo(170, 110);
c.lineTo(186, 58);
c.lineTo(89, 58);
c.stroke();
for (const x of [111, 162]) {
c.beginPath();
c.arc(x, 130, 9, 0, Math.PI * 2);
c.fill();
}
c.textAlign = 'center';
c.font = 'bold 34px sans-serif';
c.fillText('ROBOT', 128, 181);
c.fillText('MARKET', 128, 218);
build.shape(
torso,
new THREE.PlaneGeometry(0.31, 0.31),
new THREE.MeshBasicMaterial({
map: new THREE.CanvasTexture(canvas),
transparent: true,
depthWrite: false,
}),
[0, 0.99, 0.224],
);
}
}
const legs = [-1, 1].map((side) => {
const hip = new THREE.Group();
hip.position.set(side * 0.15, 0.65, 0);
root.add(hip);
build.sphere(hip, 0.09, joint, [0, 0, 0]);
build.shape(hip, new THREE.CapsuleGeometry(0.095, 0.12, 4, 12), white, [0, -0.12, 0]);
const knee = new THREE.Group();
knee.position.y = -0.25;
hip.add(knee);
build.sphere(knee, 0.105, teal, [0, 0, 0.035]);
build.shape(knee, new THREE.CapsuleGeometry(0.1, 0.12, 4, 12), white, [0, -0.13, 0]);
const foot = build.sphere(knee, 0.13, teal, [0, -0.29, 0.07], [1, 0.65, 1.6]);
build.sphere(knee, 0.13, joint, [0, -0.34, 0.07], [1.06, 0.22, 1.65]);
return {
hip,
knee,
foot,
};
});
const arms = [-1, 1].map(() => ({
upper: build.segment(build.group, null, null, 0.09, teal),
lower: build.segment(build.group, null, null, 0.085, white),
hand: build.sphere(build.group, 0.09, joint, [0, 0, 0], [1, 1.1, 1]),
}));
return {
root,
torso,
head,
legs,
arms,
scale,
z,
eyes,
};
}
/**
* Runs the family make person operation for this subsystem.
* @param {*} build - build.
* @param {*} isChild - is Child.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
function familyMakePerson(build, isChild, z) {
const scale = isChild ? 0.69 : 1.05,
root = new THREE.Group();
root.position.z = z;
build.group.add(root);
const torso = new THREE.Group();
root.add(torso);
const cloth = build.mat(isChild ? '#d98150' : '#e4c695'),
pants = build.mat(isChild ? '#527463' : '#496474');
// Tapered jacket, hips and neck replace the single rounded toy-like body.
build.shape(
torso,
new THREE.CylinderGeometry(0.255 * scale, 0.22 * scale, 0.62 * scale, 16),
cloth,
[0, 1.2 * scale, 0],
).scale.z = 0.72;
build.sphere(torso, 0.23, pants, [0, 0.88 * scale, 0], [scale, 0.55 * scale, 0.72 * scale]);
build.shape(
torso,
new THREE.CylinderGeometry(0.075 * scale, 0.09 * scale, 0.18 * scale, 12),
build.skin,
[0, 1.59 * scale, 0],
);
build.shape(
torso,
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
build.mat('#bda77f'),
[0, 1.2 * scale, 0.19 * scale],
);
for (const side of [-1, 1]) {
const collar = build.shape(
torso,
new THREE.BoxGeometry(0.12 * scale, 0.09 * scale, 0.025 * scale),
cloth,
[side * 0.07 * scale, 1.49 * scale, 0.15 * scale],
);
collar.rotation.z = side * 0.4;
}
const head = new THREE.Group();
head.position.y = 1.77 * scale;
torso.add(head);
head.scale.setScalar(isChild ? 0.9 : 0.77);
const eyes = [];
build.sphere(head, 0.27 * scale, build.skin, [0, 0, 0]);
build.sphere(head, 0.275 * scale, build.hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]);
build.sphere(head, 0.058 * scale, build.skin, [0, -0.02 * scale, 0.25 * scale]);
for (const side of [-1, 1]) {
build.sphere(head, 0.06 * scale, build.skin, [side * 0.26 * scale, 0, 0]);
build.sphere(
head,
0.037 * scale,
build.mat('#f6f1e7'),
[side * 0.09 * scale, 0.03 * scale, 0.241 * scale],
[1, 0.65, 0.5],
);
const eye = build.sphere(
head,
0.022 * scale,
build.shoes,
[side * 0.09 * scale, 0.03 * scale, 0.258 * scale],
[0.8, 1, 0.45],
);
eyes.push(eye);
const brow = build.shape(
head,
new THREE.BoxGeometry(0.075 * scale, 0.015 * scale, 0.015 * scale),
build.hair,
[side * 0.09 * scale, 0.087 * scale, 0.237 * scale],
);
brow.rotation.z = side * -0.08;
build.sphere(head, 0.04 * scale, build.mat('#d79379'), [
side * 0.15 * scale,
-0.055 * scale,
0.215 * scale,
]);
}
const smile = build.shape(
head,
new THREE.TorusGeometry(0.068 * scale, 0.012 * scale, 6, 14, Math.PI),
build.shoes,
[0, -0.075 * scale, 0.252 * scale],
);
smile.rotation.z = Math.PI;
if (isChild) {
build.sphere(head, 0.29 * scale, build.mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
build.shape(
head,
new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale),
build.mat('#e8b857'),
[0, 0.13 * scale, 0.12 * scale],
);
} else {
build.sphere(torso, 0.22, build.mat('#a77950'), [0, 1.2 * scale, -0.24 * scale], [1, 1.2, 0.5]);
}
const legs = [-1, 1].map((side) => {
const hip = new THREE.Group();
hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
root.add(hip);
build.shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
0,
-0.16 * scale,
0,
]);
const knee = new THREE.Group();
knee.position.y = -0.34 * scale;
hip.add(knee);
build.shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
0,
-0.16 * scale,
0,
]);
const foot = build.shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), build.shoes, [
0,
-0.32 * scale,
0.065 * scale,
]);
foot.scale.set(0.85, 0.65, 1.5);
return {
hip,
knee,
foot,
};
});
const arms = [-1, 1].map(() => ({
upper: build.segment(build.group, null, null, 0.072 * scale, cloth),
lower: build.segment(build.group, null, null, 0.061 * scale, build.skin),
hand: build.sphere(build.group, 0.075 * scale, build.skin, [0, 0, 0]),
}));
return {
root,
torso,
head,
legs,
arms,
scale,
z,
eyes,
};
}
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
// `wave` raises the robot's right hand, `point` stretches it forward.
/**
* Runs the family update operation for this subsystem.
* @param {*} build - build.
* @param {*} options1 - Operation options.
* @returns {*} Operation result, when applicable.
*/
function familyUpdate(
build,
{
time,
gait,
moving,
facing = Math.PI / 2,
shopping,
delivery,
finished,
talking = false,
wave = false,
point = false,
dt = 1 / 60,
},
) {
build.actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7),
isRobot = actor === build.robot;
actor.root.position.x = THREE.MathUtils.lerp(
actor.root.position.x,
finished ? (index === 0 ? -0.55 : 0.55) : shopping && !isRobot ? [-0.55, 0.25][index] : 0,
blend,
);
// The robot hovers; it bobs faster while it is speaking.
const hover = isRobot ? 0.35 + Math.sin(time * (talking ? 6 : 2.2)) * 0.07 : 0;
actor.root.position.y = THREE.MathUtils.lerp(
actor.root.position.y,
finished ? -0.6 * actor.scale : hover,
isRobot ? 1 : blend,
);
actor.root.position.z = finished ? 0 : actor.z;
actor.root.rotation.y = facing;
const bob = moving ? Math.abs(Math.sin(gait)) * 0.022 : Math.sin(time * 2) * 0.006;
actor.torso.position.y = bob;
actor.torso.rotation.z = moving ? Math.sin(gait) * 0.018 : 0;
const blinking = Math.sin(time * 1.3 + index) > 0.993;
actor.eyes.forEach((eye) => (eye.scale.y = blinking ? 0.12 : 1));
actor.head.rotation.y = shopping
? -0.65
: isRobot
? Math.sin(time * 0.9) * 0.35
: Math.sin(time * 0.7) * 0.06;
actor.torso.rotation.x = delivery && actor === build.child ? 0.22 : 0;
actor.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI;
const stride = isRobot ? 0.08 : 0.34;
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
leg.foot.rotation.x = moving ? Math.sin(phase + 0.5) * 0.12 : 0;
leg.knee.rotation.x = finished
? 2.5
: isRobot
? 0.25
: moving
? Math.max(0, -Math.sin(phase)) * 0.5
: 0.05;
});
});
build.group.updateMatrixWorld(true);
/**
* Runs the to family operation for this subsystem.
* @param {*} actor - actor.
* @param {*} point - point.
* @returns {*} Operation result, when applicable.
*/
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
/**
* Runs the shoulder operation for this subsystem.
* @param {*} actor - actor.
* @param {*} side - side.
* @returns {*} Operation result, when applicable.
*/
const shoulder = (actor, side) =>
toFamily(
actor,
new THREE.Vector3(
side * 0.29 * actor.scale,
(actor === build.robot ? 1.12 : 1.43) * actor.scale,
0,
),
);
// With characters facing +X, father's left and child's right hands meet.
const contact = new THREE.Vector3(0, 1.06, 0);
build.actors.forEach((actor) =>
actor.arms.forEach((arm, i) => {
const side = i === 0 ? -1 : 1,
inside = actor === build.father ? side === -1 : actor === build.child && side === 1;
const a = shoulder(actor, side);
let target;
if (inside && !shopping && !finished) target = contact.clone();
else if (actor === build.child && shopping && side === -1)
target = new THREE.Vector3(0.5, delivery ? 0.65 : 1.1, -0.15);
else if (actor === build.robot && side === 1 && wave)
target = toFamily(actor, new THREE.Vector3(0.42, 1.62 + Math.sin(time * 9) * 0.07, 0.12));
else if (actor === build.robot && side === 1 && point)
target = toFamily(actor, new THREE.Vector3(0.3, 1.25, 0.62));
else
target = toFamily(
actor,
new THREE.Vector3(
side * 0.34 * actor.scale,
0.88 * actor.scale,
moving ? Math.sin(gait + (i ? Math.PI : 0)) * 0.2 : 0,
),
);
const elbow = a
.clone()
.lerp(target, 0.5)
.add(new THREE.Vector3(0.07, 0, inside ? 0 : 0.07));
build.updateSegment(arm.upper, a, elbow);
build.updateSegment(arm.lower, elbow, target);
arm.hand.position.copy(target);
}),
);
}
/**
* Runs the family build group operation for this subsystem.
* @param {*} build - build.
* @returns {*} Operation result, when applicable.
*/
function familyBuildGroup(build) {
build.group = new THREE.Group();
build.mat = (color) =>
new THREE.MeshStandardMaterial({
color,
roughness: 0.82,
});
build.skin = build.mat('#edbd98');
build.hair = build.mat('#49382d');
build.shoes = build.mat('#273c36');
build.father = build.robotOnly ? null : build.makePerson(false, -0.45);
build.child = build.robotOnly ? null : build.makePerson(true, 0.45);
build.robot = build.robotOnly ? build.makeRobot(0) : null;
build.actors = build.robotOnly ? [build.robot] : [build.father, build.child];
build.update({
time: 0,
gait: 0,
moving: false,
});
build.result = {
group: build.group,
update: build.update,
};
}
const FAMILY_METHODS = {
shape: familyShape,
sphere: familySphere,
segment: familySegment,
updateSegment: familyUpdateSegment,
makeRobot: familyMakeRobot,
makePerson: familyMakePerson,
update: familyUpdate,
};
const FAMILY_STEPS = [familyBuildGroup];
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
import { FACTORY } from '../../constants/locations.js';
import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js';
import { createVendingMachine } from './vending.js';
import { createFamily } from './characters.js';
/** Projects factory-local coordinates into the shared world metre system.
* @param {number} u - Position along the factory axis.
* @param {number} v - Position normal to the factory axis.
* @returns {{x:number,z:number}} World position.
*/
function showroomPosition(u, v) {
return {
x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v,
z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v,
};
}
/** Creates one display-only sample, with a footprint and interaction position.
* @param {{scene:object,model:object,index:number}} options - Sample placement context.
* @returns {object} Sample data and model references.
*/
function createSample({ scene, model, index }) {
const u = -12 + index * 4,
v = -15;
const machine = createVendingMachine({
name: model.shortLabel,
channels: model.channels,
products: ['water', 'juice', 'biscuit', 'chocolate'],
});
const position = showroomPosition(u, v),
front = showroomPosition(u, v + 2);
machine.group.name = `showroom-${model.id}`;
machine.group.position.set(position.x, 0, position.z);
machine.group.rotation.y = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x);
machine.beacon.visible = machine.ring.visible = false;
scene.add(machine.group);
const footprint = [
[-1, -0.6],
[1, -0.6],
[1, 0.6],
[-1, 0.6],
].flatMap(([x, z]) => Object.values(showroomPosition(u + x, v + z)));
return {
model,
machine,
front,
position,
footprint,
};
}
/** Builds the factory sample row and its robot host; it does not register retail stations.
* @param {object} scene - Parent Three.js scene.
* @returns {object} Display samples, nearest lookup, and host update.
*/
export function createShowroom(scene) {
const samples = SHOWROOM_CATALOG.map((model, index) =>
createSample({
scene,
model,
index,
}),
);
const host = createFamily({
robot: true,
});
scene.add(host.group);
host.group.visible = false;
return {
samples,
nearest(player) {
return (
samples
.map((sample) => ({
sample,
distance: Math.hypot(sample.front.x - player.x, sample.front.z - player.z),
}))
.filter((item) => item.distance < SHOWROOM_REACH)
.sort((a, b) => a.distance - b.distance)[0]?.sample ?? null
);
},
update({ player, time, dt, enabled, talking }) {
const sample = this.nearest(player);
host.group.visible = Boolean(sample && enabled);
if (!sample || !enabled) return;
host.group.position.set(sample.position.x - 1.5, 0, sample.position.z + 1.5);
host.group.rotation.y = Math.atan2(
-(player.z - host.group.position.z),
player.x - host.group.position.x,
);
host.update({
dt,
time,
gait: 0,
moving: false,
talking,
wave: !talking,
point: talking,
});
},
};
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, afterEach } from 'vitest';
import { Scene } from 'three';
import { stubCanvas } from '../../tests/canvasStub.js';
import { FACTORY } from '../../constants/locations.js';
let mock;
afterEach(() => mock?.mockRestore());
describe('procedural factory and sample construction', () => {
it('constructs the factory, human family and three distinct channel-count samples', async () => {
mock = stubCanvas();
const { createFactory } = await import('./factory.js');
const { createFamily } = await import('./characters.js');
const { createShowroom } = await import('./showroom.js');
const scene = new Scene(),
factory = createFactory(scene),
family = createFamily(),
showroom = createShowroom(scene);
expect(factory.talks.length).toBeGreaterThan(5);
expect(factory.footprints.length).toBeGreaterThan(10);
expect(showroom.samples.map((sample) => sample.machine.group.userData.channels)).toEqual([
35, 48, 60,
]);
for (const sample of showroom.samples) {
const slots = [];
sample.machine.group.traverse((object) => {
if (object.userData.channel) slots.push(object.userData.channel);
});
expect(slots.length).toBe(sample.model.channels);
expect(new Set(slots).size).toBe(sample.model.channels);
}
for (const sample of showroom.samples)
expect(showroom.nearest(sample.front)?.model.id).toBe(sample.model.id);
family.update({ dt: 0.016, time: 1, gait: 1, moving: true });
family.group.updateMatrixWorld(true);
family.group.traverse((object) =>
expect(object.matrixWorld.elements.every(Number.isFinite)).toBe(true),
);
factory.update(1, 0.016, { x: FACTORY.center.x, y: 2, z: FACTORY.center.z });
showroom.update({
player: showroom.samples[0].front,
time: 1,
dt: 0.016,
enabled: true,
talking: true,
});
});
});
+444
View File
@@ -0,0 +1,444 @@
import * as THREE from 'three';
import { textTexture } from './textures.js';
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
// as a handful of instanced meshes, so forty of them cost only a few draw calls.
// People face their local +Z; `root.rotation.y` turns them.
const materials = new Map();
/**
* Runs the mat operation for this subsystem.
* @param {*} color - color.
* @param {*} extra - extra.
* @returns {*} Operation result, when applicable.
*/
const mat = (color, extra = {}) => {
const key = color + JSON.stringify(extra);
if (!materials.has(key))
materials.set(
key,
new THREE.MeshStandardMaterial({
color,
roughness: 0.8,
...extra,
}),
);
return materials.get(key);
};
/**
* Runs the part operation for this subsystem.
* @param {*} parent - parent.
* @param {*} geometry - geometry.
* @param {*} material - material.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
function part(parent, geometry, material, x = 0, y = 0, z = 0) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
mesh.castShadow = true;
parent.add(mesh);
return mesh;
}
/**
* outfit: 'hoodie' | 'suit' | 'manteau'. Colours: top (hoodie, jacket or manteau), bottom, skin,
* hair, scarf (manteau only), tie (suit only, optional), glasses.
*/
export function createPerson({
outfit = 'hoodie',
top = '#222',
bottom = '#2c3e5a',
skin = '#e2b18e',
hair = '#2a211c',
scarf = '#3b3f6b',
tie = null,
glasses = false,
shoes = '#f1f1f1',
} = {}) {
const root = new THREE.Group();
const pelvis = new THREE.Group();
pelvis.position.y = 0.94;
root.add(pelvis);
const topMat = mat(top),
bottomMat = mat(bottom),
skinMat = mat(skin),
hairMat = mat(hair);
const shoeMat = mat(outfit === 'hoodie' ? shoes : '#1a1a1a');
// Torso, neck and head.
const torso = new THREE.Group();
pelvis.add(torso);
const long = outfit === 'manteau';
part(torso, new THREE.CylinderGeometry(0.2, 0.17, 0.6, 12), topMat, 0, 0.3, 0).scale.z = 0.65;
if (long)
part(torso, new THREE.CylinderGeometry(0.18, 0.2, 0.3, 12), topMat, 0, -0.08, 0).scale.z = 0.7;
part(torso, new THREE.CylinderGeometry(0.05, 0.055, 0.1, 10), skinMat, 0, 0.64, 0);
if (outfit === 'hoodie') {
// Hood folded behind the neck, front pocket and drawstrings.
part(torso, new THREE.SphereGeometry(0.13, 12, 8), topMat, 0, 0.58, -0.1).scale.set(
1.2,
0.6,
0.7,
);
part(torso, new THREE.BoxGeometry(0.24, 0.12, 0.02), topMat, 0, 0.12, 0.125);
for (const side of [-1, 1])
part(
torso,
new THREE.CylinderGeometry(0.006, 0.006, 0.16, 4),
mat('#f2f2f2'),
side * 0.04,
0.48,
0.13,
);
} else if (outfit === 'suit') {
// White shirt opening, optional tie, lapels.
const shirt = part(
torso,
new THREE.PlaneGeometry(0.12, 0.2),
mat('#f4f4f2', {
side: THREE.DoubleSide,
}),
0,
0.49,
0.133,
);
shirt.rotation.x = -0.12;
if (tie) part(torso, new THREE.BoxGeometry(0.035, 0.22, 0.01), mat(tie), 0, 0.45, 0.138);
for (const side of [-1, 1]) {
const lapel = part(
torso,
new THREE.BoxGeometry(0.05, 0.22, 0.01),
mat(top, {
roughness: 0.5,
}),
side * 0.07,
0.47,
0.132,
);
lapel.rotation.z = side * 0.35;
}
}
const head = new THREE.Group();
head.position.y = 0.8;
torso.add(head);
part(head, new THREE.SphereGeometry(0.11, 16, 12), skinMat).scale.set(0.92, 1.05, 1);
part(head, new THREE.SphereGeometry(0.02, 6, 4), skinMat, 0, -0.01, 0.105);
for (const side of [-1, 1])
part(head, new THREE.SphereGeometry(0.012, 6, 4), mat('#1b1512'), side * 0.038, 0.02, 0.098);
if (long) {
// Headscarf: covers the hair and the neck, leaving the face open.
part(head, new THREE.SphereGeometry(0.128, 16, 12), mat(scarf), 0, 0.012, -0.022).scale.set(
1,
1.05,
1,
);
part(
head,
new THREE.CylinderGeometry(0.09, 0.17, 0.2, 14),
mat(scarf),
0,
-0.13,
-0.01,
).scale.z = 0.75;
part(
head,
new THREE.TorusGeometry(0.105, 0.018, 6, 20, Math.PI * 1.2),
mat(scarf),
0,
0.01,
0.04,
).rotation.set(0, 0, -0.1 * Math.PI);
} else {
part(head, new THREE.SphereGeometry(0.115, 14, 10), hairMat, 0, 0.035, -0.015).scale.set(
1,
0.75,
1,
);
}
if (glasses) {
const frame = mat('#222', {
metalness: 0.6,
roughness: 0.3,
});
for (const side of [-1, 1])
part(head, new THREE.TorusGeometry(0.024, 0.004, 4, 14), frame, side * 0.04, 0.02, 0.11);
part(head, new THREE.BoxGeometry(0.03, 0.004, 0.004), frame, 0, 0.022, 0.11);
}
// Arms: shoulder → elbow → hand. Rotation x < 0 swings forward.
const arms = [-1, 1].map((side) => {
const shoulder = new THREE.Group();
shoulder.position.set(side * 0.23, 0.55, 0);
torso.add(shoulder);
part(shoulder, new THREE.CapsuleGeometry(0.05, 0.2, 4, 8), topMat, 0, -0.14, 0);
const elbow = new THREE.Group();
elbow.position.y = -0.29;
shoulder.add(elbow);
part(elbow, new THREE.CapsuleGeometry(0.043, 0.18, 4, 8), topMat, 0, -0.12, 0);
part(elbow, new THREE.SphereGeometry(0.045, 8, 6), skinMat, 0, -0.27, 0);
return {
shoulder,
elbow,
};
});
// Legs: hip → knee → foot.
const legs = [-1, 1].map((side) => {
const hip = new THREE.Group();
hip.position.set(side * 0.095, 0, 0);
pelvis.add(hip);
part(hip, new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), long ? topMat : bottomMat, 0, -0.22, 0);
const knee = new THREE.Group();
knee.position.y = -0.45;
hip.add(knee);
part(knee, new THREE.CapsuleGeometry(0.058, 0.32, 4, 8), bottomMat, 0, -0.22, 0);
part(knee, new THREE.BoxGeometry(0.1, 0.07, 0.24), shoeMat, 0, -0.45, 0.05);
return {
hip,
knee,
};
});
const person = {
root,
head,
// pose: 'stand' | 'walk' | 'sit' | 'type'. `t` is a time in seconds, `phase` offsets it.
pose(kind, t = 0, phase = 0) {
const sitting = kind === 'sit' || kind === 'type';
pelvis.position.y = sitting
? 0.5
: 0.94 + (kind === 'walk' ? Math.abs(Math.sin(t * 6 + phase)) * 0.02 : 0);
torso.rotation.x = kind === 'type' ? 0.18 : sitting ? -0.05 : 0;
head.rotation.y =
kind === 'type' ? Math.sin(t * 0.4 + phase) * 0.15 : Math.sin(t * 0.6 + phase) * 0.2;
head.rotation.x = kind === 'type' ? 0.12 : 0;
legs.forEach(({ hip, knee }, i) => {
const swing = kind === 'walk' ? Math.sin(t * 6 + phase + i * Math.PI) * 0.45 : 0;
hip.rotation.x = sitting ? -Math.PI / 2 : swing;
knee.rotation.x = sitting
? Math.PI / 2
: kind === 'walk'
? Math.max(0, Math.sin(t * 6 + phase + i * Math.PI + 1)) * 0.5
: 0;
});
arms.forEach(({ shoulder, elbow }, i) => {
if (kind === 'type') {
// Hands on the keyboard, fingers busy.
shoulder.rotation.set(
-0.7 + Math.sin(t * 13 + phase + i * 2) * 0.025,
0,
(i ? 1 : -1) * -0.12,
);
elbow.rotation.x = -1.15;
} else if (kind === 'sit') {
shoulder.rotation.set(-0.3, 0, 0);
elbow.rotation.x = -1.0;
} else {
const swing =
kind === 'walk'
? -Math.sin(t * 6 + phase + i * Math.PI) * 0.4
: Math.sin(t * 1.2 + phase + i) * 0.03;
shoulder.rotation.set(swing, 0, (i ? 1 : -1) * 0.06);
elbow.rotation.x = kind === 'walk' ? -0.25 : -0.1;
}
});
},
};
person.pose('stand');
return person;
}
// Floating name badge above a person's head.
/**
* Runs the name tag operation for this subsystem.
* @param {*} name - name.
* @param {*} role - role.
* @returns {*} Operation result, when applicable.
*/
export function nameTag(name, role = '') {
const texture = textTexture(512, 160, (c, w, h) => {
c.clearRect(0, 0, w, h);
c.fillStyle = 'rgba(8, 24, 28, .82)';
c.beginPath();
c.roundRect(6, 6, w - 12, h - 12, 26);
c.fill();
c.fillStyle = '#3ee0c9';
c.fillRect(6, h - 18, w - 12, 8);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(name, w / 2, role ? 72 : 100);
if (role) {
c.fillStyle = '#9fe9da';
c.font = '40px Vazirmatn, Tahoma, sans-serif';
c.direction = 'ltr';
c.fillText(role, w / 2, 126);
}
});
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
}),
);
sprite.scale.set(1.3, 0.41, 1);
return sprite;
}
/**
* Production workers as instanced meshes, in the parent's local frame. Each worker:
* {x, z, facing, kind: 'work' | 'walk' | 'carry', to: {x, z} (walkers), suit: colour, hat: colour}.
*/
export function createCrew(parent, workers) {
const n = workers.length,
group = new THREE.Group();
parent.add(group);
/**
* Runs the instanced operation for this subsystem.
* @param {*} geometry - geometry.
* @param {*} material - material.
* @param {*} count - count.
* @returns {*} Operation result, when applicable.
*/
const instanced = (geometry, material, count = n) => {
const mesh = new THREE.InstancedMesh(geometry, material, count);
mesh.castShadow = true;
mesh.frustumCulled = false;
group.add(mesh);
return mesh;
};
const white = new THREE.MeshStandardMaterial({
color: '#ffffff',
roughness: 0.8,
});
const torso = instanced(new THREE.BoxGeometry(0.42, 0.6, 0.26), white);
const legs = instanced(
new THREE.BoxGeometry(0.15, 0.9, 0.17).translate(0, -0.45, 0),
white,
n * 2,
);
const arms = instanced(
new THREE.BoxGeometry(0.11, 0.62, 0.11).translate(0, -0.31, 0),
white,
n * 2,
);
const heads = instanced(
new THREE.SphereGeometry(0.12, 10, 8),
new THREE.MeshStandardMaterial({
color: '#ffffff',
roughness: 0.8,
}),
);
const hats = instanced(
new THREE.SphereGeometry(0.135, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2),
new THREE.MeshStandardMaterial({
color: '#ffffff',
roughness: 0.4,
}),
);
const skins = ['#e7b48f', '#d49f7a', '#c48a63', '#edc2a0'].map((c) => new THREE.Color(c));
const color = new THREE.Color();
workers.forEach((w, i) => {
color.set(w.suit);
torso.setColorAt(i, color);
color.set(w.pants ?? '#2d3a4d');
legs.setColorAt(i * 2, color);
legs.setColorAt(i * 2 + 1, color);
color.set(w.suit);
arms.setColorAt(i * 2, color);
arms.setColorAt(i * 2 + 1, color);
heads.setColorAt(i, skins[i % skins.length]);
color.set(w.hat ?? '#f2c94c');
hats.setColorAt(i, color);
w.phase = i * 1.37;
w.from = {
x: w.x,
z: w.z,
};
});
const base = new THREE.Matrix4(),
m = new THREE.Matrix4(),
local = new THREE.Matrix4(),
q = new THREE.Quaternion(),
up = new THREE.Vector3(0, 1, 0);
/**
* Runs the set limb operation for this subsystem.
* @param {*} mesh - mesh.
* @param {*} index - index.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} angle - angle.
* @returns {*} Operation result, when applicable.
*/
const setLimb = (mesh, index, x, y, angle) => {
local.makeRotationX(angle).setPosition(x, y, 0);
mesh.setMatrixAt(index, m.multiplyMatrices(base, local));
};
/**
* Updates update from the active simulation.
* @param {*} time - time.
* @returns {*} Operation result, when applicable.
*/
function update(time) {
workers.forEach((w, i) => {
let x = w.x,
z = w.z,
facing = w.facing,
walking = false;
if (w.kind === 'walk' || w.kind === 'carry') {
// Walk between two points, pausing at each end.
const dx = w.to.x - w.from.x,
dz = w.to.z - w.from.z,
len = Math.hypot(dx, dz);
const cycle = (len / 1.2) * 2 + 6,
c = (time + w.phase * 3) % cycle,
leg = len / 1.2;
let s;
if (c < leg) ((s = c / leg), (walking = true), (facing = Math.atan2(dx, dz)));
else if (c < leg + 3) ((s = 1), (facing = Math.atan2(dx, dz)));
else if (c < leg * 2 + 3)
((s = 1 - (c - leg - 3) / leg), (walking = true), (facing = Math.atan2(-dx, -dz)));
else ((s = 0), (facing = Math.atan2(-dx, -dz)));
x = w.from.x + dx * s;
z = w.from.z + dz * s;
}
const t = time + w.phase;
base.compose(
new THREE.Vector3(x, walking ? Math.abs(Math.sin(t * 6)) * 0.03 : 0, z),
q.setFromAxisAngle(up, facing),
new THREE.Vector3(1, 1, 1),
);
m.copy(base).multiply(local.makeTranslation(0, 1.2, 0));
torso.setMatrixAt(i, m);
m.copy(base).multiply(local.makeTranslation(0, 1.64, 0));
heads.setMatrixAt(i, m);
m.copy(base).multiply(local.makeTranslation(0, 1.7, 0));
hats.setMatrixAt(i, m);
for (const side of [0, 1]) {
const sign = side ? 1 : -1;
const legSwing = walking ? Math.sin(t * 6 + side * Math.PI) * 0.45 : 0;
setLimb(legs, i * 2 + side, sign * 0.1, 0.9, legSwing);
let arm;
if (w.kind === 'carry' && walking) arm = -1.1;
else if (walking) arm = -Math.sin(t * 6 + side * Math.PI) * 0.4;
// Working at a bench: both hands forward, one hammering or turning a tool.
else if (w.kind === 'work')
arm = -1.0 + (side ? Math.sin(t * 4) * 0.35 : Math.sin(t * 1.3) * 0.08);
else arm = -0.9;
setLimb(arms, i * 2 + side, sign * 0.27, 1.46, arm);
}
});
for (const mesh of [torso, legs, arms, heads, hats]) mesh.instanceMatrix.needsUpdate = true;
}
update(0);
for (const mesh of [torso, legs, arms, heads, hats])
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
return {
group,
update,
};
}
+29
View File
@@ -0,0 +1,29 @@
import * as THREE from 'three';
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
/**
* Runs the text texture operation for this subsystem.
* @param {*} width - width.
* @param {*} height - height.
* @param {*} draw - draw.
* @returns {*} Operation result, when applicable.
*/
export function textTexture(width, height, draw) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
/**
* Runs the paint operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
const paint = () => {
draw(canvas.getContext('2d'), width, height);
texture.needsUpdate = true;
};
paint();
document.fonts?.ready.then(paint);
return texture;
}
+258
View File
@@ -0,0 +1,258 @@
import * as THREE from 'three';
import { catalog } from '../economy/gameState.js';
import { textTexture } from './textures.js';
const PRODUCT_COLORS = {
juice: '#e5ae47',
water: '#9fd3e6',
milk: '#8a5a3c',
tea: '#c7783a',
lemonade: '#f0dc5a',
biscuit: '#c9935a',
chocolate: '#5a3524',
nuts: '#b9884f',
cake: '#e58fa6',
};
// Sign panel; the shared helper redraws it once the Persian web font has loaded.
/**
* Runs the label operation for this subsystem.
* @param {*} text - text.
* @param {*} sub - sub.
* @returns {*} Operation result, when applicable.
*/
const label = (text, sub) =>
textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9';
c.fillRect(0, height - 10, width, 10);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#e9fff8';
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
c.fillText(text, width / 2, 72);
c.fillStyle = '#9fe9da';
c.font = '34px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, width / 2, 126);
});
// A Robot Market machine. Local +z is the front face; the group is placed facing the path.
/**
* Creates vending machine resources for the game.
* @param {*} def - def.
* @returns {*} Operation result, when applicable.
*/
export function createVendingMachine(def) {
const group = new THREE.Group();
/**
* Runs the mat operation for this subsystem.
* @param {*} color - color.
* @param {*} extra - extra.
* @returns {*} Operation result, when applicable.
*/
const mat = (color, extra = {}) =>
new THREE.MeshStandardMaterial({
color,
roughness: 0.5,
...extra,
});
/**
* Runs the box operation for this subsystem.
* @param {*} w - w.
* @param {*} h - h.
* @param {*} d - d.
* @param {*} material - material.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @param {*} parent - parent.
* @returns {*} Operation result, when applicable.
*/
const box = (w, h, d, material, x, y, z, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
};
const shell = mat('#0b8f88', {
metalness: 0.45,
roughness: 0.3,
emissive: '#0b8f88',
emissiveIntensity: 0.15,
}),
white = mat('#eef5f3', {
metalness: 0.2,
roughness: 0.35,
});
const dark = mat('#13292b'),
steel = mat('#b7c1c0', {
metalness: 0.8,
roughness: 0.3,
});
box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
box(1.94, 0.32, 1.14, white, 0, 2.62, 0);
box(1.94, 0.12, 1.14, dark, 0, 0.06, 0);
// The lit interior glows instead of using a point light, which would cost every pixel in the scene.
box(
1.3,
1.8,
0.06,
mat('#13292b', {
emissive: '#2a7f73',
emissiveIntensity: 0.5,
}),
-0.2,
1.45,
0.56,
);
// Retail models retain 48 slots; showroom samples use their catalog channel count.
const rows = def.channels === 35 ? 5 : 6;
const columns = Math.ceil((def.channels ?? 48) / rows);
group.userData.channels = def.channels ?? 48;
for (let row = 0; row < rows; row++) {
const y = 0.78 + row * (1.4 / (rows - 1));
box(1.2, 0.02, 0.25, steel, -0.2, y, 0.45);
for (let col = 0; col < columns; col++) {
const id = def.products[(row * columns + col) % def.products.length];
const color = mat(PRODUCT_COLORS[id]);
let channelMesh;
if (catalog[id].type === 'drink') {
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.17, 8), color);
bottle.position.set(
-0.73 + col * (def.channels ? 1.06 / (columns - 1) : 0.15),
y + 0.1,
0.47,
);
group.add(bottle);
channelMesh = bottle;
} else
channelMesh = box(
0.09,
0.14,
0.06,
color,
-0.73 + col * (def.channels ? 1.06 / (columns - 1) : 0.15),
y + 0.08,
0.47,
);
channelMesh.userData.channel = row * columns + col + 1;
}
}
const glass = new THREE.Mesh(
new THREE.PlaneGeometry(1.32, 1.82),
new THREE.MeshStandardMaterial({
color: '#cdeeea',
transparent: true,
opacity: 0.16,
roughness: 0.05,
metalness: 0.3,
depthWrite: false,
}),
);
glass.position.set(-0.2, 1.45, 0.6);
group.add(glass);
// Card reader panel on the right of the front.
box(0.42, 1.8, 0.05, white, 0.7, 1.45, 0.56);
const screen = box(
0.3,
0.22,
0.02,
new THREE.MeshStandardMaterial({
color: '#7ef0d6',
emissive: '#2bbfa0',
emissiveIntensity: 0.8,
}),
0.7,
1.92,
0.6,
);
box(0.3, 0.2, 0.03, dark, 0.7, 1.55, 0.6);
box(0.22, 0.03, 0.04, steel, 0.7, 1.62, 0.62);
for (let r = 0; r < 3; r++)
for (let c = 0; c < 3; c++) box(0.07, 0.05, 0.02, steel, 0.62 + c * 0.08, 1.32 - r * 0.07, 0.6);
box(0.9, 0.3, 0.08, mat('#050f10'), -0.2, 0.38, 0.56);
const flap = new THREE.Group();
flap.position.set(-0.2, 0.53, 0.61);
group.add(flap);
box(
0.88,
0.28,
0.03,
mat('#4f6f68', {
metalness: 0.4,
}),
0,
-0.14,
0,
flap,
);
const sign = new THREE.Mesh(
new THREE.PlaneGeometry(1.86, 0.3),
new THREE.MeshBasicMaterial({
map: label('روبات مارکت', def.name),
}),
);
sign.position.set(0, 2.62, 0.575);
group.add(sign);
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.2, 10), mat('#e5ae47'));
delivery.visible = false;
group.add(delivery);
const card = box(
0.2,
0.13,
0.01,
mat('#e2d6a8', {
metalness: 0.3,
}),
0.95,
1.7,
0.66,
);
card.visible = false;
// Objective beacon: a tall column of light visible across the city, like a mission marker.
const beaconMaterial = new THREE.MeshBasicMaterial({
color: '#3ee0c9',
transparent: true,
opacity: 0.22,
depthWrite: false,
fog: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
});
const beacon = new THREE.Mesh(
new THREE.CylinderGeometry(0.7, 0.7, 90, 16, 1, true),
beaconMaterial,
);
beacon.position.y = 45;
group.add(beacon);
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.6, 1.9, 40),
new THREE.MeshBasicMaterial({
color: '#3ee0c9',
transparent: true,
opacity: 0.6,
depthWrite: false,
}),
);
ring.rotation.x = -Math.PI / 2;
ring.position.set(0, 0.09, 1.6);
group.add(ring);
return {
group,
flap,
delivery,
card,
screen,
beacon,
ring,
setDelivery(productId) {
delivery.material = mat(PRODUCT_COLORS[productId]);
delivery.scale.set(catalog[productId].type === 'drink' ? 1 : 1.6, 1, 1);
},
// Points in machine space, converted to world space by the caller.
readerPoint: new THREE.Vector3(0.7, 1.6, 0.62),
slotPoint: new THREE.Vector3(-0.2, 0.38, 0.66),
};
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Runs the say operation for this subsystem.
* @param {*} context - context.
* @param {*} speaker - speaker.
* @param {*} text - text.
* @param {*} face - face.
* @param {*} seconds - seconds.
* @returns {*} Operation result, when applicable.
*/
export // `face` picks the robot's expression portrait (public/robot/<face>.webp).
function say(context, speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * 0.06)) {
if (context.lines.some((l) => l.text === text)) return;
context.lines.push({
speaker,
text,
face,
seconds,
});
}
/**
* Updates subtitles from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateSubtitles(context, dt) {
context.lineTimer -= dt;
if (context.lineTimer > 0) return;
const next = context.lines.shift();
context.ui.patch('subtitle', {
hidden: !next,
});
context.talking = next?.speaker === 'robot';
if (!next) return;
context.ui.patch('subtitle-speaker', {
text: `${context.SPEAKERS[next.speaker]}:`,
});
context.ui.patch('subtitle-speaker', {
className: ['robot', 'father', 'child'].includes(next.speaker) ? next.speaker : 'staff',
});
context.ui.patch('subtitle-text', {
text: next.text,
});
context.ui.patch('subtitle-face', {
hidden: next.speaker !== 'robot',
});
if (next.speaker === 'robot')
context.ui.patch('subtitle-face', {
src: `./robot/${next.face}.webp`,
});
context.lineTimer = next.seconds;
}
// ---------- Sound ----------
/**
* Runs the tone operation for this subsystem.
* @param {*} context - context.
* @param {*} freq - freq.
* @param {*} duration - duration.
* @param {*} volume - volume.
* @param {*} type - type.
* @returns {*} Operation result, when applicable.
*/
export function tone(context, freq, duration = 0.12, volume = 0.05, type = 'sine') {
if (!context.soundOn) return;
const o = context.audio.createOscillator(),
g = context.audio.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, context.audio.currentTime);
o.frequency.exponentialRampToValueAtTime(freq * 0.7, context.audio.currentTime + duration);
g.gain.setValueAtTime(volume, context.audio.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, context.audio.currentTime + duration);
o.connect(g).connect(context.audio.destination);
o.start();
o.stop(context.audio.currentTime + duration);
}
+140
View File
@@ -0,0 +1,140 @@
import iran from '../../data/iran.json';
import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
import { drawFullMap, drawIran } from '../world/mapRender.js';
import { formatDistance } from '../../utils/geo.js';
/**
* Runs the size canvas operation for this subsystem.
* @param {*} context - context.
* @param {*} c - c.
* @returns {*} Operation result, when applicable.
*/
export function sizeCanvas(context, 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;
}
/**
* Runs the render map operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function renderMap(context) {
if (!context.ui.read('map-dialog').open) return;
if (context.mapTab === 'city') {
const dpr = context.sizeCanvas(context.mapCanvas);
const pos = context.mode === 'drive' ? context.car : context.player;
drawFullMap(context.mapCanvas.getContext('2d'), context.mapAssets, context.mapView, {
dpr,
markers: context.markers(),
route: context.route?.points,
selected: context.selected,
player: {
x: pos.x,
z: pos.z,
heading: context.mode === 'drive' ? context.car.heading : context.player.heading,
},
});
} else {
const dpr = context.sizeCanvas(context.iranCanvas),
c = context.iranCanvas.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
drawIran(c, iran, {
width: context.iranCanvas.width / dpr,
height: context.iranCanvas.height / dpr,
t: context.elapsed,
cities: context.CITIES,
});
}
}
/**
* Runs the render machine list operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function renderMachineList(context) {
const pos = context.mode === 'drive' ? context.car : context.player;
const visited = new Set(context.state.purchases.map((p) => p.machineId));
const items = context.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, context.state.minutes),
selected: s.def.id === context.selected,
distance: formatDistance(Math.hypot(s.x - pos.x, s.z - pos.z)),
}));
context.ui.patch('machine-list', {
items: [
...items,
...lockedPlaces.map((p) => ({
...p,
id: p.name,
locked: true,
})),
],
});
}
/**
* Runs the select machine operation for this subsystem.
* @param {*} context - context.
* @param {*} id - id.
* @param {*} center - center.
* @returns {*} Operation result, when applicable.
*/
export function selectMachine(context, id, center = false) {
context.selected = id;
const s = context.stationById(id),
def = s.def;
if (center) {
context.mapView.cx = s.x;
context.mapView.cz = s.z;
context.mapView.mpp = Math.min(context.mapView.mpp, 1.4);
}
context.ui.patch('machine-detail', {
hidden: false,
});
context.ui.patch('detail-name', {
text: `${CATEGORIES[def.category].icon} ${def.name}`,
});
context.ui.patch('detail-address', {
text: `📍 ${def.address}`,
});
context.ui.patch('detail-meta', {
text: `🕒 ${context.hoursText(def)} · ${isOpen(def, context.state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`,
});
context.ui.patch('detail-products', {
text: def.products
.map((id) => `${catalog[id].icon} ${catalog[id].name} ${fa(priceAt(def, id))}`)
.join(' · '),
});
context.ui.patch('route-button', {
text: context.waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا',
});
context.renderMachineList();
context.renderMap();
}
/**
* Runs the open map operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function openMap(context) {
if (context.anyDialogOpen()) return;
document.exitPointerLock?.();
context.keys.clear();
const pos = context.mode === 'drive' ? context.car : context.player;
Object.assign(context.mapView, {
cx: pos.x,
cz: pos.z,
mpp: 2.2,
});
context.ui.open('map-dialog');
context.selected ??= context.objectiveStation()?.def.id ?? null;
if (context.selected) context.selectMachine(context.selected);
context.renderMachineList();
context.renderMap();
}
+239
View File
@@ -0,0 +1,239 @@
import * as THREE from 'three';
import data from '../../data/mellat.json';
import { resolveCircle } from '../world/collision.js';
import { stepCar } from '../systems/vehicle.js';
/**
* Runs the look operation for this subsystem.
* @param {*} context - context.
* @param {*} dx - dx.
* @param {*} dy - dy.
* @param {*} k - k.
* @returns {*} Operation result, when applicable.
*/
export function look(context, dx, dy, k) {
// In AR view the phone's orientation aims the camera; dragging only helps when there is no sensor.
if (context.real.on) {
if (context.arView.hasOrientation) return;
context.cam.yaw -= dx * k;
context.cam.arPitch = THREE.MathUtils.clamp(
(context.cam.arPitch ?? 0) - dy * k * 0.8,
-1.2,
1.2,
);
context.cam.lastMouse = context.elapsed;
return;
}
context.cam.yaw -= dx * k;
context.cam.pitch = THREE.MathUtils.clamp(context.cam.pitch + dy * k * 0.8, -0.15, 1.15);
context.cam.lastMouse = context.elapsed;
}
// Touch joystick: x steers / strafes, y walks / throttles.
/**
* Runs the toggle car operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export // ---------- Car ----------
function toggleCar(context) {
if (context.payment.phase !== 'idle' || context.state.busy) return;
if (context.real.on) {
context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.');
return;
}
if (context.mode === 'walk') {
if (Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) > 4.5)
return;
context.mode = 'drive';
context.cam.distance = Math.max(context.cam.distance, 8);
context.tone(320, 0.2, 0.05, 'square');
context.say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.');
} else {
if (Math.abs(context.car.speed) > 3) {
context.say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.');
return;
}
// Step out on the driver's (left) side, or wherever is free.
const left = {
x: -Math.sin(context.car.heading),
z: -Math.cos(context.car.heading),
};
const spot = resolveCircle(
context.collision,
context.car.x + left.x * 2.8,
context.car.z + left.z * 2.8,
1,
);
Object.assign(context.player, {
x: spot.x,
z: spot.z,
heading: context.car.heading,
});
context.car.speed = 0;
context.mode = 'walk';
context.cam.distance = 5.5;
context.tone(260, 0.2, 0.05, 'square');
}
context.refreshHUD();
}
// ---------- Machines, shop and payment ----------
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
/**
* Updates walk from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateWalk(context, dt) {
const forward = {
x: -Math.sin(context.cam.yaw),
z: -Math.cos(context.cam.yaw),
},
right = {
x: Math.cos(context.cam.yaw),
z: -Math.sin(context.cam.yaw),
};
const iy = context.axis(['w', 'arrowup'], ['s', 'arrowdown']) + context.stick.y,
ix = context.axis(['d', 'arrowright'], ['a', 'arrowleft']) + context.stick.x;
const frozen = context.state.busy || context.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 = context.keys.has('shift');
const target = frozen || len < 0.1 ? 0 : (run ? 6.5 : 3.4) * Math.min(1, len);
context.player.speed += (target - context.player.speed) * Math.min(1, dt * 8);
if (len > 0.1 && !frozen) {
dx /= len;
dz /= len;
const want = Math.atan2(-dz, dx);
context.player.heading +=
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
Math.min(1, dt * 9);
}
// Face the machine while buying.
const station = context.payment.station ?? context.payment.delivering?.station;
if (frozen && station) {
const want = Math.atan2(-(station.z - context.player.z), station.x - context.player.x);
context.player.heading +=
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
Math.min(1, dt * 6);
}
const mx = Math.cos(context.player.heading) * context.player.speed * dt,
mz = -Math.sin(context.player.heading) * context.player.speed * dt;
const p = resolveCircle(context.collision, context.player.x + mx, context.player.z + mz, 1);
const { minX, minZ, maxX, maxZ } = data.bounds;
context.player.x = THREE.MathUtils.clamp(p.x, minX + 15, maxX - 15);
context.player.z = THREE.MathUtils.clamp(p.z, minZ + 15, maxZ - 15);
context.player.gait += dt * context.player.speed * 1.9;
}
// Collision log for ?debug runs; trimmed so it never grows without bound.
/**
* Updates drive from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateDrive(context, dt) {
if (context.debugHits.length > 200) context.debugHits.splice(0, 100);
const throttle = THREE.MathUtils.clamp(
context.axis(['w', 'arrowup'], ['s', 'arrowdown']) + context.stick.y,
-1,
1,
);
const steer = THREE.MathUtils.clamp(
context.axis(['d', 'arrowright'], ['a', 'arrowleft']) + context.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(context.car.speed) * dt) / 0.6));
for (let i = 0; i < slices; i++) {
stepCar(
context.car,
{
throttle,
steer,
handbrake: context.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 = context.car.x + Math.cos(context.car.heading) * along,
cz = context.car.z - Math.sin(context.car.heading) * along;
const p = resolveCircle(context.collision, cx, cz, 1.05);
if (p.hit) {
context.debugHits.push({
kind: 'building',
x: Math.round(cx),
z: Math.round(cz),
v: +context.car.speed.toFixed(1),
});
context.car.x += p.x - cx;
context.car.z += p.z - cz;
if (Math.abs(context.car.speed) > 4) context.tone(90, 0.2, 0.08, 'square');
context.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 context.traffic.cars) {
const dx = context.car.x - npc.x,
dz = context.car.z - npc.z,
d = Math.hypot(dx, dz);
if (d >= 3.4 || d === 0) {
if (npc.contact) npc.contact = 0;
continue;
}
context.debugHits.push({
kind: 'traffic',
x: Math.round(context.car.x),
z: Math.round(context.car.z),
v: +context.car.speed.toFixed(1),
npcDir: npc.dir,
});
context.car.x = npc.x + (dx / d) * 3.4;
context.car.z = npc.z + (dz / d) * 3.4;
const towards =
(Math.cos(context.car.heading) * -dx - Math.sin(context.car.heading) * -dz) *
Math.sign(context.car.speed) >
0;
if (towards && Math.abs(context.car.speed) > 4) context.tone(110, 0.25, 0.08, 'square');
if (towards) context.car.speed *= 0.4;
npc.contact = (npc.contact ?? 0) + dt;
if (npc.contact > 0.8) {
npc.contact = 0;
context.traffic.remove(npc);
}
}
const { minX, minZ, maxX, maxZ } = data.bounds;
context.car.x = THREE.MathUtils.clamp(context.car.x, minX + 15, maxX - 15);
context.car.z = THREE.MathUtils.clamp(context.car.z, minZ + 15, maxZ - 15);
Object.assign(context.player, {
x: context.car.x,
z: context.car.z,
heading: context.car.heading,
});
// Chase camera swings back behind the car when the mouse is idle.
if (context.elapsed - context.cam.lastMouse > 1.4 && Math.abs(context.car.speed) > 1) {
const want =
Math.atan2(-Math.cos(context.car.heading), Math.sin(context.car.heading)) +
(context.car.speed < 0 ? Math.PI : 0);
context.cam.yaw +=
Math.atan2(Math.sin(want - context.cam.yaw), Math.cos(want - context.cam.yaw)) *
Math.min(1, dt * 2.2);
context.cam.pitch += (0.28 - context.cam.pitch) * Math.min(1, dt * 1.5);
}
if (context.engine && context.soundOn) {
context.engine.o.frequency.setTargetAtTime(
45 + Math.abs(context.car.speed) * 5 + Math.max(0, throttle) * 15,
context.audio.currentTime,
0.1,
);
context.engine.g.gain.setTargetAtTime(0.025, context.audio.currentTime, 0.2);
}
}
+348
View File
@@ -0,0 +1,348 @@
import * as THREE from 'three';
import data from '../../data/mellat.json';
import { routeBetween, nearestSegment } from '../world/routing.js';
import { FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock, fa } from '../economy/gameState.js';
import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js';
import { kmh } from '../systems/vehicle.js';
import { drawMinimap } from '../world/mapRender.js';
import { bearing, formatDistance } from '../../utils/geo.js';
/**
* Runs the rebuild route mesh operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function rebuildRouteMesh(context) {
if (context.routeMesh) {
context.scene.remove(context.routeMesh);
context.routeMesh.geometry.dispose();
context.routeMesh = null;
}
if (!context.route) return;
const pts = [],
pos = [],
idx = [];
let total = 0;
for (const p of context.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 = context.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);
context.routeMesh = new THREE.Mesh(g, context.routeMaterial);
context.scene.add(context.routeMesh);
}
/**
* Updates route from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateRoute(context, dt) {
context.routeTimer -= dt;
const target = context.objectiveStation();
if (!target) {
if (context.route) {
context.route = null;
context.rebuildRouteMesh();
}
return;
}
const pos = context.mode === 'drive' ? context.car : context.player;
const changed =
target !== context.routeTarget || context.routeMode !== context.mode || !context.route;
if (
!changed &&
(context.routeTimer > 0 ||
Math.hypot(pos.x - context.routeFrom.x, pos.z - context.routeFrom.z) < 10)
)
return;
context.routeTimer = 1.2;
context.routeTarget = target;
context.routeMode = context.mode;
context.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);
context.route =
direct < 60
? {
points: [
{
x: pos.x,
z: pos.z,
},
{
x: target.front.x,
z: target.front.z,
},
],
length: direct,
}
: routeBetween(
context.mode === 'drive' ? context.driveGraph : context.walkGraph,
pos.x,
pos.z,
target.front.x,
target.front.z,
);
context.rebuildRouteMesh();
}
/**
* Runs the draw compass operation for this subsystem.
* @param {*} context - context.
* @param {*} headingDeg - heading Deg.
* @param {*} markers - markers.
* @returns {*} Operation result, when applicable.
*/
export function drawCompass(context, headingDeg, markers) {
const w = 560,
h = 44;
context.compass.clearRect(0, 0, w, h);
context.compass.fillStyle = 'rgba(10,16,14,.45)';
context.compass.fillRect(0, 10, w, 24);
context.compass.textAlign = 'center';
context.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;
context.compass.fillStyle = 'rgba(236,242,238,.8)';
if (labels[deg] !== undefined) {
context.compass.font = 'bold 13px Vazirmatn, sans-serif';
context.compass.fillStyle = deg === 0 ? '#f2c94c' : '#ecf2ee';
context.compass.fillText(labels[deg], x, 22);
} else if (deg % 15 === 0) {
context.compass.font = '10px Vazirmatn, sans-serif';
context.compass.fillText(fa(deg), x, 22);
} else context.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;
context.compass.save();
context.compass.translate(x, 6);
context.compass.rotate(Math.PI / 4);
context.compass.fillStyle = m.color;
context.compass.fillRect(-4, -4, 8, 8);
context.compass.restore();
}
context.compass.fillStyle = '#ecf2ee';
context.compass.fillRect(w / 2 - 1, 34, 2, 8);
context.compass.font = 'bold 11px Vazirmatn, sans-serif';
context.compass.fillText(fa(Math.round(headingDeg) % 360), w / 2, 6);
}
/**
* Runs the refresh hud operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function refreshHUD(context) {
context.ui.patch('balance', {
text: fa(context.state.balance),
});
const rank = rankFor(context.state.xp);
context.ui.patch('rank-name', {
text: rank.name,
});
context.ui.patch('xp', {
text: rank.next
? `${fa(context.state.xp)} / ${fa(rank.next)} XP`
: `${fa(context.state.xp)} XP`,
});
context.ui.style('xp-bar', {
width: `${Math.round(rank.progress * 100)}%`,
});
const active = activeMission(context.state);
context.ui.patch('mission-list', {
items: MISSIONS.map((m) => ({
id: m.id,
title: m.title,
done: context.state.missionsDone.includes(m.id),
active: m === active,
})),
});
context.ui.publishGame(context.state);
context.ui.patch('mode-label', {
text: context.mode === 'drive' ? 'در ماشین · F برای پیاده شدن' : 'پیاده',
});
context.ui.patch('speed-stat', {
hidden: context.mode !== 'drive',
});
}
/**
* Updates prompt from the active simulation.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function updatePrompt(context) {
const sample = context.nearShowroom();
if (sample && !context.state.busy && context.payment.phase === 'idle') {
context.ui.patch('prompt', {
text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`,
hidden: false,
});
return;
}
let text = '';
const station = context.nearStation();
if (!context.state.busy && context.payment.phase === 'idle') {
if (station)
text = isOpen(station.def, context.state.minutes)
? `E · خرید از روبات مارکت · ${station.def.name}`
: `دستگاه بسته است · ${context.hoursText(station.def)}`;
else if (
context.mode === 'walk' &&
Math.hypot(context.car.x - context.player.x, context.car.z - context.player.z) < 4.5
)
text = 'F · سوار ماشین شو';
else if (
context.mode === 'drive' &&
Math.abs(context.car.speed) < 1 &&
context.stations.some(
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
)
)
text = 'F · پیاده شو؛ دستگاه نزدیک است';
}
context.ui.patch('prompt', {
text,
hidden: !text,
});
}
// ---------- Map dialog ----------
/**
* Updates hud from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateHUD(context, dt) {
context.ui.patch('showroom-open', {
hidden:
context.real.on ||
context.mode !== 'walk' ||
Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) >= 130,
});
const pos = context.mode === 'drive' ? context.car : context.player;
context.ui.patch('clock', {
text: formatClock(context.state.minutes),
});
if (context.mode === 'drive')
context.ui.patch('speed', {
text: fa(kmh(context.car)),
});
// Where the camera actually looks: the orbit camera, or the phone itself in AR view.
const look3 = context.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(context.cam.yaw),
z: -Math.cos(context.cam.yaw),
};
const headingDeg = bearing(forward.x, forward.z);
const objective = context.objectiveStation();
context.drawCompass(
headingDeg,
objective
? [
{
bearing: bearing(objective.x - pos.x, objective.z - pos.z),
color: '#f2c94c',
},
]
: [],
);
drawMinimap(context.minimap, context.mapAssets, {
size: 220,
x: pos.x,
z: pos.z,
heading: context.mode === 'drive' ? context.car.heading : context.player.heading,
camForward: forward,
radius: context.mode === 'drive' ? 650 : 200,
markers: context.markers(),
route: context.route?.points,
});
// Objective marker above the machine, clamped to the screen edge when off-screen.
context.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(context.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;
}
context.ui.style('waypoint', {
left: `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`,
});
context.ui.style('waypoint', {
top: `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`,
});
context.ui.patch('waypoint-name', {
text: objective.def.name,
});
context.ui.patch('waypoint-distance', {
text: formatDistance(d),
});
context.ui.patch('waypoint', {
hidden: d < 6,
});
for (const s of context.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);
}
}
context.streetTimer -= dt;
if (context.streetTimer <= 0) {
context.streetTimer = 0.6;
const street = nearestSegment(context.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;
context.ui.patch('street', {
text: inFactory
? `کارخانهٔ ${FACTORY.name}`
: street
? `مشهد · ${data.names[street.name]}`
: 'حومهٔ مشهد',
});
}
context.updatePrompt();
}
+356
View File
@@ -0,0 +1,356 @@
import * as THREE from 'three';
import { MAX_WALKING_SPEED } from '../device/realworld.js';
import { machines, CATEGORIES, FACTORY } from '../../constants/locations.js';
import {
catalog,
priceAt,
isOpen,
purchaseBlocker,
formatHetoman,
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';
/**
* Runs the open showroom operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function openShowroom(context) {
const inFactory =
Math.hypot(context.player.x - FACTORY.center.x, context.player.z - FACTORY.center.z) < 130;
const sample =
context.nearShowroom() ??
(inFactory && !context.real.on && context.mode === 'walk' ? context.showroom.samples[0] : null);
if (!sample || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
context.ui.patch('showroom', {
modelId: sample.model.id,
});
context.ui.patch('showroom-response', {
text: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.',
});
context.keys.clear();
document.exitPointerLock?.();
context.ui.open('showroom');
}
/**
* Runs the interact operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function interact(context) {
const station = context.nearStation();
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
context.say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.');
return;
}
if (!isOpen(station.def, context.state.minutes)) {
context.say(
context.guideOut() ? 'robot' : 'father',
`این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(station.def)}.`,
'serious',
);
return;
}
context.shopStation = station;
context.renderShop();
document.exitPointerLock?.();
context.keys.clear();
context.ui.open('shop');
context.tone(750);
}
/**
* Runs the render shop operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function renderShop(context) {
const def = context.shopStation.def;
context.ui.patch('shop-category', {
text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${context.hoursText(def)}`,
});
context.ui.patch('shop-name', {
text: `روبات مارکت · ${def.name}`,
});
context.ui.patch('shop-address', {
text: def.address,
});
context.ui.patch('shop-note', {
text: `${def.note} · موجودی شما: ${formatHetoman(context.state.balance)}`,
});
context.ui.patch('products', {
items: def.products.map((id) => ({
id,
...catalog[id],
price: priceAt(def, id),
blocked: purchaseBlocker(context.state, def, id),
})),
});
}
/**
* Runs the start payment operation for this subsystem.
* @param {*} context - context.
* @param {*} productId - product Id.
* @returns {*} Operation result, when applicable.
*/
export function startPayment(context, productId) {
if (!beginPayment(context.state, context.payment, context.shopStation.def, productId)) return;
context.payment.station = context.shopStation;
context.payment.timer = 0;
context.ui.close('shop');
const p = catalog[productId];
context.ui.patch('payment', {
hidden: false,
});
context.ui.patch('payment-title', {
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
});
context.ui.patch('payment-step', {
text: 'در حال نزدیک شدن به کارت‌خوان…',
});
context.ui.patch('swipe', {
disabled: true,
});
context.ui.patch('cancel-payment', {
hidden: false,
});
context.say('child', 'بابا، کارت رو بده من بکشم!');
}
/**
* Runs the swipe operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function swipe(context) {
if (!swipePayment(context.state, context.payment)) return;
context.payment.timer = 0;
context.payment.station.model.card.visible = true;
context.ui.patch('swipe', {
disabled: true,
});
context.ui.patch('cancel-payment', {
hidden: true,
});
context.ui.patch('payment-step', {
text: 'پرداخت موفق ✓ محصول در حال تحویل است',
});
context.tone(1100, 0.3);
context.refreshHUD();
}
/**
* Runs the cancel operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function cancel(context) {
if (!cancelPayment(context.state, context.payment)) return;
context.ui.patch('payment', {
hidden: true,
});
}
/**
* Updates payment from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updatePayment(context, dt) {
if (context.payment.phase === 'idle') return;
context.payment.timer += dt;
const model = context.payment.station.model;
if (context.payment.phase === 'approaching' && context.payment.timer > 1) {
readyPayment(context.payment);
context.ui.patch('swipe', {
disabled: false,
});
context.ui.patch('payment-step', {
text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
});
}
if (context.payment.phase === 'approved') {
const t = Math.min(1, context.payment.timer);
model.card.position.set(0.95 - t * 0.25, 1.7 - t * 0.1, 0.66);
if (context.payment.timer > 1.2) {
model.card.visible = false;
context.ui.patch('payment', {
hidden: true,
});
model.setDelivery(context.payment.productId);
context.deliveryTimer = 2.6;
context.payment.delivering = {
station: context.payment.station,
record: context.payment.record,
};
Object.assign(context.payment, createPayment());
}
}
}
/**
* Updates delivery from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateDelivery(context, dt) {
if (context.deliveryTimer <= 0) return;
context.deliveryTimer = Math.max(0, context.deliveryTimer - dt);
const { station, record } = context.payment.delivering,
model = station.model,
t = 2.6 - context.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 (context.deliveryTimer > 0) return;
model.flap.rotation.x = 0;
context.state.busy = false;
context.tone(980, 0.18);
context.reward(record, slot);
}
/**
* Runs the reward operation for this subsystem.
* @param {*} context - context.
* @param {*} record - record.
* @param {*} product - product.
* @returns {*} Operation result, when applicable.
*/
export function reward(context, record, product) {
const awards = awardsFor(context.state.purchases.slice(0, -1), record),
rankBefore = rankFor(context.state.xp).index;
if (context.real.on)
awards.push({
label: 'خرید در دنیای واقعی',
points: 200,
});
const total = awards.reduce((s, a) => s + a.points, 0);
context.state.xp += total;
context.ui.patch('awards', {
items: awards,
total,
});
context.clearTimeout(context.reward.timeout);
context.reward.timeout = context.setTimeout(
() =>
context.ui.patch('awards', {
items: [],
}),
3200,
);
context.say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
const done = completeMissions(context.state);
for (const m of done) {
context.showBanner(
'مأموریت انجام شد',
m.title,
`+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`,
);
context.tone(660, 0.5);
}
if (rankFor(context.state.xp).index > rankBefore)
context.setTimeout(
() =>
context.showBanner(
'ارتقای درجه',
rankFor(context.state.xp).name,
'دستگاه‌های بیشتری پیدا کن',
),
done.length ? 3400 : 0,
);
const next = activeMission(context.state);
if (next) {
context.waypoint = null;
context.route = null;
context.setTimeout(
() =>
context.say(context.guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'),
1500,
);
}
context.refreshHUD();
context.checkEnd();
}
/**
* Runs the show banner operation for this subsystem.
* @param {*} context - context.
* @param {*} tag - tag.
* @param {*} title - title.
* @param {*} sub - sub.
* @returns {*} Operation result, when applicable.
*/
export function showBanner(context, tag, title, sub) {
context.ui.patch('banner', {
tag,
title,
sub,
hidden: false,
});
context.clearTimeout(context.showBanner.timeout);
context.showBanner.timeout = context.setTimeout(
() =>
context.ui.patch('banner', {
hidden: true,
}),
3200,
);
}
/**
* Runs the check end operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function checkEnd(context) {
const allDone = context.state.missionsDone.length === MISSIONS.length;
if (context.ended || (!allDone && canAffordAnything(context.state, machines))) return;
context.ended = true;
document.exitPointerLock?.();
context.ui.patch('end-tag', {
text: allDone ? 'عملیات موفق' : 'پایان بودجه',
});
context.ui.patch('end-title', {
text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد',
});
context.ui.patch('end-text', {
text: allDone
? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.'
: 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:',
});
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
context.ui.patch('end-stats', {
items: [
['امتیاز', `${fa(context.state.xp)} XP`],
['درجه', rankFor(context.state.xp).name],
['خریدها', fa(context.state.purchases.length)],
['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`],
['مأموریت‌ها', `${fa(context.state.missionsDone.length)} از ${fa(MISSIONS.length)}`],
['باقی‌مانده', formatHetoman(context.state.balance)],
],
});
context.ui.patch('continue', {
hidden: !allDone,
});
context.ui.open('end');
}
+178
View File
@@ -0,0 +1,178 @@
import {
startTracking,
requestCompass,
insideWorld,
createStabilizer,
MAX_WALKING_SPEED,
} from '../device/realworld.js';
import data from '../../data/mellat.json';
import { resolveCircle } from '../world/collision.js';
import { fa } from '../economy/gameState.js';
import { formatDistance } from '../../utils/geo.js';
/**
* Runs the set real mode operation for this subsystem.
* @param {*} context - context.
* @param {*} on - on.
* @returns {*} Operation result, when applicable.
*/
export function setRealMode(context, on) {
if (on === context.real.on) return;
context.real.stop?.();
Object.assign(context.real, {
on,
fix: null,
heading: null,
error: null,
waiting: null,
stop: null,
});
for (const s of context.stations) if (s.def.test) s.model.group.visible = on;
context.ui.classState('gps-toggle', 'on', on);
context.ui.patch('gps-status', {
hidden: !on,
});
context.ui.patch('ar-toggle', {
hidden: context.ui.patch('stable-toggle', {
hidden: !on,
}),
});
// Real-world mode is first person: the player is where the phone is and sees what it faces.
context.ui.classState('body', 'real', on);
if (!on) {
context.setArMode(false);
context.arView.stopSensors();
return;
}
context.arView.startSensors();
context.cam.arPitch = 0;
context.cam.yaw = Math.atan2(-Math.cos(context.player.heading), Math.sin(context.player.heading));
if (context.mode === 'drive') context.toggleCar();
context.mode = 'walk';
context.real.stop = startTracking({
onFix: (fix) => {
context.real.fix = fix;
context.real.error = null;
},
onHeading: (heading) => (context.real.heading = heading),
onError: (message) => (context.real.error = message),
onStatus: (message) => (context.real.waiting = message),
stabilizer: (context.real.stabilizer = createStabilizer()),
});
context.real.stabilizer.stable = context.stableMode;
context.say(
'father',
'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.',
);
context.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.
/**
* Runs the set ar mode operation for this subsystem.
* @param {*} context - context.
* @param {*} on - on.
* @returns {*} Operation result, when applicable.
*/
export async function setArMode(context, on) {
if (on && !context.arMode) {
const problem = await context.arView.start();
if (problem) {
context.say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`);
return;
}
if (!context.real.on) {
context.arView.stop();
return;
}
context.say(
'robot',
'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
'excited',
);
context.setTimeout(() => {
if (context.real.on && !context.arView.hasOrientation)
context.say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
}, 3000);
} else if (!on && context.arMode) context.arView.stop();
else return;
context.arMode = on;
for (const object of context.hiddenInAr()) object.visible = !on;
context.scene.fog = on ? null : context.fog;
context.ui.classState('body', 'ar', on);
context.ui.classState('ar-toggle', 'on', on);
}
/**
* Runs the ask real mode operation for this subsystem.
* @param {*} context - context.
* @param {*} thenStart - then Start.
* @returns {*} Operation result, when applicable.
*/
export // The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
function askRealMode(context, thenStart) {
if (context.real.on) {
context.setRealMode(false);
return;
}
context.ui.open('real-intro');
context.ui.on('real-go', async () => {
context.ui.close('real-intro');
await requestCompass();
context.setRealMode(true);
if (thenStart) context.startMission();
});
}
/**
* Updates real from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateReal(context, dt) {
let uiStatus;
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? 'در حال یافتن موقعیت…'}`;
else if (!insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
const nearest = Math.min(
...context
.activeStations()
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
);
uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`;
} else
uiStatus = `📍 GPS ±${fa(Math.round(context.real.fix.accuracy))} متر${context.real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
context.ui.patch('gps-status', {
text: uiStatus,
});
if (!context.real.fix || !insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
context.player.speed = 0;
return;
}
// Walk the avatar towards the (building-free) GPS point instead of teleporting it.
const target = resolveCircle(context.collision, context.real.fix.x, context.real.fix.z, 1);
const dx = target.x - context.player.x,
dz = target.z - context.player.z,
d = Math.hypot(dx, dz);
if (d > 150)
Object.assign(context.player, {
x: target.x,
z: target.z,
});
const speed = d > 0.5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
context.player.speed += (speed - context.player.speed) * Math.min(1, dt * 6);
if (d > 0.5) {
const step = Math.min(d, context.player.speed * dt);
context.player.x += (dx / d) * step;
context.player.z += (dz / d) * step;
}
const want = context.real.heading ?? (d > 0.5 ? Math.atan2(-dz, dx) : context.player.heading);
// Stable mode also calms the compass, which jitters near cars and metal.
context.player.heading +=
Math.atan2(Math.sin(want - context.player.heading), Math.cos(want - context.player.heading)) *
Math.min(1, dt * (context.stableMode ? 2 : 5));
context.player.gait += dt * context.player.speed * 1.9;
}
// ---------- Loop ----------
+217
View File
@@ -0,0 +1,217 @@
import * as THREE from 'three';
import iran from '../../data/iran.json';
import { clearFraction } from '../world/collision.js';
import { drawIran } from '../world/mapRender.js';
/**
* Runs the resize operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function resize(context) {
context.renderer.setSize(innerWidth, innerHeight);
context.camera.aspect = innerWidth / innerHeight;
context.camera.updateProjectionMatrix();
context.renderMap();
}
/**
* Updates camera from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateCamera(context, dt) {
const station = context.payment.station;
if (station && context.payment.phase !== 'idle') {
// Close-up of the card reader during payment.
const reader = station.model.readerPoint.clone();
station.model.group.localToWorld(reader);
const side = context.rotateLocal(1.2, 1.9, station.rot);
context.camTarget.set(reader.x + side.x, reader.y + 0.4, reader.z + side.z);
context.lookAt.lerp(reader, 1 - Math.exp(-dt * 6));
} else {
const driving = context.mode === 'drive',
height = driving ? 1.6 : 1.75;
const focus = new THREE.Vector3(context.player.x, height, context.player.z);
// Over-the-shoulder offset to the right, like a third-person shooter.
const shoulder = driving ? 0 : 0.75;
focus.x += Math.cos(context.cam.yaw) * shoulder;
focus.z += -Math.sin(context.cam.yaw) * shoulder;
let distance = context.cam.distance;
const ox = Math.sin(context.cam.yaw) * Math.cos(context.cam.pitch),
oz = Math.cos(context.cam.yaw) * Math.cos(context.cam.pitch);
const free = clearFraction(
context.collision,
focus.x,
focus.z,
focus.x + ox * distance,
focus.z + oz * distance,
0.8,
);
if (context.cam.pitch < 0.9) distance = Math.max(1.6, distance * free - 0.7);
context.camTarget.set(
focus.x + ox * distance,
Math.max(0.6, focus.y + Math.sin(context.cam.pitch) * distance),
focus.z + oz * distance,
);
context.lookAt.lerp(focus, 1 - Math.exp(-dt * 14));
}
context.camPos.lerp(context.camTarget, 1 - Math.exp(-dt * 12));
context.camera.position.copy(context.camPos);
context.camera.lookAt(context.lookAt);
}
/**
* Runs the frame operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function frame(context) {
const now = performance.now();
context.frameId = requestAnimationFrame(context.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 - context.last) / 1000, 0, context.MAX_FRAME);
context.last = now;
context.elapsed += dt;
const playing = context.ui.read('briefing').hidden;
const paused =
!playing ||
context.ui.read('shop').open ||
context.ui.read('map-dialog').open ||
context.ui.read('end').open ||
context.ui.read('showroom').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 = context.$('#iran'),
dpr = context.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: context.elapsed,
cities: context.CITIES,
});
context.briefChars = Math.min(context.BRIEF.length, context.briefChars + dt * 55);
context.ui.patch('brief-text', {
text:
context.BRIEF.slice(0, Math.floor(context.briefChars)) +
(context.briefChars < context.BRIEF.length ? '▌' : ''),
});
}
if (!paused) {
for (let i = 0; i < steps; i++) {
context.state.minutes += (step * 10) / 60;
if (context.real.on) context.updateReal(step);
else if (context.mode === 'drive') context.updateDrive(step);
else context.updateWalk(step);
context.updatePayment(step);
context.updateDelivery(step);
context.traffic.update(
step,
[
{
x: context.player.x,
z: context.player.z,
},
{
x: context.car.x,
z: context.car.z,
},
],
context.player,
);
}
context.updateRoute(dt);
context.updateHints();
context.updateTalks();
context.updateGuide(dt);
if (Math.floor(context.elapsed * 2) !== Math.floor((context.elapsed - dt) * 2))
context.checkEnd();
} else if (context.engine && context.soundOn)
context.engine.g.gain.setTargetAtTime(0, context.audio.currentTime, 0.1);
if (context.mode === 'walk' && context.engine && context.soundOn)
context.engine.g.gain.setTargetAtTime(0, context.audio.currentTime, 0.2);
context.updateSubtitles(dt);
context.family.group.visible = context.mode === 'walk' && !context.real.on;
context.family.group.position.set(context.player.x, 0, context.player.z);
context.family.group.rotation.y = context.player.heading;
context.family.update({
dt,
time: context.elapsed,
gait: context.player.gait,
moving: context.player.speed > 0.4,
shopping: context.state.busy || context.payment.phase !== 'idle',
delivery: context.deliveryTimer > 0,
});
context.factory.update(context.elapsed, paused ? 0 : dt, context.camera.position);
context.showroom.update({
player: context.player,
time: context.elapsed,
dt,
enabled: context.mode === 'walk' && !context.real.on,
talking: context.ui.read('showroom').open,
});
context.carModel.group.position.set(context.car.x, 0, context.car.z);
context.carModel.group.rotation.y = context.car.heading;
for (const [i, wheel] of context.carModel.wheels.entries()) {
wheel.rotation.z -= (context.car.speed * dt) / 0.36;
if (i >= 2) wheel.rotation.y = -context.car.steer;
}
for (const s of context.stations)
s.model.ring.material.opacity = 0.35 + Math.sin(context.elapsed * 3) * 0.25;
context.updateReachCircles();
if (context.routeMesh)
context.routeMaterial.opacity = 0.55 + Math.sin(context.elapsed * 4) * 0.15;
if (playing && context.real.on) {
// First person: the phone is the camera, at the player's GPS position and eye height.
context.camera.position.set(context.player.x, 1.6, context.player.z);
if (!context.arView.update(context.camera, dt, context.stableMode ? 5 : 10)) {
// No orientation sensor: face the compass or walking direction, or wherever the player drags.
if (context.real.heading != null && context.elapsed - context.cam.lastMouse > 2) {
const want = Math.atan2(
-Math.cos(context.player.heading),
Math.sin(context.player.heading),
);
context.cam.yaw +=
Math.atan2(Math.sin(want - context.cam.yaw), Math.cos(want - context.cam.yaw)) *
Math.min(1, dt * 3);
}
context.camera.rotation.set(context.cam.arPitch ?? 0, context.cam.yaw, 0, 'YXZ');
}
context.camPos.copy(context.camera.position);
} else if (playing) {
if (context.camera.fov !== 62) {
context.camera.fov = 62;
context.camera.updateProjectionMatrix();
}
context.updateCamera(dt);
} else {
const a = context.elapsed * 0.08;
context.camera.position.set(
context.player.x + Math.cos(a) * 30,
18,
context.player.z + Math.sin(a) * 30,
);
context.camera.lookAt(context.player.x, 2, context.player.z);
context.camPos.copy(context.camera.position);
}
context.sun.position.set(context.player.x + 160, 230, context.player.z + 110);
context.sun.target.position.set(context.player.x, 0, context.player.z);
context.sky.position.copy(context.camera.position);
context.city.update(context.camera.position);
if (!context.arMode) context.imagery.update(context.camera.position.x, context.camera.position.z);
if (playing) {
context.hudTimer -= dt;
context.updateHUD(dt);
if (context.ui.read('map-dialog').open && context.hudTimer <= 0) {
context.hudTimer = 0.5;
context.renderMap();
}
}
context.renderer.render(context.scene, context.camera);
}
// ?debug exposes live game state in the console for development and automated checks.
+166
View File
@@ -0,0 +1,166 @@
import { placeStation, updateReachCircles, reset, startMission } from './runtimeLifecycle.js';
import { look, toggleCar, updateWalk, updateDrive } from './movement.js';
import { say, updateSubtitles, tone } from './audioAndDialogue.js';
import {
openShowroom,
interact,
renderShop,
startPayment,
swipe,
cancel,
updatePayment,
updateDelivery,
reward,
showBanner,
checkEnd,
} from './purchaseFlow.js';
import {
rebuildRouteMesh,
updateRoute,
drawCompass,
refreshHUD,
updatePrompt,
updateHUD,
} from './navigationHud.js';
import {
sizeCanvas,
renderMap,
renderMachineList,
selectMachine,
openMap,
} from './mapInteraction.js';
import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.js';
import { resize, updateCamera, frame } from './renderLoop.js';
import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
import {
initializeTimers,
initializeRenderer,
initializeScene,
initializeSky,
initializeLightingAndWorldFiles,
initializeCollisionAndRoads,
initializeFactory,
initializeStations,
initializeStationById,
initializeClosestStation,
initializeParkGate,
buildParkGate,
initializeFamily,
initializeSpawnCar,
registerKeyboard,
registerPointerLook,
registerCameraZoom,
registerTouchInput,
initializeAxis,
registerSoundControl,
initializeReachRadius,
registerPurchaseControls,
registerPreferences,
initializeRouteMaterial,
initializeMapView,
registerRouteSelection,
registerMapTabs,
registerMapDragging,
registerMapSelectionAndZoom,
initializeCities,
initializeBriefingAndTracking,
initializeArMode,
initializeLast,
exposeDebugTools,
startGameLoop,
initializeDispose,
} from './runtimeSetup.js';
/** Starts the ordered runtime systems; UI rendering remains owned by React.
* @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount lifecycle.
* @returns {Promise<function>} Runtime disposer.
*/
const RUNTIME_METHODS = {
placeStation,
updateReachCircles,
reset,
look,
say,
updateSubtitles,
tone,
toggleCar,
openShowroom,
interact,
renderShop,
startPayment,
swipe,
cancel,
updatePayment,
updateDelivery,
reward,
showBanner,
checkEnd,
rebuildRouteMesh,
updateRoute,
drawCompass,
refreshHUD,
updatePrompt,
sizeCanvas,
renderMap,
renderMachineList,
selectMachine,
openMap,
startMission,
setRealMode,
setArMode,
askRealMode,
updateReal,
resize,
updateWalk,
updateDrive,
updateCamera,
updateHUD,
updateTalks,
greet,
updateGuide,
updateHints,
frame,
};
export const RUNTIME_INITIALIZERS = [
initializeTimers,
initializeRenderer,
initializeScene,
initializeSky,
initializeLightingAndWorldFiles,
initializeCollisionAndRoads,
initializeFactory,
initializeStations,
initializeStationById,
initializeClosestStation,
initializeParkGate,
buildParkGate,
initializeFamily,
initializeSpawnCar,
registerKeyboard,
registerPointerLook,
registerCameraZoom,
registerTouchInput,
initializeAxis,
registerSoundControl,
initializeReachRadius,
registerPurchaseControls,
registerPreferences,
initializeRouteMaterial,
initializeMapView,
registerRouteSelection,
registerMapTabs,
registerMapDragging,
registerMapSelectionAndZoom,
initializeCities,
initializeBriefingAndTracking,
initializeArMode,
initializeLast,
exposeDebugTools,
startGameLoop,
initializeDispose,
];
/** Binds subsystem commands to one mutable renderer context; GPU objects never enter Redux. */
export function bindRuntime(context) {
for (const [name, method] of Object.entries(RUNTIME_METHODS))
context[name] = (...args) => method(context, ...args);
}
+146
View File
@@ -0,0 +1,146 @@
import { addPolygon, resolveCircle } from '../world/collision.js';
import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js';
/**
* Runs the place station operation for this subsystem.
* @param {*} context - context.
* @param {*} def - def.
* @param {*} pos - pos.
* @param {*} rot - rot.
* @returns {*} Operation result, when applicable.
*/
export function placeStation(context, def, pos, rot) {
const model = createVendingMachine(def);
model.group.position.set(pos.x, 0, pos.z);
model.group.rotation.y = rot;
context.scene.add(model.group);
addPolygon(
context.collision,
[
[-1, -0.6],
[1, -0.6],
[1, 0.6],
[-1, 0.6],
].flatMap(([lx, lz]) => {
const p = context.rotateLocal(lx, lz, rot);
return [pos.x + p.x, pos.z + p.z];
}),
);
const front = context.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,
},
};
}
/**
* Updates reach circles from the active simulation.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function updateReachCircles(context) {
const radius = context.reachRadius(),
objective = context.real.on ? context.objectiveStation() : null;
for (const s of context.stations) {
s.reach.visible = context.real.on;
if (!context.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.
/**
* Runs the reset operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function reset(context) {
context.state = createState();
context.payment = createPayment();
// The family starts in the factory yard, facing the engineering office.
const spot = resolveCircle(
context.collision,
context.factory.start.x,
context.factory.start.z,
1,
);
context.player = {
x: spot.x,
z: spot.z,
heading: context.factory.start.heading,
speed: 0,
gait: 0,
};
context.factory.reset();
for (const t of context.factory.talks) t.done = false;
context.car = context.spawnCar();
context.mode = 'walk';
// Camera yaw: the camera sits at target + (sin yaw, cos yaw) * distance, so it looks along (-sin, -cos).
context.cam = {
yaw: Math.atan2(-Math.cos(context.player.heading), Math.sin(context.player.heading)),
pitch: 0.32,
distance: 5.5,
lastMouse: -10,
};
context.waypoint = null;
context.route = null;
context.routeTimer = 0;
context.deliveryTimer = 0;
context.ended = false;
context.hints = {
closed: false,
car: false,
};
Object.assign(context.guideState, {
station: null,
t: 0,
greeted: new Set(),
arrived: 0,
});
context.ui.patch('payment', {
hidden: true,
});
for (const s of context.stations) {
s.model.delivery.visible = s.model.card.visible = false;
s.model.flap.rotation.x = 0;
}
for (const id of ['shop', 'map-dialog', 'end', 'showroom']) context.ui.close(id);
context.keys.clear();
context.refreshHUD();
}
// ---------- Input ----------
/**
* Runs the start mission operation for this subsystem.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function startMission(context) {
context.ui.patch('briefing', {
hidden: true,
});
context.ui.patch('hud', {
hidden: false,
});
context.say(
'robot',
'سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدل‌های کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.',
'wink',
);
context.say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟');
context.say(
'father',
'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
);
context.say('child', 'اون وانت سفیده کیه داره میاد تو؟');
context.say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
import * as THREE from 'three';
import { FACTORY } from '../../constants/locations.js';
import { isOpen } from '../economy/gameState.js';
/**
* Updates talks from the active simulation.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export // People in the factory say their lines once, when the family walks up to them.
function updateTalks(context) {
if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return;
for (const t of context.factory.talks) {
if (t.done || (t.ready && !t.ready())) continue;
const p = t.pos();
if (Math.hypot(p.x - context.player.x, p.z - context.player.z) > t.radius) continue;
t.done = true;
for (const text of t.lines) context.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.
/**
* Runs the greet operation for this subsystem.
* @param {*} context - context.
* @param {*} station - station.
* @returns {*} Operation result, when applicable.
*/
export function greet(context, station) {
const def = station.def,
first = !context.guideState.greeted.has(def.id);
context.guideState.greeted.add(def.id);
if (def.id === 'factory' && first) {
context.say(
'robot',
'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!',
'excited',
);
context.say(
'robot',
`به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`,
'happy',
);
context.say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
context.say(
'robot',
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!',
'love',
);
return;
}
if (!isOpen(def, context.state.minutes)) {
context.say(
'robot',
`سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(def)}.`,
'serious',
);
return;
}
if (first) {
context.say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
context.say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
} else context.say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
}
/**
* Updates guide from the active simulation.
* @param {*} context - context.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function updateGuide(context, dt) {
const near =
context.mode === 'walk'
? context.closestStation(
context.player.x,
context.player.z,
Math.max(9, context.reachRadius()),
)
: null;
// The robot only moves to another machine after it has gone back into the previous one.
if (near && !context.guideState.station) context.guideState.station = near;
const s = context.guideState.station,
wasOut = context.guideOut();
if (!s) return;
context.guideState.t = THREE.MathUtils.clamp(
context.guideState.t + (near === s ? dt : -dt) / 1.1,
0,
1,
);
if (context.guideOut() && !wasOut) {
context.guideState.arrived = context.elapsed;
context.greet(s);
context.tone(990, 0.15);
context.tone(1320, 0.2);
}
if (wasOut && near !== s) context.say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
if (!context.guideState.t) {
context.guideState.station = null;
context.guide.group.visible = false;
return;
}
// From inside the machine front out to its left side (the card reader is on the right).
const e = context.easeOutBack(context.guideState.t),
from = {
x: -0.2,
y: 0.9,
z: 0.2,
},
to = {
x: -1.7,
y: 0,
z: 1.3,
};
const local = context.rotateLocal(
from.x + (to.x - from.x) * e,
from.z + (to.z - from.z) * e,
s.rot,
);
context.guide.group.visible = true;
context.guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
context.guide.group.scale.setScalar(0.15 + 0.85 * Math.min(1, e));
// Face the family (the rig looks along its local +X).
context.guide.group.rotation.y = Math.atan2(
-(context.player.z - context.guide.group.position.z),
context.player.x - context.guide.group.position.x,
);
if (!context.state.busy && context.payment.phase === 'idle')
s.model.flap.rotation.x = context.guideState.t < 1 ? -1 : 0;
context.guide.update({
dt,
time: context.elapsed,
gait: 0,
moving: false,
talking: context.talking,
wave: context.elapsed - context.guideState.arrived < 2.5 || !context.guideOut(),
point: context.talking && context.guideOut(),
});
}
/**
* Updates hints from the active simulation.
* @param {*} context - context.
* @returns {*} Operation result, when applicable.
*/
export function updateHints(context) {
const target = context.objectiveStation();
if (target && !isOpen(target.def, context.state.minutes) && !context.hints.closed) {
context.hints.closed = true;
context.say(
'robot',
`${target.def.name} الان بسته است (${context.hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`,
);
}
if (
context.route &&
context.route.length > 900 &&
context.mode === 'walk' &&
!context.hints.car
) {
context.hints.car = true;
context.say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
}
}
+321
View File
@@ -0,0 +1,321 @@
import * as THREE from 'three';
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
/**
* Creates car model resources for the game.
* @param {*} color - color.
* @returns {*} Operation result, when applicable.
*/
export function createCarModel(color = '#d9d9d4') {
const group = new THREE.Group();
/**
* Runs the mat operation for this subsystem.
* @param {*} c - c.
* @param {*} extra - extra.
* @returns {*} Operation result, when applicable.
*/
const mat = (c, extra = {}) =>
new THREE.MeshStandardMaterial({
color: c,
roughness: 0.45,
...extra,
});
const paint = mat(color, {
metalness: 0.55,
roughness: 0.35,
}),
glass = mat('#1e2c33', {
metalness: 0.6,
roughness: 0.15,
});
const dark = mat('#141618'),
chrome = mat('#c8ccce', {
metalness: 0.9,
roughness: 0.2,
});
/**
* Runs the box operation for this subsystem.
* @param {*} w - w.
* @param {*} h - h.
* @param {*} d - d.
* @param {*} m - m.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const box = (w, h, d, m, x, y, z) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
mesh.position.set(x, y, z);
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
box(4.4, 0.62, 1.78, paint, 0, 0.68, 0);
box(4.42, 0.16, 1.8, dark, 0, 0.38, 0);
const cabin = box(2.3, 0.62, 1.6, glass, -0.25, 1.28, 0);
cabin.scale.set(1, 1, 0.98);
box(2.1, 0.08, 1.62, paint, -0.25, 1.62, 0);
box(0.9, 0.06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -0.12;
for (const z of [-0.62, 0.62]) {
box(
0.06,
0.18,
0.34,
mat('#fff4cc', {
emissive: '#fff1b0',
emissiveIntensity: 0.6,
}),
2.21,
0.78,
z,
);
box(
0.06,
0.16,
0.34,
mat('#b3261e', {
emissive: '#7a0f0b',
emissiveIntensity: 0.5,
}),
-2.21,
0.8,
z,
);
box(0.18, 0.08, 0.1, chrome, 0.6, 1.02, z * 1.47);
}
box(0.04, 0.12, 1.0, chrome, 2.22, 0.6, 0);
const wheels = [];
for (const x of [-1.38, 1.38])
for (const z of [-0.86, 0.86]) {
const wheel = new THREE.Group();
wheel.position.set(x, 0.36, z);
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.26, 18), dark);
tyre.rotation.x = Math.PI / 2;
tyre.castShadow = true;
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.27, 10), chrome);
hub.rotation.x = Math.PI / 2;
wheel.add(tyre, hub);
group.add(wheel);
wheels.push(wheel);
}
return {
group,
wheels,
};
}
const COLORS = [
'#e9e9e4',
'#1d1f22',
'#9aa3a8',
'#d6c9a5',
'#7e1f1d',
'#2f4a6b',
'#f2c94c',
'#efefea',
];
// Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
const WIDTH = [11, 10, 9, 8]; // trunk … tertiary, matching the road strips in city.js
/**
* Creates traffic resources for the game.
* @param {*} scene - scene.
* @param {*} data - data.
* @param {*} count - count.
* @returns {*} Operation result, when applicable.
*/
export function createTraffic(scene, data, count = 28) {
const oneway = new Set(data.oneway ?? []);
const roads = data.roads
.flatMap((r, index) => {
if (r[0] > 3) return [];
const pts = [];
for (let i = 2; i < r.length; i += 2) pts.push(new THREE.Vector2(r[i], r[i + 1]));
const lengths = [0];
for (let i = 1; i < pts.length; i++)
lengths.push(lengths[i - 1] + pts[i].distanceTo(pts[i - 1]));
const xs = pts.map((p) => p.x),
zs = pts.map((p) => p.y);
return [
{
pts,
lengths,
total: lengths.at(-1),
width: WIDTH[r[0]],
oneway: oneway.has(index),
box: {
x0: Math.min(...xs),
x1: Math.max(...xs),
z0: Math.min(...zs),
z1: Math.max(...zs),
},
},
];
})
.filter((r) => r.total > 80);
/**
* Runs the sample operation for this subsystem.
* @param {*} road - road.
* @param {*} d - d.
* @returns {*} Operation result, when applicable.
*/
const sample = (road, d) => {
let i = 1;
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
const a = road.pts[i - 1],
b = road.pts[i],
t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
return {
x: a.x + (b.x - a.x) * t,
z: a.y + (b.y - a.y) * t,
dx: b.x - a.x,
dz: b.y - a.y,
};
};
let seed = 7;
/**
* Runs the random operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
// Roads whose bounding box comes within 600 m of the player, refreshed as they travel.
let nearby = [],
nearbyAt = null;
/**
* Runs the roads near operation for this subsystem.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const roadsNear = (x, z) => {
if (!nearbyAt || Math.hypot(x - nearbyAt.x, z - nearbyAt.z) > 150) {
nearbyAt = {
x,
z,
};
nearby = roads.filter(
({ box }) => x > box.x0 - 600 && x < box.x1 + 600 && z > box.z0 - 600 && z < box.z1 + 600,
);
}
return nearby;
};
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
/**
* Runs the respawn operation for this subsystem.
* @param {*} car - car.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
function respawn(car, x, z) {
const candidates = roadsNear(x, z);
for (let attempt = 0; attempt < 30 && candidates.length; attempt++) {
const road = candidates[Math.floor(random() * candidates.length)],
d = random() * road.total,
p = sample(road, d);
const dist = Math.hypot(p.x - x, p.z - z);
if (dist < 120 || dist > 600) continue;
// One-way carriageways: with the flow, in either of two lanes. Two-way roads: right-hand half.
const lane = road.oneway ? ((random() < 0.5 ? -1 : 1) * road.width) / 4 : road.width / 4;
Object.assign(car, {
road,
d,
dir: road.oneway || random() < 0.5 ? 1 : -1,
lane,
speed: 9 + random() * 7,
});
car.model.group.visible = true;
return true;
}
car.model.group.visible = false;
return false;
}
// All traffic sits in one group so it can be hidden as a whole (camera AR view).
const group = new THREE.Group(),
cars = [];
scene.add(group);
for (let i = 0; i < count; i++) {
const model = createCarModel(COLORS[i % COLORS.length]);
group.add(model.group);
model.group.visible = false;
cars.push({
model,
road: null,
d: 0,
dir: 1,
lane: 2.2,
speed: 10,
x: 1e9,
z: 1e9,
});
}
// Takes a car off the road; it respawns out of sight on a later frame.
/**
* Runs the remove operation for this subsystem.
* @param {*} car - car.
* @returns {*} Operation result, when applicable.
*/
function remove(car) {
car.road = null;
car.x = car.z = 1e9;
car.model.group.visible = false;
}
let cursor = 0;
/**
* Updates update from the active simulation.
* @param {*} dt - dt.
* @param {*} obstacles - obstacles.
* @param {*} around - around.
* @returns {*} Operation result, when applicable.
*/
function update(dt, obstacles, around) {
// Re-home a couple of distant cars per frame, which keeps the cost per frame small.
for (let n = 0; n < 2; n++) {
const car = cars[(cursor = (cursor + 1) % cars.length)];
if (!car.road || Math.hypot(car.x - around.x, car.z - around.z) > 800)
respawn(car, around.x, around.z);
}
for (const car of cars) {
if (!car.road || !car.model.group.visible) continue;
const here = sample(car.road, car.d),
len = Math.hypot(here.dx, here.dz) || 1;
const fx = (here.dx / len) * car.dir,
fz = (here.dz / len) * car.dir;
const ahead = obstacles
.concat(cars.filter((c) => c !== car && c.model.group.visible))
.some((o) => {
const ox = o.x - car.x,
oz = o.z - car.z,
along = ox * fx + oz * fz;
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
});
const speed = ahead ? 0 : car.speed;
car.d += speed * car.dir * dt;
if (car.d > car.road.total || car.d < 0) {
// At the end of a one-way road the car leaves; on a two-way road it turns back.
if (car.road.oneway) {
remove(car);
continue;
}
car.dir *= -1;
car.d = Math.max(0, Math.min(car.road.total, car.d));
}
// Keep to the right-hand lane relative to the direction of travel.
const p = sample(car.road, car.d);
car.x = p.x - fz * car.lane;
car.z = p.z + fx * car.lane;
car.model.group.position.set(car.x, 0, car.z);
car.model.group.rotation.y = Math.atan2(-fz, fx);
for (const wheel of car.model.wheels) wheel.rotation.z -= (speed * dt) / 0.36;
}
}
return {
group,
cars,
update,
remove,
};
}
+57
View File
@@ -0,0 +1,57 @@
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y.
// maxSpeed 55.6 m/s is 200 km/h.
export const CAR = {
maxSpeed: 55.6,
maxReverse: 6,
accel: 9.5,
brake: 14,
drag: 1.6,
maxSteer: 0.55,
wheelBase: 2.7,
radius: 1.6,
};
/**
* Creates car resources for the game.
* @param {*} x - x.
* @param {*} z - z.
* @param {*} heading - heading.
* @returns {*} Operation result, when applicable.
*/
export function createCar(x, z, heading = 0) {
return {
x,
z,
heading,
speed: 0,
steer: 0,
};
}
/**
* Runs the step car operation for this subsystem.
* @param {*} car - car.
* @param {*} options1 - Operation options.
* @param {*} dt - dt.
* @returns {*} Operation result, when applicable.
*/
export function stepCar(car, { throttle = 0, steer = 0, handbrake = false }, dt) {
car.steer += (steer * CAR.maxSteer - car.steer) * Math.min(1, dt * 6);
if (throttle > 0) car.speed += (car.speed < 0 ? CAR.brake : CAR.accel) * throttle * dt;
else if (throttle < 0)
car.speed += (car.speed > 0 ? -CAR.brake : -CAR.accel * 0.6) * -throttle * dt;
else car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.drag * dt);
if (handbrake)
car.speed -= Math.sign(car.speed) * Math.min(Math.abs(car.speed), CAR.brake * 1.4 * dt);
car.speed = Math.max(-CAR.maxReverse, Math.min(CAR.maxSpeed, car.speed));
// High speed narrows the usable steering angle so the car stays controllable.
const steerAngle = car.steer / (1 + Math.abs(car.speed) * 0.045);
car.heading -= ((car.speed * Math.tan(steerAngle)) / CAR.wheelBase) * dt;
car.x += Math.cos(car.heading) * car.speed * dt;
car.z -= Math.sin(car.heading) * car.speed * dt;
return car;
}
/**
* Runs the kmh operation for this subsystem.
* @param {*} car - car.
* @returns {*} Operation result, when applicable.
*/
export const kmh = (car) => Math.round(Math.abs(car.speed) * 3.6);
+35
View File
@@ -0,0 +1,35 @@
/**
* Runs the decode buildings operation for this subsystem.
* @param {*} buffer - buffer.
* @returns {*} Operation result, when applicable.
*/
// Reads public/data/buildings.bin (written by scripts/build-buildings.mjs) into the same
// [height, x0, z0, x1, z1, …] arrays the OSM buildings in mellat.json use.
export function decodeBuildings(buffer) {
const view = new DataView(buffer);
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
if (magic !== 'RMB2') throw new Error(`unknown buildings file ${magic}`);
const count = view.getUint32(4, true),
minX = view.getFloat32(8, true),
minZ = view.getFloat32(12, true),
step = view.getFloat32(16, true);
const buildings = new Array(count);
let o = 20;
for (let b = 0; b < count; b++) {
const n = view.getUint8(o),
height = view.getUint8(o + 1) * 0.5;
o += 2;
const building = new Array(1 + n * 2);
building[0] = height;
let qx = 0,
qz = 0;
for (let i = 0; i < n; i++, o += 4) {
qx = i ? qx + view.getInt16(o, true) : view.getUint16(o, true);
qz = i ? qz + view.getInt16(o + 2, true) : view.getUint16(o + 2, true);
building[1 + i * 2] = minX + qx * step;
building[2 + i * 2] = minZ + qz * step;
}
buildings[b] = building;
}
return buildings;
}
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
// 2D collision against building footprints, stored as flat [x0, z0, x1, z1, …] rings.
const CELL = 24;
/**
* Runs the key operation for this subsystem.
* @param {*} cx - cx.
* @param {*} cz - cz.
* @returns {*} Operation result, when applicable.
*/
const key = (cx, cz) => cx * 100003 + cz;
/**
* Creates collision world resources for the game.
* @param {*} polygons - polygons.
* @returns {*} Operation result, when applicable.
*/
export function createCollisionWorld(polygons) {
const world = {
polygons: [],
grid: new Map(),
};
polygons.forEach((p) => addPolygon(world, p));
return world;
}
/**
* Runs the add polygon operation for this subsystem.
* @param {*} world - world.
* @param {*} pts - pts.
* @returns {*} Operation result, when applicable.
*/
export function addPolygon(world, pts) {
let minX = Infinity,
minZ = Infinity,
maxX = -Infinity,
maxZ = -Infinity;
for (let i = 0; i < pts.length; i += 2) {
minX = Math.min(minX, pts[i]);
maxX = Math.max(maxX, pts[i]);
minZ = Math.min(minZ, pts[i + 1]);
maxZ = Math.max(maxZ, pts[i + 1]);
}
const index =
world.polygons.push({
pts,
minX,
minZ,
maxX,
maxZ,
}) - 1;
for (let cx = Math.floor(minX / CELL); cx <= Math.floor(maxX / CELL); cx++)
for (let cz = Math.floor(minZ / CELL); cz <= Math.floor(maxZ / CELL); cz++) {
const k = key(cx, cz);
if (!world.grid.has(k)) world.grid.set(k, []);
world.grid.get(k).push(index);
}
}
/**
* Runs the nearby operation for this subsystem.
* @param {*} world - world.
* @param {*} x - x.
* @param {*} z - z.
* @param {*} r - r.
* @returns {*} Operation result, when applicable.
*/
function nearby(world, x, z, r) {
const found = new Set();
for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++)
for (let cz = Math.floor((z - r) / CELL); cz <= Math.floor((z + r) / CELL); cz++)
for (const i of world.grid.get(key(cx, cz)) ?? []) found.add(i);
return [...found]
.map((i) => world.polygons[i])
.filter((p) => x + r > p.minX && x - r < p.maxX && z + r > p.minZ && z - r < p.maxZ);
}
/**
* Runs the inside polygon operation for this subsystem.
* @param {*} pts - pts.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
export function insidePolygon(pts, x, z) {
let inside = false;
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const xi = pts[i],
zi = pts[i + 1],
xj = pts[j],
zj = pts[j + 1];
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside;
}
return inside;
}
/**
* Runs the closest on edges operation for this subsystem.
* @param {*} pts - pts.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
function closestOnEdges(pts, x, z) {
let best = {
d: Infinity,
x: 0,
z: 0,
};
for (let i = 0, j = pts.length - 2; i < pts.length; j = i, i += 2) {
const ax = pts[j],
az = pts[j + 1],
dx = pts[i] - ax,
dz = pts[i + 1] - az;
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1)));
const px = ax + dx * t,
pz = az + dz * t,
d = Math.hypot(x - px, z - pz);
if (d < best.d)
best = {
d,
x: px,
z: pz,
};
}
return best;
}
// Pushes a circle out of every footprint it overlaps. Returns the corrected position.
/**
* Runs the resolve circle operation for this subsystem.
* @param {*} world - world.
* @param {*} x - x.
* @param {*} z - z.
* @param {*} r - r.
* @returns {*} Operation result, when applicable.
*/
export function resolveCircle(world, x, z, r) {
let hit = false;
for (let pass = 0; pass < 3; pass++) {
let moved = false;
for (const p of nearby(world, x, z, r)) {
const inside = insidePolygon(p.pts, x, z),
c = closestOnEdges(p.pts, x, z);
if (!inside && c.d >= r) continue;
const len = c.d || 1e-6,
nx = ((x - c.x) / len) * (inside ? -1 : 1),
nz = ((z - c.z) / len) * (inside ? -1 : 1);
x = c.x + nx * r;
z = c.z + nz * r;
moved = hit = true;
}
if (!moved) break;
}
return {
x,
z,
hit,
};
}
/**
* Runs the blocked operation for this subsystem.
* @param {*} world - world.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
export const blocked = (world, x, z) =>
nearby(world, x, z, 0).some((p) => insidePolygon(p.pts, x, z));
// Fraction (0–1) of the segment a→b that is free before it enters a footprint.
/**
* Runs the clear fraction operation for this subsystem.
* @param {*} world - world.
* @param {*} ax - ax.
* @param {*} az - az.
* @param {*} bx - bx.
* @param {*} bz - bz.
* @param {*} step - step.
* @returns {*} Operation result, when applicable.
*/
export function clearFraction(world, ax, az, bx, bz, step = 1) {
const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az) / step));
for (let i = 1; i <= steps; i++) {
const t = i / steps;
if (blocked(world, ax + (bx - ax) * t, az + (bz - az) * t)) return (i - 1) / steps;
}
return 1;
}
+198
View File
@@ -0,0 +1,198 @@
import { loadTileUrl } from '../../services/worldService.js';
import * as THREE from 'three';
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
// out underneath them, and tiles out of range are released.
/**
* Runs the tile lon operation for this subsystem.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const tileLon = (x, z) => (x / 2 ** z) * 360 - 180;
/**
* Runs the tile lat operation for this subsystem.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const tileLat = (y, z) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / 2 ** z))) * 180) / Math.PI;
// World-space corners of a Web Mercator tile: north-west, north-east, south-west, south-east.
/**
* Runs the tile corners operation for this subsystem.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
export function tileCorners(x, y, z) {
const n = tileLat(y, z),
s = tileLat(y + 1, z),
w = tileLon(x, z),
e = tileLon(x + 1, z);
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
}
/**
* Creates imagery resources for the game.
* @param {*} scene - scene.
* @param {*} index - index.
* @param {*} options2 - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function createImagery(
scene,
index,
{ baseUrl = './imagery/', lowQuality = false, anisotropy = 4 } = {},
) {
const group = new THREE.Group();
scene.add(group);
const loader = new THREE.TextureLoader();
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
const levels = [
{
z: 17,
range: lowQuality ? 450 : 750,
y: 0.012,
offset: -3,
},
{
z: 15,
range: lowQuality ? 1800 : 2800,
y: 0.004,
offset: -1,
},
].map((level) => ({
...level,
tiles: (index[level.z] ?? []).map((key) => {
const [x, y] = key.split('/').map(Number),
corners = tileCorners(x, y, level.z);
const cx = corners.reduce((s, c) => s + c.x, 0) / 4,
cz = corners.reduce((s, c) => s + c.z, 0) / 4;
return {
key,
x,
y,
corners,
cx,
cz,
mesh: null,
loading: false,
};
}),
}));
let loads = 0;
let disposed = false;
const controller = new AbortController();
/**
* Runs the show operation for this subsystem.
* @param {*} level - level.
* @param {*} tile - tile.
* @returns {*} Operation result, when applicable.
*/
async function show(level, tile) {
if (tile.mesh || tile.loading || loads >= 6) return;
tile.loading = true;
loads++;
let tileUrl;
try {
tileUrl = await loadTileUrl(
`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`,
controller.signal,
);
} catch {
loads--;
tile.loading = false;
tile.failed = true;
return;
}
loader.load(
tileUrl,
(texture) => {
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;
const [nw, ne, sw, se] = tile.corners,
geometry = new THREE.BufferGeometry();
geometry.setAttribute(
'position',
new THREE.Float32BufferAttribute(
[nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z],
3,
),
);
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
geometry.setIndex([0, 2, 1, 1, 2, 3]);
tile.mesh = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({
map: texture,
toneMapped: false,
polygonOffset: true,
polygonOffsetFactor: level.offset,
polygonOffsetUnits: level.offset * 2,
}),
);
group.add(tile.mesh);
},
undefined,
() => {
URL.revokeObjectURL(tileUrl);
loads--;
tile.loading = false;
tile.failed = true;
},
);
}
/**
* Runs the hide operation for this subsystem.
* @param {*} tile - tile.
* @returns {*} Operation result, when applicable.
*/
function hide(tile) {
if (!tile.mesh) return;
group.remove(tile.mesh);
tile.mesh.geometry.dispose();
tile.mesh.material.map.dispose();
tile.mesh.material.dispose();
tile.mesh = null;
}
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) {
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
if (!moved && loads >= 6) return;
last = {
x,
z,
};
for (const level of levels) {
const wanted = [];
for (const tile of level.tiles) {
const d = Math.hypot(tile.cx - x, tile.cz - z);
if (d > level.range * 1.2) hide(tile);
else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
}
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
}
},
};
}
+583
View File
@@ -0,0 +1,583 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
const SCALE = 0.75; // tile pixels per metre
const TILE_PX = 512,
TILE_M = TILE_PX / SCALE,
MAX_TILES = 48;
const OVERVIEW_SCALE = 0.07;
const AREA = {
campus: '#28322c',
park: '#2c4a32',
grass: '#30503a',
forest: '#27432e',
pitch: '#33603c',
water: '#2b5b6e',
industrial: '#262c29',
farmland: '#2a3326',
};
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
/**
* Runs the road color operation for this subsystem.
* @param {*} type - type.
* @returns {*} Operation result, when applicable.
*/
const roadColor = (type) => (type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63');
/**
* Runs the tile key operation for this subsystem.
* @param {*} tx - tx.
* @param {*} tz - tz.
* @returns {*} Operation result, when applicable.
*/
const tileKey = (tx, tz) => tx * 100003 + tz;
/**
* Runs the path operation for this subsystem.
* @param {*} c - c.
* @param {*} pts - pts.
* @returns {*} Operation result, when applicable.
*/
function path(c, pts) {
c.beginPath();
c.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
}
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
/**
* Runs the draw features operation for this subsystem.
* @param {*} c - c.
* @param {*} options1 - Operation options.
* @param {*} data - data.
* @param {*} minWidth - min Width.
* @returns {*} Operation result, when applicable.
*/
function drawFeatures(c, { areas, roads, buildings }, data, minWidth) {
for (const area of areas) {
c.fillStyle = AREA[data.areaKinds[area[0]]];
path(c, area.slice(1));
c.fill();
}
c.lineCap = c.lineJoin = 'round';
for (const road of roads) {
c.strokeStyle = roadColor(road[0]);
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
path(c, road.slice(2));
c.stroke();
}
c.fillStyle = '#3c4943';
for (const b of buildings) {
path(c, b.slice(1));
c.fill();
}
}
/**
* Creates map assets resources for the game.
* @param {*} data - data.
* @param {*} extraLabels - extra Labels.
* @returns {*} Operation result, when applicable.
*/
export function createMapAssets(data, extraLabels = []) {
const { minX, minZ, maxX, maxZ } = data.bounds;
// Bin every feature into the tiles its bounding box touches.
const bins = new Map();
/**
* Runs the bin operation for this subsystem.
* @param {*} list - list.
* @param {*} feature - feature.
* @param {*} first - first.
* @returns {*} Operation result, when applicable.
*/
const bin = (list, feature, first) => {
let x0 = Infinity,
z0 = Infinity,
x1 = -Infinity,
z1 = -Infinity;
for (let i = first; i < feature.length; i += 2) {
x0 = Math.min(x0, feature[i]);
x1 = Math.max(x1, feature[i]);
z0 = Math.min(z0, feature[i + 1]);
z1 = Math.max(z1, feature[i + 1]);
}
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
const k = tileKey(tx, tz);
if (!bins.has(k))
bins.set(k, {
areas: [],
roads: [],
buildings: [],
});
bins.get(k)[list].push(feature);
}
};
data.areas.forEach((a) => bin('areas', a, 1));
[...data.roads].sort((a, b) => b[0] - a[0]).forEach((r) => bin('roads', r, 2));
data.buildings.forEach((b) => bin('buildings', b, 1));
const cache = new Map();
/**
* Runs the tile operation for this subsystem.
* @param {*} tx - tx.
* @param {*} tz - tz.
* @returns {*} Operation result, when applicable.
*/
function tile(tx, tz) {
const k = tileKey(tx, tz);
if (cache.has(k)) {
const canvas = cache.get(k);
cache.delete(k);
cache.set(k, canvas);
return canvas;
}
const canvas = document.createElement('canvas');
canvas.width = canvas.height = TILE_PX;
const c = canvas.getContext('2d');
c.fillStyle = '#1b231f';
c.fillRect(0, 0, TILE_PX, TILE_PX);
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
drawFeatures(
c,
bins.get(k) ?? {
areas: [],
roads: [],
buildings: [],
},
data,
1.6,
);
cache.set(k, canvas);
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
return canvas;
}
const overview = document.createElement('canvas');
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
const o = overview.getContext('2d');
o.fillStyle = '#1b231f';
o.fillRect(0, 0, overview.width, overview.height);
o.setTransform(
OVERVIEW_SCALE,
0,
0,
OVERVIEW_SCALE,
-minX * OVERVIEW_SCALE,
-minZ * OVERVIEW_SCALE,
);
drawFeatures(
o,
{
areas: data.areas,
roads: data.roads.filter((r) => r[0] <= 5).sort((a, b) => b[0] - a[0]),
buildings: [],
},
data,
14,
);
// One label per named boulevard, at the middle of its longest way.
const streets = new Map();
for (const road of data.roads) {
if (road[1] < 0 || road[0] > 3) continue;
let len = 0;
for (let i = 4; i < road.length; i += 2)
len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
const name = data.names[road[1]];
if ((streets.get(name)?.len ?? 0) < len) {
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
const angle = Math.atan2(
road[Math.min(mid + 3, road.length - 1)] - road[mid - 1],
road[Math.min(mid + 2, road.length - 2)] - road[mid - 2],
);
streets.set(name, {
name,
len,
x: road[mid],
z: road[mid + 1],
angle,
});
}
}
return {
tile,
overview,
bounds: data.bounds,
labels: [...data.labels, ...extraLabels],
streets: [...streets.values()].filter((s) => s.len > 150),
};
}
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
// metres; pxPerMetre decides between detailed tiles and the overview.
/**
* Runs the paint world operation for this subsystem.
* @param {*} c - c.
* @param {*} assets - assets.
* @param {*} x0 - x0.
* @param {*} z0 - z0.
* @param {*} x1 - x1.
* @param {*} z1 - z1.
* @param {*} pxPerMetre - px Per Metre.
* @returns {*} Operation result, when applicable.
*/
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
const { minX, minZ } = assets.bounds;
c.fillStyle = '#141b18';
c.fillRect(x0, z0, x1 - x0, z1 - z0);
if (pxPerMetre < 0.22) {
c.drawImage(
assets.overview,
minX,
minZ,
assets.overview.width / OVERVIEW_SCALE,
assets.overview.height / OVERVIEW_SCALE,
);
return;
}
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + 0.5, TILE_M + 0.5);
}
/**
* Runs the rotate point operation for this subsystem.
* @param {*} x - x.
* @param {*} z - z.
* @param {*} cx - cx.
* @param {*} cz - cz.
* @param {*} angle - angle.
* @param {*} k - k.
* @returns {*} Operation result, when applicable.
*/
function rotatePoint(x, z, cx, cz, angle, k) {
const dx = (x - cx) * k,
dz = (z - cz) * k,
cos = Math.cos(angle),
sin = Math.sin(angle);
return [dx * cos - dz * sin, dx * sin + dz * cos];
}
/**
* Runs the machine icon operation for this subsystem.
* @param {*} c - c.
* @param {*} x - x.
* @param {*} y - y.
* @param {*} options3 - Operation options.
* @returns {*} Operation result, when applicable.
*/
function machineIcon(c, x, y, { objective, visited, open }) {
c.save();
c.translate(x, y);
if (objective) {
c.rotate(Math.PI / 4);
c.fillStyle = '#f2c94c';
c.strokeStyle = '#1b1406';
c.lineWidth = 2;
c.fillRect(-7, -7, 14, 14);
c.strokeRect(-7, -7, 14, 14);
} else {
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
c.strokeStyle = '#08201c';
c.lineWidth = 2;
c.beginPath();
c.roundRect(-6, -8, 12, 16, 3);
c.fill();
c.stroke();
c.fillStyle = '#08201c';
c.fillRect(-3, -5, 6, 6);
}
c.restore();
if (visited) {
c.fillStyle = '#fff';
c.font = 'bold 11px sans-serif';
c.fillText('✓', x + 8, y - 6);
}
}
// Circular minimap rotated so the camera's forward direction points up.
/**
* Runs the draw minimap operation for this subsystem.
* @param {*} ctx - ctx.
* @param {*} assets - assets.
* @param {*} view - view.
* @returns {*} Operation result, when applicable.
*/
export function drawMinimap(ctx, assets, view) {
const { size, x, z, heading, camForward, radius, markers, route } = view;
const r = size / 2,
k = r / radius,
angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#141b18';
ctx.fillRect(0, 0, size, size);
ctx.translate(r, r);
ctx.save();
ctx.rotate(angle);
ctx.scale(k, k);
ctx.translate(-x, -z);
paintWorld(
ctx,
assets,
x - radius * 1.5,
z - radius * 1.5,
x + radius * 1.5,
z + radius * 1.5,
k,
);
ctx.restore();
if (route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
ctx.beginPath();
route.forEach((p, i) => {
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of markers) {
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
const d = Math.hypot(px, py);
if (d > r - 12) {
if (!m.objective) continue;
px *= (r - 12) / d;
py *= (r - 12) / d;
}
machineIcon(ctx, px, py, m);
}
// Player arrow, then north marker on the rim.
ctx.save();
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#0b100e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.lineTo(7, 8);
ctx.lineTo(0, 4);
ctx.lineTo(-7, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
const nl = Math.hypot(nx, ny) || 1;
ctx.fillStyle = '#f2c94c';
ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.stroke();
}
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
/**
* Runs the draw full map operation for this subsystem.
* @param {*} ctx - ctx.
* @param {*} assets - assets.
* @param {*} view - view.
* @param {*} state - state.
* @returns {*} Operation result, when applicable.
*/
export function drawFullMap(ctx, assets, view, state) {
const { width, height } = ctx.canvas,
dpr = state.dpr,
w = width / dpr,
h = height / dpr;
/**
* Runs the to screen operation for this subsystem.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#101614';
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.scale(1 / view.mpp, 1 / view.mpp);
ctx.translate(-view.cx, -view.cz);
paintWorld(
ctx,
assets,
view.cx - (w / 2) * view.mpp,
view.cz - (h / 2) * view.mpp,
view.cx + (w / 2) * view.mpp,
view.cz + (h / 2) * view.mpp,
1 / view.mpp,
);
ctx.restore();
// Grid lines every 500 m, like a tactical map.
ctx.strokeStyle = 'rgba(160,200,185,.08)';
ctx.lineWidth = 1;
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
const [sx] = toScreen(gx, 0);
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, h);
ctx.stroke();
}
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
const [, sy] = toScreen(0, gz);
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(w, sy);
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (view.mpp < 3) {
ctx.font = '11px Vazirmatn, sans-serif';
ctx.fillStyle = 'rgba(235,240,232,.75)';
for (const s of assets.streets) {
const [sx, sy] = toScreen(s.x, s.z);
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
ctx.save();
ctx.translate(sx, sy);
let a = s.angle;
if (a > Math.PI / 2) a -= Math.PI;
if (a < -Math.PI / 2) a += Math.PI;
ctx.rotate(a);
ctx.fillText(s.name, 0, 0);
ctx.restore();
}
}
for (const l of assets.labels) {
if (l.kind === 'school' && view.mpp > 1.6) continue;
const [sx, sy] = toScreen(l.x, l.z);
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
if (l.kind === 'industrial' && view.mpp > 6) continue;
ctx.fillStyle = {
park: '#9fd49a',
university: '#c9b6f2',
school: '#e8c79a',
metro: '#ff8a80',
mall: '#f5b8d9',
industrial: '#a9b2ad',
factory: '#3ee0c9',
}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
sx,
sy + 14,
);
}
if (state.route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 4;
ctx.setLineDash([8, 5]);
ctx.beginPath();
state.route.forEach((p, i) => {
const [sx, sy] = toScreen(p.x, p.z);
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of state.markers) {
const [sx, sy] = toScreen(m.x, m.z);
if (m.id === state.selected) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
ctx.stroke();
}
machineIcon(ctx, sx, sy, m);
}
const [px, py] = toScreen(state.player.x, state.player.z);
ctx.save();
ctx.translate(px, py);
ctx.rotate(
Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2,
);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -12);
ctx.lineTo(8, 9);
ctx.lineTo(0, 5);
ctx.lineTo(-8, 9);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
return toScreen;
}
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
/**
* Runs the draw iran operation for this subsystem.
* @param {*} ctx - ctx.
* @param {*} iran - iran.
* @param {*} options2 - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function drawIran(
ctx,
iran,
{ width, height, t = 0, highlight = 'Razavi Khorasan', cities = [] },
) {
const lon0 = 43.8,
lon1 = 63.6,
lat0 = 24.8,
lat1 = 40;
const kx = 0.84,
k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * 0.94;
const offX = (width - (lon1 - lon0) * kx * k) / 2,
offY = (height - (lat1 - lat0) * k) / 2;
/**
* Runs the p operation for this subsystem.
* @param {*} lon - lon.
* @param {*} lat - lat.
* @returns {*} Operation result, when applicable.
*/
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
ctx.clearRect(0, 0, width, height);
for (const province of iran.provinces) {
const active = province.id === highlight;
ctx.fillStyle = active
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
: 'rgba(40,56,50,.85)';
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) {
ctx.beginPath();
for (let i = 0; i < ring.length; i += 2) {
const [x, y] = p(ring[i], ring[i + 1]);
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
ctx.textAlign = 'center';
for (const city of cities) {
const [x, y] = p(city.lon, city.lat);
if (city.active) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 6 + ((t * 18) % 18), 0, Math.PI * 2);
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
ctx.beginPath();
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
ctx.fill();
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
}
}
+315
View File
@@ -0,0 +1,315 @@
// Road graph, A* routing and nearest-street lookup over the OSM road polylines.
// A road is [typeIndex, nameIndex, x0, z0, x1, z1, …]; ways that share an OSM node share coordinates.
export const DRIVABLE_MAX_TYPE = 7; // trunk … service in data.roadTypes
/**
* Runs the build graph operation for this subsystem.
* @param {*} roads - roads.
* @param {*} options1 - Operation options.
* @returns {*} Operation result, when applicable.
*/
export function buildGraph(roads, { drivable = false } = {}) {
const ids = new Map(),
xs = [],
zs = [],
adj = [];
/**
* Runs the node operation for this subsystem.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
const node = (x, z) => {
const k = `${x},${z}`;
if (!ids.has(k)) {
ids.set(k, xs.length);
xs.push(x);
zs.push(z);
adj.push([]);
}
return ids.get(k);
};
for (const road of roads) {
if (drivable && road[0] > DRIVABLE_MAX_TYPE) continue;
let prev = node(road[2], road[3]);
for (let i = 4; i < road.length; i += 2) {
const next = node(road[i], road[i + 1]),
cost = Math.hypot(xs[next] - xs[prev], zs[next] - zs[prev]);
if (next !== prev) {
adj[prev].push(next, cost);
adj[next].push(prev, cost);
}
prev = next;
}
}
const graph = {
xs,
zs,
adj,
main: largestComponent(adj),
grid: new Map(),
};
graph.main.forEach((inMain, i) => {
if (!inMain) return;
const k = cellKey(Math.floor(xs[i] / 50), Math.floor(zs[i] / 50));
if (!graph.grid.has(k)) graph.grid.set(k, []);
graph.grid.get(k).push(i);
});
return graph;
}
/**
* Runs the cell key operation for this subsystem.
* @param {*} cx - cx.
* @param {*} cz - cz.
* @returns {*} Operation result, when applicable.
*/
const cellKey = (cx, cz) => cx * 100003 + cz;
/**
* Runs the largest component operation for this subsystem.
* @param {*} adj - adj.
* @returns {*} Operation result, when applicable.
*/
function largestComponent(adj) {
const label = new Int32Array(adj.length).fill(-1),
sizes = [];
for (let s = 0; s < adj.length; s++) {
if (label[s] >= 0) continue;
const id = sizes.length,
stack = [s];
let size = 0;
label[s] = id;
while (stack.length) {
const n = stack.pop();
size++;
for (let i = 0; i < adj[n].length; i += 2)
if (label[adj[n][i]] < 0) {
label[adj[n][i]] = id;
stack.push(adj[n][i]);
}
}
sizes.push(size);
}
const best = sizes.indexOf(Math.max(...sizes));
return Array.from(label, (l) => l === best);
}
// Nearest node of the main connected component, searching outward ring by ring.
/**
* Runs the nearest node operation for this subsystem.
* @param {*} graph - graph.
* @param {*} x - x.
* @param {*} z - z.
* @returns {*} Operation result, when applicable.
*/
export function nearestNode(graph, x, z) {
const cx = Math.floor(x / 50),
cz = Math.floor(z / 50);
let best = -1,
bestD = Infinity;
for (let ring = 0; ring < 40; ring++) {
for (let i = cx - ring; i <= cx + ring; i++)
for (let j = cz - ring; j <= cz + ring; j++) {
if (Math.max(Math.abs(i - cx), Math.abs(j - cz)) !== ring) continue;
for (const n of graph.grid.get(cellKey(i, j)) ?? []) {
const d = Math.hypot(graph.xs[n] - x, graph.zs[n] - z);
if (d < bestD) {
bestD = d;
best = n;
}
}
}
if (best >= 0 && bestD < ring * 50) break;
}
return best;
}
/**
* Runs the find path operation for this subsystem.
* @param {*} graph - graph.
* @param {*} start - start.
* @param {*} goal - goal.
* @returns {*} Operation result, when applicable.
*/
export function findPath(graph, start, goal) {
if (start < 0 || goal < 0) return null;
const { xs, zs, adj } = graph,
g = new Map([[start, 0]]),
from = new Map(),
closed = new Set();
/**
* Runs the h operation for this subsystem.
* @param {*} n - n.
* @returns {*} Operation result, when applicable.
*/
const h = (n) => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
const heap = [[h(start), start]];
/**
* Runs the push operation for this subsystem.
* @param {*} item - item.
* @returns {*} Operation result, when applicable.
*/
const push = (item) => {
heap.push(item);
for (let i = heap.length - 1; i > 0;) {
const p = (i - 1) >> 1;
if (heap[p][0] <= heap[i][0]) break;
[heap[p], heap[i]] = [heap[i], heap[p]];
i = p;
}
};
/**
* Runs the pop operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
const pop = () => {
const top = heap[0],
last = heap.pop();
if (heap.length) {
heap[0] = last;
for (let i = 0; ;) {
const l = i * 2 + 1,
r = l + 1;
let m = i;
if (l < heap.length && heap[l][0] < heap[m][0]) m = l;
if (r < heap.length && heap[r][0] < heap[m][0]) m = r;
if (m === i) break;
[heap[m], heap[i]] = [heap[i], heap[m]];
i = m;
}
}
return top;
};
while (heap.length) {
const [, n] = pop();
if (n === goal) {
const path = [n];
while (from.has(path[0])) path.unshift(from.get(path[0]));
return path;
}
if (closed.has(n)) continue;
closed.add(n);
for (let i = 0; i < adj[n].length; i += 2) {
const m = adj[n][i],
cost = g.get(n) + adj[n][i + 1];
if (cost < (g.get(m) ?? Infinity)) {
g.set(m, cost);
from.set(m, n);
push([cost + h(m), m]);
}
}
}
return null;
}
/**
* Runs the route between operation for this subsystem.
* @param {*} graph - graph.
* @param {*} ax - ax.
* @param {*} az - az.
* @param {*} bx - bx.
* @param {*} bz - bz.
* @returns {*} Operation result, when applicable.
*/
export function routeBetween(graph, ax, az, bx, bz) {
const path = findPath(graph, nearestNode(graph, ax, az), nearestNode(graph, bx, bz));
if (!path) return null;
const points = [
{
x: ax,
z: az,
},
...path.map((n) => ({
x: graph.xs[n],
z: graph.zs[n],
})),
{
x: bx,
z: bz,
},
];
return {
points,
length: polylineLength(points),
};
}
/**
* Runs the polyline length operation for this subsystem.
* @param {*} points - points.
* @returns {*} Operation result, when applicable.
*/
export function polylineLength(points) {
let total = 0;
for (let i = 1; i < points.length; i++)
total += Math.hypot(points[i].x - points[i - 1].x, points[i].z - points[i - 1].z);
return total;
}
// Spatial index of road segments, used for street names and keeping trees off paths.
/**
* Runs the build segment index operation for this subsystem.
* @param {*} roads - roads.
* @returns {*} Operation result, when applicable.
*/
export function buildSegmentIndex(roads) {
const grid = new Map();
for (const road of roads)
for (let i = 4; i < road.length; i += 2) {
const seg = [road[i - 2], road[i - 1], road[i], road[i + 1], road[0], road[1]];
for (
let cx = Math.floor(Math.min(seg[0], seg[2]) / 40);
cx <= Math.floor(Math.max(seg[0], seg[2]) / 40);
cx++
)
for (
let cz = Math.floor(Math.min(seg[1], seg[3]) / 40);
cz <= Math.floor(Math.max(seg[1], seg[3]) / 40);
cz++
) {
const k = cellKey(cx, cz);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(seg);
}
}
return grid;
}
/**
* Runs the nearest segment operation for this subsystem.
* @param {*} index - index.
* @param {*} x - x.
* @param {*} z - z.
* @param {*} maxDistance - max Distance.
* @param {*} filter - filter.
* @returns {*} Operation result, when applicable.
*/
export function nearestSegment(index, x, z, maxDistance, filter = () => true) {
let best = null,
bestD = maxDistance;
for (let cx = Math.floor((x - maxDistance) / 40); cx <= Math.floor((x + maxDistance) / 40); cx++)
for (
let cz = Math.floor((z - maxDistance) / 40);
cz <= Math.floor((z + maxDistance) / 40);
cz++
)
for (const s of index.get(cellKey(cx, cz)) ?? []) {
if (!filter(s)) continue;
const dx = s[2] - s[0],
dz = s[3] - s[1];
const t = Math.max(
0,
Math.min(1, ((x - s[0]) * dx + (z - s[1]) * dz) / (dx * dx + dz * dz || 1)),
);
const d = Math.hypot(x - s[0] - dx * t, z - s[1] - dz * t);
if (d < bestD) {
bestD = d;
best = {
type: s[4],
name: s[5],
distance: d,
x: s[0] + dx * t,
z: s[1] + dz * t,
dx,
dz,
};
}
}
return best;
}
-22
View File
@@ -1,22 +0,0 @@
// Local tangent-plane projection around the playable Park Mellat area.
// World axes: +x east, -z north, 1 unit = 1 metre.
export const ORIGIN = {lat: 36.315, lon: 59.539};
const METRES_PER_DEG_LAT = 110574;
const METRES_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * Math.PI / 180);
export function toLocal(lat, lon) {
return {x: (lon - ORIGIN.lon) * METRES_PER_DEG_LON, z: (ORIGIN.lat - lat) * METRES_PER_DEG_LAT};
}
export function toLatLon(x, z) {
return {lat: ORIGIN.lat - z / METRES_PER_DEG_LAT, lon: ORIGIN.lon + x / METRES_PER_DEG_LON};
}
// Compass bearing in degrees (0 = north, 90 = east) of a world-space direction.
export function bearing(dx, dz) {
return (Math.atan2(dx, -dz) * 180 / Math.PI + 360) % 360;
}
export function formatDistance(metres) {
return metres < 1000 ? `${Math.round(metres).toLocaleString('fa-IR')} متر` : `${(Math.round(metres / 100) / 10).toLocaleString('fa-IR')} کیلومتر`;
}
+47
View File
@@ -0,0 +1,47 @@
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();
/**
* Runs the start operation for this subsystem.
* @returns {*} Operation result, when applicable.
*/
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);
controller.abort();
}
}
}
start();
// Abort-scoped input and network work must stop before disposing graphics.
return () => {
controller.abort();
disposeRef.current?.();
disposeRef.current = null;
};
}, [root, bridge]);
return {
error,
};
}
-89
View File
@@ -1,89 +0,0 @@
import * as THREE from 'three';
import {toLocal} from './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
// out underneath them, and tiles out of range are released.
const tileLon = (x, z) => x / 2 ** z * 360 - 180;
const tileLat = (y, z) => Math.atan(Math.sinh(Math.PI * (1 - 2 * y / 2 ** z))) * 180 / Math.PI;
// World-space corners of a Web Mercator tile: north-west, north-east, south-west, south-east.
export function tileCorners(x, y, z) {
const n = tileLat(y, z), s = tileLat(y + 1, z), w = tileLon(x, z), e = tileLon(x + 1, z);
return [toLocal(n, w), toLocal(n, e), toLocal(s, w), toLocal(s, e)];
}
export function createImagery(scene, index, {baseUrl = './imagery/', lowQuality = false, anisotropy = 4} = {}) {
const group = new THREE.Group();
scene.add(group);
const loader = new THREE.TextureLoader();
// Zoom 15 sits just under zoom 17 so the detailed tiles always win where both exist.
const levels = [
{z: 17, range: lowQuality ? 450 : 750, y: .012, offset: -3},
{z: 15, range: lowQuality ? 1800 : 2800, y: .004, offset: -1}
].map(level => ({
...level,
tiles: (index[level.z] ?? []).map(key => {
const [x, y] = key.split('/').map(Number), corners = tileCorners(x, y, level.z);
const cx = corners.reduce((s, c) => s + c.x, 0) / 4, cz = corners.reduce((s, c) => s + c.z, 0) / 4;
return {key, x, y, corners, cx, cz, mesh: null, loading: false};
})
}));
let loads = 0;
function show(level, tile) {
if (tile.mesh || tile.loading || loads >= 6) return;
tile.loading = true;
loads++;
loader.load(`${baseUrl}${level.z}/${tile.x}/${tile.y}.jpg`, texture => {
loads--;
tile.loading = false;
texture.colorSpace = THREE.SRGBColorSpace;
// Sharp at the low angles a driver sees the road from.
texture.anisotropy = anisotropy;
const [nw, ne, sw, se] = tile.corners, geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute([nw.x, level.y, nw.z, ne.x, level.y, ne.z, sw.x, level.y, sw.z, se.x, level.y, se.z], 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 1, 1, 0, 0, 1, 0], 2));
geometry.setIndex([0, 2, 1, 1, 2, 3]);
tile.mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
map: texture, toneMapped: false, polygonOffset: true, polygonOffsetFactor: level.offset, polygonOffsetUnits: level.offset * 2
}));
group.add(tile.mesh);
}, undefined, () => {
loads--;
tile.loading = false;
tile.failed = true;
});
}
function hide(tile) {
if (!tile.mesh) return;
group.remove(tile.mesh);
tile.mesh.geometry.dispose();
tile.mesh.material.map.dispose();
tile.mesh.material.dispose();
tile.mesh = null;
}
let last = null;
return {
group,
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) {
const moved = !last || Math.hypot(x - last.x, z - last.z) > 25;
if (!moved && loads >= 6) return;
last = {x, z};
for (const level of levels) {
const wanted = [];
for (const tile of level.tiles) {
const d = Math.hypot(tile.cx - x, tile.cz - z);
if (d > level.range * 1.2) hide(tile);
else if (d < level.range && !tile.mesh && !tile.failed) wanted.push([d, tile]);
}
wanted.sort((a, b) => a[0] - b[0]).forEach(([, tile]) => show(level, tile));
}
}
};
}
-103
View File
@@ -1,103 +0,0 @@
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
export const CATEGORIES = {
factory: {label: 'کارخانه', icon: '🏭'},
park: {label: 'پارک', icon: '🌳'},
university: {label: 'دانشگاه', icon: '🎓'},
school: {label: 'مدرسه', icon: '🏫'},
metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'},
mall: {label: 'مرکز خرید', icon: '🛍'},
test: {label: 'نقطهٔ آزمایشی GPS', icon: '🧪'}
};
const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = {
name: 'شرکت روبات مارکت',
tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند',
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: {x: -10308.4, z: -14964.2},
axis: {x: Math.SQRT1_2, z: Math.SQRT1_2}
};
export const machines = [
{
id: 'factory', name: 'دستگاه دفتر فنی مهندسی', category: 'factory',
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
anchor: null, hours: [0, 24 * 60], priceFactor: .5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه'
},
{
id: 'mellat-park', name: 'بوستان ملت', category: 'park',
address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
anchor: {x: -487.6, z: -326.4}, hours: ALL_DAY, priceFactor: 1,
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک'
},
{
id: 'school-rahil', name: 'مجموعه آموزشی راحیل', category: 'school',
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
anchor: {x: -554.6, z: -569.7}, hours: [7 * 60 + 30, 13 * 60 + 30], priceFactor: .9,
products: ['water', 'milk', 'juice', 'biscuit', 'cake'],
note: 'فقط در ساعت کاری مدرسه (۷:۳۰ تا ۱۳:۳۰) باز است'
},
{
id: 'ferdowsi-university', name: 'دانشگاه فردوسی مشهد', category: 'university',
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی',
anchor: {x: -936, z: 645}, hours: ALL_DAY, priceFactor: .8,
products: ['water', 'tea', 'milk', 'biscuit', 'chocolate', 'nuts', 'cake'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر'
},
{
id: 'literature-faculty', name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university',
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
anchor: {x: -417.1, z: 966.3}, hours: ALL_DAY, priceFactor: .8,
products: ['water', 'tea', 'lemonade', 'biscuit', 'chocolate'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر'
},
{
id: 'medical-university', name: 'دانشگاه علوم پزشکی مشهد', category: 'university',
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
anchor: {x: -272.1, z: 150.6}, hours: ALL_DAY, priceFactor: .8,
products: ['water', 'juice', 'milk', 'nuts', 'biscuit'],
note: 'تخفیف دانشجویی: ۲۰٪ ارزان‌تر'
},
{
id: 'azadi-metro', name: 'ایستگاه مترو آزادی', category: 'metro',
address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی',
anchor: {x: 494.1, z: 280.3}, hours: [6 * 60, 22 * 60], priceFactor: 1,
products: ['water', 'tea', 'juice', 'chocolate', 'biscuit'],
note: 'دستگاه کنار گیت‌های مترو'
},
{
id: 'mashhad-mall', name: 'مشهد مال', category: 'mall',
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
anchor: {x: 1940.6, z: -367.8}, hours: [10 * 60, 23 * 60], priceFactor: 1.1,
products: ['water', 'juice', 'milk', 'tea', 'lemonade', 'chocolate', 'cake', 'nuts', 'biscuit'],
note: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر'
}
];
// GPS test points at real coordinates beside the factory, for checking real-world mode on a phone.
// They exist only while real-world mode is on; `spot` is where the player stands to buy.
const testPoint = (n, lat, lon) => ({
id: `test-${n}`, name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`, category: 'test', test: true,
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
anchor: null, spot: {lat, lon}, hours: [0, 24 * 60], priceFactor: .5,
products: ['water', 'biscuit'],
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید'
});
machines.push(testPoint(1, 36.450276, 59.423431), testPoint(2, 36.450172, 59.423209), testPoint(3, 36.449976, 59.422866));
// Shown on the map as the next area to unlock.
export const lockedPlaces = [
{name: 'پارک کوهسنگی', note: 'خارج از محدودهٔ فعلی · به‌زودی'},
{name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · به‌زودی'}
];
export const START = {machineId: 'factory'};
export const CITY_START = 'mellat-park';
export const machineById = id => machines.find(m => m.id === id) ?? null;
-1443
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { AppRoutes } from '@/routes/AppRoutes.jsx';
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';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<AppRoutes />
</Provider>,
);
-367
View File
@@ -1,367 +0,0 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
const SCALE = .75; // tile pixels per metre
const TILE_PX = 512, TILE_M = TILE_PX / SCALE, MAX_TILES = 48;
const OVERVIEW_SCALE = .07;
const AREA = {campus: '#28322c', park: '#2c4a32', grass: '#30503a', forest: '#27432e', pitch: '#33603c', water: '#2b5b6e', industrial: '#262c29', farmland: '#2a3326'};
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
const roadColor = type => type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63';
const tileKey = (tx, tz) => tx * 100003 + tz;
function path(c, pts) {
c.beginPath();
c.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
}
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
function drawFeatures(c, {areas, roads, buildings}, data, minWidth) {
for (const area of areas) {
c.fillStyle = AREA[data.areaKinds[area[0]]];
path(c, area.slice(1));
c.fill();
}
c.lineCap = c.lineJoin = 'round';
for (const road of roads) {
c.strokeStyle = roadColor(road[0]);
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
path(c, road.slice(2));
c.stroke();
}
c.fillStyle = '#3c4943';
for (const b of buildings) {
path(c, b.slice(1));
c.fill();
}
}
export function createMapAssets(data, extraLabels = []) {
const {minX, minZ, maxX, maxZ} = data.bounds;
// Bin every feature into the tiles its bounding box touches.
const bins = new Map();
const bin = (list, feature, first) => {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (let i = first; i < feature.length; i += 2) {
x0 = Math.min(x0, feature[i]);
x1 = Math.max(x1, feature[i]);
z0 = Math.min(z0, feature[i + 1]);
z1 = Math.max(z1, feature[i + 1]);
}
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
const k = tileKey(tx, tz);
if (!bins.has(k)) bins.set(k, {areas: [], roads: [], buildings: []});
bins.get(k)[list].push(feature);
}
};
data.areas.forEach(a => bin('areas', a, 1));
[...data.roads].sort((a, b) => b[0] - a[0]).forEach(r => bin('roads', r, 2));
data.buildings.forEach(b => bin('buildings', b, 1));
const cache = new Map();
function tile(tx, tz) {
const k = tileKey(tx, tz);
if (cache.has(k)) {
const canvas = cache.get(k);
cache.delete(k);
cache.set(k, canvas);
return canvas;
}
const canvas = document.createElement('canvas');
canvas.width = canvas.height = TILE_PX;
const c = canvas.getContext('2d');
c.fillStyle = '#1b231f';
c.fillRect(0, 0, TILE_PX, TILE_PX);
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
drawFeatures(c, bins.get(k) ?? {areas: [], roads: [], buildings: []}, data, 1.6);
cache.set(k, canvas);
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
return canvas;
}
const overview = document.createElement('canvas');
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
const o = overview.getContext('2d');
o.fillStyle = '#1b231f';
o.fillRect(0, 0, overview.width, overview.height);
o.setTransform(OVERVIEW_SCALE, 0, 0, OVERVIEW_SCALE, -minX * OVERVIEW_SCALE, -minZ * OVERVIEW_SCALE);
drawFeatures(o, {areas: data.areas, roads: data.roads.filter(r => r[0] <= 5).sort((a, b) => b[0] - a[0]), buildings: []}, data, 14);
// One label per named boulevard, at the middle of its longest way.
const streets = new Map();
for (const road of data.roads) {
if (road[1] < 0 || road[0] > 3) continue;
let len = 0;
for (let i = 4; i < road.length; i += 2) len += Math.hypot(road[i] - road[i - 2], road[i + 1] - road[i - 1]);
const name = data.names[road[1]];
if ((streets.get(name)?.len ?? 0) < len) {
const mid = Math.floor((road.length - 2) / 4) * 2 + 2;
const angle = Math.atan2(road[Math.min(mid + 3, road.length - 1)] - road[mid - 1], road[Math.min(mid + 2, road.length - 2)] - road[mid - 2]);
streets.set(name, {name, len, x: road[mid], z: road[mid + 1], angle});
}
}
return {tile, overview, bounds: data.bounds, labels: [...data.labels, ...extraLabels], streets: [...streets.values()].filter(s => s.len > 150)};
}
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
// metres; pxPerMetre decides between detailed tiles and the overview.
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
const {minX, minZ} = assets.bounds;
c.fillStyle = '#141b18';
c.fillRect(x0, z0, x1 - x0, z1 - z0);
if (pxPerMetre < .22) {
c.drawImage(assets.overview, minX, minZ, assets.overview.width / OVERVIEW_SCALE, assets.overview.height / OVERVIEW_SCALE);
return;
}
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + .5, TILE_M + .5);
}
function rotatePoint(x, z, cx, cz, angle, k) {
const dx = (x - cx) * k, dz = (z - cz) * k, cos = Math.cos(angle), sin = Math.sin(angle);
return [dx * cos - dz * sin, dx * sin + dz * cos];
}
function machineIcon(c, x, y, {objective, visited, open}) {
c.save();
c.translate(x, y);
if (objective) {
c.rotate(Math.PI / 4);
c.fillStyle = '#f2c94c';
c.strokeStyle = '#1b1406';
c.lineWidth = 2;
c.fillRect(-7, -7, 14, 14);
c.strokeRect(-7, -7, 14, 14);
} else {
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
c.strokeStyle = '#08201c';
c.lineWidth = 2;
c.beginPath();
c.roundRect(-6, -8, 12, 16, 3);
c.fill();
c.stroke();
c.fillStyle = '#08201c';
c.fillRect(-3, -5, 6, 6);
}
c.restore();
if (visited) {
c.fillStyle = '#fff';
c.font = 'bold 11px sans-serif';
c.fillText('✓', x + 8, y - 6);
}
}
// Circular minimap rotated so the camera's forward direction points up.
export function drawMinimap(ctx, assets, view) {
const {size, x, z, heading, camForward, radius, markers, route} = view;
const r = size / 2, k = r / radius, angle = -Math.PI / 2 - Math.atan2(camForward.z, camForward.x);
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#141b18';
ctx.fillRect(0, 0, size, size);
ctx.translate(r, r);
ctx.save();
ctx.rotate(angle);
ctx.scale(k, k);
ctx.translate(-x, -z);
paintWorld(ctx, assets, x - radius * 1.5, z - radius * 1.5, x + radius * 1.5, z + radius * 1.5, k);
ctx.restore();
if (route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 3;
ctx.setLineDash([6, 4]);
ctx.beginPath();
route.forEach((p, i) => {
const [px, py] = rotatePoint(p.x, p.z, x, z, angle, k);
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of markers) {
let [px, py] = rotatePoint(m.x, m.z, x, z, angle, k);
const d = Math.hypot(px, py);
if (d > r - 12) {
if (!m.objective) continue;
px *= (r - 12) / d;
py *= (r - 12) / d;
}
machineIcon(ctx, px, py, m);
}
// Player arrow, then north marker on the rim.
ctx.save();
ctx.rotate(angle + Math.atan2(-Math.sin(heading), Math.cos(heading)) + Math.PI / 2);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#0b100e';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -10);
ctx.lineTo(7, 8);
ctx.lineTo(0, 4);
ctx.lineTo(-7, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
const [nx, ny] = rotatePoint(x, z - 1, x, z, angle, 1);
const nl = Math.hypot(nx, ny) || 1;
ctx.fillStyle = '#f2c94c';
ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ش', nx / nl * (r - 12), ny / nl * (r - 12));
ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(r, r, r - 2, 0, Math.PI * 2);
ctx.stroke();
}
// Full-screen city map. view = {cx, cz, mpp} (centre in metres, metres per CSS pixel).
export function drawFullMap(ctx, assets, view, state) {
const {width, height} = ctx.canvas, dpr = state.dpr, w = width / dpr, h = height / dpr;
const toScreen = (x, z) => [(x - view.cx) / view.mpp + w / 2, (z - view.cz) / view.mpp + h / 2];
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#101614';
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.scale(1 / view.mpp, 1 / view.mpp);
ctx.translate(-view.cx, -view.cz);
paintWorld(ctx, assets, view.cx - w / 2 * view.mpp, view.cz - h / 2 * view.mpp, view.cx + w / 2 * view.mpp, view.cz + h / 2 * view.mpp, 1 / view.mpp);
ctx.restore();
// Grid lines every 500 m, like a tactical map.
ctx.strokeStyle = 'rgba(160,200,185,.08)';
ctx.lineWidth = 1;
for (let gx = Math.ceil(assets.bounds.minX / 500) * 500; gx < assets.bounds.maxX; gx += 500) {
const [sx] = toScreen(gx, 0);
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, h);
ctx.stroke();
}
for (let gz = Math.ceil(assets.bounds.minZ / 500) * 500; gz < assets.bounds.maxZ; gz += 500) {
const [, sy] = toScreen(0, gz);
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(w, sy);
ctx.stroke();
}
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (view.mpp < 3) {
ctx.font = '11px Vazirmatn, sans-serif';
ctx.fillStyle = 'rgba(235,240,232,.75)';
for (const s of assets.streets) {
const [sx, sy] = toScreen(s.x, s.z);
if (sx < -100 || sy < -20 || sx > w + 100 || sy > h + 20) continue;
ctx.save();
ctx.translate(sx, sy);
let a = s.angle;
if (a > Math.PI / 2) a -= Math.PI;
if (a < -Math.PI / 2) a += Math.PI;
ctx.rotate(a);
ctx.fillText(s.name, 0, 0);
ctx.restore();
}
}
for (const l of assets.labels) {
if (l.kind === 'school' && view.mpp > 1.6) continue;
const [sx, sy] = toScreen(l.x, l.z);
ctx.font = `${l.kind === 'school' ? 10 : 12}px Vazirmatn, sans-serif`;
if (l.kind === 'industrial' && view.mpp > 6) continue;
ctx.fillStyle = {park: '#9fd49a', university: '#c9b6f2', school: '#e8c79a', metro: '#ff8a80', mall: '#f5b8d9', industrial: '#a9b2ad', factory: '#3ee0c9'}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name, sx, sy + 14);
}
if (state.route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 4;
ctx.setLineDash([8, 5]);
ctx.beginPath();
state.route.forEach((p, i) => {
const [sx, sy] = toScreen(p.x, p.z);
i ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
});
ctx.stroke();
ctx.setLineDash([]);
}
for (const m of state.markers) {
const [sx, sy] = toScreen(m.x, m.z);
if (m.id === state.selected) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(sx, sy, 16, 0, Math.PI * 2);
ctx.stroke();
}
machineIcon(ctx, sx, sy, m);
}
const [px, py] = toScreen(state.player.x, state.player.z);
ctx.save();
ctx.translate(px, py);
ctx.rotate(Math.atan2(-Math.sin(state.player.heading), Math.cos(state.player.heading)) + Math.PI / 2);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, -12);
ctx.lineTo(8, 9);
ctx.lineTo(0, 5);
ctx.lineTo(-8, 9);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
return toScreen;
}
// Iran overview for the briefing and the map's country tab. `t` animates the highlight.
export function drawIran(ctx, iran, {width, height, t = 0, highlight = 'Razavi Khorasan', cities = []}) {
const lon0 = 43.8, lon1 = 63.6, lat0 = 24.8, lat1 = 40;
const kx = .84, k = Math.min(width / ((lon1 - lon0) * kx), height / (lat1 - lat0)) * .94;
const offX = (width - (lon1 - lon0) * kx * k) / 2, offY = (height - (lat1 - lat0) * k) / 2;
const p = (lon, lat) => [offX + (lon - lon0) * kx * k, offY + (lat1 - lat) * k];
ctx.clearRect(0, 0, width, height);
for (const province of iran.provinces) {
const active = province.id === highlight;
ctx.fillStyle = active ? `rgba(62,224,201,${.35 + Math.sin(t * 3) * .12})` : 'rgba(40,56,50,.85)';
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) {
ctx.beginPath();
for (let i = 0; i < ring.length; i += 2) {
const [x, y] = p(ring[i], ring[i + 1]);
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
ctx.textAlign = 'center';
for (const city of cities) {
const [x, y] = p(city.lon, city.lat);
if (city.active) {
ctx.strokeStyle = '#f2c94c';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 6 + (t * 18) % 18, 0, Math.PI * 2);
ctx.globalAlpha = 1 - ((t * 18) % 18) / 18;
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.fillStyle = city.active ? '#f2c94c' : 'rgba(210,220,214,.6)';
ctx.beginPath();
ctx.arc(x, y, city.active ? 5 : 3, 0, Math.PI * 2);
ctx.fill();
ctx.font = `${city.active ? 'bold 15px' : '11px'} Vazirmatn, sans-serif`;
ctx.fillText(city.active ? city.name : `${city.name} 🔒`, x, y - 10);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} BriefingPanelProps */
/** Presents the BriefingPanel view using plain models and semantic native elements.
* @param {BriefingPanelProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function BriefingPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="briefing" model={nodes['briefing']}>
<div className="brief-map">
<GameNode as="canvas" id="iran" model={nodes['iran']} />
</div>
<div className="brief-panel">
<img
className="brief-robot"
src="./robot/front.webp"
alt="روبات راهنمای روبات مارکت"
width="136"
height="223"
/>
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
<h1>{'عملیات مشهد'}</h1>
<p className="sub">{'روبات مارکت · شکار دستگاه‌های وندینگ'}</p>
<GameNode
as="pre"
id="brief-text"
aria-live="polite"
model={nodes['brief-text']}
></GameNode>
<GameNode
as="button"
id="start-mission"
type="button"
model={nodes['start-mission']}
onAction={() => actions.emit('start-mission')}
>
{'شروع عملیات'}
</GameNode>
<div className="brief-actions">
<GameNode
as="button"
id="real-mode"
type="button"
className="ghost"
model={nodes['real-mode']}
onAction={() => actions.emit('real-mode')}
>
{'📍 بازی در دنیای واقعی (GPS)'}
</GameNode>
<GameNode
as="button"
id="install"
type="button"
className="ghost"
hidden
model={nodes['install']}
onAction={() => actions.emit('install')}
>
{'⬇ نصب بازی روی گوشی'}
</GameNode>
</div>
<p className="keys">
{
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
}
</p>
</div>
</GameNode>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
/** Presents the EndDialog view using plain models and semantic native elements.
* @param {EndDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function EndDialog({ nodes, actions }) {
return (
<>
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
{'پایان عملیات'}
</GameNode>
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
<GameNode as="ul" id="end-stats" model={nodes['end-stats']}>
<ViewLists
id="end-stats"
model={nodes['end-stats']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<div className="actions">
<GameNode
as="button"
id="again"
type="button"
model={nodes['again']}
onAction={() => actions.emit('again')}
>
{'عملیات دوباره'}
</GameNode>
<GameNode
as="button"
id="continue"
type="button"
className="ghost"
model={nodes['continue']}
onAction={() => actions.emit('continue')}
>
{'ادامهٔ گشت در شهر'}
</GameNode>
</div>
</GameNode>
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { ShowroomDialog } from './ShowroomDialog.jsx';
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 (
<>
<ShowroomDialog {...props} />
<PaymentPanel {...props} />
<MapDialog {...props} />
<ShopDialog {...props} />
<RealIntroDialog {...props} />
<InstallDialog {...props} />
<UpdateNotice {...props} />
<EndDialog {...props} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { LocationStatus } from './LocationStatus.jsx';
import { MissionList } from './MissionList.jsx';
import { StatusBar } from './StatusBar.jsx';
import { RankProgress } from './RankProgress.jsx';
import { GameToolbar } from './GameToolbar.jsx';
import { TouchControls } from './TouchControls.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} GameHudProps */
/** Presents the GameHud view using plain models and semantic native elements.
* @param {GameHudProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function GameHud({ nodes, actions }) {
return (
<>
<GameNode as="div" id="hud" hidden model={nodes['hud']}>
<GameNode as="canvas" id="compass" width="560" height="44" model={nodes['compass']} />
<GameNode
as="button"
id="minimap-button"
type="button"
aria-label="باز کردن نقشهٔ شهر (M)"
model={nodes['minimap-button']}
onAction={() => actions.emit('minimap-button')}
>
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
<span className="map-hint">{'نقشه · M'}</span>
</GameNode>
<LocationStatus nodes={nodes} actions={actions} />
<MissionList nodes={nodes} actions={actions} />
<GameNode as="div" id="waypoint" hidden model={nodes['waypoint']}>
<i></i>
<GameNode as="b" id="waypoint-name" model={nodes['waypoint-name']}></GameNode>
<GameNode as="span" id="waypoint-distance" model={nodes['waypoint-distance']}></GameNode>
</GameNode>
<GameNode
as="button"
id="prompt"
hidden
type="button"
aria-label="تعامل با بازی"
model={nodes['prompt']}
onAction={() => actions.emit('prompt')}
></GameNode>
<GameNode as="div" id="subtitle" hidden model={nodes['subtitle']}>
<GameNode as="img" id="subtitle-face" alt="" hidden model={nodes['subtitle-face']} />
<GameNode as="b" id="subtitle-speaker" model={nodes['subtitle-speaker']}></GameNode>
<GameNode as="span" id="subtitle-text" model={nodes['subtitle-text']}></GameNode>
</GameNode>
<GameNode as="div" id="awards" aria-live="polite" model={nodes['awards']}>
<ViewLists
id="awards"
model={nodes['awards']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<GameNode as="div" id="banner" hidden model={nodes['banner']}>
<ViewLists
id="banner"
model={nodes['banner']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<StatusBar nodes={nodes} actions={actions} />
<RankProgress nodes={nodes} actions={actions} />
<GameToolbar nodes={nodes} actions={actions} />
<TouchControls nodes={nodes} actions={actions} />
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
<GameNode as="small" id="attribution" model={nodes['attribution']}>
{'نقشه: © مشارکت‌کنندگان OpenStreetMap'}
</GameNode>
</GameNode>
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'react-redux';
import { useAppSelector } from '@/store/hooks.js';
import { selectNodes } from '@/store/slices/interfaceSlice.js';
import { createInterfaceBridge } from '@/game/interfaceBridge.js';
import { useGameEngine } from '@/hooks/useGameEngine.js';
import { setupPwa } from '@/game/device/pwa.js';
import { GameSurfaces } from './GameSurfaces.jsx';
import { BriefingPanel } from './BriefingPanel.jsx';
import { GameHud } from './GameHud.jsx';
import { GameDialogs } from './GameDialogs.jsx';
/** Composes React views around the isolated Three.js runtime. */
export function GamePage() {
const root = useRef(null),
store = useStore();
const bridge = useMemo(() => createInterfaceBridge(store), [store]);
const nodes = useAppSelector(selectNodes);
const { error } = useGameEngine(root, bridge);
useEffect(() => {
bridge.activate();
const dispose = setupPwa(bridge);
return () => {
dispose();
bridge.dispose();
};
}, [bridge]);
useEffect(() => {
const classes = nodes.body?.classes ?? {};
for (const [name, enabled] of Object.entries(classes))
document.body.classList.toggle(name, enabled);
return () => {
for (const name of Object.keys(classes)) document.body.classList.remove(name);
};
}, [nodes.body]);
const props = {
nodes,
actions: bridge,
};
return (
<main ref={root}>
<GameSurfaces {...props} />
<BriefingPanel {...props} />
<GameHud {...props} />
<GameDialogs {...props} />
{error && (
<div
id="engine-error"
role="alert"
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
>
<p>بازی اجرا نشد: {error}</p>
<button type="button" onClick={() => location.reload()}>
تلاش دوباره
</button>
</div>
)}
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
/** Presents the GameSurfaces view using plain models and semantic native elements.
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function GameSurfaces({ nodes }) {
return (
<>
<GameNode
as="video"
id="camera"
playsInline
muted
autoPlay
hidden
model={nodes['camera']}
></GameNode>
<GameNode as="canvas" id="world" model={nodes['world']} />
<GameNode as="div" id="loading" model={nodes['loading']}>
{'در حال ساختن شهر مشهد…'}
</GameNode>
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
/** Presents the GameToolbar using the current interface snapshot. */
export function GameToolbar({ nodes, actions }) {
return (
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
<GameNode
as="button"
id="showroom-open"
type="button"
hidden
model={nodes['showroom-open']}
onAction={() => actions.emit('showroom-open')}
>
مدل‌های کارخانه · Q
</GameNode>
<GameNode
as="button"
id="gps-toggle"
type="button"
model={nodes['gps-toggle']}
onAction={() => actions.emit('gps-toggle')}
>
{'📍 دنیای واقعی'}
</GameNode>
<GameNode
as="button"
id="ar-toggle"
type="button"
hidden
model={nodes['ar-toggle']}
onAction={() => actions.emit('ar-toggle')}
>
{'📷 دوربین'}
</GameNode>
<GameNode
as="button"
id="stable-toggle"
type="button"
className="on"
hidden
model={nodes['stable-toggle']}
onAction={() => actions.emit('stable-toggle')}
>
{'🎯 پایدار: روشن'}
</GameNode>
<GameNode
as="button"
id="sound"
type="button"
aria-pressed="false"
model={nodes['sound']}
onAction={() => actions.emit('sound')}
>
{'صدا: خاموش'}
</GameNode>
<GameNode
as="button"
id="quality"
type="button"
model={nodes['quality']}
onAction={() => actions.emit('quality')}
>
{'گرافیک'}
</GameNode>
<GameNode
as="button"
id="restart"
type="button"
model={nodes['restart']}
onAction={() => actions.emit('restart')}
>
{'شروع دوباره'}
</GameNode>
</GameNode>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
/** Presents the InstallDialog view using plain models and semantic native elements.
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function InstallDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="ios-install"
model={nodes['ios-install']}
onClose={() => actions.close('ios-install')}
>
<h2>{'نصب روی آیفون'}</h2>
<ol>
<li>
{'در Safari دکمهٔ '}
<b>{'Share'}</b>
{' (مربع با فلش رو به بالا) را بزنید.'}
</li>
<li>
{'گزینهٔ '}
<b>{'Add to Home Screen'}</b>
{' را انتخاب کنید.'}
</li>
<li>
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.'}
</li>
</ol>
<GameNode
as="button"
type="button"
id="ios-close"
model={nodes['ios-close']}
onAction={() => actions.emit('ios-close')}
>
{'متوجه شدم'}
</GameNode>
</GameNode>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
/** Presents the LocationStatus using the current interface snapshot. */
export function LocationStatus({ nodes }) {
return (
<GameNode as="div" id="location" model={nodes['location']}>
<GameNode as="span" id="street" model={nodes['street']}>
{'مشهد'}
</GameNode>
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
{'پیاده'}
</GameNode>
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
</GameNode>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
/** Presents the MachineDetails using the current interface snapshot. */
export function MachineDetails({ nodes, actions }) {
return (
<GameNode as="div" id="machine-detail" hidden model={nodes['machine-detail']}>
<GameNode as="h4" id="detail-name" model={nodes['detail-name']}></GameNode>
<GameNode as="p" id="detail-address" model={nodes['detail-address']}></GameNode>
<GameNode as="p" id="detail-meta" model={nodes['detail-meta']}></GameNode>
<GameNode as="p" id="detail-products" model={nodes['detail-products']}></GameNode>
<GameNode
as="button"
id="route-button"
type="button"
model={nodes['route-button']}
onAction={() => actions.emit('route-button')}
>
{'مسیریابی تا اینجا'}
</GameNode>
</GameNode>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MachineListProps */
/** Presents the MachineList using the current interface snapshot. */
export function MachineList({ nodes, actions }) {
return (
<GameNode as="ul" id="machine-list" model={nodes['machine-list']}>
<ViewLists
id="machine-list"
model={nodes['machine-list']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { MachineDetails } from './MachineDetails.jsx';
import { MachineList } from './MachineList.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
/** Presents the MapDialog view using plain models and semantic native elements.
* @param {MapDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function MapDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="map-dialog"
model={nodes['map-dialog']}
onClose={() => actions.close('map-dialog')}
>
<header>
<div className="tabs" role="tablist">
<GameNode
as="button"
type="button"
role="tab"
data-tab="city"
aria-selected="true"
id="tab-city"
model={nodes['tab-city']}
onAction={() => actions.emit('tab-city')}
>
{'مشهد'}
</GameNode>
<GameNode
as="button"
type="button"
role="tab"
data-tab="iran"
aria-selected="false"
id="tab-iran"
model={nodes['tab-iran']}
onAction={() => actions.emit('tab-iran')}
>
{'ایران'}
</GameNode>
</div>
<GameNode
as="button"
type="button"
className="close"
id="map-close"
aria-label="بستن"
model={nodes['map-close']}
onAction={() => actions.emit('map-close')}
>
{'✕'}
</GameNode>
</header>
<div className="map-body">
<div className="map-view">
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
<p className="map-help">
{'کشیدن: جابه‌جایی · چرخ ماوس: زوم · کلیک روی دستگاه: انتخاب'}
</p>
</div>
<aside className="map-side">
<h3>{'دستگاه‌های روبات مارکت'}</h3>
<MachineDetails nodes={nodes} actions={actions} />
<MachineList nodes={nodes} actions={actions} />
</aside>
</div>
</GameNode>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MissionListProps */
/** Presents the MissionList using the current interface snapshot. */
export function MissionList({ nodes, actions }) {
return (
<GameNode as="aside" id="objectives" model={nodes['objectives']}>
<h2>{'مأموریت‌ها'}</h2>
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
<ViewLists
id="mission-list"
model={nodes['mission-list']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
</GameNode>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
/** Presents the PaymentPanel view using plain models and semantic native elements.
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function PaymentPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
<small>{'کارت‌خوان روبات مارکت'}</small>
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
<div className="actions">
<GameNode
as="button"
id="swipe"
type="button"
disabled
model={nodes['swipe']}
onAction={() => actions.emit('swipe')}
>
{'کشیدن کارت · C'}
</GameNode>
<GameNode
as="button"
id="cancel-payment"
type="button"
className="ghost"
model={nodes['cancel-payment']}
onAction={() => actions.emit('cancel-payment')}
>
{'انصراف'}
</GameNode>
</div>
</GameNode>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
/** Presents the RankProgress using the current interface snapshot. */
export function RankProgress({ nodes }) {
return (
<GameNode as="div" id="rank" model={nodes['rank']}>
<GameNode as="b" id="rank-name" model={nodes['rank-name']}>
{'تازه‌وارد'}
</GameNode>
<GameNode as="span" id="xp" model={nodes['xp']}>
{'۰ XP'}
</GameNode>
<div className="bar">
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
</div>
</GameNode>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
/** Presents the RealIntroDialog view using plain models and semantic native elements.
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function RealIntroDialog({ nodes, actions }) {
return (
<>
<GameNode
as="dialog"
id="real-intro"
model={nodes['real-intro']}
onClose={() => actions.close('real-intro')}
>
<img src="./robot/serious.webp" alt="" width="72" height="68" />
<h2>{'بازی در دنیای واقعی'}</h2>
<p>
{
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
}
</p>
<ul>
<li>{'هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.'}</li>
<li>{'بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.'}</li>
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.'}</li>
<li>{'موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.'}</li>
</ul>
<div className="actions">
<GameNode
as="button"
type="button"
id="real-go"
model={nodes['real-go']}
onAction={() => actions.emit('real-go')}
>
{'قبول دارم، شروع کن'}
</GameNode>
<GameNode
as="button"
type="button"
id="real-cancel"
className="ghost"
model={nodes['real-cancel']}
onAction={() => actions.emit('real-cancel')}
>
{'انصراف'}
</GameNode>
</div>
</GameNode>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
/** Presents the ShopDialog view using plain models and semantic native elements.
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
* @returns {import('react').ReactElement} Game panel.
*/
export function ShopDialog({ nodes, actions }) {
return (
<>
<GameNode as="dialog" id="shop" model={nodes['shop']} onClose={() => actions.close('shop')}>
<header>
<div>
<GameNode as="small" id="shop-category" model={nodes['shop-category']}></GameNode>
<GameNode as="h2" id="shop-name" model={nodes['shop-name']}></GameNode>
<GameNode as="p" id="shop-address" model={nodes['shop-address']}></GameNode>
</div>
<GameNode
as="button"
type="button"
className="close"
id="shop-close"
aria-label="بستن"
model={nodes['shop-close']}
onAction={() => actions.emit('shop-close')}
>
{'✕'}
</GameNode>
</header>
<GameNode as="p" id="shop-note" model={nodes['shop-note']}></GameNode>
<GameNode as="div" id="products" model={nodes['products']}>
<ViewLists
id="products"
model={nodes['products']}
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<p className="rate">
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.'}
</p>
</GameNode>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { ViewLists } from './ViewLists.jsx';
import { PaymentPanel } from './PaymentPanel.jsx';
afterEach(cleanup);
describe('React purchase interface', () => {
it('selects an affordable product and blocks unaffordable products', () => {
const onAction = vi.fn();
render(
<ViewLists
id="products"
onAction={onAction}
model={{
items: [
{ id: 'water', name: 'آب معدنی', type: 'drink', cost: 15, price: 15 },
{ id: 'nuts', name: 'آجیل', type: 'snack', cost: 65, price: 65, blocked: 'funds' },
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /آب معدنی/ }));
expect(onAction).toHaveBeenCalledWith('select-product', 'water');
expect(screen.getByRole('button', { name: /آجیل/ })).toBeDisabled();
});
it('uses the payment ready state for the explicit swipe action', () => {
const actions = { emit: vi.fn() },
nodes = {
payment: { hidden: false },
swipe: { disabled: false },
'payment-title': { text: 'آب معدنی' },
};
render(<PaymentPanel nodes={nodes} actions={actions} />);
fireEvent.click(screen.getByRole('button', { name: /کشیدن کارت/ }));
expect(actions.emit).toHaveBeenCalledWith('swipe');
fireEvent.click(screen.getByRole('button', { name: 'انصراف' }));
expect(actions.emit).toHaveBeenCalledWith('cancel-payment');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
import { ShowroomImage } from './ShowroomImage.jsx';
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
/** Presents the factory sample catalog and questions answered by the robot host.
* @param {ShowroomDialogProps} props - Showroom state and game commands.
* @returns {import('react').ReactNode} Sample dialog, or null before a sample is selected.
*/
export function ShowroomDialog({ nodes, actions }) {
const model = showroomModelById(nodes.showroom?.modelId);
if (!model) return null;
return (
<GameNode
as="dialog"
id="showroom"
model={nodes.showroom}
onClose={() => actions.close('showroom')}
aria-labelledby="showroom-title"
className="w-[min(92vw,52rem)] max-h-[90dvh] overflow-y-auto rounded-xl border border-white/20 bg-surface p-6 text-hud"
>
<header className="mb-5 flex items-center justify-between gap-4">
<h2 id="showroom-title" className="text-xl font-bold">
نمایشگاه کارخانه · {model.name}
</h2>
<button
type="button"
aria-label="بستن معرفی دستگاه"
onClick={() => actions.close('showroom')}
>
✕
</button>
</header>
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
{SHOWROOM_CATALOG.map((item) => (
<button
key={item.id}
className="rounded border border-teal px-4 py-2"
type="button"
aria-pressed={item.id === model.id}
onClick={() => actions.emit('showroom-select', item.id)}
>
{item.shortLabel}
</button>
))}
</nav>
<div className="grid gap-6 md:grid-cols-2">
<ShowroomImage key={model.id} model={model} />
<section>
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
<p>{model.description}</p>
<ul className="my-4 list-inside list-disc">
{model.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="text-sm opacity-75">
این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند.
</p>
</section>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button
className="rounded bg-teal px-4 py-3 font-bold text-surface"
type="button"
onClick={() =>
actions.emit('showroom-question', {
modelId: model.id,
topic: 'price',
})
}
>
روبات، قیمتش چنده؟
</button>
<button
className="rounded border border-teal px-4 py-3"
type="button"
onClick={() =>
actions.emit('showroom-question', {
modelId: model.id,
topic: 'features',
})
}
>
روبات، امکاناتش چیه؟
</button>
</div>
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
<img
className="h-16 w-16 shrink-0 object-contain"
src="./robot/happy.webp"
alt="روبات راهنمای نمایشگاه"
/>
<p role="status">
{nodes['showroom-response']?.text ??
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
</p>
</div>
</GameNode>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { ShowroomDialog } from './ShowroomDialog.jsx';
afterEach(cleanup);
describe('showroom robot questions', () => {
it('asks about the selected model and displays catalog data in rial', () => {
const actions = { emit: vi.fn(), close: vi.fn() },
nodes = { showroom: { modelId: 'rm-48', open: false } };
render(<ShowroomDialog nodes={nodes} actions={actions} />);
fireEvent.click(screen.getByRole('button', { name: 'روبات، قیمتش چنده؟', hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-question', {
modelId: 'rm-48',
topic: 'price',
});
expect(screen.getByText(/۵٬۴۸۰٬۰۰۰٬۰۰۰ ریال/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60');
});
});

Some files were not shown because too many files have changed in this diff Show More