Compare commits

...
10 Commits
Author SHA1 Message Date
HamedandClaude Opus 5.5 47b2b72ea9 Add Docker deployment with automatic HTTPS
Deploy game to GitHub Pages / deploy (push) Failing after 21m51s
A two-stage image runs the tests and builds the game with Node, then
serves dist/ with Caddy, which obtains and renews Let's Encrypt
certificates for robotmarkets.ir (configurable via SITE_ADDRESS).
HTTPS is required for installing the PWA and for GPS mode.

- Caddyfile: gzip/zstd, long-lived caching for hashed assets, no-cache
  for the page, service worker and manifest, SPA fallback, permissions
  policy for location and motion sensors
- docker-compose.yml: ports 80/443, persistent certificate volumes
- README: server setup, updates, running behind an existing proxy, and
  workarounds when Docker Hub is unreachable from servers in Iran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:01 +03:30
HamedandClaude Opus 5.5 2f74788caa Fix the car getting stuck on the Chenaran highway
Traffic cars picked a random direction even on one-way carriageways,
so some drove head-on in the player's lane and stopped dead in front of
them; every bump also threw the player's car backwards while the
traffic car stayed put, so the player could not get past.

- Map data records one-way roads (oneway tags, motorways, roundabouts)
- Traffic drives with the flow in one of two lanes on one-way roads and
  on the right-hand half of two-way roads, spawning only from nearby roads
- Bumps slow the player down instead of reversing them; a traffic car
  pressed against for more than 0.8 s pulls away out of sight
- Regression test for traffic direction and lanes; ?debug collision log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:29:58 +03:30
HamedandClaude Opus 5.5 0939dac9cd Start at the factory, add PWA install and GPS real-world mode
The game now begins inside the Negin Sazan Pishgam Radmehr Atousa
vending-machine factory on the Mashhad–Chenaran road, and the world
extends ~20 km along the real driving route to Park Mellat and
Ferdowsi University.

- Factory compound: production hall with assembly line, office, yard,
  gate and company signs; first mission at its half-price demo machine
- Map data merged from four OSM extracts (factory zone, route corridor,
  region main roads, city zone), clipped to a 15 x 18 km region
- City meshes chunked and culled by distance; traffic recycled around
  the player; minimap and city map drawn from cached tiles + overview
- The Robot Market robot no longer walks with the family: it comes out
  of a machine when they arrive, greets them with expression portraits
  and goes back inside when they leave
- PWA: manifest, icons from the robot avatar, offline service worker
  generated at build time, install button / iOS steps, update prompt;
  Vazirmatn font bundled instead of Google Fonts
- Real-world mode: safety notice, GPS + compass tracking with accuracy
  smoothing, 20-40 m arrival radius, speed lock and in-person bonus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:56:06 +03:30
Hamed e2a1b5fba1 update 2026-10-25 12:47:13 +03:30
HamedandClaude Opus 5.5 95a2ae851d Turn the game into a third-person Mashhad city game
Replace the linear forest walk with an open area of real western Mashhad
built from OpenStreetMap data, starting at Park Mellat. A father and child,
guided by the Robot Market robot, find vending machines at a park, school,
universities, a metro station and a mall, on foot or by car.

