Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b2b72ea9 | ||
|
|
2f74788caa | ||
|
|
0939dac9cd | ||
|
|
e2a1b5fba1 | ||
|
|
95a2ae851d | ||
|
|
8a49dc07fa | ||
|
|
6d4c4b7dbe | ||
|
|
b5f3137fff | ||
|
|
f329acc61c | ||
|
|
5857b11bd8 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "game",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "npx vite build && npx vite preview --port 4173 --strictPort"],
|
||||
"port": 4173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.claude
|
||||
*.log
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Deploy game to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Test game logic
|
||||
run: node --test tests/*.test.js
|
||||
- run: npm run build
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,26 @@
|
||||
# SITE_ADDRESS is the domain list, e.g. "robotmarkets.ir, www.robotmarkets.ir" (automatic HTTPS),
|
||||
# or ":80" for plain HTTP behind another proxy or for a local test.
|
||||
{$SITE_ADDRESS::80} {
|
||||
root * /srv
|
||||
encode zstd gzip
|
||||
|
||||
# Hashed build files never change; the page, service worker and manifest must always be revalidated
|
||||
# so installed copies pick up new versions.
|
||||
@hashed path /assets/*
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
@fresh path / /index.html /sw.js /manifest.webmanifest
|
||||
header @fresh Cache-Control "no-cache"
|
||||
@manifest path /manifest.webmanifest
|
||||
header @manifest Content-Type "application/manifest+json"
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
# Real-world mode needs location and the compass; the camera is reserved for the AR phase.
|
||||
Permissions-Policy "geolocation=(self), camera=(self), accelerometer=(self), gyroscope=(self), magnetometer=(self)"
|
||||
-Server
|
||||
}
|
||||
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Stage 1: run the tests and build the static game with Vite.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN node --test tests/*.test.js && npm run build
|
||||
|
||||
# Stage 2: serve dist/ with Caddy, which also obtains and renews the HTTPS certificate.
|
||||
FROM caddy:2.8.4-alpine
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=build /app/dist /srv
|
||||
EXPOSE 80 443 443/udp
|
||||
@@ -1,6 +1,72 @@
|
||||
# تپهٔ آرزوها — روبات مارکت
|
||||
# عملیات مشهد — روبات مارکت
|
||||
|
||||
بازی سهبعدی JavaScript با Three.js: پدر و بچه در جنگل راه میروند، از دستگاه خرید میکنند و به پیکنیک میرسند. مدلها با کد ساخته شدهاند.
|
||||
بازی سهبعدی مرورگری (Three.js) با حالوهوای بازیهای اکشن سومشخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاهها راه میافتند، در خیابانهای واقعی مشهد میگردند، دستگاههای وندینگ روبات مارکت را پیدا میکنند و با هر خرید امتیاز میگیرند. روبات راهنمای روبات مارکت داخل دستگاهها زندگی میکند: وقتی خانواده به یک دستگاه میرسند، از آن بیرون میآید، خوشآمد میگوید و راهنماییشان میکند.
|
||||
|
||||
بازی یک PWA است: روی گوشی نصب میشود و آفلاین هم اجرا میشود.
|
||||
|
||||
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
|
||||
|
||||
## داستان و هدف
|
||||
|
||||
- **شروع:** سالن تولید کارخانهٔ **شرکت نگین سازان پیشگام رادمهر آتوسا** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است.
|
||||
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیلآباد.
|
||||
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استانهای ایران را نشان میدهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفلاند.
|
||||
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمالغرب تا غرب مشهد، همه از دادههای واقعی OpenStreetMap:
|
||||
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
|
||||
- همهٔ خیابانها و ساختمانها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
|
||||
- شبکهٔ خیابانهای اصلی در کل منطقه
|
||||
- **دستگاهها:** دستگاه نمونهٔ کارخانه (نصف قیمت)، بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاهها و محصولاتشان فرضیاند.
|
||||
|
||||
## کنترل
|
||||
|
||||
| کار | صفحهکلید | موبایل |
|
||||
|---|---|---|
|
||||
| حرکت / رانندگی | W A S D یا جهتها | جویاستیک |
|
||||
| دویدن | Shift | دکمهٔ «دویدن» |
|
||||
| چرخاندن دوربین | ماوس (با کلیک قفل میشود) یا کشیدن | کشیدن روی صفحه |
|
||||
| زوم دوربین | چرخ ماوس | — |
|
||||
| سوار و پیاده شدن از ماشین | F | دکمهٔ F |
|
||||
| خرید از دستگاه | E | دکمهٔ E |
|
||||
| کشیدن کارت | C | دکمهٔ E |
|
||||
| ترمز دستی | Space | — |
|
||||
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
|
||||
|
||||
کنترلها با صفحهکلید فارسی هم کار میکنند. دوربین فقط سومشخص است.
|
||||
|
||||
## نقشه و مسیریابی
|
||||
|
||||
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز میشود. فهرست دستگاهها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده میشود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابانها پیدا میکند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابانهای سوارهرو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطبنمای بالای صفحه نمایش داده میشود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
|
||||
|
||||
## امتیاز
|
||||
|
||||
| رویداد | امتیاز |
|
||||
|---|---|
|
||||
| هر خرید | ۱۰۰ |
|
||||
| اولین خرید در هر مکان (کشف) | ۲۵۰ |
|
||||
| خرید نوشیدنی و خوراکی از یک دستگاه | ۱۵۰ |
|
||||
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
|
||||
| خرید در حالت دنیای واقعی | ۲۰۰ |
|
||||
|
||||
هفت مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجهها از «تازهوارد» تا «افسانهٔ مشهد» بالا میروند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریتها انجام شود، عملیات تمام میشود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
|
||||
|
||||
## نصب روی گوشی (PWA)
|
||||
|
||||
- **اندروید و Chrome کامپیوتر:** دکمهٔ «نصب بازی روی گوشی» در صفحهٔ شروع، یا گزینهٔ Install در منوی مرورگر
|
||||
- **آیفون:** در Safari، دکمهٔ Share و بعد Add to Home Screen. دکمهٔ نصب در بازی همین راهنما را نشان میدهد.
|
||||
|
||||
آیکون از آواتار روبات روبات مارکت ساخته شده (`public/icons`). بعد از اولین اجرا همهٔ فایلها، از جمله نقشه و فونت Vazirmatn (داخل پروژه، بدون Google Fonts)، کش میشوند و بازی آفلاین اجرا میشود. با انتشار نسخهٔ جدید، پیام «نسخهٔ جدید بازی آماده است» نمایش داده میشود. Service Worker را `vite.config.js` در زمان build میسازد.
|
||||
|
||||
## حالت دنیای واقعی (GPS)
|
||||
|
||||
دکمهٔ «📍 بازی در دنیای واقعی» در صفحهٔ شروع یا «📍 دنیای واقعی» در نوار پایین این حالت را روشن میکند. اول نکتههای ایمنی نمایش داده میشود و بعد اجازهٔ موقعیت و قطبنما گرفته میشود.
|
||||
|
||||
- خانواده روی نقشهٔ بازی همانجایی است که گوشی هست و با راه رفتن واقعی جابهجا میشود. جهتشان از قطبنمای گوشی یا جهت حرکت گرفته میشود.
|
||||
- موقعیتهای GPS بر اساس دقتشان نرم و ترکیب میشوند. «رسیدن» به دستگاه با شعاع ۲۰ تا ۴۰ متر (بسته به دقت GPS) سنجیده میشود. وقتی رسیدید، روبات از دستگاه بیرون میآید و خرید ۲۰۰ امتیاز اضافه دارد.
|
||||
- در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمیشود.
|
||||
- بیرون از محدودهٔ بازی، فاصله تا نزدیکترین دستگاه نمایش داده میشود.
|
||||
- موقعیت فقط روی خود گوشی پردازش میشود. دوربین و قطبنما به HTTPS نیاز دارند.
|
||||
|
||||
فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته میشود: تصویر زندهٔ دوربین بهعنوان پسزمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن.
|
||||
|
||||
## اجرا
|
||||
|
||||
@@ -11,43 +77,67 @@ npm run build
|
||||
node --test tests/*.test.js
|
||||
```
|
||||
|
||||
## کنترل
|
||||
- `?quality=low` یا دکمهٔ «گرافیک» سایهها را خاموش میکند (پیشفرض گوشیها).
|
||||
- `?debug` وضعیت بازی را در `window.game` در دسترس میگذارد.
|
||||
|
||||
- چپ / راست یا A / D: حرکت
|
||||
- بالا / پایین: زوم دوربین، بدون حرکت شخصیت
|
||||
- دکمههای چهارجهته: نگه داشتن برای حرکت یا زوم در موبایل
|
||||
- E یا دکمهٔ محصولات: باز کردن دستگاه در نزدیکی آن
|
||||
- شروع دوباره: بازنشانی داستان، موجودی و زوم
|
||||
## انتشار روی سرور با Docker
|
||||
|
||||
گفتوگوها کوتاهاند و در کادر سفید پایین صفحه خودکار عوض میشوند. در طول تمام گفتوگوها، از جمله پل، حرکت آزاد است. هنگام خرید، تحویل محصول یا باز بودن دفترچه حرکت موقتاً متوقف میشود.
|
||||
تصویر Docker دو مرحله دارد: Node تستها را اجرا و بازی را build میکند، و بعد Caddy فایلها را سرو میکند. Caddy گواهی HTTPS را از Let's Encrypt خودکار میگیرد و تمدید میکند. HTTPS برای نصب روی گوشی و GPS ضروری است.
|
||||
|
||||
## دستگاه ۶۰ کانال و خرید با هتومان
|
||||
**پیشنیاز:**
|
||||
- رکورد A دامنههای `robotmarkets.ir` و `www.robotmarkets.ir` به IP سرور اشاره کند.
|
||||
- پورتهای ۸۰ و ۴۴۳ سرور باز و آزاد باشند.
|
||||
|
||||
واحد بازی «هتومان» است: **۱۰ هتومان = ۱۰۰٬۰۰۰ ریال**، یعنی هر هتومان ۱۰٬۰۰۰ ریال. موجودی اولیه ۵۰۰ هتومان است. همهٔ اعداد و قیمتها نمایشیاند و پرداخت واقعی ندارند.
|
||||
```sh
|
||||
git clone git@github.com:hamedAbdollahzade/vending-machine-game.git && cd vending-machine-game
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
دستگاه شش ردیف دهتایی با شمارههای ۱ تا ۶۰ دارد. فقط کانالهای **۲۱ تا ۶۰** قابل خریدند. با نزدیک شدن به دستگاه، نشان E / تعامل روی کارتخوان نمایش داده میشود. با E یا لمس نشان، پنجرهٔ خرید باز میشود: فقط شمارههای ۲۱ تا ۶۰ سمت راست و کد، محصول فرضی و قیمت کانالهای قابل خرید سمت چپ قرار دارند. کلیک روی شماره یا محصول، کد را پر میکند؛ «پرداخت» یا Enter مرحلهٔ کارتخوان را باز میکند. دوربین به کارتخوان نزدیک میشود؛ سپس با دکمهٔ «کشیدن کارت» یا کلید C مبلغ کسر میشود، پیام خرید موفق نمایش داده میشود و محصول تحویل داده میشود. پیش از کارت کشیدن امکان انصراف بدون کسر موجودی وجود دارد. کدهای فارسی، عربی و انگلیسی پشتیبانی میشوند. کانال نامعتبر پول کم نمیکند. توضیح تبدیل واحد هم کنار دستگاه و هم داخل پنجره نمایش داده میشود.
|
||||
- **دامنهٔ دیگر:** `SITE_ADDRESS="example.ir, www.example.ir" docker compose up -d --build`
|
||||
- **بهروزرسانی:** `git pull && docker compose up -d --build`. گواهیها در volume `caddy_data` میمانند. بازیهای نصبشده روی گوشی پیام نسخهٔ جدید را نشان میدهند.
|
||||
- **اگر روی سرور از قبل Nginx یا پروکسی دیگری روی ۸۰ و ۴۴۳ هست:** در `docker-compose.yml` پورتها را به `"8080:80"` تغییر دهید و `SITE_ADDRESS=":80"` بگذارید. بعد دامنه را در همان پروکسی به `127.0.0.1:8080` بفرستید. در این حالت HTTPS با همان پروکسی است.
|
||||
|
||||
محصولات فرضی بین کانالها تکرار میشوند: آبمیوه ۳۵، آب ۱۵، بیسکویت ۲۵، شیرکاکائو ۴۰، چای سرد ۴۵، لیموناد ۴۰، شکلات ۳۰، آجیل ۶۵ و کیک ۳۵ هتومان. مسیر تحویل از کانال انتخابشده شروع میشود. برای تکمیل مأموریت، هزینهٔ حداقل یک نوشیدنی و یک خوراکی کنار گذاشته میشود.
|
||||
**سرور داخل ایران:** Docker Hub و گاهی npm به IPهای ایران سرویس نمیدهند. دو راه وجود دارد:
|
||||
1. **mirror داخلی برای Docker Hub:** در `/etc/docker/daemon.json` یک mirror تعریف کنید، مثلاً `{"registry-mirrors": ["https://docker.arvancloud.ir"]}`، و بعد `sudo systemctl restart docker`.
|
||||
2. **ساخت روی کامپیوتر خودتان و انتقال تصویر آماده:**
|
||||
```sh
|
||||
docker compose build
|
||||
docker save robot-market-game:latest | gzip > robot-market-game.tar.gz
|
||||
scp robot-market-game.tar.gz docker-compose.yml user@server:~/game/
|
||||
ssh user@server 'cd ~/game && gunzip -c robot-market-game.tar.gz | docker load && docker compose up -d --no-build'
|
||||
```
|
||||
|
||||
## ساخت دوبارهٔ دادهٔ نقشه
|
||||
|
||||
`src/data/mellat.json` و `src/data/iran.json` از خروجی خام OpenStreetMap (Overpass API) و Natural Earth ساخته میشوند. کوئریها در ابتدای اسکریپت آمدهاند:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
چهار خروجی OSM (محدودهٔ شهر، محدودهٔ کارخانه، کریدور مسیر و خیابانهای اصلی) با هم ادغام میشوند و عنصرهای تکراری حذف میشوند. خیابانها هم در مرز منطقه بریده میشوند. برای افزودن محدودهٔ جدید، یک خروجی دیگر به `--osm` اضافه کنید و در صورت نیاز `REGION` را در اسکریپت بزرگتر کنید.
|
||||
|
||||
## ساختار
|
||||
|
||||
- `src/main.js`: صحنه، دوربین، دستگاه، رابط و حلقهٔ بازی
|
||||
- `src/controls.js`: جهت حرکت و شرایط مجاز بودن حرکت؛ دیالوگ مانع حرکت نیست
|
||||
- `src/characters.js`: شخصیتهای مفصلی، تناسب بدن، لباس، چشمها، قدم زدن و تماس دستها
|
||||
- `src/payment.js`: مراحل پرداخت، انصراف و جلوگیری از برداشت تکراری
|
||||
- `src/game-state.js`: قیمتهای هتومان، موجودی، اعتبارسنجی کد و خرید
|
||||
- `src/story.js`: متنهای کوتاه، فصلها، انتخاب مسیر و کشفها
|
||||
- `src/style.css`: چیدمان واکنشگرا و کادر سفید متن
|
||||
- `tests/`: آزمون هر دو مسیر داستان، حرکت، پول و تبدیلهای شخصیتها
|
||||
- `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/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام بهروزرسانی
|
||||
- `src/realworld.js`: GPS، قطبنما، نرمسازی موقعیت و شعاع رسیدن
|
||||
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
|
||||
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
|
||||
|
||||
قیمتها و موجودی فقط در ماژول game-state تعریف شدهاند. محاسبات با هتومان صحیح انجام میشود؛ رابط با ارقام فارسی نمایش میدهد.
|
||||
## محدودیتها و گامهای بعدی
|
||||
|
||||
## محدودیتها
|
||||
- مدلها هندسی و سبکاند و کیفیت گرافیکی بازیهای AAA را ندارند.
|
||||
- خیابانها یکطرفه و چراغ راهنمایی ندارند، و ترافیک فقط روی بلوارهای اصلی است.
|
||||
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محلهها و شهرهای دیگر.
|
||||
|
||||
مدلها هنوز هندسی و سبک هستند؛ تناسب بدن و حرکت طبیعیتر شده اما مدل انسانی واقعگرایانه یا موشنکپچر اضافه نشده است. پرداخت واقعی، ذخیرهٔ پیشرفت و برخورد آزاد با درختها وجود ندارد. بازیکن در مسیر تعیینشده حرکت میکند. زمان کامل تجربه هنوز با بازیکن اندازهگیری نشده است.
|
||||
|
||||
پنجرههای خرید، دفترچه، پایان و پرداخت پسزمینهٔ شیشهای نیمهشفاف دارند. نشان تعامل به موقعیت سهبعدی دستگاه متصل است و با حرکت دوربین جابهجا میشود.
|
||||
|
||||
## تعامل تکدکمهای
|
||||
|
||||
گزینههای چندانتخابی داستان حذف شدهاند. هر رویداد یک کار مشخص دارد: تماشای خرگوش، برداشتن نقاشی، ادامه در مسیر آفتابی و کامل کردن نقاشی با آسمان طلایی. برای انجام آن، کلید **F** یا همان دکمهٔ روی صفحه را بزنید. متنهای معمولی خودکار عوض میشوند و حرکت آزاد میماند. **E** تعامل با دستگاه و **C** کشیدن کارت است. انتخاب شمارهٔ محصول و دکمهٔ پرداخت در پنجرهٔ دستگاه باقی است.
|
||||
نقشه: © مشارکتکنندگان OpenStreetMap (ODbL). مرز استانها: Natural Earth.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
game:
|
||||
build: .
|
||||
image: robot-market-game:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
environment:
|
||||
# Point these domains' DNS A records at the server before the first start.
|
||||
SITE_ADDRESS: ${SITE_ADDRESS:-robotmarkets.ir, www.robotmarkets.ir}
|
||||
volumes:
|
||||
# Keeps the HTTPS certificates across restarts and image updates.
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.5",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -457,6 +458,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/vazirmatn": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.5.tgz",
|
||||
"integrity": "sha512-rWQH9i4Zp/64tQTHhy/ijOzdC50kXuBSPDEYDs63TI7EMMG5VzrQrVj6qroIsw6glHumJzpY6tayX4hWCR4RQA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "game",
|
||||
"version": "1.0.0",
|
||||
"description": "A Persian 3D forest walk and vending-machine game",
|
||||
"main": "index.js",
|
||||
"description": "A Persian third-person 3D city game: find Robot Market vending machines across real Mashhad streets",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
@@ -13,6 +12,7 @@
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.5",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "عملیات مشهد · روبات مارکت",
|
||||
"short_name": "روبات مارکت",
|
||||
"description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاههای وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
|
||||
"lang": "fa",
|
||||
"dir": "rtl",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "fullscreen",
|
||||
"display_override": ["fullscreen", "standalone"],
|
||||
"orientation": "any",
|
||||
"background_color": "#0d1412",
|
||||
"theme_color": "#0d1412",
|
||||
"categories": ["games", "education"],
|
||||
"icons": [
|
||||
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
|
||||
{"src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,207 @@
|
||||
// Converts raw OpenStreetMap and Natural Earth exports into the compact files in src/data.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <ne_10m_admin_1_states_provinces.geojson>
|
||||
//
|
||||
// Each --osm file is an Overpass API response with `out geom` (send a User-Agent header); elements
|
||||
// that appear in several files are merged. The playable world is built from four extracts:
|
||||
// city zone: [bbox:36.300,59.512,36.330,59.566];(way["highway"];way["building"];way["leisure"~"park|garden|pitch"];
|
||||
// way["landuse"~"grass|forest|park|recreation_ground"];way["natural"~"water|wood"];
|
||||
// way["amenity"~"school|university|college"];relation["amenity"="university"];relation["leisure"="park"];node["amenity"="school"];)
|
||||
// factory zone: [bbox:36.4430,59.4140,36.4580,59.4340];(way["highway"];way["building"];way["leisure"~"park|garden|pitch"];way["landuse"];
|
||||
// way["natural"~"water|wood"];way["amenity"];relation["leisure"="park"];)
|
||||
// corridor: the same feature types within ~250 m of the factory → Park Mellat driving route (around:250,<route lat,lon list>)
|
||||
// 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';
|
||||
|
||||
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 (!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);
|
||||
}
|
||||
// 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};
|
||||
// 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 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);
|
||||
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);
|
||||
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 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 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
} 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))});
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
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) {
|
||||
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 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()]
|
||||
};
|
||||
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 (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}`);
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Lightweight articulated characters; all coordinates below are in the family group.
|
||||
export function createFamily() {
|
||||
// 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');
|
||||
@@ -33,6 +34,80 @@ export function createFamily() {
|
||||
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;
|
||||
@@ -95,42 +170,49 @@ export function createFamily() {
|
||||
return {root, torso, head, legs, arms, scale, z, eyes};
|
||||
}
|
||||
|
||||
const father = makePerson(false, -.45), child = makePerson(true, .45);
|
||||
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];
|
||||
|
||||
|
||||
function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit, dt = 1 / 60}) {
|
||||
const actors = [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);
|
||||
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, shopping ? (index === 0 ? -.55 : .25) : 0, blend);
|
||||
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.32 : 0, blend);
|
||||
actor.root.position.z = actor.z;
|
||||
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 : watchRabbit ? .55 : Math.sin(time * .7) * .06;
|
||||
actor.torso.rotation.x = delivery && index === 1 ? .22 : 0;
|
||||
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;
|
||||
leg.hip.rotation.x = finished ? -1.3 : moving ? Math.sin(phase) * .34 : 0;
|
||||
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 ? 1.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
|
||||
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, 1.43 * actor.scale, 0));
|
||||
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 = index === 0 ? side === -1 : side === 1;
|
||||
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 (index === 1 && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
|
||||
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);
|
||||
@@ -139,6 +221,6 @@ export function createFamily() {
|
||||
}));
|
||||
}
|
||||
|
||||
update({time: 0, gait: 0, moving: false, facing: Math.PI / 2});
|
||||
update({time: 0, gait: 0, moving: false});
|
||||
return {group, update};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
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; each chunk's meshes are hidden when they are out of view range.
|
||||
const CHUNK = 2000, VIEW_RANGE = 2400;
|
||||
|
||||
export function createCity(scene, data, {collision, segments}) {
|
||||
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 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 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.
|
||||
const tanks = [], units = [];
|
||||
for (const building of data.buildings) {
|
||||
const walls = bucket('walls', building[1], building[2], () => ({pos: [], uv: [], col: [], idx: []}));
|
||||
const roofs = bucket('roofs', building[1], building[2], () => ({pos: [], col: [], idx: []}));
|
||||
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;
|
||||
}
|
||||
const color = BUILDING_COLORS[Math.floor(random() * BUILDING_COLORS.length)].clone().multiplyScalar(.9 + random() * .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));
|
||||
// Rooftop water tanks and air coolers, typical of Mashhad apartment blocks.
|
||||
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]);
|
||||
}
|
||||
}
|
||||
const wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85});
|
||||
const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9});
|
||||
for (const {name, out} of buckets.values()) {
|
||||
const [kind, sub] = name.split(':');
|
||||
const mesh = addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub])
|
||||
: kind === 'road' ? toMesh(out, roadMaterials[sub])
|
||||
: kind === 'walls' ? toMesh(out, wallMaterial, 'compute') : toMesh(out, roofMaterial));
|
||||
mesh.castShadow = kind === 'walls' || kind === 'roofs';
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Dialogue deliberately never locks walking. Only transactional UI and delivery do.
|
||||
export function movementDirection(pressed, held) {
|
||||
return Number(pressed.has('arrowright') || pressed.has('d') || held.has(1))
|
||||
- Number(pressed.has('arrowleft') || pressed.has('a') || held.has(-1));
|
||||
}
|
||||
|
||||
export function canWalk({shopOpen = false, notebookOpen = false, endingOpen = false, busy = false, finished = false}) {
|
||||
return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import * as THREE from 'three';
|
||||
import {createVendingMachine} from './vending.js';
|
||||
import {createCarModel} from './traffic.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Negin Sazan Pishgam Radmehr Atousa compound: production hall (where the game starts),
|
||||
* office, 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 and key positions.
|
||||
*/
|
||||
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});
|
||||
const group = new THREE.Group();
|
||||
group.position.set(center.x, 0, center.z);
|
||||
group.rotation.y = rot;
|
||||
scene.add(group);
|
||||
const footprints = [];
|
||||
// Axis-aligned box in the local frame; solid ones also block movement.
|
||||
const box = (u0, u1, v0, v1, y0, y1, material, solid = true, parent = group) => {
|
||||
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 (solid) footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
|
||||
return mesh;
|
||||
};
|
||||
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
|
||||
|
||||
// Yard: asphalt, painted driveway, and the concrete fence with a gate onto Kaleh Street.
|
||||
const yard = new THREE.Mesh(new THREE.PlaneGeometry(190, 100), mat('#8f8c86', {roughness: .95, polygonOffset: true, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
|
||||
yard.rotation.x = -Math.PI / 2;
|
||||
yard.position.set(-15, .08, 0);
|
||||
yard.receiveShadow = true;
|
||||
group.add(yard);
|
||||
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);
|
||||
const teal = mat('#0b8f88', {metalness: .3, roughness: .4}), white = mat('#eef4f2', {roughness: .5});
|
||||
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
|
||||
|
||||
// Gate sign with the company name.
|
||||
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});
|
||||
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), signMaterial);
|
||||
gateSign.position.set(82, 6.6, -20);
|
||||
gateSign.rotation.y = 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, mat('#59646b', {metalness: .5}), false);
|
||||
|
||||
// Production hall: corrugated walls, teal band, a wide door facing the driveway.
|
||||
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
|
||||
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
|
||||
corrugated.repeat.set(12, 2);
|
||||
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
|
||||
const H = 10, U0 = -62, U1 = 2, V0 = 15, V1 = 45, D0 = -20, D1 = -4;
|
||||
box(U0, U1, V1, V1 + .4, 0, H, wall);
|
||||
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
|
||||
box(U1, U1 + .4, V0, V1 + .4, 0, H, wall);
|
||||
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;
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(U1 - U0, V1 - V0), mat('#b9c4bd', {roughness: .35, polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32}));
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.set((U0 + U1) / 2, .1, (V0 + V1) / 2);
|
||||
floor.receiveShadow = true;
|
||||
group.add(floor);
|
||||
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
|
||||
for (let u = U0 + 8; u < U1; u += 12) box(u - 3, u + 3, 29, 31, H - .3, H - .1, lamp, false);
|
||||
const hallSign = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), signMaterial);
|
||||
hallSign.position.set((D0 + D1) / 2, H + 3, V0 - .6);
|
||||
hallSign.rotation.y = Math.PI;
|
||||
group.add(hallSign);
|
||||
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, mat('#59646b', {metalness: .5}), false);
|
||||
|
||||
// Assembly line: a conveyor carrying machine frames, robot arms, and finished machines in a row.
|
||||
box(-60, -36, 33, 37, 0, .9, mat('#3d4549', {metalness: .5}));
|
||||
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
|
||||
for (let u = -59; u < -36; u += 1.2) {
|
||||
const roller = new THREE.Mesh(new THREE.CylinderGeometry(.12, .12, 3.8, 8), rollers);
|
||||
roller.rotation.x = Math.PI / 2;
|
||||
roller.position.set(u, .95, 35);
|
||||
group.add(roller);
|
||||
}
|
||||
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(-58 + i * 6, 2.2, 35);
|
||||
frame.castShadow = true;
|
||||
group.add(frame);
|
||||
frames.push(frame);
|
||||
}
|
||||
const arms = [];
|
||||
for (const u of [-52, -42]) {
|
||||
box(u - .6, u + .6, 39, 40.2, 0, 1.2, mat('#f2c94c'));
|
||||
const arm = new THREE.Group();
|
||||
arm.position.set(u, 1.2, 39.6);
|
||||
group.add(arm);
|
||||
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), mat('#f2c94c'));
|
||||
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);
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
|
||||
finished.beacon.visible = finished.ring.visible = false;
|
||||
finished.group.position.set(-32 + i * 4.2, 0, 43.4);
|
||||
finished.group.rotation.y = Math.PI;
|
||||
group.add(finished.group);
|
||||
footprints.push([[-33 + i * 4.2, 42.6], [-31 + i * 4.2, 42.6], [-31 + i * 4.2, 44.6], [-33 + i * 4.2, 44.6]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
|
||||
}
|
||||
// Crates of Robot Market stock along the end wall.
|
||||
const crate = mat('#b98b55');
|
||||
for (let i = 0; i < 6; i++) box(-61, -58.6, 17 + i * 2.6, 19.2 + i * 2.6, 0, 1.2 + (i % 3) * 1.1, crate);
|
||||
|
||||
// Office block with the company name on its roof.
|
||||
const windows = textTexture(128, 128, (c, w, h) => {
|
||||
c.fillStyle = '#e9e4d8';
|
||||
c.fillRect(0, 0, w, h);
|
||||
c.fillStyle = '#4c6672';
|
||||
c.fillRect(18, 22, 92, 70);
|
||||
c.fillStyle = 'rgba(255,255,255,.25)';
|
||||
c.fillRect(22, 26, 26, 62);
|
||||
});
|
||||
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
|
||||
const officeMaterial = mat('#ffffff', {map: windows});
|
||||
const office = box(8, 32, -34, -22, 0, 9.6, officeMaterial);
|
||||
office.material = [officeMaterial, officeMaterial, mat('#bdb5a5'), mat('#bdb5a5'), officeMaterial, officeMaterial];
|
||||
windows.repeat.set(6, 3);
|
||||
const roofSign = new THREE.Mesh(new THREE.PlaneGeometry(22, 3.4), signMaterial);
|
||||
roofSign.position.set(20, 11.6, -21.9);
|
||||
roofSign.rotation.y = Math.PI;
|
||||
group.add(roofSign);
|
||||
for (const u of [11, 29]) box(u - .15, u + .15, -22.2, -21.9, 9.6, 13.2, mat('#59646b'), false);
|
||||
|
||||
// Staff cars in the car park.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
|
||||
parked.group.position.set(36 + i * 4, 0, -18);
|
||||
parked.group.rotation.y = Math.PI / 2;
|
||||
group.add(parked.group);
|
||||
footprints.push([[34.9 + i * 4, -20.3], [37.1 + i * 4, -20.3], [37.1 + i * 4, -15.7], [34.9 + i * 4, -15.7]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
|
||||
}
|
||||
|
||||
// The demo machine stands inside the hall facing the door; the family starts in front of it.
|
||||
const demo = toWorld(-8, 41.5), facing = {x: -normal.x, z: -normal.z};
|
||||
const car = toWorld(52, -6);
|
||||
return {
|
||||
footprints,
|
||||
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
|
||||
car: {x: car.x, z: car.z, heading: Math.atan2(-axis.z, axis.x)},
|
||||
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
|
||||
update(time) {
|
||||
arms.forEach((arm, i) => arm.rotation.y = Math.sin(time * .8 + i * 2) * .9);
|
||||
frames.forEach((frame, i) => frame.position.x = -58 + ((time * .6 + i * 6) % 24));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,56 +1,56 @@
|
||||
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
|
||||
export const INITIAL_BALANCE = 500;
|
||||
export const formatHetoman = value => `${value.toLocaleString('fa-IR')} هتومان`;
|
||||
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;
|
||||
|
||||
const assortment = [
|
||||
{id: 'juice', code: '11', name: 'آبمیوه', icon: '🧃', cost: 35000, type: 'drink'},
|
||||
{id: 'water', code: '12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'},
|
||||
{id: 'biscuit', code: '13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'},
|
||||
{id: 'milk', code: '14', name: 'شیر کاکائو', icon: '🥛', cost: 40000, type: 'drink'},
|
||||
{id: 'tea', code: '15', name: 'چای سرد', icon: '🧋', cost: 45000, type: 'drink'},
|
||||
{id: 'lemonade', code: '16', name: 'لیموناد', icon: '🍋', cost: 40000, type: 'drink'},
|
||||
{id: 'chocolate', code: '17', name: 'شکلات', icon: '🍫', cost: 30000, type: 'snack'},
|
||||
{id: 'nuts', code: '18', name: 'آجیل', icon: '🥜', cost: 65000, type: 'snack'},
|
||||
{id: 'cake', code: '19', name: 'کیک', icon: '🧁', cost: 35000, type: 'snack'}
|
||||
];
|
||||
|
||||
// All 60 physical channels exist; only 21–60 are available in this scene.
|
||||
export const channels = Array.from({length: 60}, (_, index) => {
|
||||
const channel = index + 1, base = assortment[(channel - 22 + 63) % assortment.length];
|
||||
return {
|
||||
...base,
|
||||
baseId: base.id,
|
||||
id: `channel-${channel}`,
|
||||
code: String(channel),
|
||||
channel,
|
||||
cost: base.cost / 1000,
|
||||
available: channel >= 21
|
||||
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 products = channels.filter(channel => channel.available);
|
||||
|
||||
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, basket: [], busy: false, finished: false, recycled: false};
|
||||
return {balance: INITIAL_BALANCE, purchases: [], xp: 0, missionsDone: [], minutes: START_MINUTES, busy: false};
|
||||
}
|
||||
|
||||
export function readyForPicnic(state) {
|
||||
return state.basket.some(p => p.type === 'drink') && state.basket.some(p => p.type === 'snack');
|
||||
// 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, id) {
|
||||
const product = products.find(p => p.id === id);
|
||||
if (!product || state.busy || state.finished || state.balance < product.cost) return null;
|
||||
// Keep enough money for the missing picnic category, so the quest stays completable.
|
||||
const next = [...state.basket, product];
|
||||
const reserve = ['drink', 'snack'].reduce((total, type) => total + (next.some(p => p.type === type) ? 0 : Math.min(...products.filter(p => p.type === type).map(p => p.cost))), 0);
|
||||
if (state.balance - product.cost < reserve) return null;
|
||||
state.balance -= product.cost;
|
||||
state.basket.push(product);
|
||||
state.busy = true;
|
||||
return product;
|
||||
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;
|
||||
}
|
||||
|
||||
export function productByCode(value) {
|
||||
const normalized = String(value).trim().replace(/[۰-۹]/g, c => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g, c => String('٠١٢٣٤٥٦٧٨٩'.indexOf(c)));
|
||||
return products.find(p => p.code === normalized) ?? null;
|
||||
// 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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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')} کیلومتر`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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: '🛍'}
|
||||
};
|
||||
|
||||
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: 'بیشترین تنوع محصول؛ قیمت کمی بالاتر'
|
||||
}
|
||||
];
|
||||
|
||||
// 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;
|
||||
@@ -0,0 +1,367 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,40 @@
|
||||
import {purchase} from './game-state.js';
|
||||
import {purchase, purchaseBlocker} from './game-state.js';
|
||||
|
||||
// idle → approaching (camera moves to the reader) → ready → approved (card swiped, charged once).
|
||||
export function createPayment() {
|
||||
return {phase: 'idle', product: null};
|
||||
return {phase: 'idle', machine: null, productId: null, record: null};
|
||||
}
|
||||
|
||||
export function beginPayment(state, payment, product) {
|
||||
if (payment.phase !== 'idle' || !product || !purchase({
|
||||
...state,
|
||||
basket: [...state.basket]
|
||||
}, product.id)) return false;
|
||||
payment.product = product;
|
||||
payment.phase = 'approaching';
|
||||
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;
|
||||
}
|
||||
|
||||
export function readyPayment(payment) {
|
||||
if (payment.phase !== 'approaching') return false;
|
||||
payment.phase = 'ready';
|
||||
return true;
|
||||
}
|
||||
|
||||
export function swipePayment(state, payment) {
|
||||
if (payment.phase !== 'ready') return false;
|
||||
state.busy = false;
|
||||
if (!purchase(state, payment.product.id)) {
|
||||
payment.phase = 'idle';
|
||||
payment.product = null;
|
||||
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;
|
||||
}
|
||||
|
||||
export function cancelPayment(state, payment) {
|
||||
if (!['approaching', 'ready'].includes(payment.phase)) return false;
|
||||
payment.phase = 'idle';
|
||||
payment.product = null;
|
||||
Object.assign(payment, createPayment());
|
||||
state.busy = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Installable app: registers the offline service worker, offers "install" where the browser allows
|
||||
// it (Android/desktop Chrome), shows Add-to-Home-Screen steps on iOS, and offers a reload on updates.
|
||||
const $ = s => document.querySelector(s);
|
||||
|
||||
export function setupPwa() {
|
||||
const installButton = $('#install');
|
||||
const standalone = matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches || navigator.standalone;
|
||||
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
let deferred = null;
|
||||
|
||||
if (!standalone && ios) installButton.hidden = false;
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault();
|
||||
deferred = event;
|
||||
installButton.hidden = false;
|
||||
});
|
||||
window.addEventListener('appinstalled', () => installButton.hidden = true);
|
||||
installButton.onclick = async () => {
|
||||
if (deferred) {
|
||||
deferred.prompt();
|
||||
await deferred.userChoice;
|
||||
deferred = null;
|
||||
installButton.hidden = true;
|
||||
} else if (ios) $('#ios-install').showModal();
|
||||
};
|
||||
$('#ios-close').onclick = () => $('#ios-install').close();
|
||||
|
||||
// Only built pages have sw.js; the dev server serves source files directly.
|
||||
if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
|
||||
navigator.serviceWorker.register('./sw.js').then(registration => {
|
||||
const offer = worker => {
|
||||
$('#update-toast').hidden = false;
|
||||
$('#update-reload').onclick = () => worker.postMessage('skip-waiting');
|
||||
};
|
||||
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing;
|
||||
worker?.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
let reloading = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {toLocal} from './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.
|
||||
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(.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) * .2);
|
||||
return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt};
|
||||
}
|
||||
|
||||
// How close counts as "at the machine": never tighter than 20 m, wider when GPS is poor.
|
||||
export const arrivalRadius = accuracy => Math.min(40, Math.max(20, accuracy * 1.5));
|
||||
|
||||
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) and onError(message).
|
||||
export function startTracking({onFix, onHeading, onError}) {
|
||||
if (!('geolocation' in navigator)) {
|
||||
onError('این دستگاه موقعیتیابی (GPS) ندارد.');
|
||||
return () => {};
|
||||
}
|
||||
let estimate = null;
|
||||
const watch = navigator.geolocation.watchPosition(position => {
|
||||
const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon);
|
||||
estimate = smoothFix(estimate, {...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
|
||||
onFix({...estimate, lat, lon});
|
||||
}, error => onError(error.code === error.PERMISSION_DENIED ? 'اجازهٔ دسترسی به موقعیت داده نشد. از تنظیمات مرورگر اجازه بدهید.' : 'موقعیت پیدا نشد؛ به فضای باز بروید.'), {
|
||||
enableHighAccuracy: true, maximumAge: 2000, timeout: 20000
|
||||
});
|
||||
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
|
||||
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.
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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
|
||||
|
||||
export function buildGraph(roads, {drivable = false} = {}) {
|
||||
const ids = new Map(), xs = [], zs = [], adj = [];
|
||||
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;
|
||||
}
|
||||
|
||||
const cellKey = (cx, cz) => cx * 100003 + cz;
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
const h = n => Math.hypot(xs[n] - xs[goal], zs[n] - zs[goal]);
|
||||
const heap = [[h(start), start]];
|
||||
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;
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
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)};
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {CATEGORIES} from './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');
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
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 => p.category !== 'factory')) >= 5
|
||||
}
|
||||
];
|
||||
|
||||
// Marks and returns missions that the current purchases complete for the first time.
|
||||
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;
|
||||
}
|
||||
|
||||
export const activeMission = state => MISSIONS.find(m => !state.missionsDone.includes(m.id)) ?? null;
|
||||
@@ -1,74 +0,0 @@
|
||||
export const chapters = {
|
||||
morning: {title: 'تپهٔ آرزوها', lines: ['پدر: بریم تپه؟ امروز راه رو با هم کشف میکنیم.']},
|
||||
rabbit: {
|
||||
title: 'یک دوست کوچک',
|
||||
lines: ['بچه: یه خرگوش! آروم نگاهش کنیم؟'],
|
||||
action: {value: 'watch', label: 'تماشای خرگوش'}
|
||||
},
|
||||
bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد میشیم.']},
|
||||
fish: {title: 'زیر پل', lines: ['بچه: ماهیها رو ببین!']},
|
||||
courage: {title: 'اولین خرید من', lines: ['پدر: حالا برای پیکنیک خرید کنیم.']},
|
||||
wind: {title: 'باد و نقاشی', lines: ['بچه: نقاشیم رفت کنار بوته!']},
|
||||
paper: {
|
||||
title: 'پیداش کردیم',
|
||||
lines: ['بچه: سالمه! بالای تپه کاملش میکنم.'],
|
||||
action: {value: 'recover', label: 'برداشتن نقاشی'}
|
||||
},
|
||||
fork: {
|
||||
title: 'مسیر تپه',
|
||||
lines: ['پدر: از راه آفتابی به تپه میرسیم.'],
|
||||
action: {value: 'sunny', label: 'ادامهٔ مسیر'}
|
||||
},
|
||||
sunny: {title: 'رنگهای جنگل', lines: ['بچه: این رنگها رو توی نقاشیم میکشم!']},
|
||||
shade: {title: 'درخت خاطرهها', lines: ['پدر: بچه که بودم هم زیر همین درخت مینشستم.']},
|
||||
summit: {
|
||||
title: 'کنار هم',
|
||||
lines: ['بچه: دفعهٔ بعد، من راه رو نشونت میدم!'],
|
||||
action: {value: 'gold', label: 'کامل کردن نقاشی'}
|
||||
}
|
||||
};
|
||||
|
||||
export function createStory() {
|
||||
return {done: [], rabbitChoice: null, route: null, sky: null};
|
||||
}
|
||||
|
||||
export function completeChapter(story, id, choice) {
|
||||
if (!chapters[id] || story.done.includes(id)) return false;
|
||||
if (chapters[id].action && chapters[id].action.value !== choice) return false;
|
||||
story.done.push(id);
|
||||
if (id === 'rabbit') story.rabbitChoice = choice;
|
||||
if (id === 'fork') story.route = choice;
|
||||
if (id === 'summit') story.sky = choice;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function nextChapter(story, x) {
|
||||
const done = id => story.done.includes(id);
|
||||
if (!done('morning')) return 'morning';
|
||||
for (const [id, threshold] of [['rabbit', -3.7], ['bridge', -2.2], ['fish', 0], ['courage', 2.7], ['wind', 12], ['paper', 14], ['fork', 16]]) {
|
||||
if (!done(id)) return x >= threshold ? id : null;
|
||||
}
|
||||
if (!done(story.route)) return x >= 19 ? story.route : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function storyReady(story) {
|
||||
return ['morning', 'rabbit', 'bridge', 'fish', 'courage', 'wind', 'paper', 'fork', story.route].every(id => story.done.includes(id));
|
||||
}
|
||||
|
||||
export function routeOffset(story, x) {
|
||||
if (x > 12 && x < 15 && story.done.includes('wind')) return Math.sin((x - 12) / 3 * Math.PI) * 1.5;
|
||||
if (x <= 16 || x >= 21 || !story.route) return 0;
|
||||
return Math.sin((x - 16) / 5 * Math.PI) * (story.route === 'sunny' ? 2.2 : -2.2);
|
||||
}
|
||||
|
||||
export function discoveries(story) {
|
||||
const entries = [];
|
||||
if (story.done.includes('rabbit')) entries.push(story.rabbitChoice === 'sketch' ? '🐇 نقاشی خرگوش' : '🐇 تماشای آرام خرگوش');
|
||||
if (story.done.includes('fish')) entries.push('🐟 ماهیهای زیر پل');
|
||||
if (story.done.includes('courage')) entries.push('🌉 پل شجاعت');
|
||||
if (story.done.includes('paper')) entries.push('📄 نقاشی بازیابیشده');
|
||||
if (story.done.includes('sunny')) entries.push('🦋 رنگهای پروانهها');
|
||||
if (story.done.includes('shade')) entries.push('🌳 خاطرهٔ درخت قدیمی');
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
|
||||
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;
|
||||
const paint = () => {
|
||||
draw(canvas.getContext('2d'), width, height);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
paint();
|
||||
document.fonts?.ready.then(paint);
|
||||
return texture;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Low-poly saloon facing local +X, sized like a Peugeot/Samand (4.4 m long).
|
||||
export function createCarModel(color = '#d9d9d4') {
|
||||
const group = new THREE.Group();
|
||||
const mat = (c, extra = {}) => new THREE.MeshStandardMaterial({color: c, roughness: .45, ...extra});
|
||||
const paint = mat(color, {metalness: .55, roughness: .35}), glass = mat('#1e2c33', {metalness: .6, roughness: .15});
|
||||
const dark = mat('#141618'), chrome = mat('#c8ccce', {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;
|
||||
};
|
||||
box(4.4, .62, 1.78, paint, 0, .68, 0);
|
||||
box(4.42, .16, 1.8, dark, 0, .38, 0);
|
||||
const cabin = box(2.3, .62, 1.6, glass, -.25, 1.28, 0);
|
||||
cabin.scale.set(1, 1, .98);
|
||||
box(2.1, .08, 1.62, paint, -.25, 1.62, 0);
|
||||
box(.9, .06, 1.7, paint, 1.45, 1.0, 0).rotation.z = -.12;
|
||||
for (const z of [-.62, .62]) {
|
||||
box(.06, .18, .34, mat('#fff4cc', {emissive: '#fff1b0', emissiveIntensity: .6}), 2.21, .78, z);
|
||||
box(.06, .16, .34, mat('#b3261e', {emissive: '#7a0f0b', emissiveIntensity: .5}), -2.21, .8, z);
|
||||
box(.18, .08, .1, chrome, .6, 1.02, z * 1.47);
|
||||
}
|
||||
box(.04, .12, 1.0, chrome, 2.22, .6, 0);
|
||||
const wheels = [];
|
||||
for (const x of [-1.38, 1.38]) for (const z of [-.86, .86]) {
|
||||
const wheel = new THREE.Group();
|
||||
wheel.position.set(x, .36, z);
|
||||
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(.36, .36, .26, 18), dark);
|
||||
tyre.rotation.x = Math.PI / 2;
|
||||
tyre.castShadow = true;
|
||||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(.2, .2, .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
|
||||
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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.
|
||||
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() < .5 ? -1 : 1) * road.width / 4 : road.width / 4;
|
||||
Object.assign(car, {road, d, dir: road.oneway || random() < .5 ? 1 : -1, lane, speed: 9 + random() * 7});
|
||||
car.model.group.visible = true;
|
||||
return true;
|
||||
}
|
||||
car.model.group.visible = false;
|
||||
return false;
|
||||
}
|
||||
const cars = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const model = createCarModel(COLORS[i % COLORS.length]);
|
||||
scene.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.
|
||||
function remove(car) {
|
||||
car.road = null;
|
||||
car.x = car.z = 1e9;
|
||||
car.model.group.visible = false;
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
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 / .36;
|
||||
}
|
||||
}
|
||||
return {cars, update, remove};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Arcade car physics. Heading h faces world direction (cos h, 0, -sin h), matching Three's rotation.y.
|
||||
export const CAR = {maxSpeed: 33, maxReverse: 6, accel: 7, brake: 14, drag: 1.6, maxSteer: .55, wheelBase: 2.7, radius: 1.6};
|
||||
|
||||
export function createCar(x, z, heading = 0) {
|
||||
return {x, z, heading, speed: 0, steer: 0};
|
||||
}
|
||||
|
||||
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 * .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) * .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;
|
||||
}
|
||||
|
||||
export const kmh = car => Math.round(Math.abs(car.speed) * 3.6);
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as THREE from 'three';
|
||||
import {catalog} from './game-state.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.
|
||||
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.
|
||||
export function createVendingMachine(def) {
|
||||
const group = new THREE.Group();
|
||||
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .5, ...extra});
|
||||
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: .45, roughness: .3, emissive: '#0b8f88', emissiveIntensity: .15}), white = mat('#eef5f3', {metalness: .2, roughness: .35});
|
||||
const dark = mat('#13292b'), steel = mat('#b7c1c0', {metalness: .8, roughness: .3});
|
||||
box(1.9, 2.4, 1.1, shell, 0, 1.3, 0);
|
||||
box(1.94, .32, 1.14, white, 0, 2.62, 0);
|
||||
box(1.94, .12, 1.14, dark, 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, .06, mat('#13292b', {emissive: '#2a7f73', emissiveIntensity: .5}), -.2, 1.45, .56);
|
||||
// Six shelves of products behind the glass, coloured by what this machine sells.
|
||||
for (let row = 0; row < 6; row++) {
|
||||
const y = .78 + row * .28;
|
||||
box(1.2, .02, .25, steel, -.2, y, .45);
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const id = def.products[(row * 8 + col) % def.products.length];
|
||||
const color = mat(PRODUCT_COLORS[id]);
|
||||
if (catalog[id].type === 'drink') {
|
||||
const bottle = new THREE.Mesh(new THREE.CylinderGeometry(.035, .04, .17, 8), color);
|
||||
bottle.position.set(-.73 + col * .15, y + .1, .47);
|
||||
group.add(bottle);
|
||||
} else box(.09, .14, .06, color, -.73 + col * .15, y + .08, .47);
|
||||
}
|
||||
}
|
||||
const glass = new THREE.Mesh(new THREE.PlaneGeometry(1.32, 1.82), new THREE.MeshStandardMaterial({color: '#cdeeea', transparent: true, opacity: .16, roughness: .05, metalness: .3, depthWrite: false}));
|
||||
glass.position.set(-.2, 1.45, .6);
|
||||
group.add(glass);
|
||||
// Card reader panel on the right of the front.
|
||||
box(.42, 1.8, .05, white, .7, 1.45, .56);
|
||||
const screen = box(.3, .22, .02, new THREE.MeshStandardMaterial({color: '#7ef0d6', emissive: '#2bbfa0', emissiveIntensity: .8}), .7, 1.92, .6);
|
||||
box(.3, .2, .03, dark, .7, 1.55, .6);
|
||||
box(.22, .03, .04, steel, .7, 1.62, .62);
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) box(.07, .05, .02, steel, .62 + c * .08, 1.32 - r * .07, .6);
|
||||
box(.9, .3, .08, mat('#050f10'), -.2, .38, .56);
|
||||
const flap = new THREE.Group();
|
||||
flap.position.set(-.2, .53, .61);
|
||||
group.add(flap);
|
||||
box(.88, .28, .03, mat('#4f6f68', {metalness: .4}), 0, -.14, 0, flap);
|
||||
const sign = new THREE.Mesh(new THREE.PlaneGeometry(1.86, .3), new THREE.MeshBasicMaterial({map: label('روبات مارکت', def.name)}));
|
||||
sign.position.set(0, 2.62, .575);
|
||||
group.add(sign);
|
||||
const delivery = new THREE.Mesh(new THREE.CylinderGeometry(.06, .06, .2, 10), mat('#e5ae47'));
|
||||
delivery.visible = false;
|
||||
group.add(delivery);
|
||||
const card = box(.2, .13, .01, mat('#e2d6a8', {metalness: .3}), .95, 1.7, .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: .22, depthWrite: false, fog: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide});
|
||||
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.7, .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: .6, depthWrite: false}));
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(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(.7, 1.6, .62),
|
||||
slotPoint: new THREE.Vector3(-.2, .38, .66)
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {canWalk,movementDirection} from '../src/controls.js';
|
||||
import {products,createState,purchase,INITIAL_BALANCE,formatHetoman} from '../src/game-state.js';
|
||||
import {createFamily} from '../src/characters.js';
|
||||
test('Walking remains enabled during dialogue; arrows up/down only zoom',()=>{
|
||||
assert.ok(canWalk({chapterId:'bridge'}));assert.ok(canWalk({chapterId:'fish'}));
|
||||
assert.equal(canWalk({busy:true}),false);assert.equal(canWalk({shopOpen:true}),false);
|
||||
assert.equal(movementDirection(new Set(['arrowup']),new Set()),0);
|
||||
assert.equal(movementDirection(new Set(['arrowright']),new Set()),1);
|
||||
assert.equal(movementDirection(new Set(),new Set([-1])),-1);
|
||||
});
|
||||
test('Toman balance, charges and picnic reserve',()=>{
|
||||
assert.equal(INITIAL_BALANCE,500);assert.match(formatHetoman(15),/هتومان/);
|
||||
for(const p of products){const s=createState();assert.ok(purchase(s,p.id));assert.equal(s.balance,INITIAL_BALANCE-p.cost);}
|
||||
const s={...createState(),balance:40};assert.equal(purchase(s,'channel-22'),null);assert.equal(s.balance,40);assert.ok(purchase(s,'channel-23'));s.busy=false;assert.ok(purchase(s,'channel-24'));assert.equal(s.balance,0);
|
||||
});
|
||||
test('Character transforms stay finite through walk, pickup and sitting',()=>{
|
||||
const f=createFamily();for(const facing of [-Math.PI/2,Math.PI/2,Math.PI])for(const mode of ['walk','shop','sit']){
|
||||
f.update({time:3,gait:2,dt:.016,moving:mode==='walk',facing,shopping:mode==='shop',finished:mode==='sit'});f.group.updateMatrixWorld(true);f.group.traverse(o=>assert.ok(o.matrixWorld.elements.every(Number.isFinite)));
|
||||
}
|
||||
});
|
||||
|
||||
test('Sixty channels, exactly 21–60 purchasable, Hetoman conversion',async()=>{
|
||||
const {channels,productByCode,hetomanToRial}=await import('../src/game-state.js');
|
||||
assert.equal(channels.length,60);assert.equal(products.length,40);
|
||||
assert.equal(hetomanToRial(10),100000);
|
||||
for(let n=1;n<=60;n++){assert.equal(Boolean(productByCode(String(n))),n>=21);}
|
||||
assert.equal(productByCode('۲۲').channel,22);assert.equal(productByCode('٦٠').channel,60);assert.equal(productByCode('61'),null);
|
||||
const s=createState();assert.equal(purchase(s,'channel-20'),null);assert.equal(s.balance,500);
|
||||
});
|
||||
@@ -1,15 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createState,productByCode} from '../src/game-state.js';
|
||||
import {createPayment,beginPayment,swipePayment,cancelPayment} from '../src/payment.js';
|
||||
test('Payment charges once, only after ready and explicit swipe',()=>{
|
||||
const s=createState(),p=createPayment(),product=productByCode('21');assert.ok(product);
|
||||
assert.ok(beginPayment(s,p,product));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,true);
|
||||
assert.equal(swipePayment(s,p),false);assert.equal(beginPayment(s,p,product),false);
|
||||
p.phase='ready';assert.ok(swipePayment(s,p));assert.equal(s.balance,500-product.cost);assert.equal(s.basket.length,1);
|
||||
assert.equal(swipePayment(s,p),false);assert.equal(cancelPayment(s,p),false);
|
||||
import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js';
|
||||
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js';
|
||||
import {machineById, machines} from '../src/locations.js';
|
||||
|
||||
const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
|
||||
|
||||
test('Payment charges once, only after ready and an explicit swipe', () => {
|
||||
const state = createState(), payment = createPayment();
|
||||
assert.ok(beginPayment(state, payment, park, 'juice'));
|
||||
assert.equal(state.balance, 500);
|
||||
assert.equal(swipePayment(state, payment), false, 'not ready yet');
|
||||
assert.ok(readyPayment(payment));
|
||||
assert.ok(swipePayment(state, payment));
|
||||
assert.equal(state.balance, 465);
|
||||
assert.equal(swipePayment(state, payment), false, 'no double charge');
|
||||
assert.equal(state.purchases.length, 1);
|
||||
assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends');
|
||||
});
|
||||
test('Cancel or invalid payment never changes balance or basket',()=>{
|
||||
const s=createState(),p=createPayment();assert.equal(beginPayment(s,p,productByCode('20')),false);
|
||||
assert.ok(beginPayment(s,p,productByCode('60')));assert.ok(cancelPayment(s,p));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,false);
|
||||
|
||||
test('Cancel, closed machines, unknown products and low funds never charge', () => {
|
||||
const state = createState(), payment = createPayment();
|
||||
assert.ok(beginPayment(state, payment, park, 'cake'));
|
||||
assert.ok(cancelPayment(state, payment));
|
||||
assert.equal(state.balance, 500);
|
||||
assert.equal(state.busy, false);
|
||||
assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
|
||||
assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
|
||||
state.minutes = 14 * 60;
|
||||
assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
|
||||
state.minutes = 9 * 60;
|
||||
state.balance = 10;
|
||||
assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
|
||||
assert.equal(beginPayment(state, payment, park, 'water'), false);
|
||||
});
|
||||
|
||||
test('Prices follow machine discounts and the clock is Persian', () => {
|
||||
assert.equal(priceAt(uni, 'nuts'), 52);
|
||||
assert.equal(priceAt(park, 'nuts'), 65);
|
||||
assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
|
||||
const state = createState();
|
||||
state.balance = 5;
|
||||
assert.equal(canAffordAnything(state, machines), false);
|
||||
state.balance = 15;
|
||||
assert.ok(canAffordAnything(state, machines));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {smoothFix, arrivalRadius, insideWorld} from '../src/realworld.js';
|
||||
|
||||
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
|
||||
const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
|
||||
assert.deepEqual([first.x, first.z], [0, 0]);
|
||||
const equal = smoothFix(first, {x: 10, z: 0, accuracy: 10, time: 1000});
|
||||
assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
|
||||
assert.ok(equal.accuracy < 10, 'two fixes are better than one');
|
||||
const poor = smoothFix(first, {x: 50, z: 0, accuracy: 60, time: 1000});
|
||||
assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
|
||||
const stale = smoothFix(first, {x: 99, z: 0, accuracy: 30, time: 60000});
|
||||
assert.equal(stale.x, 99);
|
||||
});
|
||||
|
||||
test('Arrival radius widens with poor GPS and bounds check the world', () => {
|
||||
assert.equal(arrivalRadius(5), 20);
|
||||
assert.equal(arrivalRadius(20), 30);
|
||||
assert.equal(arrivalRadius(100), 40);
|
||||
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
|
||||
assert.ok(insideWorld(bounds, 0, 0));
|
||||
assert.equal(insideWorld(bounds, 11, 0), false);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {awardsFor, rankFor, completeMissions, activeMission, POINTS, RANKS} from '../src/scoring.js';
|
||||
import {createState, purchase} from '../src/game-state.js';
|
||||
import {machineById} from '../src/locations.js';
|
||||
|
||||
const total = awards => awards.reduce((s, a) => s + a.points, 0);
|
||||
|
||||
test('Discovery, combo and all-category bonuses are paid once', () => {
|
||||
const state = createState(), park = machineById('mellat-park');
|
||||
const buy = (machine, product) => {
|
||||
const before = state.purchases.slice();
|
||||
return awardsFor(before, purchase(state, machine, product));
|
||||
};
|
||||
assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
|
||||
assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
|
||||
assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
|
||||
assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
|
||||
buy(machineById('factory'), 'water');
|
||||
buy(machineById('school-rahil'), 'water');
|
||||
buy(machineById('ferdowsi-university'), 'water');
|
||||
buy(machineById('azadi-metro'), 'water');
|
||||
state.minutes = 11 * 60;
|
||||
const last = buy(machineById('mashhad-mall'), 'water');
|
||||
assert.ok(last.some(a => a.points === POINTS.allCategories));
|
||||
});
|
||||
|
||||
test('Ranks and missions advance with progress', () => {
|
||||
assert.equal(rankFor(0).name, RANKS[0].name);
|
||||
assert.equal(rankFor(RANKS[1].xp).index, 1);
|
||||
assert.equal(rankFor(1e6).progress, 1);
|
||||
const state = createState();
|
||||
assert.equal(activeMission(state).id, 'factory');
|
||||
purchase(state, machineById('factory'), 'water');
|
||||
assert.deepEqual(completeMissions(state).map(m => m.id), ['factory']);
|
||||
assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
|
||||
purchase(state, machineById('mellat-park'), 'water');
|
||||
assert.deepEqual(completeMissions(state).map(m => m.id), ['first']);
|
||||
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
|
||||
assert.equal(activeMission(state).id, 'school');
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from '../src/story.js';
|
||||
import {createState,purchase,readyForPicnic,productByCode} from '../src/game-state.js';
|
||||
for(const route of ['sunny'])test(`Complete ${route} story and picnic`,()=>{
|
||||
const story=createStory();assert.equal(nextChapter(story,-8),'morning');
|
||||
for(const [id,x,choice] of [['morning',-8],['rabbit',-3,'watch'],['bridge',-2],['fish',0],['courage',3],['wind',12],['paper',14,'recover'],['fork',16,route],[route,19]]){
|
||||
assert.equal(nextChapter(story,x),id);assert.ok(completeChapter(story,id,choice));assert.equal(completeChapter(story,id,choice),false);
|
||||
}
|
||||
assert.ok(storyReady(story));assert.equal(nextChapter(story,22),null);assert.ok(discoveries(story).length>=5);
|
||||
assert.equal(routeOffset(story,16),0);assert.equal(routeOffset(story,21),0);assert.equal(Math.sign(routeOffset(story,18)),route==='sunny'?1:-1);
|
||||
assert.equal(completeChapter(story,'summit','invalid'),false);assert.ok(completeChapter(story,'summit','gold'));assert.equal(story.sky,'gold');
|
||||
const state=createState();assert.ok(purchase(state,productByCode('۲۲').id));state.busy=false;assert.ok(purchase(state,productByCode('۲۴').id));assert.ok(readyForPicnic(state));assert.equal(state.balance,440);
|
||||
});
|
||||
test('No early triggers, unknown choices, or leaked reset state',()=>{
|
||||
const story=createStory();assert.equal(storyReady(story),false);completeChapter(story,'morning');assert.equal(nextChapter(story,-8),null);
|
||||
assert.equal(completeChapter(story,'rabbit','wrong'),false);assert.equal(completeChapter(story,'unknown'),false);
|
||||
assert.deepEqual(discoveries(createStory()),[]);assert.equal(routeOffset(createStory(),18),0);
|
||||
});
|
||||
|
||||
test('Every story action exposes one fixed action instead of choices',async()=>{
|
||||
const {chapters}=await import('../src/story.js');
|
||||
for(const chapter of Object.values(chapters))assert.equal(chapter.choices,undefined);
|
||||
for(const id of ['rabbit','paper','fork','summit']){const s=createStory();assert.equal(completeChapter(s,id,'wrong'),false);assert.ok(completeChapter(s,id,chapters[id].action.value));}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {toLocal, toLatLon, bearing, ORIGIN} from '../src/geo.js';
|
||||
import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js';
|
||||
import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js';
|
||||
import {createCar, stepCar} from '../src/vehicle.js';
|
||||
import {createTraffic} from '../src/traffic.js';
|
||||
import {Scene} from 'three';
|
||||
import {machines, machineById, FACTORY} from '../src/locations.js';
|
||||
|
||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
|
||||
|
||||
test('Projection round-trips and bearings follow the compass', () => {
|
||||
const p = toLocal(36.32092, 59.53744), back = toLatLon(p.x, p.z);
|
||||
assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
|
||||
assert.ok(p.z < 0, 'north of the origin is -z');
|
||||
assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), {x: 0, z: 0});
|
||||
assert.equal(bearing(0, -1), 0);
|
||||
assert.equal(bearing(1, 0), 90);
|
||||
});
|
||||
|
||||
test('Circles are pushed out of footprints and segments stop at walls', () => {
|
||||
const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
|
||||
assert.ok(blocked(world, 5, 5));
|
||||
const out = resolveCircle(world, 9.5, 5, 1);
|
||||
assert.ok(out.hit && out.x >= 10.99);
|
||||
assert.equal(resolveCircle(world, 20, 20, 1).hit, false);
|
||||
assert.ok(Math.abs(clearFraction(world, -10, 5, 10, 5) - .5) < .06);
|
||||
});
|
||||
|
||||
test('Real map: the factory connects by car to the city, and every city machine is reachable', () => {
|
||||
const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
|
||||
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
|
||||
assert.ok(toCity && toCity.length > 18000 && toCity.length < 23000, `factory → Park Mellat is ~20 km (${toCity?.length})`);
|
||||
const start = machineById('mellat-park').anchor;
|
||||
for (const m of machines.filter(m => m.anchor)) {
|
||||
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
|
||||
assert.ok(onFoot, `walk route to ${m.id}`);
|
||||
assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);
|
||||
assert.ok(routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z), `drive route to ${m.id}`);
|
||||
}
|
||||
const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, s => s[5] >= 0);
|
||||
assert.equal(data.names[street.name], 'بولوار ملک آباد');
|
||||
});
|
||||
|
||||
test('Car accelerates, steers right with positive input and brakes', () => {
|
||||
const car = createCar(0, 0, 0);
|
||||
for (let i = 0; i < 60; i++) stepCar(car, {throttle: 1}, 1 / 30);
|
||||
assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
|
||||
for (let i = 0; i < 30; i++) stepCar(car, {throttle: 1, steer: 1}, 1 / 30);
|
||||
assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
|
||||
for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30);
|
||||
assert.ok(car.speed <= 0);
|
||||
});
|
||||
|
||||
test('Traffic drives with the flow on one-way carriageways and stays inside them', () => {
|
||||
const oneway = new Set(data.oneway);
|
||||
assert.ok(data.roads.some((r, i) => data.names[r[1]]?.includes('چناران') && oneway.has(i)), 'Chenaran highway is one-way per carriageway');
|
||||
const traffic = createTraffic(new Scene(), data, 28), around = {x: -10008, z: -15049};
|
||||
for (let i = 0; i < 400; i++) traffic.update(1 / 30, [], around);
|
||||
const active = traffic.cars.filter(c => c.road);
|
||||
assert.ok(active.length > 10, `cars spawned near the player (${active.length})`);
|
||||
for (const car of active.filter(c => c.road.oneway)) {
|
||||
assert.equal(car.dir, 1);
|
||||
assert.ok(Math.abs(car.lane) < car.road.width / 2, 'lane inside the carriageway');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {defineConfig} from 'vite';
|
||||
import {readdirSync, statSync} from 'node:fs';
|
||||
import {join, relative} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
const listFiles = dir => readdirSync(dir).flatMap(name => {
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? listFiles(path) : [path];
|
||||
});
|
||||
|
||||
// Emits sw.js listing every built file, so the installed game works offline. The cache name is a
|
||||
// hash of that list: a new build gets a new cache and the page offers to reload into it.
|
||||
function offlineServiceWorker() {
|
||||
return {
|
||||
name: 'offline-service-worker',
|
||||
apply: 'build',
|
||||
generateBundle(_, bundle) {
|
||||
const files = [...Object.keys(bundle), ...listFiles('public').map(f => relative('public', f).replaceAll('\\', '/'))]
|
||||
.filter(f => f !== 'sw.js' && !f.endsWith('.map'));
|
||||
const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
|
||||
this.emitFile({
|
||||
type: 'asset', fileName: 'sw.js', source: `// Generated by vite.config.js — do not edit.
|
||||
const CACHE = 'robot-market-${version}';
|
||||
const FILES = ${JSON.stringify(['./', ...files.map(f => `./${f}`)])};
|
||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES))));
|
||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k.startsWith('robot-market-') && k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())));
|
||||
self.addEventListener('message', event => event.data === 'skip-waiting' && self.skipWaiting());
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET' || new URL(request.url).origin !== location.origin) return;
|
||||
// Pages: network first so updates arrive, cached page when offline. Everything else: cache first.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(fetch(request).catch(() => caches.match('./')));
|
||||
return;
|
||||
}
|
||||
event.respondWith(caches.match(request, {ignoreSearch: true}).then(hit => hit ?? fetch(request)));
|
||||
});
|
||||
`
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// Relative assets also load under a GitHub Pages repository path.
|
||||
base: './',
|
||||
plugins: [offlineServiceWorker()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
'robotmarkets.ir',
|
||||
'www.robotmarkets.ir',
|
||||
],
|
||||
},
|
||||
});
|
||||