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>
This commit is contained in:
2026-10-25 11:27:48 +03:30
co-authored by Claude Opus 5.5
parent 8a49dc07fa
commit 95a2ae851d
31 changed files with 3131 additions and 2770 deletions
+60 -43
View File
@@ -1,14 +1,46 @@
# تپهٔ آرزوها — روبات مارکت
# عملیات مشهد — روبات مارکت
بازی سه‌بعدی JavaScript با Three.js: پدر و بچه در جنگل راه می‌روند، از دستگاه خرید می‌کنند و به پیک‌نیک می‌رسند. مدل‌ها با کد ساخته شده‌اند. شخصیت کودک با روبات فیروزه‌ای و سفید Robot Market، الهام‌گرفته از تصویر مرجع، جایگزین شده است؛ پدر همراه او باقی می‌ماند. روبات دارای صورت نورانی، آنتن و انیمیشن راه‌رفتن، دریافت محصول و نشستن است.
بازی سه‌بعدی مرورگری (Three.js) با حال‌وهوای بازی‌های اکشن سوم‌شخص، بدون سلاح و خشونت. یک پدر و فرزندش، همراه روبات راهنمای روبات مارکت، در خیابان‌های واقعی مشهد می‌گردند، دستگاه‌های وندینگ روبات مارکت را پیدا می‌کنند و با هر خرید امتیاز می‌گیرند.
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
## انتشار در GitHub Pages
## داستان و هدف
در تنظیمات مخزن، بخش **Settings → Pages → Build and deployment**، گزینهٔ **Source** را روی **GitHub Actions** قرار دهید. انتشار مستقیم از `main / (root)` مناسب این پروژه نیست: فایل‌های سورس باید ابتدا با Vite ساخته شوند تا JavaScript، استایل‌ها و وابستگی‌ها برای مرورگر آماده شوند.
- **شروع:** ورودی جنوبی بوستان ملت، بزرگراه وکیل‌آباد
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند.
- **محدودهٔ قابل بازی:** حدود ۴٫۸ در ۳٫۳ کیلومتر از غرب مشهد، شامل بوستان ملت، دانشگاه فردوسی، دانشگاه علوم پزشکی، ایستگاه‌های مترو و مشهد مال. خیابان‌ها، ساختمان‌ها و پارک‌ها از داده‌های واقعی OpenStreetMap ساخته شده‌اند.
- **دستگاه‌ها:** بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاه‌ها و محصولاتشان فرضی‌اند.
گردش‌کار `.github/workflows/pages.yml` با هر push به `main` تست‌ها و build را اجرا می‌کند و فقط خروجی `dist` را منتشر می‌کند. پس از تغییر Source، تغییرات را به `main` بفرستید یا از بخش **Actions** گردش‌کار **Deploy game to GitHub Pages** را دستی اجرا کنید. تنظیم `base: './'` در Vite مسیر فایل‌های خروجی را با زیرمسیر مخزن سازگار نگه می‌دارد.
## کنترل
| کار | صفحه‌کلید | موبایل |
|---|---|---|
| حرکت / رانندگی | W A S D یا جهت‌ها | جوی‌استیک |
| دویدن | Shift | دکمهٔ «دویدن» |
| چرخاندن دوربین | ماوس (با کلیک قفل می‌شود) یا کشیدن | کشیدن روی صفحه |
| زوم دوربین | چرخ ماوس | — |
| سوار و پیاده شدن از ماشین | F | دکمهٔ F |
| خرید از دستگاه | E | دکمهٔ E |
| کشیدن کارت | C | دکمهٔ E |
| ترمز دستی | Space | — |
| نقشهٔ شهر | M یا کلیک روی نقشهٔ گوشه | لمس نقشه |
کنترل‌ها با صفحه‌کلید فارسی هم کار می‌کنند. دوربین فقط سوم‌شخص است.
## نقشه و مسیریابی
با کلیک روی نقشهٔ گرد گوشهٔ صفحه، نقشهٔ کامل شهر باز می‌شود. فهرست دستگاه‌ها با آدرس، ساعت کاری، باز یا بسته بودن، محصولات و فاصله نمایش داده می‌شود. «مسیریابی تا اینجا» مسیر را روی شبکهٔ واقعی خیابان‌ها پیدا می‌کند (پیاده روی همهٔ مسیرها، با ماشین فقط روی خیابان‌های سواره‌رو). مسیر به شکل خط طلایی روی زمین، روی نقشهٔ گوشه و روی قطب‌نمای بالای صفحه نمایش داده می‌شود. بدون انتخاب دستی، هدف مأموریت فعلی مقصد است.
## امتیاز
| رویداد | امتیاز |
|---|---|
| هر خرید | ۱۰۰ |
| اولین خرید در هر مکان (کشف) | ۲۵۰ |
| خرید نوشیدنی و خوراکی از یک دستگاه | ۱۵۰ |
| خرید از هر پنج نوع مکان | ۵۰۰ |
شش مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجه‌ها از «تازه‌وارد» تا «افسانهٔ مشهد» بالا می‌روند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریت‌ها انجام شود، عملیات تمام می‌شود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
## اجرا
@@ -19,51 +51,36 @@ npm run build
node --test tests/*.test.js
```
## کنترل
- `?quality=low` یا دکمهٔ «گرافیک» سایه‌ها را خاموش می‌کند (پیش‌فرض گوشی‌ها).
- `?debug` وضعیت بازی را در `window.game` در دسترس می‌گذارد.
- چپ / راست یا A / D: حرکت
- بالا / پایین: زوم دوربین، بدون حرکت شخصیت
- دکمه‌های چهارجهته: نگه داشتن برای حرکت یا زوم در موبایل
- E یا دکمهٔ محصولات: باز کردن دستگاه در نزدیکی آن
- شروع دوباره: بازنشانی داستان، موجودی و زوم
## ساخت دوبارهٔ دادهٔ نقشه
گفت‌وگوها کوتاه‌اند و در کادر سفید پایین صفحه خودکار عوض می‌شوند. هنگام گفت‌وگوها، خرید، تحویل محصول یا باز بودن دفترچه حرکت موقتاً متوقف می‌شود.
`src/data/mellat.json` و `src/data/iran.json` از خروجی خام OpenStreetMap (Overpass API) و Natural Earth ساخته می‌شوند. کوئری‌ها در ابتدای اسکریپت آمده‌اند:
## دستگاه ۶۰ کانال و خرید با هتومان
```sh
node scripts/build-map-data.mjs city-osm.json poi-osm.json ne_10m_admin_1_states_provinces.geojson
```
واحد بازی «هتومان» است: **۱۰ هتومان = ۱۰۰٬۰۰۰ ریال**، یعنی هر هتومان ۱۰٬۰۰۰ ریال. موجودی اولیه ۵۰۰ هتومان است. همهٔ اعداد و قیمت‌ها نمایشی‌اند و پرداخت واقعی ندارند.
دستگاه شش ردیف ده‌تایی با شماره‌های ۱ تا ۶۰ دارد. فقط کانال‌های **۲۱ تا ۶۰** قابل خریدند. با نزدیک شدن به دستگاه، نشان E / تعامل روی کارت‌خوان نمایش داده می‌شود. با E یا لمس نشان، پنجرهٔ خرید باز می‌شود: فقط شماره‌های ۲۱ تا ۶۰ سمت راست و کد، محصول فرضی و قیمت کانال‌های قابل خرید سمت چپ قرار دارند. کلیک روی شماره یا محصول، کد را پر می‌کند؛ «پرداخت» یا Enter مرحلهٔ کارت‌خوان را باز می‌کند. دوربین به کارت‌خوان نزدیک می‌شود؛ سپس با دکمهٔ «کشیدن کارت» یا کلید C مبلغ کسر می‌شود، پیام خرید موفق نمایش داده می‌شود و محصول تحویل داده می‌شود. پیش از کارت کشیدن امکان انصراف بدون کسر موجودی وجود دارد. کدهای فارسی، عربی و انگلیسی پشتیبانی می‌شوند. کانال نامعتبر پول کم نمی‌کند. توضیح تبدیل واحد هم کنار دستگاه و هم داخل پنجره نمایش داده می‌شود.
محصولات فرضی بین کانال‌ها تکرار می‌شوند: آب‌میوه ۳۵، آب ۱۵، بیسکویت ۲۵، شیرکاکائو ۴۰، چای سرد ۴۵، لیموناد ۴۰، شکلات ۳۰، آجیل ۶۵ و کیک ۳۵ هتومان. مسیر تحویل از کانال انتخاب‌شده شروع می‌شود. برای تکمیل مأموریت، هزینهٔ حداقل یک نوشیدنی و یک خوراکی کنار گذاشته می‌شود.
برای اضافه کردن محدودهٔ جدید، bbox اسکریپت و `ORIGIN` در `src/geo.js` را تغییر دهید.
## ساختار
- `src/main.js`: صحنه، دوربین، دستگاه، رابط و حلقهٔ بازی
- `src/controls.js`: جهت حرکت و شرایط مجاز بودن حرکت؛ دیالوگ حرکت را موقتاً متوقف می‌کند
- `src/characters.js`: شخصیت‌های مفصلی، تناسب بدن، لباس، چشم‌ها، قدم زدن و تماس دست‌ها
- `src/payment.js`: مراحل پرداخت، انصراف و جلوگیری از برداشت تکراری
- `src/game-state.js`: قیمت‌های هتومان، موجودی، اعتبارسنجی کد و خرید
- `src/story.js`: متن‌های کوتاه، فصل‌ها، انتخاب مسیر و کشف‌ها
- `src/style.css`: چیدمان واکنش‌گرا و کادر سفید متن
- `tests/`: آزمون مسیر داستان، حرکت، پول و تبدیل‌های شخصیت‌ها
- `src/main.js`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها)
- `src/map-render.js`: نقشهٔ گوشه، نقشهٔ کامل شهر و نقشهٔ ایران
- `src/routing.js`: گراف خیابان‌ها، مسیریابی A* و پیدا کردن نام خیابان
- `src/collision.js`: برخورد با ساختمان‌ها برای شخصیت‌ها، ماشین و دوربین
- `src/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/game-state.js`، `src/payment.js`، `src/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `src/characters.js`: پدر، فرزند و روبات راهنما
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
قیمت‌ها و موجودی فقط در ماژول game-state تعریف شده‌اند. محاسبات با هتومان صحیح انجام می‌شود؛ رابط با ارقام فارسی نمایش می‌دهد.
## محدودیت‌ها و گام‌های بعدی
## محدودیت‌ها
- مدل‌ها هندسی و سبک‌اند و کیفیت گرافیکی بازی‌های AAA را ندارند.
- خیابان‌ها یک‌طرفه و چراغ راهنمایی ندارند، و ترافیک فقط روی بلوارهای اصلی است.
- پیشنهادهای بعدی: امتیاز ایمنی (خط عابر و چراغ قرمز)، تاکسی و قطار شهری، چرخهٔ روز و شب، ذخیرهٔ پیشرفت، جدول امتیاز آنلاین، و محله‌ها و شهرهای دیگر.
مدل‌ها هنوز هندسی و سبک هستند؛ تناسب بدن و حرکت طبیعی‌تر شده اما مدل انسانی واقع‌گرایانه یا موشن‌کپچر اضافه نشده است. پرداخت واقعی، ذخیرهٔ پیشرفت و برخورد آزاد با درخت‌ها وجود ندارد. بازیکن در مسیر تعیین‌شده حرکت می‌کند. زمان کامل تجربه هنوز با بازیکن اندازه‌گیری نشده است.
پنجره‌های خرید، دفترچه، پایان و پرداخت پس‌زمینهٔ شیشه‌ای نیمه‌شفاف دارند. نشان تعامل به موقعیت سه‌بعدی دستگاه متصل است و با حرکت دوربین جابه‌جا می‌شود.
## تعامل تک‌دکمه‌ای
گزینه‌های چندانتخابی داستان حذف شده‌اند. هر رویداد یک کار مشخص دارد: تماشای خرگوش، برداشتن نقاشی، ادامه در مسیر آفتابی و کامل کردن نقاشی با آسمان طلایی. برای انجام آن، کلید **F** یا همان دکمهٔ روی صفحه را بزنید. متن‌های معمولی خودکار عوض می‌شوند و پس از پایان گفت‌وگو حرکت دوباره آزاد می‌شود. **E** تعامل با دستگاه و **C** کشیدن کارت است. انتخاب شمارهٔ محصول و دکمهٔ پرداخت در پنجرهٔ دستگاه باقی است.
## سفر از خانه به کوه
بازی با خروج خانواده از خانه شروع می‌شود. با F یا دکمهٔ روی صفحه سوار ماشین شوید؛ با W یا ↑ گاز بدهید، با S یا ↓ ترمز کنید و با A/D فرمان بدهید؛ پس از رسیدن به ورودی جنگل، در ورودی جنگل دوباره F را بزنید تا پیاده شوید. در مسیر جنگل، جمعی از دخترها و پسرها کنار آتش می‌رقصند و خانواده آدرس دستگاه وندینگ را از آن‌ها می‌پرسد. پس از خرید نوشیدنی و خوراکی، مسیر را تا پیک‌نیک بالای کوه ادامه دهید.
دوربین پیش‌فرض سوم‌شخص است؛ با V یا دکمهٔ تغییر دوربین بین نمای اول‌شخص و سوم‌شخص جابه‌جا شوید. برای نگاه به اطراف در نمای اول‌شخص یا چرخاندن دوربین در نمای سوم‌شخص روی صحنه بکشید؛ هنگام تمرکز خودکار روی رویدادها و دستگاه، نمای دوربین ثابت است. W/S یا چپ/راست حرکت در مسیر و Shift دویدن است. این نسخه از مدل‌های هندسی استفاده می‌کند و کیفیت گرافیکی یک بازی AAA ندارد.
گفت‌وگوی هر رویداد هنگام رسیدن به همان نقطه نمایش داده می‌شود و تا پایان آن حرکت متوقف می‌ماند. دوربین نزدیک دستگاه خودکار روی آن تمرکز می‌کند. خانواده پس از رسیدن به قله روی فرش می‌نشینند.
نقشه: © مشارکت‌کنندگان OpenStreetMap (ODbL). مرز استان‌ها: Natural Earth.
+134 -1
View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "game",
"version": "1.0.0",
"description": "A Persian 3D forest walk and vending-machine game",
"main": "index.js",
"description": "A Persian third-person 3D city game: find Robot Market vending machines across real Mashhad streets",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
+163
View File
@@ -0,0 +1,163 @@
// Converts raw OpenStreetMap and Natural Earth exports into the compact files in src/data.
//
// Usage:
// node scripts/build-map-data.mjs <city-osm.json> <poi-osm.json> <ne_10m_admin_1_states_provinces.geojson>
//
// The OSM files are Overpass API responses (send a User-Agent header):
// city: [out:json][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"];);out geom;
// 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 [cityPath, poiPath, provincesPath] = process.argv.slice(2);
if (!cityPath || !poiPath || !provincesPath) {
console.error('usage: node scripts/build-map-data.mjs <city-osm.json> <poi-osm.json> <provinces.geojson>');
process.exit(1);
}
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
const city = JSON.parse(readFileSync(cityPath, 'utf8')).elements;
const poi = JSON.parse(readFileSync(poiPath, 'utf8')).elements;
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 = [];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus'];
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' : 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 ?? {};
if (e.type === 'way' && t.highway && roadTypes.includes(t.highway.replace('_link', '')) && e.geometry?.length > 1) {
roads.push([roadTypes.indexOf(t.highway.replace('_link', '')), nameId(t.name), ...flat(e.geometry)]);
} 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')) {
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.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)));
const corner = (lat, lon) => roundPoint(toLocal(lat, lon));
const min = corner(BBOX.maxLat, BBOX.minLon), max = corner(BBOX.minLat, BBOX.maxLon);
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,
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}`);
+20 -14
View File
@@ -33,7 +33,7 @@ export function createFamily() {
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
}
// Procedural Robot Market mascot: the child-sized playable companion.
// 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';
@@ -169,15 +169,20 @@ export function createFamily() {
return {root, torso, head, legs, arms, scale, z, eyes};
}
const father = makePerson(false, -.45), child = makeRobot(.45);
const father = makePerson(false, -.45), child = makePerson(true, .45), robot = makeRobot(1.45);
robot.root.scale.setScalar(.7);
function update({time, gait, moving, facing, shopping, delivery, finished, watchRabbit, dt = 1 / 60}) {
const actors = [father, child];
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, dt = 1 / 60}) {
const actors = [father, child, robot];
actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7);
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping ? (index === 0 ? -.55 : .25) : 0, blend);
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : 0, blend);
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
const restX = isRobot ? -.7 : 0;
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping ? [-.55, .25, -.9][index] : restX, blend);
// The robot hovers; it bobs faster while it is speaking.
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
actor.root.position.z = finished ? 0 : actor.z;
actor.root.rotation.y = facing;
const bob = moving ? Math.abs(Math.sin(gait)) * .022 : Math.sin(time * 2) * .006;
@@ -185,22 +190,23 @@ export function createFamily() {
actor.torso.rotation.z = moving ? Math.sin(gait) * .018 : 0;
const blinking = Math.sin(time * 1.3 + index) > .993;
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
actor.head.rotation.y = shopping ? -.65 : watchRabbit ? .55 : Math.sin(time * .7) * .06;
actor.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.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI;
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * .34 : 0;
const stride = isRobot ? .08 : .34;
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
leg.foot.rotation.x = moving ? Math.sin(phase + .5) * .12 : 0;
leg.knee.rotation.x = finished ? 2.5 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
leg.knee.rotation.x = finished ? 2.5 : isRobot ? .25 : moving ? Math.max(0, -Math.sin(phase)) * .5 : .05;
});
});
group.updateMatrixWorld(true);
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, 1.43 * actor.scale, 0));
const shoulder = (actor, side) => toFamily(actor, new THREE.Vector3(side * .29 * actor.scale, (actor === robot ? 1.12 : 1.43) * actor.scale, 0));
// With characters facing +X, father's left and child's right hands meet.
const contact = new THREE.Vector3(0, 1.06, 0);
actors.forEach((actor, index) => actor.arms.forEach((arm, i) => {
const side = i === 0 ? -1 : 1, inside = index === 0 ? side === -1 : side === 1;
const side = i === 0 ? -1 : 1, inside = index === 0 ? side === -1 : index === 1 && side === 1;
const a = shoulder(actor, side);
let target;
if (inside && !shopping && !finished) target = contact.clone();
@@ -213,6 +219,6 @@ export function createFamily() {
}));
}
update({time: 0, gait: 0, moving: false, facing: Math.PI / 2});
return {group, update};
update({time: 0, gait: 0, moving: false});
return {group, update, robot: robot.root};
}
+388
View File
@@ -0,0 +1,388 @@
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 = {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'};
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;
}
export function createCity(scene, data, {collision, segments}) {
const tex = textures(), group = new THREE.Group();
scene.add(group);
const random = seeded(1405);
const {minX, minZ, maxX, maxZ} = data.bounds;
// Dusty urban ground, and a darker desert beyond the mapped area.
const desert = new THREE.Mesh(new THREE.PlaneGeometry(9000, 9000), groundMaterial({color: '#b8a888'}, 0));
desert.rotation.x = -Math.PI / 2;
desert.position.y = -.02;
desert.receiveShadow = true;
group.add(desert);
const urban = {pos: [], idx: []};
flatPolygon(urban, [minX, minZ, maxX, minZ, maxX, maxZ, minX, maxZ], 0);
group.add(toMesh(urban, groundMaterial({color: '#c8bea8'}, 0)));
const areaBuffers = {};
for (const area of data.areas) {
const kind = data.areaKinds[area[0]];
areaBuffers[kind] ??= {pos: [], idx: []};
flatPolygon(areaBuffers[kind], area.slice(1), LAYER[kind][0]);
}
for (const [kind, out] of Object.entries(areaBuffers)) group.add(toMesh(out, groundMaterial({color: AREA_COLOR[kind], roughness: kind === 'water' ? .25 : .95, metalness: kind === 'water' ? .2 : 0}, LAYER[kind][1])));
const roadBuffers = {};
const buffer = name => roadBuffers[name] ??= {pos: [], uv: [], idx: []};
for (const road of data.roads) {
const type = data.roadTypes[road[0]], bucket = BUCKET[type], pts = road.slice(2), width = WIDTH[type];
ribbon(buffer(bucket), pts, width, LAYER[bucket][0], bucket === 'major' ? 9 : 8);
if (road[0] <= DRIVABLE_MAX_TYPE) {
if (type !== 'service') ribbon(buffer('sidewalk'), pts, width + 4, LAYER.sidewalk[0], 6);
disc(buffer(bucket), pts[0], pts[1], width / 2, LAYER[bucket][0]);
disc(buffer(bucket), pts.at(-2), pts.at(-1), width / 2, LAYER[bucket][0]);
}
}
for (const [bucket, out] of Object.entries(roadBuffers)) group.add(toMesh(out, groundMaterial({map: tex[bucket]}, LAYER[bucket][1])));
// Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs.
const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []};
const tanks = [], units = [];
for (const building of data.buildings) {
const height = building[0];
let pts = building.slice(1);
if (signedArea(pts) > 0) {
const reversed = [];
for (let i = pts.length - 2; i >= 0; i -= 2) reversed.push(pts[i], pts[i + 1]);
pts = reversed;
}
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, cz + (random() - .5) * 2, random() * Math.PI]);
}
}
const wallMesh = toMesh(walls, new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85}), 'compute');
wallMesh.castShadow = true;
group.add(wallMesh);
const roofMesh = toMesh(roofs, new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9}));
roofMesh.castShadow = true;
group.add(roofMesh);
const dummy = new THREE.Object3D();
const instanced = (geometry, material, items, place) => {
const mesh = new THREE.InstancedMesh(geometry, material, Math.max(1, items.length));
mesh.count = items.length;
items.forEach((item, i) => {
place(item);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
};
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, z, r]) => {
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, tz, .8 + random() * .6]);
}
}
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, tz, .7 + random() * .4]);
}
}
}
trees.length = Math.min(trees.length, 7000);
instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, z, s]) => {
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, z, s]) => {
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));
trees.forEach((_, i) => crowns.setColorAt(i, crownColors[i % 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);
group.add(new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide})));
return {group};
}
+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, transactional UI and delivery pause walking.
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, chapterId = null}) {
return !shopOpen && !notebookOpen && !endingOpen && !busy && !finished && !chapterId;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44 -44
View File
@@ -1,56 +1,56 @@
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const formatHetoman = value => `${value.toLocaleString('fa-IR')} هتومان`;
export const START_MINUTES = 8 * 60 + 30;
export const fa = n => n.toLocaleString('fa-IR');
export const formatHetoman = value => `${fa(value)} هتومان`;
export const hetomanToRial = value => value * 10000;
const assortment = [
{id: 'juice', code: '11', name: 'آب‌میوه', icon: '🧃', cost: 35000, type: 'drink'},
{id: 'water', code: '12', name: 'آب معدنی', icon: '💧', cost: 15000, type: 'drink'},
{id: 'biscuit', code: '13', name: 'بیسکویت', icon: '🍪', cost: 25000, type: 'snack'},
{id: 'milk', code: '14', name: 'شیر کاکائو', icon: '🥛', cost: 40000, type: 'drink'},
{id: 'tea', code: '15', name: 'چای سرد', icon: '🧋', cost: 45000, type: 'drink'},
{id: 'lemonade', code: '16', name: 'لیموناد', icon: '🍋', cost: 40000, type: 'drink'},
{id: 'chocolate', code: '17', name: 'شکلات', icon: '🍫', cost: 30000, type: 'snack'},
{id: 'nuts', code: '18', name: 'آجیل', icon: '🥜', cost: 65000, type: 'snack'},
{id: 'cake', code: '19', name: 'کیک', icon: '🧁', cost: 35000, type: 'snack'}
];
// All 60 physical channels exist; only 21–60 are available in this scene.
export const channels = Array.from({length: 60}, (_, index) => {
const channel = index + 1, base = assortment[(channel - 22 + 63) % assortment.length];
return {
...base,
baseId: base.id,
id: `channel-${channel}`,
code: String(channel),
channel,
cost: base.cost / 1000,
available: channel >= 21
export const formatClock = minutes => {
const m = Math.floor(minutes) % (24 * 60);
return `${fa(Math.floor(m / 60)).padStart(2, '۰')}:${fa(m % 60).padStart(2, '۰')}`;
};
});
export const products = channels.filter(channel => channel.available);
export const catalog = {
juice: {id: 'juice', name: 'آب‌میوه', icon: '🧃', cost: 35, type: 'drink'},
water: {id: 'water', name: 'آب معدنی', icon: '💧', cost: 15, type: 'drink'},
milk: {id: 'milk', name: 'شیر کاکائو', icon: '🥛', cost: 40, type: 'drink'},
tea: {id: 'tea', name: 'چای سرد', icon: '🧋', cost: 45, type: 'drink'},
lemonade: {id: 'lemonade', name: 'لیموناد', icon: '🍋', cost: 40, type: 'drink'},
biscuit: {id: 'biscuit', name: 'بیسکویت', icon: '🍪', cost: 25, type: 'snack'},
chocolate: {id: 'chocolate', name: 'شکلات', icon: '🍫', cost: 30, type: 'snack'},
nuts: {id: 'nuts', name: 'آجیل', icon: '🥜', cost: 65, type: 'snack'},
cake: {id: 'cake', name: 'کیک', icon: '🧁', cost: 35, type: 'snack'}
};
export const priceAt = (machine, productId) => Math.round(catalog[productId].cost * machine.priceFactor);
export function isOpen(machine, minutes) {
const m = minutes % (24 * 60);
return m >= machine.hours[0] && m < machine.hours[1];
}
export function createState() {
return {balance: INITIAL_BALANCE, basket: [], busy: false, finished: false, recycled: false};
return {balance: INITIAL_BALANCE, purchases: [], xp: 0, missionsDone: [], minutes: START_MINUTES, busy: false};
}
export function readyForPicnic(state) {
return state.basket.some(p => p.type === 'drink') && state.basket.some(p => p.type === 'snack');
// Returns why a purchase cannot happen, or null when it can.
export function purchaseBlocker(state, machine, productId) {
if (!machine || !machine.products.includes(productId)) return 'unknown';
if (state.busy) return 'busy';
if (!isOpen(machine, state.minutes)) return 'closed';
if (state.balance < priceAt(machine, productId)) return 'funds';
return null;
}
export function purchase(state, id) {
const product = products.find(p => p.id === id);
if (!product || state.busy || state.finished || state.balance < product.cost) return null;
// Keep enough money for the missing picnic category, so the quest stays completable.
const next = [...state.basket, product];
const reserve = ['drink', 'snack'].reduce((total, type) => total + (next.some(p => p.type === type) ? 0 : Math.min(...products.filter(p => p.type === type).map(p => p.cost))), 0);
if (state.balance - product.cost < reserve) return null;
state.balance -= product.cost;
state.basket.push(product);
state.busy = true;
return product;
export function purchase(state, machine, productId) {
if (purchaseBlocker(state, machine, productId)) return null;
const product = catalog[productId], price = priceAt(machine, productId);
const record = {machineId: machine.id, category: machine.category, productId, type: product.type, price, minutes: state.minutes};
state.balance -= price;
state.purchases.push(record);
return record;
}
export function productByCode(value) {
const normalized = String(value).trim().replace(/[۰-۹]/g, c => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g, c => String('٠١٢٣٤٥٦٧٨٩'.indexOf(c)));
return products.find(p => p.code === normalized) ?? null;
// The run ends once no open or later-opening machine sells anything the player can afford.
export function canAffordAnything(state, machines) {
return machines.some(m => m.products.some(id => priceAt(m, id) <= state.balance) && (state.minutes % 1440) < m.hours[1]);
}
+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')} کیلومتر`;
}
-40
View File
@@ -1,40 +0,0 @@
export function createJourney() {
return {phase: 'home', time: 0, distance: 0, speed: 0, lane: 0};
}
export function advanceJourney(journey) {
const next = {home: 'boarding', entrance: 'walking'}[journey.phase];
if (!next) return false;
journey.phase = next;
journey.time = 0;
return true;
}
export function tickJourney(journey, dt, paused = false, input = {}) {
if (paused) return;
journey.time += dt;
if (journey.phase === 'boarding' && journey.time >= 2) {
journey.phase = 'driving';
journey.time = 0;
return;
}
if (journey.phase === 'driving') {
const throttle = Math.max(-1, Math.min(1, input.throttle ?? 0));
journey.speed = Math.max(0, Math.min(6, journey.speed + (throttle > 0 ? 3.5 : throttle < 0 ? -7 : -2.5) * dt));
journey.distance = Math.min(21, journey.distance + journey.speed * dt);
journey.lane = Math.max(-.75, Math.min(.75, journey.lane + (input.steer ?? 0) * dt * journey.speed * .25));
if (journey.distance >= 21) {
journey.phase = 'entrance';
journey.time = 0;
journey.speed = 0;
journey.lane = 0;
}
}
}
export const journeyLabels = {
home: ['از خانه تا کوه', 'پدر: همه‌چی آماده‌ست؟ بیا سوار ماشین بشیم.', 'سوار ماشین شو · F'],
boarding: ['آغاز سفر', 'داریم سوار ماشین می‌شیم…', ''],
driving: ['جادهٔ جنگلی', 'با W یا ↑ گاز بده، با S یا ↓ ترمز کن و با A / D فرمان بده؛ تا ورودی جنگل رانندگی کن.', ''],
entrance: ['ورودی جنگل', 'پدر: رسیدیم. از اینجا پیاده می‌ریم؛ بالای کوه پیک‌نیک می‌کنیم.', 'پیاده شو و وارد جنگل شو · F']
};
+73
View File
@@ -0,0 +1,73 @@
// 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 = {
park: {label: 'پارک', icon: '🌳'},
university: {label: 'دانشگاه', icon: '🎓'},
school: {label: 'مدرسه', icon: '🏫'},
metro: {label: 'ایستگاه مترو', icon: 'Ⓜ'},
mall: {label: 'مرکز خرید', icon: '🛍'}
};
const ALL_DAY = [7 * 60, 23 * 60];
export const machines = [
{
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: 'mellat-park', carAnchor: {x: -445.8, z: -275.7}};
export const machineById = id => machines.find(m => m.id === id) ?? null;
+983 -926
View File
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
// Tactical 2D maps drawn from the same OSM data as the 3D city.
const SCALE = .75; // raster pixels per metre
const AREA = {campus: '#28322c', park: '#2c4a32', grass: '#30503a', forest: '#27432e', pitch: '#33603c', water: '#2b5b6e'};
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';
export function createMapAssets(data) {
const {minX, minZ, maxX, maxZ} = data.bounds;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil((maxX - minX) * SCALE);
canvas.height = Math.ceil((maxZ - minZ) * SCALE);
const c = canvas.getContext('2d');
c.fillStyle = '#1b231f';
c.fillRect(0, 0, canvas.width, canvas.height);
c.setTransform(SCALE, 0, 0, SCALE, -minX * SCALE, -minZ * SCALE);
const path = 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]);
};
for (const area of data.areas) {
c.fillStyle = AREA[data.areaKinds[area[0]]];
path(area.slice(1));
c.fill();
}
c.lineCap = c.lineJoin = 'round';
// Minor roads first so boulevards draw on top.
for (const road of [...data.roads].sort((a, b) => b[0] - a[0])) {
c.strokeStyle = roadColor(road[0]);
c.lineWidth = Math.max(1.6, ROAD_WIDTH[road[0]]);
path(road.slice(2));
c.stroke();
}
c.fillStyle = '#3c4943';
for (const b of data.buildings) {
path(b.slice(1));
c.fill();
}
// 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 {canvas, bounds: data.bounds, labels: data.labels, streets: [...streets.values()].filter(s => s.len > 150)};
}
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 / SCALE, k / SCALE);
ctx.drawImage(assets.canvas, -(x - assets.bounds.minX) * SCALE, -(z - assets.bounds.minZ) * SCALE);
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);
const [ox, oy] = toScreen(assets.bounds.minX, assets.bounds.minZ);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(assets.canvas, ox, oy, assets.canvas.width / SCALE / view.mpp, assets.canvas.height / SCALE / view.mpp);
// 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`;
ctx.fillStyle = {park: '#9fd49a', university: '#c9b6f2', school: '#e8c79a', metro: '#ff8a80', mall: '#f5b8d9'}[l.kind];
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() {
return {phase: 'idle', product: null};
return {phase: 'idle', machine: null, productId: null, record: null};
}
export function beginPayment(state, payment, product) {
if (payment.phase !== 'idle' || !product || !purchase({
...state,
basket: [...state.basket]
}, product.id)) return false;
payment.product = product;
payment.phase = 'approaching';
export function beginPayment(state, payment, machine, productId) {
if (payment.phase !== 'idle' || purchaseBlocker(state, machine, productId)) return false;
Object.assign(payment, {phase: 'approaching', machine, productId, record: null});
state.busy = true;
return true;
}
export function readyPayment(payment) {
if (payment.phase !== 'approaching') return false;
payment.phase = 'ready';
return true;
}
export function swipePayment(state, payment) {
if (payment.phase !== 'ready') return false;
state.busy = false;
if (!purchase(state, payment.product.id)) {
payment.phase = 'idle';
payment.product = null;
const record = purchase(state, payment.machine, payment.productId);
if (!record) {
Object.assign(payment, createPayment());
return false;
}
state.busy = true;
payment.record = record;
payment.phase = 'approved';
return true;
}
export function cancelPayment(state, payment) {
if (!['approaching', 'ready'].includes(payment.phase)) return false;
payment.phase = 'idle';
payment.product = null;
Object.assign(payment, createPayment());
state.busy = false;
return true;
}
+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;
}
-127
View File
@@ -1,127 +0,0 @@
import * as THREE from 'three';
export function createJourneyScenery(scene, pathZ) {
const mat = c => new THREE.MeshStandardMaterial({color: c, roughness: .7});
const wood = mat('#705340'), stone = mat('#a9a393'), black = mat('#172126'), skin = mat('#dca57e');
function part(parent, geo, material, x, y, z) {
const m = new THREE.Mesh(geo, material);
m.position.set(x, y, z);
m.castShadow = true;
m.receiveShadow = true;
parent.add(m);
return m;
}
const box = (p, w, h, d, m, x, y, z) => part(p, new THREE.BoxGeometry(w, h, d), m, x, y, z);
const home = new THREE.Group();
home.position.set(-35, 0, 7);
scene.add(home);
box(home, 8, 4, 6, mat('#d2c1a3'), 0, 2, 0);
const roof = part(home, new THREE.ConeGeometry(6.5, 2, 4), mat('#6d493d'), 0, 5, 0);
roof.rotation.y = Math.PI / 4;
box(home, 1.3, 2.6, .15, wood, 0, 1.3, -3.08);
for (const x of [-2.5, 2.5]) {
box(home, 1.6, 1.4, .12, mat('#8eafb3'), x, 2.4, -3.08);
box(home, .08, 1.4, .15, wood, x, 2.4, -3.16);
}
box(scene, 31, .035, 5, mat('#555957'), -22, .02, 0);
for (let x = -36; x < -8; x += 3) box(scene, 1.5, .045, .12, mat('#ddd0a3'), x, .04, 0);
const car = new THREE.Group();
scene.add(car);
const paint = mat('#bd703c'), glass = mat('#72989e');
box(car, 4.4, .65, 1.9, paint, 0, .8, 0);
box(car, 2.3, .8, 1.75, glass, -.15, 1.5, 0);
box(car, 2.5, .12, 1.9, paint, -.15, 1.96, 0);
for (const x of [-1.35, 1.35]) for (const z of [-1, 1]) {
const wheel = part(car, new THREE.CylinderGeometry(.42, .42, .22, 16), black, x, .48, z);
wheel.rotation.x = Math.PI / 2;
box(car, .09, .2, .45, mat(x > 0 ? '#fff1c0' : '#b34232'), x > 0 ? 2.23 : -2.23, .9, z * .65);
}
box(car, .1, .5, 1.7, black, -1.32, 1.45, 0);
box(scene, 5, .04, 3, stone, -11, .025, -4);
const gate = new THREE.Group();
gate.position.set(-8.5, 0, pathZ(-8.5));
scene.add(gate);
for (const z of [-2.5, 2.5]) box(gate, .3, 3.7, .3, wood, 0, 1.85, z);
box(gate, .4, .65, 5.8, wood, 0, 3.4, 0);
const signCanvas = document.createElement('canvas');
signCanvas.width = 512;
signCanvas.height = 128;
const ctx = signCanvas.getContext('2d');
ctx.fillStyle = '#24392d';
ctx.fillRect(0, 0, 512, 128);
ctx.fillStyle = '#f2dfae';
ctx.textAlign = 'center';
ctx.font = 'bold 42px Tahoma';
ctx.fillText('ورودی جنگل · تپهٔ آرزوها', 256, 80);
const sign = part(gate, new THREE.PlaneGeometry(4, .9), new THREE.MeshBasicMaterial({
map: new THREE.CanvasTexture(signCanvas),
side: THREE.DoubleSide
}), .25, 3.4, 0);
sign.rotation.y = Math.PI / 2;
const camp = new THREE.Group();
camp.position.set(4, 0, 4.4);
scene.add(camp);
for (let i = 0; i < 12; i++) {
const a = i * Math.PI / 6;
part(camp, new THREE.DodecahedronGeometry(.24), stone, Math.cos(a) * .9, .16, Math.sin(a) * .9);
}
for (let i = 0; i < 4; i++) {
const log = box(camp, 1.4, .16, .17, wood, 0, .23, 0);
log.rotation.y = i * Math.PI / 4;
}
const flames = [];
for (let i = 0; i < 7; i++) {
const flame = part(camp, new THREE.ConeGeometry(.18 + (i % 2) * .1, .8, 7), new THREE.MeshBasicMaterial({
color: i % 2 ? '#ffb32f' : '#f56528',
transparent: true,
opacity: .85
}), Math.sin(i * 2) * .3, .65, Math.cos(i * 2) * .3);
flames.push(flame);
}
const light = new THREE.PointLight('#ff993d', 8, 10);
light.position.y = 1.2;
camp.add(light);
const dancers = [];
for (let i = 0; i < 6; i++) {
const root = new THREE.Group(), a = i * Math.PI / 3;
root.position.set(Math.cos(a) * 2.2, 0, Math.sin(a) * 2.2);
root.rotation.y = -a - Math.PI / 2;
camp.add(root);
const female = i % 2 === 0, cloth = mat(['#ae684a', '#698b84', '#c29d59', '#75829a', '#985b69', '#6f8158'][i]);
part(root, new THREE.CylinderGeometry(.23, .20, .6, 10), cloth, 0, 1.15, 0);
part(root, new THREE.SphereGeometry(.22, 12, 10), skin, 0, 1.7, 0);
part(root, new THREE.SphereGeometry(.235, 12, 10), mat('#322923'), 0, 1.8, -.045).scale.set(1, female ? 1.4 : .65, 1);
if (female) part(root, new THREE.CylinderGeometry(.23, .4, .5, 12), cloth, 0, .8, 0);
for (const side of [-1, 1]) box(root, .14, .65, .17, black, side * .15, .4, 0);
const arms = [];
for (const side of [-1, 1]) {
const arm = new THREE.Group();
arm.position.set(side * .27, 1.4, 0);
root.add(arm);
box(arm, .13, .6, .14, cloth, 0, -.25, 0);
arms.push(arm);
}
const cup = part(arms[0], new THREE.CylinderGeometry(.07, .06, .2, 10), mat('#e6b853'), 0, -.58, 0);
dancers.push({root, arms, a});
}
return {
car, update(time, phase, progress = 0, lane = 0) {
car.position.set(phase === 'home' || phase === 'boarding' ? -32 : phase === 'driving' ? -32 + progress * 21 : -11, .05, ['entrance', 'walking'].includes(phase) ? -4 : -.3 + lane - 3.7 * Math.pow(progress, 6));
flames.forEach((f, i) => {
f.scale.y = .7 + Math.sin(time * 11 + i) * .25;
f.rotation.y = time + i;
});
light.intensity = 7 + Math.sin(time * 17);
dancers.forEach(({root, arms, a}, i) => {
root.position.y = Math.abs(Math.sin(time * 3 + i)) * .12;
root.rotation.z = Math.sin(time * 3 + i) * .09;
arms.forEach((arm, j) => {
arm.rotation.z = (j ? 1 : -1) * (.7 + Math.sin(time * 3 + a + j) * .5);
arm.rotation.x = Math.cos(time * 2 + i) * .3;
});
});
}
};
}
+75
View File
@@ -0,0 +1,75 @@
import {CATEGORIES} from './locations.js';
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));
if (!categories.has(record.category) && categories.size === Object.keys(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: '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) >= 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;
-80
View File
@@ -1,80 +0,0 @@
export const chapters = {
morning: {title: 'ورود به جنگل', lines: ['پدر: ماشین همین‌جا می‌مونه. پیاده می‌ریم تا بالای کوه.']},
rabbit: {
title: 'یک دوست کوچک',
lines: ['بچه: یه خرگوش! آروم نگاهش کنیم؟'],
action: {value: 'watch', label: 'تماشای خرگوش'}
},
bridge: {title: 'روی پل', lines: ['پدر: دستم رو بگیر؛ با هم رد می‌شیم.']},
fish: {title: 'زیر پل', lines: ['بچه: ماهی‌ها رو ببین!']},
camp: {
title: 'دور آتش',
lines: ['بچه: بابا! اون دخترها و پسرها دور آتیش می‌رقصن. نوشیدنی‌هاشون رو از کجا گرفتن؟', 'پدر: بریم بپرسیم. سلام! نوشیدنی رو از کجا خریدین؟', 'یکی از دوستان: سلام! کمی جلوتر یه دستگاه روبات مارکت هست؛ نوشیدنی و خوراکی داره.', 'بچه: پس ما هم از همون‌جا برای پیک‌نیک خرید کنیم!'],
action: {value: 'ask', label: 'رفتن به دستگاه وندینگ'}
},
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], ['camp', 2.7], ['courage', 6.5], ['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', 'camp', '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('camp')) entries.push('🔥 دوستان کنار آتش و آدرس وندینگ');
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;
}
+242 -1367
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
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 that drive up and down the main boulevards and stop for anything in front of them.
export function createTraffic(scene, data, count = 32) {
const roads = data.roads.filter(r => r[0] <= 2).map(r => {
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]));
return {pts, lengths, total: lengths.at(-1)};
}).filter(r => r.total > 140).sort((a, b) => b.total - a.total);
const cars = [];
for (let i = 0; i < Math.min(count, roads.length * 2); i++) {
const road = roads[i % roads.length], model = createCarModel(COLORS[i % COLORS.length]);
scene.add(model.group);
cars.push({road, model, d: (i * 97) % road.total, dir: i % 2 ? 1 : -1, speed: 8 + (i % 5) * 1.3, wait: 0, x: 0, z: 0});
}
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};
};
function update(dt, obstacles) {
for (const car of cars) {
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;
// Yield to the player and to other cars directly ahead in the same lane.
const ahead = obstacles.concat(cars.filter(c => c !== car)).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) {
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 * 2.2;
car.z = p.z + fx * 2.2;
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};
}
+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: 22, 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);
+103
View File
@@ -0,0 +1,103 @@
import * as THREE from 'three';
import {catalog} from './game-state.js';
const PRODUCT_COLORS = {juice: '#e5ae47', water: '#9fd3e6', milk: '#8a5a3c', tea: '#c7783a', lemonade: '#f0dc5a', biscuit: '#c9935a', chocolate: '#5a3524', nuts: '#b9884f', cake: '#e58fa6'};
function label(text, sub, width = 512, height = 160) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const c = canvas.getContext('2d');
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);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
// 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, 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)
};
}
-44
View File
@@ -1,44 +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('Dialogue pauses walking; arrows up/down only zoom',()=>{
assert.equal(canWalk({chapterId:'bridge'}),false);assert.equal(canWalk({chapterId:'fish'}),false);
assert.ok(canWalk({chapterId:null}));
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);
});
test('Robot Market mascot replaces the child and keeps its articulated rig',()=>{
const family=createFamily();
const robot=family.group.getObjectByName('robot-market-mascot');
assert.ok(robot);
assert.equal(family.group.children.filter(node=>node.type==='Group').length,2);
for(const mode of ['walk','shop','sit']){
family.update({time:2,gait:1,dt:.016,moving:mode==='walk',facing:Math.PI/2,shopping:mode==='shop',delivery:mode==='shop',finished:mode==='sit'});
family.group.updateMatrixWorld(true);
robot.traverse(node=>assert.ok(node.matrixWorld.elements.every(Number.isFinite)));
}
});
-22
View File
@@ -1,22 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createJourney, advanceJourney, tickJourney} from '../src/journey.js';
test('Driving requires player input and exit requires F at the entrance', () => {
const j=createJourney();tickJourney(j,20);assert.equal(j.phase,'home');
assert.ok(advanceJourney(j));tickJourney(j,2,true);assert.equal(j.phase,'boarding');
tickJourney(j,2);assert.equal(j.phase,'driving');
tickJourney(j,30);assert.equal(j.distance,0);assert.equal(j.phase,'driving');assert.equal(advanceJourney(j),false);
for(let i=0;i<200;i++)tickJourney(j,.05,false,{throttle:1});
assert.equal(j.phase,'entrance');assert.equal(j.distance,21);assert.equal(j.speed,0);
tickJourney(j,20);assert.equal(j.phase,'entrance');assert.ok(advanceJourney(j));assert.equal(j.phase,'walking');
assert.equal(advanceJourney(j),false);assert.equal(createJourney().distance,0);
});
test('Brake, steering bounds and pause work while driving', () => {
const j=createJourney();advanceJourney(j);tickJourney(j,2);
for(let i=0;i<20;i++)tickJourney(j,.05,false,{throttle:1,steer:1});
assert.ok(j.speed>0);assert.ok(j.lane>0 && j.lane<=.75);
const snapshot={...j};tickJourney(j,1,true,{throttle:1});assert.deepEqual(j,snapshot);
for(let i=0;i<30;i++)tickJourney(j,.05,false,{throttle:-1});
assert.equal(j.speed,0);assert.equal(j.phase,'driving');
});
+43 -11
View File
@@ -1,15 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createState,productByCode} from '../src/game-state.js';
import {createPayment,beginPayment,swipePayment,cancelPayment} from '../src/payment.js';
test('Payment charges once, only after ready and explicit swipe',()=>{
const s=createState(),p=createPayment(),product=productByCode('21');assert.ok(product);
assert.ok(beginPayment(s,p,product));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,true);
assert.equal(swipePayment(s,p),false);assert.equal(beginPayment(s,p,product),false);
p.phase='ready';assert.ok(swipePayment(s,p));assert.equal(s.balance,500-product.cost);assert.equal(s.basket.length,1);
assert.equal(swipePayment(s,p),false);assert.equal(cancelPayment(s,p),false);
import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js';
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js';
import {machineById, machines} from '../src/locations.js';
const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
test('Payment charges once, only after ready and an explicit swipe', () => {
const state = createState(), payment = createPayment();
assert.ok(beginPayment(state, payment, park, 'juice'));
assert.equal(state.balance, 500);
assert.equal(swipePayment(state, payment), false, 'not ready yet');
assert.ok(readyPayment(payment));
assert.ok(swipePayment(state, payment));
assert.equal(state.balance, 465);
assert.equal(swipePayment(state, payment), false, 'no double charge');
assert.equal(state.purchases.length, 1);
assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends');
});
test('Cancel or invalid payment never changes balance or basket',()=>{
const s=createState(),p=createPayment();assert.equal(beginPayment(s,p,productByCode('20')),false);
assert.ok(beginPayment(s,p,productByCode('60')));assert.ok(cancelPayment(s,p));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,false);
test('Cancel, closed machines, unknown products and low funds never charge', () => {
const state = createState(), payment = createPayment();
assert.ok(beginPayment(state, payment, park, 'cake'));
assert.ok(cancelPayment(state, payment));
assert.equal(state.balance, 500);
assert.equal(state.busy, false);
assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
state.minutes = 14 * 60;
assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
state.minutes = 9 * 60;
state.balance = 10;
assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
assert.equal(beginPayment(state, payment, park, 'water'), false);
});
test('Prices follow machine discounts and the clock is Persian', () => {
assert.equal(priceAt(uni, 'nuts'), 52);
assert.equal(priceAt(park, 'nuts'), 65);
assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
const state = createState();
state.balance = 5;
assert.equal(canAffordAnything(state, machines), false);
state.balance = 15;
assert.ok(canAffordAnything(state, machines));
});
+39
View File
@@ -0,0 +1,39 @@
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('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, 'first');
purchase(state, machineById('mellat-park'), 'water');
const done = completeMissions(state);
assert.deepEqual(done.map(m => m.id), ['first']);
assert.equal(state.balance, 500 - 15 + 30);
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],['camp',3,'ask'],['courage',7],['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','camp']){const s=createStory();assert.equal(completeChapter(s,id,'wrong'),false);assert.ok(completeChapter(s,id,chapters[id].action.value));}
});
+51
View File
@@ -0,0 +1,51 @@
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 {machines} 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: every machine is reachable on foot and by car from the start', () => {
const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
const start = machines[0].anchor;
for (const m of machines) {
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);
});