- Iran province briefing map; only Mashhad is unlocked
- Third-person camera, building collision, arcade car and ambient traffic
- Minimap and full city map with addresses, opening hours and A* routing
- Purchase scoring, missions with cash rewards, ranks and an end screen
- Map data build script (OSM + Natural Earth) and new logic tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:27:48 +03:30
Hamed 8a49dc07fa Replace child character with Robot Market mascot 2026-10-22 14:36:12 +03:30
Hamed 6d4c4b7dbe Fix GitHub Pages deployment and document game URL 2026-10-21 11:06:58 +03:30
Hamed b5f3137fff update 2026-10-21 10:58:14 +03:30
Hamed f329acc61c update 2026-10-20 13:30:44 +03:30
Hamed 5857b11bd8 Add interactive picnic journey and synchronize gameplay stages 2026-10-20 12:26:14 +03:30
56 changed files with 4342 additions and 2373 deletions
+11
View File
@@ -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
}
]
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.github
.idea
.claude
*.log
+39
View File
@@ -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
+26
View File
@@ -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
}
+13
View File
@@ -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
+120 -30
View File
@@ -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 node --test tests/*.test.js
``` ```
## کنترل - `?quality=low` یا دکمهٔ «گرافیک» سایه‌ها را خاموش می‌کند (پیش‌فرض گوشی‌ها).
- `?debug` وضعیت بازی را در `window.game` در دسترس می‌گذارد.
- چپ / راست یا A / D: حرکت ## انتشار روی سرور با Docker
- بالا / پایین: زوم دوربین، بدون حرکت شخصیت
- دکمه‌های چهارجهته: نگه داشتن برای حرکت یا زوم در موبایل
- E یا دکمهٔ محصولات: باز کردن دستگاه در نزدیکی آن
- شروع دوباره: بازنشانی داستان، موجودی و زوم
گفت‌وگوها کوتاه‌اند و در کادر سفید پایین صفحه خودکار عوض می‌شوند. در طول تمام گفت‌وگوها، از جمله پل، حرکت آزاد است. هنگام خرید، تحویل محصول یا باز بودن دفترچه حرکت موقتاً متوقف می‌شود. تصویر 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/main.js`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/controls.js`: جهت حرکت و شرایط مجاز بودن حرکت؛ دیالوگ مانع حرکت نیست - `src/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها) در تکه‌های ۲ کیلومتری که دورها پنهان می‌شوند
- `src/characters.js`: شخصیت‌های مفصلی، تناسب بدن، لباس، چشم‌ها، قدم زدن و تماس دست‌ها - `src/map-render.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشی‌های کش‌شده و نمای کلی) و نقشهٔ ایران
- `src/payment.js`: مراحل پرداخت، انصراف و جلوگیری از برداشت تکراری - `src/routing.js`: گراف خیابان‌ها، مسیریابی A* و پیدا کردن نام خیابان
- `src/game-state.js`: قیمت‌های هتومان، موجودی، اعتبارسنجی کد و خرید - `src/collision.js`: برخورد با ساختمان‌ها برای شخصیت‌ها، ماشین و دوربین
- `src/story.js`: متن‌های کوتاه، فصل‌ها، انتخاب مسیر و کشف‌ها - `src/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/style.css`: چیدمان واکنش‌گرا و کادر سفید متن - `src/game-state.js`، `src/payment.js`، `src/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `tests/`: آزمون هر دو مسیر داستان، حرکت، پول و تبدیل‌های شخصیت‌ها - `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 را ندارند.
- خیابان‌ها یک‌طرفه و چراغ راهنمایی ندارند، و ترافیک فقط روی بلوارهای اصلی است.
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محله‌ها و شهرهای دیگر.
مدل‌ها هنوز هندسی و سبک هستند؛ تناسب بدن و حرکت طبیعی‌تر شده اما مدل انسانی واقع‌گرایانه یا موشن‌کپچر اضافه نشده است. پرداخت واقعی، ذخیرهٔ پیشرفت و برخورد آزاد با درخت‌ها وجود ندارد. بازیکن در مسیر تعیین‌شده حرکت می‌کند. زمان کامل تجربه هنوز با بازیکن اندازه‌گیری نشده است. نقشه: © مشارکت‌کنندگان OpenStreetMap (ODbL). مرز استان‌ها: Natural Earth.
پنجره‌های خرید، دفترچه، پایان و پرداخت پس‌زمینهٔ شیشه‌ای نیمه‌شفاف دارند. نشان تعامل به موقعیت سه‌بعدی دستگاه متصل است و با حرکت دوربین جابه‌جا می‌شود.
## تعامل تک‌دکمه‌ای
گزینه‌های چندانتخابی داستان حذف شده‌اند. هر رویداد یک کار مشخص دارد: تماشای خرگوش، برداشتن نقاشی، ادامه در مسیر آفتابی و کامل کردن نقاشی با آسمان طلایی. برای انجام آن، کلید **F** یا همان دکمهٔ روی صفحه را بزنید. متن‌های معمولی خودکار عوض می‌شوند و حرکت آزاد می‌ماند. **E** تعامل با دستگاه و **C** کشیدن کارت است. انتخاب شمارهٔ محصول و دکمهٔ پرداخت در پنجرهٔ دستگاه باقی است.
+20
View File
@@ -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:
+173 -1
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fontsource/vazirmatn": "^5.2.5",
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
@@ -457,6 +458,15 @@
"node": ">=18" "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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+2 -2
View File
@@ -1,8 +1,7 @@
{ {
"name": "game", "name": "game",
"version": "1.0.0", "version": "1.0.0",
"description": "A Persian 3D forest walk and vending-machine game", "description": "A Persian third-person 3D city game: find Robot Market vending machines across real Mashhad streets",
"main": "index.js",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "vite build", "build": "vite build",
@@ -13,6 +12,7 @@
"license": "ISC", "license": "ISC",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fontsource/vazirmatn": "^5.2.5",
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+20
View File
@@ -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"}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+207
View File
@@ -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}`);
+100 -18
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three'; import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the family group. // Lightweight articulated characters; all coordinates below are in the group.
export function createFamily() { // 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 group = new THREE.Group();
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82}); const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36'); 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()); 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) { function makePerson(isChild, z) {
const scale = isChild ? .69 : 1.05, root = new THREE.Group(); const scale = isChild ? .69 : 1.05, root = new THREE.Group();
root.position.z = z; root.position.z = z;
@@ -95,42 +170,49 @@ export function createFamily() {
return {root, torso, head, legs, arms, scale, z, eyes}; 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];
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit, dt = 1 / 60}) { // `wave` raises the robot's right hand, `point` stretches it forward.
const actors = [father, child]; 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) => { actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7); const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, shopping ? (index === 0 ? -.55 : .25) : 0, blend); actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.32 : 0, blend); // The robot hovers; it bobs faster while it is speaking.
actor.root.position.z = actor.z; 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; actor.root.rotation.y = facing;
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006; const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
actor.torso.position.y = bob; actor.torso.position.y = bob;
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0; actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
const blinking = Math.sin(time * 1.3 + index) > .993; const blinking = Math.sin(time * 1.3 + index) > .993;
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1); actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
actor.head.rotation.y = shopping ? -.65 : watchRabbit ? .55 : Math.sin(time * .7) * .06; actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
actor.torso.rotation.x = delivery && index === 1 ? .22 : 0; actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
actor.legs.forEach((leg, i) => { actor.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI; 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.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); group.updateMatrixWorld(true);
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone(); 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. // With characters facing +X, father's left and child's right hands meet.
const contact = new THREE.Vector3(0, 1.06, 0); const contact = new THREE.Vector3(0, 1.06, 0);
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => { 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); const a = shoulder(actor, side);
let target; let target;
if (inside && !shopping && !finished) target = contact.clone(); 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)); 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)); const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
updateSegment(arm.upper, a, elbow); 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}; return {group, update};
} }
+430
View File
@@ -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;
}
}
};
}
+84
View File
@@ -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;
}
-9
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+194
View File
@@ -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));
}
};
}
+43 -43
View File
@@ -1,56 +1,56 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500; 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; export const hetomanToRial = value => value * 10000;
export const formatClock = minutes => {
const m = Math.floor(minutes) % (24 * 60);
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
};
const assortment = [ export const catalog = {
{id: 'juice', code: '11', name: 'آب‌میوه', icon: '🧃', cost: 35000, type: 'drink'}, juice: {id: 'juice', name: 'آب‌میوه', icon: '🧃', cost: 35, type: 'drink'},
{id: 'water', code: '12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'}, water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
{id: 'biscuit', code: '13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'}, milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
{id: 'milk', code: '14', name: 'شیر کاکائو', icon: '🥛', cost: 40000, type: 'drink'}, tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
{id: 'tea', code: '15', name: 'چای سرد', icon: '🧋', cost: 45000, type: 'drink'}, lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
{id: 'lemonade', code: '16', name: 'لیموناد', icon: '🍋', cost: 40000, type: 'drink'}, biscuit: {id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack'},
{id: 'chocolate', code: '17', name: 'شکلات', icon: '🍫', cost: 30000, type: 'snack'}, chocolate: {id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack'},
{id: 'nuts', code: '18', name: 'آجیل', icon: '🥜', cost: 65000, type: 'snack'}, nuts: {id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack'},
{id: 'cake', code: '19', name: 'کیک', icon: '🧁', cost: 35000, type: 'snack'} cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack'}
]; };
// All 60 physical channels exist; only 21–60 are available in this scene. export const priceAt = (machine, productId) => Math.round(catalog[productId].cost * machine.priceFactor);
export const channels = Array.from({length: 60}, (_, index) => {
const channel = index + 1, base = assortment[(channel - 22 + 63) % assortment.length]; export function isOpen(machine, minutes) {
return { const m = minutes % (24 * 60);
...base, return m >= machine.hours[0] && m < machine.hours[1];
baseId: base.id, }
id: `channel-${channel}`,
code: String(channel),
channel,
cost: base.cost / 1000,
available: channel >= 21
};
});
export const products = channels.filter(channel => channel.available);
export function createState() { 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) { // Returns why a purchase cannot happen, or null when it can.
return state.basket.some(p => p.type === 'drink') && state.basket.some(p => p.type === 'snack'); 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) { export function purchase(state, machine, productId) {
const product = products.find(p => p.id === id); if (purchaseBlocker(state, machine, productId)) return null;
if (!product || state.busy || state.finished || state.balance < product.cost) return null; const product = catalog[productId], price = priceAt(machine, productId);
// Keep enough money for the missing picnic category, so the quest stays completable. const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes};
const next = [...state.basket, product]; state.balance -= price;
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); state.purchases.push(record);
if (state.balance - product.cost < reserve) return null; return record;
state.balance -= product.cost;
state.basket.push(product);
state.busy = true;
return product;
} }
export function productByCode(value) { // The run ends once no open or later-opening machine sells anything the player can afford.
const normalized = String(value).trim().replace(/[۰-۹]/g, c => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g, c => String('٠١٢٣٤٥٦٧٨٩'.indexOf(c))); export function canAffordAnything(state, machines) {
return products.find(p => p.code === normalized) ?? null; return machines.some(m => m.products.some(id => priceAt(m, id) <= state.balance) && (state.minutes % 1440) < m.hours[1]);
} }
+22
View File
@@ -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')} کیلومتر`;
}
+91
View File
@@ -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;
+1177 -760
View File
File diff suppressed because it is too large Load Diff
+367
View File
@@ -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);
}
}
+18 -14
View File
@@ -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() { export function createPayment() {
return {phase: 'idle', product: null}; return {phase: 'idle', machine: null, productId: null, record: null};
} }
export function beginPayment(state, payment, product) { export function beginPayment(state, payment, machine, productId) {
if (payment.phase !== 'idle' || !product || !purchase({ if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
...state, Object.assign(payment, {phase: 'approaching', machine, productId, record: null});
basket: [...state.basket]
}, product.id)) return false;
payment.product = product;
payment.phase = 'approaching';
state.busy = true; state.busy = true;
return true; return true;
} }
export function readyPayment(payment) {
if (payment.phase !== 'approaching') return false;
payment.phase = 'ready';
return true;
}
export function swipePayment(state, payment) { export function swipePayment(state, payment) {
if (payment.phase !== 'ready') return false; if (payment.phase !== 'ready') return false;
state.busy = false; state.busy = false;
if (!purchase(state, payment.product.id)) { const record = purchase(state, payment.machine, payment.productId);
payment.phase = 'idle'; if (!record) {
payment.product = null; Object.assign(payment, createPayment());
return false; return false;
} }
state.busy = true;
payment.record = record;
payment.phase = 'approved'; payment.phase = 'approved';
return true; return true;
} }
export function cancelPayment(state, payment) { export function cancelPayment(state, payment) {
if (!['approaching', 'ready'].includes(payment.phase)) return false; if (!['approaching', 'ready'].includes(payment.phase)) return false;
payment.phase = 'idle'; Object.assign(payment, createPayment());
payment.product = null;
state.busy = false; state.busy = false;
return true; return true;
} }
+49
View File
@@ -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();
});
}
+60
View File
@@ -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.
}
}
+176
View File
@@ -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;
}
+82
View File
@@ -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;
-74
View File
@@ -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;
}
+275 -1346
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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;
}
+145
View File
@@ -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};
}
+23
View File
@@ -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);
+98
View File
@@ -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)
};
}
-31
View File
@@ -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);
});
+43 -11
View File
@@ -1,15 +1,47 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {createState,productByCode} from '../src/game-state.js'; import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js';
import {createPayment,beginPayment,swipePayment,cancelPayment} from '../src/payment.js'; import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js';
test('Payment charges once, only after ready and explicit swipe',()=>{ import {machineById, machines} from '../src/locations.js';
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); const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
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); test('Payment charges once, only after ready and an explicit swipe', () => {
assert.equal(swipePayment(s,p),false);assert.equal(cancelPayment(s,p),false); 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); test('Cancel, closed machines, unknown products and low funds never charge', () => {
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); 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));
}); });
+24
View File
@@ -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);
});
+41
View File
@@ -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');
});
-25
View File
@@ -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));}
});
+68
View File
@@ -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');
}
});
+56
View File
@@ -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',
],
},
});