Start at the factory, add PWA install and GPS real-world mode

The game now begins inside the Negin Sazan Pishgam Radmehr Atousa
vending-machine factory on the Mashhad–Chenaran road, and the world
extends ~20 km along the real driving route to Park Mellat and
Ferdowsi University.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:56:06 +03:30
co-authored by Claude Opus 5.5
parent e2a1b5fba1
commit 0939dac9cd
39 changed files with 1111 additions and 228 deletions
+40 -11
View File
@@ -1,15 +1,21 @@
# عملیات مشهد — روبات مارکت
بازی سه‌بعدی مرورگری (Three.js) با حال‌وهوای بازی‌های اکشن سوم‌شخص، بدون سلاح و خشونت. یک پدر و فرزندش، همراه روبات راهنمای روبات مارکت، در خیابان‌های واقعی مشهد می‌گردند، دستگاه‌های وندینگ روبات مارکت را پیدا می‌کنند و با هر خرید امتیاز می‌گیرند.
بازی سه‌بعدی مرورگری (Three.js) با حال‌وهوای بازی‌های اکشن سوم‌شخص، بدون سلاح و خشونت. یک پدر و فرزندش از کارخانهٔ سازندهٔ دستگاه‌ها راه می‌افتند، در خیابان‌های واقعی مشهد می‌گردند، دستگاه‌های وندینگ روبات مارکت را پیدا می‌کنند و با هر خرید امتیاز می‌گیرند. روبات راهنمای روبات مارکت داخل دستگاه‌ها زندگی می‌کند: وقتی خانواده به یک دستگاه می‌رسند، از آن بیرون می‌آید، خوش‌آمد می‌گوید و راهنمایی‌شان می‌کند.
بازی یک PWA است: روی گوشی نصب می‌شود و آفلاین هم اجرا می‌شود.
🎮 [اجرای آنلاین بازی](https://hamedabdollahzade.github.io/vending-machine-game/)
## داستان و هدف
- **شروع:** ورودی جنوبی بوستان ملت، بزرگراه وکیل‌آباد
- **شروع:** سالن تولید کارخانهٔ **شرکت نگین سازان پیشگام رادمهر آتوسا** (بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان). کارخانه سالن تولید با خط مونتاژ، دفتر، حیاط، دروازه و تابلوی شرکت دارد. ماشین خانواده در حیاط پارک است.
- **مسیر:** از کارخانه تا بوستان ملت حدود ۲۰ کیلومتر رانندگی است، از بزرگراه مشهد–چناران، بزرگراه پیامبر اعظم، بزرگراه آزادی و وکیل‌آباد.
- **نقشهٔ ایران:** صفحهٔ توجیه مأموریت نقشهٔ استان‌های ایران را نشان می‌دهد. فعلاً فقط مشهد (خراسان رضوی) باز است و شهرهای دیگر قفل‌اند.
- **محدودهٔ قابل بازی:** حدود ۴٫۸ در ۳٫۳ کیلومتر از غرب مشهد، شامل بوستان ملت، دانشگاه فردوسی، دانشگاه علوم پزشکی، ایستگاه‌های مترو و مشهد مال. خیابان‌ها، ساختمان‌ها و پارک‌ها از داده‌های واقعی OpenStreetMap ساخته شده‌اند.
- **دستگاه‌ها:** بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاه‌ها و محصولاتشان فرضی‌اند.
- **محدودهٔ قابل بازی:** حدود ۱۵ در ۱۸ کیلومتر از شمال‌غرب تا غرب مشهد، همه از داده‌های واقعی OpenStreetMap:
- جزئیات کامل اطراف کارخانه و محدودهٔ بوستان ملت و دانشگاه فردوسی
- همهٔ خیابان‌ها و ساختمان‌ها در فاصلهٔ ۲۵۰ متری مسیر رانندگی
- شبکهٔ خیابان‌های اصلی در کل منطقه
- **دستگاه‌ها:** دستگاه نمونهٔ کارخانه (نصف قیمت)، بوستان ملت، مجموعه آموزشی راحیل (فقط ۷:۳۰ تا ۱۳:۳۰)، دانشگاه فردوسی و دانشکدهٔ ادبیات (۲۰٪ تخفیف دانشجویی)، دانشگاه علوم پزشکی، ایستگاه مترو آزادی، مشهد مال (از ساعت ۱۰). همهٔ دستگاه‌ها و محصولاتشان فرضی‌اند.
## کنترل
@@ -38,9 +44,29 @@
| هر خرید | ۱۰۰ |
| اولین خرید در هر مکان (کشف) | ۲۵۰ |
| خرید نوشیدنی و خوراکی از یک دستگاه | ۱۵۰ |
| خرید از هر پنج نوع مکان | ۵۰۰ |
| خرید از هر پنج نوع مکان شهری | ۵۰۰ |
| خرید در حالت دنیای واقعی | ۲۰۰ |
شش مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجه‌ها از «تازه‌وارد» تا «افسانهٔ مشهد» بالا می‌روند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریت‌ها انجام شود، عملیات تمام می‌شود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
هفت مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجه‌ها از «تازه‌وارد» تا «افسانهٔ مشهد» بالا می‌روند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریت‌ها انجام شود، عملیات تمام می‌شود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است.
## نصب روی گوشی (PWA)
- **اندروید و Chrome کامپیوتر:** دکمهٔ «نصب بازی روی گوشی» در صفحهٔ شروع، یا گزینهٔ Install در منوی مرورگر
- **آیفون:** در Safari، دکمهٔ Share و بعد Add to Home Screen. دکمهٔ نصب در بازی همین راهنما را نشان می‌دهد.
آیکون از آواتار روبات روبات مارکت ساخته شده (`public/icons`). بعد از اولین اجرا همهٔ فایل‌ها، از جمله نقشه و فونت Vazirmatn (داخل پروژه، بدون Google Fonts)، کش می‌شوند و بازی آفلاین اجرا می‌شود. با انتشار نسخهٔ جدید، پیام «نسخهٔ جدید بازی آماده است» نمایش داده می‌شود. Service Worker را `vite.config.js` در زمان build می‌سازد.
## حالت دنیای واقعی (GPS)
دکمهٔ «📍 بازی در دنیای واقعی» در صفحهٔ شروع یا «📍 دنیای واقعی» در نوار پایین این حالت را روشن می‌کند. اول نکته‌های ایمنی نمایش داده می‌شود و بعد اجازهٔ موقعیت و قطب‌نما گرفته می‌شود.
- خانواده روی نقشهٔ بازی همان‌جایی است که گوشی هست و با راه رفتن واقعی جابه‌جا می‌شود. جهتشان از قطب‌نمای گوشی یا جهت حرکت گرفته می‌شود.
- موقعیت‌های GPS بر اساس دقتشان نرم و ترکیب می‌شوند. «رسیدن» به دستگاه با شعاع ۲۰ تا ۴۰ متر (بسته به دقت GPS) سنجیده می‌شود. وقتی رسیدید، روبات از دستگاه بیرون می‌آید و خرید ۲۰۰ امتیاز اضافه دارد.
- در سرعت بیش از حدود ۲۲ کیلومتر بر ساعت خرید غیرفعال است. ماشین مجازی هم در این حالت استفاده نمی‌شود.
- بیرون از محدودهٔ بازی، فاصله تا نزدیک‌ترین دستگاه نمایش داده می‌شود.
- موقعیت فقط روی خود گوشی پردازش می‌شود. دوربین و قطب‌نما به HTTPS نیاز دارند.
فاز بعد، حالت دوربین (AR)، روی همین پایه ساخته می‌شود: تصویر زندهٔ دوربین به‌عنوان پس‌زمینه، و دستگاه و روبات در جهت و فاصلهٔ واقعی روی آن.
## اجرا
@@ -59,21 +85,24 @@ node --test tests/*.test.js
`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
node scripts/build-map-data.mjs --osm city.json zone-factory.json corridor.json major.json --poi poi.json --provinces ne_10m_admin_1_states_provinces.geojson
```
برای اضافه کردن محدودهٔ جدید، bbox اسکریپت و `ORIGIN` در `src/geo.js` را تغییر دهید.
چهار خروجی OSM (محدودهٔ شهر، محدودهٔ کارخانه، کریدور مسیر و خیابان‌های اصلی) با هم ادغام می‌شوند و عنصرهای تکراری حذف می‌شوند. خیابان‌ها هم در مرز منطقه بریده می‌شوند. برای افزودن محدودهٔ جدید، یک خروجی دیگر به `--osm` اضافه کنید و در صورت نیاز `REGION` را در اسکریپت بزرگ‌تر کنید.
## ساختار
- `src/main.js`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها)
- `src/map-render.js`: نقشهٔ گوشه، نقشهٔ کامل شهر و نقشهٔ ایران
- `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/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاه‌ها بیرون می‌آید
- `src/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها
- `src/pwa.js` و `public/manifest.webmanifest`: نصب، کار آفلاین و پیام به‌روزرسانی
- `src/realworld.js`: GPS، قطب‌نما، نرم‌سازی موقعیت و شعاع رسیدن
- `src/vending.js`، `src/vehicle.js`، `src/traffic.js`: مدل دستگاه، فیزیک ماشین و ترافیک شهری
- `src/geo.js`: تبدیل طول و عرض جغرافیایی به مختصات بازی
+45 -6
View File
@@ -5,9 +5,14 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0d1412">
<title>عملیات مشهد | روبات مارکت</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;600;800&display=swap" rel="stylesheet">
<meta name="description" content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاه‌های وندینگ را در مشهد پیدا کن و امتیاز بگیر.">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="روبات مارکت">
<link rel="manifest" href="./manifest.webmanifest">
<link rel="icon" type="image/png" href="./icons/favicon-64.png">
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
</head>
<body>
<canvas id="world"></canvas>
@@ -17,11 +22,16 @@
<section id="briefing">
<div class="brief-map"><canvas id="iran"></canvas></div>
<div class="brief-panel">
<img class="brief-robot" src="./robot/front.webp" alt="روبات راهنمای روبات مارکت" width="136" height="223">
<small class="tag">عملیات ۰۱ · خراسان رضوی</small>
<h1>عملیات مشهد</h1>
<p class="sub">روبات مارکت · شکار دستگاه‌های وندینگ</p>
<pre id="brief-text" aria-live="polite"></pre>
<button id="start-mission" type="button">شروع عملیات</button>
<div class="brief-actions">
<button id="real-mode" type="button" class="ghost">📍 بازی در دنیای واقعی (GPS)</button>
<button id="install" type="button" class="ghost" hidden>⬇ نصب بازی روی گوشی</button>
</div>
<p class="keys">حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M</p>
</div>
</section>
@@ -33,14 +43,14 @@
<canvas id="minimap" width="220" height="220"></canvas>
<span class="map-hint">نقشه · M</span>
</button>
<div id="location"><span id="street">مشهد</span><small id="mode-label">پیاده</small></div>
<div id="location"><span id="street">مشهد</span><small id="mode-label">پیاده</small><small id="gps-status" hidden></small></div>
<aside id="objectives">
<h2>مأموریت‌ها</h2>
<ol id="mission-list"></ol>
</aside>
<div id="waypoint" hidden><i></i><b id="waypoint-name"></b><span id="waypoint-distance"></span></div>
<div id="prompt" hidden></div>
<div id="subtitle" hidden><b id="subtitle-speaker"></b><span id="subtitle-text"></span></div>
<div id="subtitle" hidden><img id="subtitle-face" alt="" hidden><b id="subtitle-speaker"></b><span id="subtitle-text"></span></div>
<div id="awards" aria-live="polite"></div>
<div id="banner" hidden></div>
<div id="status">
@@ -50,6 +60,7 @@
</div>
<div id="rank"><b id="rank-name">تازه‌وارد</b><span id="xp">۰ XP</span><div class="bar"><i id="xp-bar"></i></div></div>
<div id="toolbar">
<button id="gps-toggle" type="button">📍 دنیای واقعی</button>
<button id="sound" type="button" aria-pressed="false">صدا: خاموش</button>
<button id="quality" type="button">گرافیک</button>
<button id="restart" type="button">شروع دوباره</button>
@@ -80,7 +91,7 @@
<dialog id="map-dialog">
<header>
<div class="tabs" role="tablist">
<button type="button" role="tab" data-tab="city" aria-selected="true">مشهد · بوستان ملت</button>
<button type="button" role="tab" data-tab="city" aria-selected="true">مشهد</button>
<button type="button" role="tab" data-tab="iran" aria-selected="false">ایران</button>
</div>
<button type="button" class="close" id="map-close" aria-label="بستن">✕</button>
@@ -119,6 +130,34 @@
<p class="rate">۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.</p>
</dialog>
<dialog id="real-intro">
<img src="./robot/serious.webp" alt="" width="72" height="68">
<h2>بازی در دنیای واقعی</h2>
<p>با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.</p>
<ul>
<li>هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.</li>
<li>بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.</li>
<li>هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.</li>
<li>موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.</li>
</ul>
<div class="actions">
<button type="button" id="real-go">قبول دارم، شروع کن</button>
<button type="button" id="real-cancel" class="ghost">انصراف</button>
</div>
</dialog>
<dialog id="ios-install">
<h2>نصب روی آیفون</h2>
<ol>
<li>در Safari دکمهٔ <b>Share</b> (مربع با فلش رو به بالا) را بزنید.</li>
<li>گزینهٔ <b>Add to Home Screen</b> را انتخاب کنید.</li>
<li>روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.</li>
</ol>
<button type="button" id="ios-close">متوجه شدم</button>
</dialog>
<div id="update-toast" hidden>نسخهٔ جدید بازی آماده است <button type="button" id="update-reload">به‌روزرسانی</button></div>
<dialog id="end">
<small id="end-tag">پایان عملیات</small>
<h2 id="end-title"></h2>
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fontsource/vazirmatn": "^5.2.5",
"three": "^0.180.0"
},
"devDependencies": {
@@ -457,6 +458,15 @@
"node": ">=18"
}
},
"node_modules/@fontsource/vazirmatn": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.5.tgz",
"integrity": "sha512-rWQH9i4Zp/64tQTHhy/ijOzdC50kXuBSPDEYDs63TI7EMMG5VzrQrVj6qroIsw6glHumJzpY6tayX4hWCR4RQA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+1
View File
@@ -12,6 +12,7 @@
"license": "ISC",
"type": "module",
"dependencies": {
"@fontsource/vazirmatn": "^5.2.5",
"three": "^0.180.0"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "عملیات مشهد · روبات مارکت",
"short_name": "روبات مارکت",
"description": "از کارخانهٔ نگین سازان پیشگام رادمهر آتوسا تا بوستان ملت: دستگاه‌های وندینگ روبات مارکت را در مشهد پیدا کن و امتیاز بگیر.",
"lang": "fa",
"dir": "rtl",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display_override": ["fullscreen", "standalone"],
"orientation": "any",
"background_color": "#0d1412",
"theme_color": "#0d1412",
"categories": ["games", "education"],
"icons": [
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+55 -20
View File
@@ -1,26 +1,41 @@
// 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>
// node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <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;
// Each --osm file is an Overpass API response with `out geom` (send a User-Agent header); elements
// that appear in several files are merged. The playable world is built from four extracts:
// city zone: [bbox:36.300,59.512,36.330,59.566];(way["highway"];way["building"];way["leisure"~"park|garden|pitch"];
// way["landuse"~"grass|forest|park|recreation_ground"];way["natural"~"water|wood"];
// way["amenity"~"school|university|college"];relation["amenity"="university"];relation["leisure"="park"];node["amenity"="school"];)
// factory zone: [bbox:36.4430,59.4140,36.4580,59.4340];(way["highway"];way["building"];way["leisure"~"park|garden|pitch"];way["landuse"];
// way["natural"~"water|wood"];way["amenity"];relation["leisure"="park"];)
// corridor: the same feature types within ~250 m of the factory → Park Mellat driving route (around:250,<route lat,lon list>)
// main roads: [bbox:36.298,59.405,36.462,59.572];way["highway"~"^(trunk|primary|secondary|tertiary|motorway)(_link)?$"];
// poi: nwr["railway"="station"](36.28,59.48,36.36,59.60);nwr["amenity"="university"](…);out center tags;
// Map data © OpenStreetMap contributors (ODbL). Province outlines: Natural Earth (public domain).
import {readFileSync, writeFileSync} from 'node:fs';
import {ORIGIN, toLocal} from '../src/geo.js';
const [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>');
const args = {osm: []};
for (let i = 2, key = null; i < process.argv.length; i++) {
if (process.argv[i].startsWith('--')) key = process.argv[i].slice(2);
else if (key === 'osm') args.osm.push(process.argv[i]);
else args[key] = process.argv[i];
}
if (!args.osm.length || !args.poi || !args.provinces) {
console.error('usage: node scripts/build-map-data.mjs --osm <file>… --poi <poi-osm.json> --provinces <provinces.geojson>');
process.exit(1);
}
// Points of interest are kept only inside the detailed city zone.
const BBOX = {minLat: 36.300, maxLat: 36.330, minLon: 59.512, maxLon: 59.566};
const city = JSON.parse(readFileSync(cityPath, 'utf8')).elements;
const poi = JSON.parse(readFileSync(poiPath, 'utf8')).elements;
// The playable region: from the factory on the Chenaran road to Park Mellat and Ferdowsi University.
const REGION = {minLat: 36.298, maxLat: 36.462, minLon: 59.405, maxLon: 59.572};
const merged = new Map();
for (const file of args.osm) for (const e of JSON.parse(readFileSync(file, 'utf8')).elements) merged.set(`${e.type}/${e.id}`, e);
const city = [...merged.values()];
const poi = JSON.parse(readFileSync(args.poi, 'utf8')).elements;
const provincesPath = args.provinces;
const r1 = v => Math.round(v * 10) / 10;
const flat = geometry => geometry.flatMap(({lat, lon}) => {
const {x, z} = toLocal(lat, lon);
@@ -51,13 +66,15 @@ const nameId = name => {
return nameIndex.get(name);
};
const roads = [], buildings = [], areas = [], labels = [];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus'];
const areaKinds = ['park', 'grass', 'forest', 'pitch', 'water', 'campus', 'industrial', 'farmland'];
const areaKind = t => t.leisure === 'park' || t.leisure === 'garden' || t.landuse === 'park' || t.landuse === 'recreation_ground' ? 'park'
: t.landuse === 'grass' ? 'grass'
: t.landuse === 'forest' || t.natural === 'wood' ? 'forest'
: t.leisure === 'pitch' ? 'pitch'
: t.natural === 'water' ? 'water'
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus' : null;
: ['school', 'university', 'college'].includes(t.amenity) ? 'campus'
: t.landuse === 'industrial' ? 'industrial'
: t.landuse === 'farmland' ? 'farmland' : null;
// Joins a relation's outer member ways end to end into closed rings.
function joinRings(members) {
@@ -84,8 +101,19 @@ function joinRings(members) {
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)]);
// Motorways are drawn and routed like trunk roads.
const highway = t.highway?.replace('_link', '').replace('motorway', 'trunk');
if (e.type === 'way' && highway && roadTypes.includes(highway) && e.geometry?.length > 1) {
// Whole ways come back from Overpass, so cut them where they leave the region.
const inRegion = ({lat, lon}) => lat >= REGION.minLat && lat <= REGION.maxLat && lon >= REGION.minLon && lon <= REGION.maxLon;
let run = [];
for (const point of [...e.geometry, null]) {
if (point && inRegion(point)) run.push(point);
else {
if (run.length > 1) roads.push([roadTypes.indexOf(highway), nameId(t.name), ...flat(run)]);
run = [];
}
}
} else if (e.type === 'way' && t.building && e.geometry?.length > 3) {
const levels = Number.parseFloat(t['building:levels']), height = Number.parseFloat(t.height);
const h = Number.isFinite(height) ? height : Number.isFinite(levels) ? levels * 3.2 : (2 + Math.floor(hash(e.id) * 4)) * 3.2;
@@ -95,9 +123,9 @@ for (const e of city) {
} 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')) {
if (t.name && (t.amenity === 'school' || t.amenity === 'university' || t.leisure === 'park' || t.landuse === 'industrial')) {
const c = e.type === 'node' ? e : e.bounds ? {lat: (e.bounds.minlat + e.bounds.maxlat) / 2, lon: (e.bounds.minlon + e.bounds.maxlon) / 2} : null;
if (c) labels.push({name: t.name, kind: t.amenity ?? 'park', ...roundPoint(toLocal(c.lat, c.lon))});
if (c) labels.push({name: t.name, kind: t.landuse === 'industrial' ? 'industrial' : t.amenity ?? 'park', ...roundPoint(toLocal(c.lat, c.lon))});
}
}
for (const e of poi) {
@@ -120,8 +148,15 @@ const polygonArea = pts => {
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);
// The world spans every road we kept, plus a margin.
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
for (const road of roads) for (let i = 2; i < road.length; i += 2) {
minX = Math.min(minX, road[i]);
maxX = Math.max(maxX, road[i]);
minZ = Math.min(minZ, road[i + 1]);
maxZ = Math.max(maxZ, road[i + 1]);
}
const min = {x: Math.floor(minX - 200), z: Math.floor(minZ - 200)}, max = {x: Math.ceil(maxX + 200), z: Math.ceil(maxZ + 200)};
const city_ = {
origin: ORIGIN,
attribution: '© OpenStreetMap contributors',
+15 -13
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three';
// Lightweight articulated characters; all coordinates below are in the family group.
export function createFamily() {
// Lightweight articulated characters; all coordinates below are in the group.
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
export function createFamily({robot: robotOnly = false} = {}) {
const group = new THREE.Group();
const mat = color => new THREE.MeshStandardMaterial({color, roughness: .82});
const skin = mat('#edbd98'), hair = mat('#49382d'), shoes = mat('#273c36');
@@ -169,17 +170,16 @@ export function createFamily() {
return {root, torso, head, legs, arms, scale, z, eyes};
}
const father = makePerson(false, -.45), child = makePerson(true, .45), robot = makeRobot(1.45);
robot.root.scale.setScalar(.7);
const father = robotOnly ? null : makePerson(false, -.45), child = robotOnly ? null : makePerson(true, .45);
const robot = robotOnly ? makeRobot(0) : null;
const actors = robotOnly ? [robot] : [father, child];
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, dt = 1 / 60}) {
const actors = [father, child, robot];
// `wave` raises the robot's right hand, `point` stretches it forward.
function update({time, gait, moving, facing = Math.PI / 2, shopping, delivery, finished, talking = false, wave = false, point = false, dt = 1 / 60}) {
actors.forEach((actor, index) => {
const blend = 1 - Math.exp(-dt * 7), isRobot = actor === robot;
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);
actor.root.position.x = THREE.MathUtils.lerp(actor.root.position.x, finished ? (index === 0 ? -.55 : .55) : shopping && !isRobot ? [-.55, .25][index] : 0, blend);
// The robot hovers; it bobs faster while it is speaking.
const hover = isRobot ? .35 + Math.sin(time * (talking ? 6 : 2.2)) * .07 : 0;
actor.root.position.y = THREE.MathUtils.lerp(actor.root.position.y, finished ? -.6 * actor.scale : hover, isRobot ? 1 : blend);
@@ -191,7 +191,7 @@ export function createFamily() {
const blinking = Math.sin(time * 1.3 + index) > .993;
actor.eyes.forEach(eye => eye.scale.y = blinking ? .12 : 1);
actor.head.rotation.y = shopping ? -.65 : isRobot ? Math.sin(time * .9) * .35 : Math.sin(time * .7) * .06;
actor.torso.rotation.x = delivery && index === 1 ? .22 : 0;
actor.torso.rotation.x = delivery && actor === child ? .22 : 0;
actor.legs.forEach((leg, i) => {
const phase = gait + i * Math.PI;
const stride = isRobot ? .08 : .34;
@@ -206,11 +206,13 @@ export function createFamily() {
// 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 : index === 1 && side === 1;
const side = i === 0 ? -1 : 1, inside = actor === father ? side === -1 : actor === child && side === 1;
const a = shoulder(actor, side);
let target;
if (inside && !shopping && !finished) target = contact.clone();
else if (index === 1 && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
else if (actor === child && shopping && side === -1) target = new THREE.Vector3(.5, delivery ? .65 : 1.1, -.15);
else if (actor === robot && side === 1 && wave) target = toFamily(actor, new THREE.Vector3(.42, 1.62 + Math.sin(time * 9) * .07, .12));
else if (actor === robot && side === 1 && point) target = toFamily(actor, new THREE.Vector3(.3, 1.25, .62));
else target = toFamily(actor, new THREE.Vector3(side * .34 * actor.scale, .88 * actor.scale, moving ? Math.sin(gait + (i ? Math.PI : 0)) * .2 : 0));
const elbow = a.clone().lerp(target, .5).add(new THREE.Vector3(.07, 0, inside ? 0 : .07));
updateSegment(arm.upper, a, elbow);
@@ -220,5 +222,5 @@ export function createFamily() {
}
update({time: 0, gait: 0, moving: false});
return {group, update, robot: robot.root};
return {group, update};
}
+94 -52
View File
@@ -6,8 +6,8 @@ import {blocked, insidePolygon} from './collision.js';
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 LAYER = {industrial: [.006, 1], farmland: [.008, 1], campus: [.01, 1], park: [.02, 2], grass: [.025, 3], forest: [.025, 3], pitch: [.03, 4], water: [.035, 5], sidewalk: [.04, 6], dirt: [.045, 7], paved: [.05, 8], minor: [.06, 9], major: [.07, 10]};
const AREA_COLOR = {campus: '#d8ccb2', park: '#7a9a55', grass: '#8aa660', forest: '#5d7f47', pitch: '#5f9a58', water: '#4f93a8', industrial: '#bdb6a6', farmland: '#a8a46e'};
const BUILDING_COLORS = ['#dccbaa', '#e7dcc4', '#c9a585', '#bdb4a3', '#ece6da', '#d2b48f', '#b7957a'].map(c => new THREE.Color(c));
function canvasTexture(width, height, draw, repeat = true) {
@@ -178,47 +178,58 @@ function signedArea(pts) {
return a / 2;
}
// The world is split into 2 km chunks; each chunk's meshes are hidden when they are out of view range.
const CHUNK = 2000, VIEW_RANGE = 2400;
export function createCity(scene, data, {collision, segments}) {
const tex = textures(), group = new THREE.Group();
scene.add(group);
const random = seeded(1405);
const {minX, minZ, maxX, maxZ} = data.bounds;
const chunked = [], buckets = new Map();
const bucket = (name, x, z, make) => {
const key = `${name}|${Math.floor(x / CHUNK)},${Math.floor(z / CHUNK)}`;
if (!buckets.has(key)) buckets.set(key, {name, out: make()});
return buckets.get(key).out;
};
const addChunked = mesh => {
mesh.geometry.computeBoundingSphere();
chunked.push(mesh);
group.add(mesh);
return mesh;
};
// 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)));
// Open ground that follows the camera, so the horizon never shows an edge.
const ground = new THREE.Mesh(new THREE.PlaneGeometry(14000, 14000), groundMaterial({color: '#c6baa0'}, 0));
ground.rotation.x = -Math.PI / 2;
ground.position.y = -.02;
ground.receiveShadow = true;
group.add(ground);
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]);
flatPolygon(bucket(`area:${kind}`, area[1], area[2], () => ({pos: [], idx: []})), 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 areaMaterials = {};
for (const kind of data.areaKinds) areaMaterials[kind] = groundMaterial({color: AREA_COLOR[kind], roughness: kind === 'water' ? .25 : .95, metalness: kind === 'water' ? .2 : 0}, LAYER[kind][1]);
const 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]);
const road = (name, x, z) => bucket(`road:${name}`, x, z, () => ({pos: [], uv: [], idx: []}));
for (const r of data.roads) {
const type = data.roadTypes[r[0]], kind = BUCKET[type], pts = r.slice(2), width = WIDTH[type];
ribbon(road(kind, pts[0], pts[1]), pts, width, LAYER[kind][0], kind === 'major' ? 9 : 8);
if (r[0] <= DRIVABLE_MAX_TYPE) {
if (type !== 'service') ribbon(road('sidewalk', pts[0], pts[1]), pts, width + 4, LAYER.sidewalk[0], 6);
disc(road(kind, pts[0], pts[1]), pts[0], pts[1], width / 2, LAYER[kind][0]);
disc(road(kind, pts[0], pts[1]), pts.at(-2), pts.at(-1), width / 2, LAYER[kind][0]);
}
}
for (const [bucket, out] of Object.entries(roadBuffers)) group.add(toMesh(out, groundMaterial({map: tex[bucket]}, LAYER[bucket][1])));
const roadMaterials = {};
for (const kind of ['major', 'minor', 'paved', 'dirt', 'sidewalk']) roadMaterials[kind] = groundMaterial({map: tex[kind]}, LAYER[kind][1]);
// Buildings: textured walls with one window per 3.6 m bay and 3.2 m storey, flat roofs.
const walls = {pos: [], uv: [], col: [], idx: []}, roofs = {pos: [], col: [], idx: []};
const tanks = [], units = [];
for (const building of data.buildings) {
const walls = bucket('walls', building[1], building[2], () => ({pos: [], uv: [], col: [], idx: []}));
const roofs = bucket('roofs', building[1], building[2], () => ({pos: [], col: [], idx: []}));
const height = building[0];
let pts = building.slice(1);
if (signedArea(pts) > 0) {
@@ -247,34 +258,51 @@ export function createCity(scene, data, {collision, segments}) {
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]);
(random() < .5 ? tanks : units).push([cx + (random() - .5) * 2, height, random() * Math.PI, cz + (random() - .5) * 2, 0]);
}
}
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 wallMaterial = new THREE.MeshStandardMaterial({map: tex.walls, vertexColors: true, roughness: .85});
const roofMaterial = new THREE.MeshStandardMaterial({vertexColors: true, roughness: .9});
for (const {name, out} of buckets.values()) {
const [kind, sub] = name.split(':');
const mesh = addChunked(kind === 'area' ? toMesh(out, areaMaterials[sub])
: kind === 'road' ? toMesh(out, roadMaterials[sub])
: kind === 'walls' ? toMesh(out, wallMaterial, 'compute') : toMesh(out, roofMaterial));
mesh.castShadow = kind === 'walls' || kind === 'roofs';
}
const dummy = new THREE.Object3D();
// One instanced mesh per chunk, so small props are culled with the rest of their chunk.
// Each item is an array whose first entry is x and second-to-last entry is z.
const instanced = (geometry, material, items, place) => {
const mesh = new THREE.InstancedMesh(geometry, material, Math.max(1, items.length));
mesh.count = items.length;
const byChunk = new Map();
items.forEach((item, i) => {
place(item);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
const key = `${Math.floor(item[0] / CHUNK)},${Math.floor(item.at(-2) / CHUNK)}`;
if (!byChunk.has(key)) byChunk.set(key, []);
byChunk.get(key).push([item, i]);
});
mesh.castShadow = mesh.receiveShadow = true;
group.add(mesh);
return mesh;
const meshes = [];
for (const list of byChunk.values()) {
const mesh = new THREE.InstancedMesh(geometry, material, list.length);
list.forEach(([item], i) => {
place(item);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
});
mesh.castShadow = mesh.receiveShadow = true;
mesh.computeBoundingSphere();
mesh.userData.items = list.map(([, i]) => i);
chunked.push(mesh);
group.add(mesh);
meshes.push(mesh);
}
return meshes;
};
instanced(new THREE.CylinderGeometry(.55, .55, 1.1, 10), new THREE.MeshStandardMaterial({color: '#d8d8d2', metalness: .4, roughness: .4}), tanks, ([x, y, z]) => {
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]) => {
instanced(new THREE.BoxGeometry(1, .8, .9), new THREE.MeshStandardMaterial({color: '#a9aaa4', roughness: .6}), units, ([x, y, r, z]) => {
dummy.position.set(x, y + .4, z);
dummy.rotation.set(0, r, 0);
dummy.scale.setScalar(1);
@@ -296,7 +324,7 @@ export function createCity(scene, data, {collision, segments}) {
}
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]);
if (insidePolygon(pts, tx, tz) && freeSpot(tx, tz)) trees.push([tx, .8 + random() * .6, tz, 0]);
}
}
for (const road of data.roads) {
@@ -307,23 +335,22 @@ export function createCity(scene, data, {collision, segments}) {
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]);
if (!blocked(collision, tx, tz) && !nearestSegment(segments, tx, tz, WIDTH[type] / 2 + 1.5, s => s[4] <= DRIVABLE_MAX_TYPE)) trees.push([tx, .7 + random() * .4, tz, 0]);
}
}
}
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]) => {
instanced(new THREE.CylinderGeometry(.14, .22, 2.6, 5, 1, true), new THREE.MeshStandardMaterial({color: '#6a5039', roughness: .9}), trees, ([x, s, z]) => {
dummy.position.set(x, 1.3 * s, z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(s);
});
const crowns = instanced(new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .85, flatShading: true}), trees, ([x, z, s]) => {
const crowns = instanced(new THREE.IcosahedronGeometry(1.7, 0), new THREE.MeshStandardMaterial({color: '#ffffff', roughness: .85, flatShading: true}), trees, ([x, s, z]) => {
dummy.position.set(x, 3.4 * s, z);
dummy.rotation.set(0, x * 13 % 6, 0);
dummy.scale.set(s, s * (1 + (x * 7 % 1) * .3), s);
});
const crownColors = ['#4d7a3e', '#5f8a45', '#3f6b3a', '#6f9150'].map(c => new THREE.Color(c));
trees.forEach((_, i) => crowns.setColorAt(i, crownColors[i % crownColors.length]));
for (const mesh of crowns) mesh.userData.items.forEach((tree, i) => mesh.setColorAt(i, crownColors[tree % crownColors.length]));
// Metro station entrances with the Mashhad Urban Railway "M" sign.
const signTexture = canvasTexture(128, 128, (c, w, h) => {
@@ -383,6 +410,21 @@ export function createCity(scene, data, {collision, segments}) {
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};
const ridge = new THREE.Mesh(ridgeGeometry, new THREE.MeshBasicMaterial({vertexColors: true, fog: false, side: THREE.DoubleSide}));
group.add(ridge);
let lastCheck = null;
return {
group,
// Keeps the ground and horizon around the camera and shows only chunks within range.
update(camera) {
ground.position.set(camera.x, -.02, camera.z);
ridge.position.set(camera.x, 0, camera.z);
if (lastCheck && Math.hypot(camera.x - lastCheck.x, camera.z - lastCheck.z) < 60) return;
lastCheck = {x: camera.x, z: camera.z};
for (const mesh of chunked) {
const sphere = mesh.boundingSphere ?? mesh.geometry.boundingSphere;
mesh.visible = Math.hypot(sphere.center.x - camera.x, sphere.center.z - camera.z) - sphere.radius < VIEW_RANGE;
}
}
};
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+194
View File
@@ -0,0 +1,194 @@
import * as THREE from 'three';
import {createVendingMachine} from './vending.js';
import {createCarModel} from './traffic.js';
import {FACTORY} from './locations.js';
import {textTexture} from './textures.js';
function stripes(c, w, h, base, line, every) {
c.fillStyle = base;
c.fillRect(0, 0, w, h);
c.fillStyle = line;
for (let x = 0; x < w; x += every) c.fillRect(x, 0, every * .35, h);
}
/**
* The Negin Sazan Pishgam Radmehr Atousa compound: production hall (where the game starts),
* office, fenced yard and gate. Layout uses a local frame: u runs along the driveway towards the
* gate, v across it. Returns world-space collision footprints and key positions.
*/
export function createFactory(scene) {
const {center, axis} = FACTORY, normal = {x: -axis.z, z: axis.x};
const rot = Math.atan2(-axis.z, axis.x);
const toWorld = (u, v) => ({x: center.x + axis.x * u + normal.x * v, z: center.z + axis.z * u + normal.z * v});
const group = new THREE.Group();
group.position.set(center.x, 0, center.z);
group.rotation.y = rot;
scene.add(group);
const footprints = [];
// Axis-aligned box in the local frame; solid ones also block movement.
const box = (u0, u1, v0, v1, y0, y1, material, solid = true, parent = group) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(u1 - u0, y1 - y0, v1 - v0), material);
mesh.position.set((u0 + u1) / 2, (y0 + y1) / 2, (v0 + v1) / 2);
mesh.castShadow = mesh.receiveShadow = true;
parent.add(mesh);
if (solid) footprints.push([[u0, v0], [u1, v0], [u1, v1], [u0, v1]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
return mesh;
};
const mat = (color, extra = {}) => new THREE.MeshStandardMaterial({color, roughness: .7, ...extra});
// Yard: asphalt, painted driveway, and the concrete fence with a gate onto Kaleh Street.
const yard = new THREE.Mesh(new THREE.PlaneGeometry(190, 100), mat('#8f8c86', {roughness: .95, polygonOffset: true, polygonOffsetFactor: -14, polygonOffsetUnits: -28}));
yard.rotation.x = -Math.PI / 2;
yard.position.set(-15, .08, 0);
yard.receiveShadow = true;
group.add(yard);
const fence = mat('#d9d3c4');
box(-110, 80, -50.3, -49.7, 0, 2.4, fence);
box(-110, 80, 49.7, 50.3, 0, 2.4, fence);
box(-110.3, -109.7, -50, 50, 0, 2.4, fence);
box(79.7, 80.3, -50, -9, 0, 2.4, fence);
box(79.7, 80.3, 9, 50, 0, 2.4, fence);
const teal = mat('#0b8f88', {metalness: .3, roughness: .4}), white = mat('#eef4f2', {roughness: .5});
for (const v of [-9.5, 9.5]) box(79, 81, v - 1, v + 1, 0, 4.2, teal);
// Gate sign with the company name.
const nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillRect(0, h - 18, w, 18);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#ffffff';
c.font = 'bold 66px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.name, w / 2, 120);
c.fillStyle = '#9fe9da';
c.font = '44px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.tagline, w / 2, 210);
c.font = 'bold 34px sans-serif';
c.fillStyle = '#f2c94c';
c.fillText('ROBOT MARKET · VENDING MACHINES', w / 2, 270);
});
const signMaterial = new THREE.MeshBasicMaterial({map: nameSign, side: THREE.DoubleSide});
const gateSign = new THREE.Mesh(new THREE.PlaneGeometry(14, 4.4), signMaterial);
gateSign.position.set(82, 6.6, -20);
gateSign.rotation.y = Math.PI / 2;
group.add(gateSign);
for (const v of [-25.5, -14.5]) box(81.6, 82.4, v - .3, v + .3, 0, 8.8, mat('#59646b', {metalness: .5}), false);
// Production hall: corrugated walls, teal band, a wide door facing the driveway.
const corrugated = textTexture(256, 64, (c, w, h) => stripes(c, w, h, '#c9d3d6', '#b2bec2', 16));
corrugated.wrapS = corrugated.wrapT = THREE.RepeatWrapping;
corrugated.repeat.set(12, 2);
const wall = mat('#ffffff', {map: corrugated, metalness: .35, roughness: .55});
const H = 10, U0 = -62, U1 = 2, V0 = 15, V1 = 45, D0 = -20, D1 = -4;
box(U0, U1, V1, V1 + .4, 0, H, wall);
box(U0 - .4, U0, V0, V1 + .4, 0, H, wall);
box(U1, U1 + .4, V0, V1 + .4, 0, H, wall);
box(U0 - .4, D0, V0 - .4, V0, 0, H, wall);
box(D1, U1 + .4, V0 - .4, V0, 0, H, wall);
box(D0, D1, V0 - .4, V0, 7, H, wall, false);
box(U0 - .4, U1 + .4, V0 - .5, V0 - .3, H - 1.6, H - .6, teal, false);
const roof = box(U0 - 1, U1 + 1, V0 - 1, V1 + 1, H, H + .4, mat('#9aa6aa', {metalness: .4}), false);
// The roof stays out of the shadow map, so daylight still reaches the hall floor.
roof.castShadow = false;
const floor = new THREE.Mesh(new THREE.PlaneGeometry(U1 - U0, V1 - V0), mat('#b9c4bd', {roughness: .35, polygonOffset: true, polygonOffsetFactor: -16, polygonOffsetUnits: -32}));
floor.rotation.x = -Math.PI / 2;
floor.position.set((U0 + U1) / 2, .1, (V0 + V1) / 2);
floor.receiveShadow = true;
group.add(floor);
const lamp = new THREE.MeshBasicMaterial({color: '#fffbe8'});
for (let u = U0 + 8; u < U1; u += 12) box(u - 3, u + 3, 29, 31, H - .3, H - .1, lamp, false);
const hallSign = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), signMaterial);
hallSign.position.set((D0 + D1) / 2, H + 3, V0 - .6);
hallSign.rotation.y = Math.PI;
group.add(hallSign);
for (const u of [(D0 + D1) / 2 - 7.5, (D0 + D1) / 2 + 7.5]) box(u - .2, u + .2, V0 - .7, V0 - .5, H, H + 5.5, mat('#59646b', {metalness: .5}), false);
// Assembly line: a conveyor carrying machine frames, robot arms, and finished machines in a row.
box(-60, -36, 33, 37, 0, .9, mat('#3d4549', {metalness: .5}));
const rollers = mat('#9aa4a8', {metalness: .8, roughness: .3});
for (let u = -59; u < -36; u += 1.2) {
const roller = new THREE.Mesh(new THREE.CylinderGeometry(.12, .12, 3.8, 8), rollers);
roller.rotation.x = Math.PI / 2;
roller.position.set(u, .95, 35);
group.add(roller);
}
const frames = [], frameMaterial = mat('#0b8f88', {wireframe: true});
for (let i = 0; i < 4; i++) {
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.1, 2.4, 1.8), i % 2 ? frameMaterial : teal);
frame.position.set(-58 + i * 6, 2.2, 35);
frame.castShadow = true;
group.add(frame);
frames.push(frame);
}
const arms = [];
for (const u of [-52, -42]) {
box(u - .6, u + .6, 39, 40.2, 0, 1.2, mat('#f2c94c'));
const arm = new THREE.Group();
arm.position.set(u, 1.2, 39.6);
group.add(arm);
const upper = new THREE.Mesh(new THREE.BoxGeometry(.35, 2.2, .35), mat('#f2c94c'));
upper.position.y = 1.1;
upper.castShadow = true;
const fore = new THREE.Mesh(new THREE.BoxGeometry(.28, .28, 2.4), mat('#e0b23a'));
fore.position.set(0, 2.1, -1.1);
fore.castShadow = true;
arm.add(upper, fore);
arms.push(arm);
}
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({name: 'آمادهٔ نصب', products: ['water', 'juice', 'biscuit', 'chocolate']});
finished.beacon.visible = finished.ring.visible = false;
finished.group.position.set(-32 + i * 4.2, 0, 43.4);
finished.group.rotation.y = Math.PI;
group.add(finished.group);
footprints.push([[-33 + i * 4.2, 42.6], [-31 + i * 4.2, 42.6], [-31 + i * 4.2, 44.6], [-33 + i * 4.2, 44.6]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
}
// Crates of Robot Market stock along the end wall.
const crate = mat('#b98b55');
for (let i = 0; i < 6; i++) box(-61, -58.6, 17 + i * 2.6, 19.2 + i * 2.6, 0, 1.2 + (i % 3) * 1.1, crate);
// Office block with the company name on its roof.
const windows = textTexture(128, 128, (c, w, h) => {
c.fillStyle = '#e9e4d8';
c.fillRect(0, 0, w, h);
c.fillStyle = '#4c6672';
c.fillRect(18, 22, 92, 70);
c.fillStyle = 'rgba(255,255,255,.25)';
c.fillRect(22, 26, 26, 62);
});
windows.wrapS = windows.wrapT = THREE.RepeatWrapping;
const officeMaterial = mat('#ffffff', {map: windows});
const office = box(8, 32, -34, -22, 0, 9.6, officeMaterial);
office.material = [officeMaterial, officeMaterial, mat('#bdb5a5'), mat('#bdb5a5'), officeMaterial, officeMaterial];
windows.repeat.set(6, 3);
const roofSign = new THREE.Mesh(new THREE.PlaneGeometry(22, 3.4), signMaterial);
roofSign.position.set(20, 11.6, -21.9);
roofSign.rotation.y = Math.PI;
group.add(roofSign);
for (const u of [11, 29]) box(u - .15, u + .15, -22.2, -21.9, 9.6, 13.2, mat('#59646b'), false);
// Staff cars in the car park.
for (let i = 0; i < 4; i++) {
const parked = createCarModel(['#2f4a6b', '#e9e9e4', '#7e1f1d', '#9aa3a8'][i]);
parked.group.position.set(36 + i * 4, 0, -18);
parked.group.rotation.y = Math.PI / 2;
group.add(parked.group);
footprints.push([[34.9 + i * 4, -20.3], [37.1 + i * 4, -20.3], [37.1 + i * 4, -15.7], [34.9 + i * 4, -15.7]].flatMap(([u, v]) => Object.values(toWorld(u, v))));
}
// The demo machine stands inside the hall facing the door; the family starts in front of it.
const demo = toWorld(-8, 41.5), facing = {x: -normal.x, z: -normal.z};
const car = toWorld(52, -6);
return {
footprints,
demo: {x: demo.x, z: demo.z, rot: Math.atan2(facing.x, facing.z)},
car: {x: car.x, z: car.z, heading: Math.atan2(-axis.z, axis.x)},
label: {name: FACTORY.name, kind: 'factory', ...toWorld(-20, 0)},
update(time) {
arms.forEach((arm, i) => arm.rotation.y = Math.sin(time * .8 + i * 2) * .9);
frames.forEach((frame, i) => frame.position.x = -58 + ((time * .6 + i * 6) % 24));
}
};
}
+19 -1
View File
@@ -1,6 +1,7 @@
// Robot Market machines in the playable Park Mellat area. Positions are path points taken from
// the OpenStreetMap data (local metres, see geo.js); the machine is placed beside that path.
export const CATEGORIES = {
factory: {label: 'کارخانه', icon: '🏭'},
park: {label: 'پارک', icon: '🌳'},
university: {label: 'دانشگاه', icon: '🎓'},
school: {label: 'مدرسه', icon: '🏫'},
@@ -10,7 +11,23 @@ export const CATEGORIES = {
const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = {
name: 'شرکت نگین سازان پیشگام رادمهر آتوسا',
tagline: 'تولیدکنندهٔ دستگاه‌های وندینگ روبات مارکت',
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: {x: -10308.4, z: -14964.2},
axis: {x: Math.SQRT1_2, z: Math.SQRT1_2}
};
export const machines = [
{
id: 'factory', name: 'دستگاه نمونهٔ کارخانه', category: 'factory',
address: `${FACTORY.address}، سالن تولید`,
anchor: null, hours: [0, 24 * 60], priceFactor: .5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه نمونهٔ خط تولید؛ نصف قیمت برای مهمان‌های کارخانه'
},
{
id: 'mellat-park', name: 'بوستان ملت', category: 'park',
address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
@@ -68,6 +85,7 @@ export const lockedPlaces = [
{name: 'حرم و مرکز شهر', note: 'خارج از محدودهٔ فعلی · به‌زودی'}
];
export const START = {machineId: 'mellat-park', carAnchor: {x: -445.8, z: -275.7}};
export const START = {machineId: 'factory'};
export const CITY_START = 'mellat-park';
export const machineById = id => machines.find(m => m.id === id) ?? null;
+217 -52
View File
@@ -1,16 +1,22 @@
import * as THREE from 'three';
import '@fontsource/vazirmatn/400.css';
import '@fontsource/vazirmatn/600.css';
import '@fontsource/vazirmatn/800.css';
import './style.css';
import {setupPwa} from './pwa.js';
import {startTracking, requestCompass, arrivalRadius, insideWorld, MAX_WALKING_SPEED} from './realworld.js';
import data from './data/mellat.json';
import iran from './data/iran.json';
import {createCity} from './city.js';
import {createCollisionWorld, addPolygon, resolveCircle, clearFraction} from './collision.js';
import {buildGraph, routeBetween, buildSegmentIndex, nearestSegment, DRIVABLE_MAX_TYPE} from './routing.js';
import {machines, CATEGORIES, START, lockedPlaces} from './locations.js';
import {machines, CATEGORIES, START, CITY_START, FACTORY, lockedPlaces} from './locations.js';
import {createState, catalog, priceAt, isOpen, purchaseBlocker, formatHetoman, formatClock, fa, canAffordAnything} from './game-state.js';
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from './payment.js';
import {awardsFor, rankFor, completeMissions, activeMission, MISSIONS} from './scoring.js';
import {createFamily} from './characters.js';
import {createVendingMachine} from './vending.js';
import {createFactory} from './factory.js';
import {createCar, stepCar, kmh} from './vehicle.js';
import {createCarModel, createTraffic} from './traffic.js';
import {createMapAssets, drawMinimap, drawFullMap, drawIran} from './map-render.js';
@@ -42,7 +48,7 @@ renderer.toneMappingExposure = 1.05;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog('#d8cdb4', 220, 1500);
const camera = new THREE.PerspectiveCamera(62, 1, .3, 6000);
const camera = new THREE.PerspectiveCamera(62, 1, .3, 7000);
// Morning sky over Mashhad: a gradient dome, warm haze at the horizon.
const sky = new THREE.Mesh(new THREE.SphereGeometry(5000, 32, 16), new THREE.ShaderMaterial({
@@ -69,8 +75,14 @@ const ROAD_HALF_WIDTH = [5.5, 5, 4.5, 4, 3.2, 3, 2.5, 2];
const rotateLocal = (lx, lz, rot) => ({x: lx * Math.cos(rot) + lz * Math.sin(rot), z: -lx * Math.sin(rot) + lz * Math.cos(rot)});
// Machines stand beside their path point, on whichever side is free, facing the path.
// The game starts inside the vending-machine factory on the Mashhad–Chenaran road.
const factory = createFactory(scene);
factory.footprints.forEach(f => addPolygon(collision, f));
// City machines stand beside their path point, on whichever side is free, facing the path;
// the factory's demo machine has a fixed place inside the production hall.
const stations = machines.map(def => {
if (!def.anchor) return placeStation(def, factory.demo, factory.demo.rot);
const seg = nearestSegment(segments, def.anchor.x, def.anchor.z, 40) ?? {dx: 1, dz: 0, type: 9};
const len = Math.hypot(seg.dx, seg.dz) || 1, nx = -seg.dz / len, nz = seg.dx / len;
const off = (seg.type <= DRIVABLE_MAX_TYPE ? ROAD_HALF_WIDTH[seg.type] : 1.2) + 2.4;
@@ -83,7 +95,11 @@ const stations = machines.map(def => {
}
}
pos ??= {x: def.anchor.x + nx * off, z: def.anchor.z + nz * off};
const model = createVendingMachine(def), rot = Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z);
return placeStation(def, pos, Math.atan2(def.anchor.x - pos.x, def.anchor.z - pos.z));
});
function placeStation(def, pos, rot) {
const model = createVendingMachine(def);
model.group.position.set(pos.x, 0, pos.z);
model.group.rotation.y = rot;
scene.add(model.group);
@@ -93,16 +109,17 @@ const stations = machines.map(def => {
}));
const front = rotateLocal(0, 1.9, rot);
return {def, model, x: pos.x, z: pos.z, rot, front: {x: pos.x + front.x, z: pos.z + front.z}};
});
}
const stationById = id => stations.find(s => s.def.id === id);
createCity(scene, data, {collision, segments});
const traffic = createTraffic(scene, data, 34);
const mapAssets = createMapAssets(data);
const city = createCity(scene, data, {collision, segments});
const traffic = createTraffic(scene, data, 28);
const mapAssets = createMapAssets(data, [factory.label]);
// Park gate sign at the start, so the first view says where you are.
// Park gate sign at the first city machine.
{
const start = stationById(START.machineId), sign = document.createElement('canvas');
const start = stationById(CITY_START), sign = document.createElement('canvas');
sign.width = 512;
sign.height = 128;
const c = sign.getContext('2d');
@@ -133,19 +150,18 @@ const mapAssets = createMapAssets(data);
// ---------- Actors ----------
const family = createFamily();
scene.add(family.group);
// The Robot Market guide lives inside the machines and comes out when the family arrives.
const guide = createFamily({robot: true});
guide.group.visible = false;
scene.add(guide.group);
const carModel = createCarModel('#f2f2ee');
scene.add(carModel.group);
let state, payment, player, car, mode, cam, waypoint, route, routeTimer, deliveryTimer, ended;
const keys = new Set();
function spawnCar() {
const seg = nearestSegment(segments, START.carAnchor.x, START.carAnchor.z, 60, s => s[4] <= DRIVABLE_MAX_TYPE);
const len = Math.hypot(seg.dx, seg.dz) || 1, heading = Math.atan2(-seg.dz / len, seg.dx / len);
// Park on the right-hand kerb of the lane.
const right = {x: Math.sin(heading), z: Math.cos(heading)};
return createCar(seg.x + right.x * 1.2, seg.z + right.z * 1.2, heading);
}
// The family car waits in the factory yard, pointing at the gate.
const spawnCar = () => createCar(factory.car.x, factory.car.z, factory.car.heading);
function reset() {
state = createState();
@@ -162,7 +178,8 @@ function reset() {
routeTimer = 0;
deliveryTimer = 0;
ended = false;
hints = {near: false, closed: false, car: false};
hints = {closed: false, car: false};
Object.assign(guideState, {station: null, t: 0, greeted: new Set(), arrived: 0});
$('#payment').hidden = true;
for (const s of stations) {
s.model.delivery.visible = s.model.card.visible = false;
@@ -269,9 +286,10 @@ const axis = (pos, neg) => Number(pos.some(k => keys.has(k))) - Number(neg.some(
const SPEAKERS = {robot: 'روبات', father: 'پدر', child: 'بچه'};
let lines = [], lineTimer = 0, talking = false;
function say(speaker, text, seconds = Math.max(2.6, text.length * .06)) {
// `face` picks the robot's expression portrait (public/robot/<face>.webp).
function say(speaker, text, face = 'happy', seconds = Math.max(2.6, text.length * .06)) {
if (lines.some(l => l.text === text)) return;
lines.push({speaker, text, seconds});
lines.push({speaker, text, face, seconds});
}
function updateSubtitles(dt) {
@@ -284,6 +302,8 @@ function updateSubtitles(dt) {
$('#subtitle-speaker').textContent = `${SPEAKERS[next.speaker]}:`;
$('#subtitle-speaker').className = next.speaker;
$('#subtitle-text').textContent = next.text;
$('#subtitle-face').hidden = next.speaker !== 'robot';
if (next.speaker === 'robot') $('#subtitle-face').src = `./robot/${next.face}.webp`;
lineTimer = next.seconds;
}
@@ -327,12 +347,16 @@ $('#sound').onclick = async () => {
// ---------- Car ----------
function toggleCar() {
if (payment.phase !== 'idle' || state.busy) return;
if (real.on) {
say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.');
return;
}
if (mode === 'walk') {
if (Math.hypot(car.x - player.x, car.z - player.z) > 4.5) return;
mode = 'drive';
cam.distance = Math.max(cam.distance, 8);
tone(320, .2, .05, 'square');
say('robot', 'کمربندها بسته؟ مسیر طلایی روی زمین راهنمای شماست.');
say('father', 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.');
} else {
if (Math.abs(car.speed) > 3) {
say('father', 'اول ماشین را نگه دار، بعد پیاده می‌شویم.');
@@ -351,15 +375,21 @@ function toggleCar() {
}
// ---------- Machines, shop and payment ----------
const nearStation = () => mode === 'walk' ? stations.find(s => Math.hypot(s.front.x - player.x, s.front.z - player.z) < 3.4) : null;
const hoursText = m => `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
// In real-world mode "at the machine" is a GPS radius, because the fix itself is off by metres.
const reachRadius = () => real.on && real.fix ? arrivalRadius(real.fix.accuracy) : 3.4;
const nearStation = () => mode === 'walk' ? stations.find(s => Math.hypot(s.front.x - player.x, s.front.z - player.z) < reachRadius()) : null;
const hoursText = m => m.hours[1] - m.hours[0] >= 24 * 60 ? 'شبانه‌روزی' : `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
let shopStation = null;
function interact() {
const station = nearStation();
if (!station || state.busy || payment.phase !== 'idle' || ended) return;
if (real.on && (real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.');
return;
}
if (!isOpen(station.def, state.minutes)) {
say('robot', `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`);
say(guideOut() ? 'robot' : 'father', `این دستگاه الان بسته است. ساعت کاری: ${hoursText(station.def)}.`, 'serious');
return;
}
shopStation = station;
@@ -464,6 +494,7 @@ function updateDelivery(dt) {
function reward(record, product) {
const awards = awardsFor(state.purchases.slice(0, -1), record), rankBefore = rankFor(state.xp).index;
if (real.on) awards.push({label: 'خرید در دنیای واقعی', points: 200});
const total = awards.reduce((s, a) => s + a.points, 0);
state.xp += total;
$('#awards').replaceChildren(
@@ -489,7 +520,7 @@ function reward(record, product) {
if (next) {
waypoint = null;
route = null;
setTimeout(() => say('robot', `مأموریت بعدی: ${next.title}`), 1500);
setTimeout(() => say(guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'), 1500);
}
refreshHUD();
checkEnd();
@@ -592,7 +623,10 @@ function updateRoute(dt) {
routeTarget = target;
routeMode = mode;
routeFrom = {x: pos.x, z: pos.z};
route = routeBetween(mode === 'drive' ? driveGraph : walkGraph, pos.x, pos.z, target.front.x, target.front.z);
// Close targets are reached directly; the street network would detour out of buildings.
const direct = Math.hypot(target.front.x - pos.x, target.front.z - pos.z);
route = direct < 60 ? {points: [{x: pos.x, z: pos.z}, {x: target.front.x, z: target.front.z}], length: direct}
: routeBetween(mode === 'drive' ? driveGraph : walkGraph, pos.x, pos.z, target.front.x, target.front.z);
rebuildRouteMesh();
}
@@ -734,7 +768,7 @@ $('#route-button').onclick = () => {
routeTimer = 0;
updateRoute(0);
const s = stationById(selected);
say('robot', `مسیر تا ${s.def.name} مشخص شد؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بیا.`);
say('father', `مسیر تا ${s.def.name} رو گذاشتم؛ ${route ? formatDistance(route.length) : ''}. دنبال خط طلایی بریم.`);
selectMachine(selected);
};
@@ -785,7 +819,7 @@ mapCanvas.addEventListener('pointerup', e => {
});
mapCanvas.addEventListener('wheel', e => {
e.preventDefault();
mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), .35, 7);
mapView.mpp = THREE.MathUtils.clamp(mapView.mpp * (e.deltaY > 0 ? 1.15 : 1 / 1.15), .35, 30);
renderMap();
}, {passive: false});
@@ -797,23 +831,96 @@ const CITIES = [
{name: 'زاهدان', lat: 29.50, lon: 60.86}, {name: 'کرمانشاه', lat: 34.31, lon: 47.07}, {name: 'بندرعباس', lat: 27.18, lon: 56.27}
];
const BRIEF = [
'موقعیت: مشهد، بوستان ملت، بزرگراه وکیل‌آباد',
'تیم: پدر، فرزند و روبات راهنمای روبات مارکت',
'هدف: دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید و از آن‌ها خرید کنید.',
`نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست',
'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.',
'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.'
].join('\n');
let briefChars = 0;
$('#start-mission').onclick = () => {
function startMission() {
$('#briefing').hidden = true;
$('#hud').hidden = false;
say('robot', 'سلام! من روبات راهنمای روبات مارکت هستم. اولین دستگاه همین‌جا، کنار ورودی بوستان ملته.');
say('child', 'بابا، بریم اولین خریدمون رو بکنیم!');
say('robot', 'نزدیک دستگاه برو و E را بزن. نقشه هم گوشهٔ صفحه است؛ با M بازش کن.');
if (!isTouch) canvas.requestPointerLock?.()?.catch?.(() => {});
};
say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟');
say('father', 'آره! بیا جلوی دستگاه نمونه وایسیم ببینیم چی می‌شه.');
if (!isTouch && !real.on) canvas.requestPointerLock?.()?.catch?.(() => {});
}
$('#start-mission').onclick = startMission;
// ---------- Real-world (GPS) mode ----------
// The family mirrors the phone's position on the real map of Mashhad. Walking to a real place where
// a machine stands lets the player buy there, with a bonus.
const real = {on: false, fix: null, heading: null, error: null, stop: null};
function setRealMode(on) {
if (on === real.on) return;
real.stop?.();
Object.assign(real, {on, fix: null, heading: null, error: null, stop: null});
$('#gps-toggle').classList.toggle('on', on);
$('#gps-status').hidden = !on;
if (!on) return;
if (mode === 'drive') toggleCar();
mode = 'walk';
real.stop = startTracking({
onFix: fix => {
real.fix = fix;
real.error = null;
},
onHeading: heading => real.heading = heading,
onError: message => real.error = message
});
say('father', 'حالت دنیای واقعی روشن شد. هر جا ما بریم، روی نقشه هم همون‌جا هستیم.');
}
// The safety notice comes first; permissions are requested from the tap on its button (iOS needs that).
function askRealMode(thenStart) {
if (real.on) {
setRealMode(false);
return;
}
$('#real-intro').showModal();
$('#real-go').onclick = async () => {
$('#real-intro').close();
await requestCompass();
setRealMode(true);
if (thenStart) startMission();
};
}
$('#real-mode').onclick = () => askRealMode(true);
$('#gps-toggle').onclick = () => askRealMode(false);
$('#real-cancel').onclick = () => $('#real-intro').close();
function updateReal(dt) {
const status = $('#gps-status');
if (real.error) status.textContent = `⚠ ${real.error}`;
else if (!real.fix) status.textContent = '📍 در حال یافتن موقعیت…';
else if (!insideWorld(data.bounds, real.fix.x, real.fix.z)) {
const nearest = Math.min(...stations.map(s => Math.hypot(s.x - real.fix.x, s.z - real.fix.z)));
status.textContent = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`;
} else status.textContent = `📍 GPS ±${fa(Math.round(real.fix.accuracy))} متر${real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
if (!real.fix || !insideWorld(data.bounds, real.fix.x, real.fix.z)) {
player.speed = 0;
return;
}
// Walk the avatar towards the (building-free) GPS point instead of teleporting it.
const target = resolveCircle(collision, real.fix.x, real.fix.z, 1);
const dx = target.x - player.x, dz = target.z - player.z, d = Math.hypot(dx, dz);
if (d > 150) Object.assign(player, {x: target.x, z: target.z});
const speed = d > .5 ? Math.min(8, Math.max(1.2, d * 1.5)) : 0;
player.speed += (speed - player.speed) * Math.min(1, dt * 6);
if (d > .5) {
const step = Math.min(d, player.speed * dt);
player.x += dx / d * step;
player.z += dz / d * step;
}
const want = real.heading ?? (d > .5 ? Math.atan2(-dz, dx) : player.heading);
player.heading += Math.atan2(Math.sin(want - player.heading), Math.cos(want - player.heading)) * Math.min(1, dt * 5);
player.gait += dt * player.speed * 1.9;
}
// ---------- Loop ----------
const camPos = new THREE.Vector3(), camTarget = new THREE.Vector3(), lookAt = new THREE.Vector3();
@@ -936,7 +1043,7 @@ function updateHUD(dt) {
drawCompass(headingDeg, objective ? [{bearing: bearing(objective.x - pos.x, objective.z - pos.z), color: '#f2c94c'}] : []);
drawMinimap(minimap, mapAssets, {
size: 220, x: pos.x, z: pos.z, heading: mode === 'drive' ? car.heading : player.heading, camForward: forward,
radius: mode === 'drive' ? 420 : 200, markers: markers(), route: route?.points
radius: mode === 'drive' ? 650 : 200, markers: markers(), route: route?.points
});
// Objective marker above the machine, clamped to the screen edge when off-screen.
const wp = $('#waypoint');
@@ -949,7 +1056,7 @@ function updateHUD(dt) {
sy = innerHeight - 60;
}
$('#waypoint').style.left = `${THREE.MathUtils.clamp(sx, 60, innerWidth - 60)}px`;
$('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 60)}px`;
$('#waypoint').style.top = `${THREE.MathUtils.clamp(sy, 90, innerHeight - 130)}px`;
$('#waypoint-name').textContent = objective.def.name;
$('#waypoint-distance').textContent = formatDistance(d);
wp.hidden = d < 6;
@@ -963,20 +1070,73 @@ function updateHUD(dt) {
if (streetTimer <= 0) {
streetTimer = .6;
const street = nearestSegment(segments, pos.x, pos.z, 35, s => s[5] >= 0);
$('#street').textContent = street ? `مشهد · ${data.names[street.name]}` : 'مشهد · محدودهٔ بوستان ملت';
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
$('#street').textContent = inFactory ? `کارخانهٔ ${FACTORY.name}` : street ? `مشهد · ${data.names[street.name]}` : 'حومهٔ مشهد';
}
updatePrompt();
}
let hints = {near: false, closed: false, car: false};
let hints = {closed: false, car: false};
// ---------- Robot guide ----------
// When the family walks up to a machine the robot pops out of it, greets them and explains what to
// do; when they leave it goes back inside. It is never a walking companion.
const guideState = {station: null, t: 0, greeted: new Set(), arrived: 0};
const guideOut = () => guideState.t >= 1;
const easeOutBack = t => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
function greet(station) {
const def = station.def, first = !guideState.greeted.has(def.id);
guideState.greeted.add(def.id);
if (def.id === 'factory' && first) {
say('robot', 'سلام! من روبات راهنمای روبات مارکتم؛ همین الان از خط تولید اومدم بیرون!', 'excited');
say('robot', `به ${FACTORY.name} خوش اومدین. همهٔ دستگاه‌های شهر همین‌جا ساخته می‌شن.`, 'happy');
say('robot', 'اول از همین دستگاه نمونه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
say('robot', 'بعد ماشینتون تو حیاطه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', 'love');
return;
}
if (!isOpen(def, state.minutes)) {
say('robot', `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hoursText(def)}.`, 'serious');
return;
}
if (first) {
say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
} else say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
}
function updateGuide(dt) {
const near = mode === 'walk' ? stations.find(s => Math.hypot(s.front.x - player.x, s.front.z - player.z) < Math.max(9, reachRadius())) : null;
// The robot only moves to another machine after it has gone back into the previous one.
if (near && !guideState.station) guideState.station = near;
const s = guideState.station, wasOut = guideOut();
if (!s) return;
guideState.t = THREE.MathUtils.clamp(guideState.t + (near === s ? dt : -dt) / 1.1, 0, 1);
if (guideOut() && !wasOut) {
guideState.arrived = elapsed;
greet(s);
tone(990, .15);
tone(1320, .2);
}
if (wasOut && near !== s) say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
if (!guideState.t) {
guideState.station = null;
guide.group.visible = false;
return;
}
// From inside the machine front out to its left side (the card reader is on the right).
const e = easeOutBack(guideState.t), from = {x: -.2, y: .9, z: .2}, to = {x: -1.7, y: 0, z: 1.3};
const local = rotateLocal(from.x + (to.x - from.x) * e, from.z + (to.z - from.z) * e, s.rot);
guide.group.visible = true;
guide.group.position.set(s.x + local.x, from.y + (to.y - from.y) * e, s.z + local.z);
guide.group.scale.setScalar(.15 + .85 * Math.min(1, e));
// Face the family (the rig looks along its local +X).
guide.group.rotation.y = Math.atan2(-(player.z - guide.group.position.z), player.x - guide.group.position.x);
if (!state.busy && payment.phase === 'idle') s.model.flap.rotation.x = guideState.t < 1 ? -1 : 0;
guide.update({dt, time: elapsed, gait: 0, moving: false, talking, wave: elapsed - guideState.arrived < 2.5 || !guideOut(), point: talking && guideOut()});
}
function updateHints() {
const station = nearStation();
if (station && !hints.near && isOpen(station.def, state.minutes)) {
hints.near = true;
say('robot', `رسیدیم به دستگاه ${station.def.name}. برای خرید E را بزن.`);
}
if (!station) hints.near = false;
const target = objectiveStation();
if (target && !isOpen(target.def, state.minutes) && !hints.closed) {
hints.closed = true;
@@ -984,7 +1144,7 @@ function updateHints() {
}
if (route && route.length > 900 && mode === 'walk' && !hints.car) {
hints.car = true;
say('father', 'راه طولانیه؛ بهتره با ماشین بریم. ماشین کنار ایستگاه مترو پارکه.');
say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
}
}
@@ -1010,14 +1170,16 @@ function frame() {
if (!paused) {
for (let i = 0; i < steps; i++) {
state.minutes += step * 10 / 60;
if (mode === 'drive') updateDrive(step);
if (real.on) updateReal(step);
else if (mode === 'drive') updateDrive(step);
else updateWalk(step);
updatePayment(step);
updateDelivery(step);
traffic.update(step, [{x: player.x, z: player.z}, {x: car.x, z: car.z}]);
traffic.update(step, [{x: player.x, z: player.z}, {x: car.x, z: car.z}], player);
}
updateRoute(dt);
updateHints();
updateGuide(dt);
if (Math.floor(elapsed * 2) !== Math.floor((elapsed - dt) * 2)) checkEnd();
} else if (engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .1);
if (mode === 'walk' && engine && soundOn) engine.g.gain.setTargetAtTime(0, audio.currentTime, .2);
@@ -1026,7 +1188,8 @@ function frame() {
family.group.visible = mode === 'walk';
family.group.position.set(player.x, 0, player.z);
family.group.rotation.y = player.heading;
family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0, talking});
family.update({dt, time: elapsed, gait: player.gait, moving: player.speed > .4, shopping: state.busy || payment.phase !== 'idle', delivery: deliveryTimer > 0});
factory.update(elapsed);
carModel.group.position.set(car.x, 0, car.z);
carModel.group.rotation.y = car.heading;
for (const [i, wheel] of carModel.wheels.entries()) {
@@ -1046,6 +1209,7 @@ function frame() {
sun.position.set(player.x + 160, 230, player.z + 110);
sun.target.position.set(player.x, 0, player.z);
sky.position.copy(camera.position);
city.update(camera.position);
if (playing) {
hudTimer -= dt;
updateHUD(dt);
@@ -1071,9 +1235,10 @@ if (new URLSearchParams(location.search).has('debug')) window.game = {
return payment;
}, get elapsed() {
return elapsed;
}, stations
}, stations, guideState, real, keys
};
reset();
resize();
setupPwa();
$('#loading').remove();
requestAnimationFrame(frame);
+105 -32
View File
@@ -1,41 +1,93 @@
// 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'};
// Tactical 2D maps drawn from the same OSM data as the 3D city. The region is too large for one
// raster, so detail is drawn in lazily rendered, cached tiles and an overview covers zoomed-out views.
const SCALE = .75; // tile pixels per metre
const TILE_PX = 512, TILE_M = TILE_PX / SCALE, MAX_TILES = 48;
const OVERVIEW_SCALE = .07;
const AREA = {campus: '#28322c', park: '#2c4a32', grass: '#30503a', forest: '#27432e', pitch: '#33603c', water: '#2b5b6e', industrial: '#262c29', farmland: '#2a3326'};
const ROAD_WIDTH = [11, 10, 9, 8, 6.5, 6, 5, 4, 4, 2.4, 2, 1.6, 3, 2.2];
const roadColor = type => type <= 2 ? '#d7dcd3' : type <= 7 ? '#8e9891' : '#5f6b63';
const tileKey = (tx, tz) => tx * 100003 + tz;
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) {
function path(c, pts) {
c.beginPath();
c.moveTo(pts[0], pts[1]);
for (let i = 2; i < pts.length; i += 2) c.lineTo(pts[i], pts[i + 1]);
}
// Draws features in world metres; the caller sets up the transform. Minor roads go first.
function drawFeatures(c, {areas, roads, buildings}, data, minWidth) {
for (const area of areas) {
c.fillStyle = AREA[data.areaKinds[area[0]]];
path(area.slice(1));
path(c, 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])) {
for (const road of roads) {
c.strokeStyle = roadColor(road[0]);
c.lineWidth = Math.max(1.6, ROAD_WIDTH[road[0]]);
path(road.slice(2));
c.lineWidth = Math.max(minWidth, ROAD_WIDTH[road[0]]);
path(c, road.slice(2));
c.stroke();
}
c.fillStyle = '#3c4943';
for (const b of data.buildings) {
path(b.slice(1));
for (const b of buildings) {
path(c, b.slice(1));
c.fill();
}
}
export function createMapAssets(data, extraLabels = []) {
const {minX, minZ, maxX, maxZ} = data.bounds;
// Bin every feature into the tiles its bounding box touches.
const bins = new Map();
const bin = (list, feature, first) => {
let x0 = Infinity, z0 = Infinity, x1 = -Infinity, z1 = -Infinity;
for (let i = first; i < feature.length; i += 2) {
x0 = Math.min(x0, feature[i]);
x1 = Math.max(x1, feature[i]);
z0 = Math.min(z0, feature[i + 1]);
z1 = Math.max(z1, feature[i + 1]);
}
for (let tx = Math.floor((x0 - 8) / TILE_M); tx <= Math.floor((x1 + 8) / TILE_M); tx++)
for (let tz = Math.floor((z0 - 8) / TILE_M); tz <= Math.floor((z1 + 8) / TILE_M); tz++) {
const k = tileKey(tx, tz);
if (!bins.has(k)) bins.set(k, {areas: [], roads: [], buildings: []});
bins.get(k)[list].push(feature);
}
};
data.areas.forEach(a => bin('areas', a, 1));
[...data.roads].sort((a, b) => b[0] - a[0]).forEach(r => bin('roads', r, 2));
data.buildings.forEach(b => bin('buildings', b, 1));
const cache = new Map();
function tile(tx, tz) {
const k = tileKey(tx, tz);
if (cache.has(k)) {
const canvas = cache.get(k);
cache.delete(k);
cache.set(k, canvas);
return canvas;
}
const canvas = document.createElement('canvas');
canvas.width = canvas.height = TILE_PX;
const c = canvas.getContext('2d');
c.fillStyle = '#1b231f';
c.fillRect(0, 0, TILE_PX, TILE_PX);
c.setTransform(SCALE, 0, 0, SCALE, -tx * TILE_M * SCALE, -tz * TILE_M * SCALE);
drawFeatures(c, bins.get(k) ?? {areas: [], roads: [], buildings: []}, data, 1.6);
cache.set(k, canvas);
if (cache.size > MAX_TILES) cache.delete(cache.keys().next().value);
return canvas;
}
const overview = document.createElement('canvas');
overview.width = Math.ceil((maxX - minX) * OVERVIEW_SCALE);
overview.height = Math.ceil((maxZ - minZ) * OVERVIEW_SCALE);
const o = overview.getContext('2d');
o.fillStyle = '#1b231f';
o.fillRect(0, 0, overview.width, overview.height);
o.setTransform(OVERVIEW_SCALE, 0, 0, OVERVIEW_SCALE, -minX * OVERVIEW_SCALE, -minZ * OVERVIEW_SCALE);
drawFeatures(o, {areas: data.areas, roads: data.roads.filter(r => r[0] <= 5).sort((a, b) => b[0] - a[0]), buildings: []}, data, 14);
// One label per named boulevard, at the middle of its longest way.
const streets = new Map();
for (const road of data.roads) {
@@ -49,7 +101,22 @@ export function createMapAssets(data) {
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)};
return {tile, overview, bounds: data.bounds, labels: [...data.labels, ...extraLabels], streets: [...streets.values()].filter(s => s.len > 150)};
}
// Paints the map for the world rectangle x0..x1, z0..z1 into a context already transformed to world
// metres; pxPerMetre decides between detailed tiles and the overview.
function paintWorld(c, assets, x0, z0, x1, z1, pxPerMetre) {
const {minX, minZ} = assets.bounds;
c.fillStyle = '#141b18';
c.fillRect(x0, z0, x1 - x0, z1 - z0);
if (pxPerMetre < .22) {
c.drawImage(assets.overview, minX, minZ, assets.overview.width / OVERVIEW_SCALE, assets.overview.height / OVERVIEW_SCALE);
return;
}
for (let tx = Math.floor(x0 / TILE_M); tx <= Math.floor(x1 / TILE_M); tx++)
for (let tz = Math.floor(z0 / TILE_M); tz <= Math.floor(z1 / TILE_M); tz++)
c.drawImage(assets.tile(tx, tz), tx * TILE_M, tz * TILE_M, TILE_M + .5, TILE_M + .5);
}
function rotatePoint(x, z, cx, cz, angle, k) {
@@ -100,8 +167,9 @@ export function drawMinimap(ctx, assets, view) {
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.scale(k, k);
ctx.translate(-x, -z);
paintWorld(ctx, assets, x - radius * 1.5, z - radius * 1.5, x + radius * 1.5, z + radius * 1.5, k);
ctx.restore();
if (route?.length > 1) {
ctx.strokeStyle = '#f2c94c';
@@ -162,9 +230,12 @@ export function drawFullMap(ctx, assets, view, state) {
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);
ctx.save();
ctx.translate(w / 2, h / 2);
ctx.scale(1 / view.mpp, 1 / view.mpp);
ctx.translate(-view.cx, -view.cz);
paintWorld(ctx, assets, view.cx - w / 2 * view.mpp, view.cz - h / 2 * view.mpp, view.cx + w / 2 * view.mpp, view.cz + h / 2 * view.mpp, 1 / view.mpp);
ctx.restore();
// Grid lines every 500 m, like a tactical map.
ctx.strokeStyle = 'rgba(160,200,185,.08)';
ctx.lineWidth = 1;
@@ -204,7 +275,9 @@ export function drawFullMap(ctx, assets, view, state) {
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];
if (l.kind === 'industrial' && view.mpp > 6) continue;
ctx.fillStyle = {park: '#9fd49a', university: '#c9b6f2', school: '#e8c79a', metro: '#ff8a80', mall: '#f5b8d9', industrial: '#a9b2ad', factory: '#3ee0c9'}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name, sx, sy + 14);
}
if (state.route?.length > 1) {
+49
View File
@@ -0,0 +1,49 @@
// Installable app: registers the offline service worker, offers "install" where the browser allows
// it (Android/desktop Chrome), shows Add-to-Home-Screen steps on iOS, and offers a reload on updates.
const $ = s => document.querySelector(s);
export function setupPwa() {
const installButton = $('#install');
const standalone = matchMedia('(display-mode: standalone), (display-mode: fullscreen)').matches || navigator.standalone;
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
let deferred = null;
if (!standalone && ios) installButton.hidden = false;
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
deferred = event;
installButton.hidden = false;
});
window.addEventListener('appinstalled', () => installButton.hidden = true);
installButton.onclick = async () => {
if (deferred) {
deferred.prompt();
await deferred.userChoice;
deferred = null;
installButton.hidden = true;
} else if (ios) $('#ios-install').showModal();
};
$('#ios-close').onclick = () => $('#ios-install').close();
// Only built pages have sw.js; the dev server serves source files directly.
if (!('serviceWorker' in navigator) || import.meta.env.DEV) return;
navigator.serviceWorker.register('./sw.js').then(registration => {
const offer = worker => {
$('#update-toast').hidden = false;
$('#update-reload').onclick = () => worker.postMessage('skip-waiting');
};
if (registration.waiting && navigator.serviceWorker.controller) offer(registration.waiting);
registration.addEventListener('updatefound', () => {
const worker = registration.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
});
});
}).catch(() => {});
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return;
reloading = true;
location.reload();
});
}
+60
View File
@@ -0,0 +1,60 @@
import {toLocal} from './geo.js';
// Real-world mode: the family follows the phone's GPS position. Phone GPS in a city is off by
// 5–20 m and jumps around, so fixes are blended by their accuracy and arrival uses a wide radius.
export const MAX_WALKING_SPEED = 6; // m/s (~22 km/h); faster means a vehicle, so buying pauses.
// Blends a new fix into the previous estimate, trusting whichever is more accurate.
export function smoothFix(previous, fix) {
if (!previous || fix.time - previous.time > 30000) return {...fix};
const weight = previous.accuracy ** 2 / (previous.accuracy ** 2 + fix.accuracy ** 2);
const dt = Math.max(.001, (fix.time - previous.time) / 1000);
const x = previous.x + (fix.x - previous.x) * weight, z = previous.z + (fix.z - previous.z) * weight;
// The blended estimate is a little better than either input, but never better than 3 m.
const accuracy = Math.max(3, Math.sqrt(1 / (1 / previous.accuracy ** 2 + 1 / fix.accuracy ** 2)) + Math.hypot(x - previous.x, z - previous.z) * .2);
return {x, z, accuracy, time: fix.time, speed: fix.speed ?? Math.hypot(x - previous.x, z - previous.z) / dt};
}
// How close counts as "at the machine": never tighter than 20 m, wider when GPS is poor.
export const arrivalRadius = accuracy => Math.min(40, Math.max(20, accuracy * 1.5));
export const insideWorld = (bounds, x, z) => x > bounds.minX && x < bounds.maxX && z > bounds.minZ && z < bounds.maxZ;
// Watches the phone's position and compass. Calls onFix({x, z, accuracy, speed, lat, lon}),
// onHeading(radians in the game's heading convention) and onError(message).
export function startTracking({onFix, onHeading, onError}) {
if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
return () => {};
}
let estimate = null;
const watch = navigator.geolocation.watchPosition(position => {
const {latitude: lat, longitude: lon, accuracy, speed} = position.coords, local = toLocal(lat, lon);
estimate = smoothFix(estimate, {...local, accuracy: Math.max(3, accuracy), speed: Number.isFinite(speed) ? speed : null, time: position.timestamp});
onFix({...estimate, lat, lon});
}, error => onError(error.code === error.PERMISSION_DENIED ? 'اجازهٔ دسترسی به موقعیت داده نشد. از تنظیمات مرورگر اجازه بدهید.' : 'موقعیت پیدا نشد؛ به فضای باز بروید.'), {
enableHighAccuracy: true, maximumAge: 2000, timeout: 20000
});
// Compass: iOS reports webkitCompassHeading; Android reports absolute alpha (counter-clockwise).
const orientation = event => {
const compass = event.webkitCompassHeading ?? (event.absolute && event.alpha != null ? 360 - event.alpha : null);
// Game heading 0 faces east and grows counter-clockwise, compass 0 faces north and grows clockwise.
if (compass != null) onHeading(Math.PI / 2 - compass * Math.PI / 180);
};
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(eventName, orientation);
return () => {
navigator.geolocation.clearWatch(watch);
window.removeEventListener(eventName, orientation);
};
}
// iOS needs an explicit permission prompt for the compass, and it must come from a tap.
export async function requestCompass() {
try {
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) await DeviceOrientationEvent.requestPermission();
} catch {
// Without the compass the family faces the direction of travel instead.
}
}
+12 -5
View File
@@ -1,5 +1,8 @@
import {CATEGORIES} from './locations.js';
// The bonus for visiting every kind of place counts city locations, not the factory.
const CITY_CATEGORIES = Object.keys(CATEGORIES).filter(c => c !== 'factory');
export const POINTS = {purchase: 100, discovery: 250, combo: 150, allCategories: 500};
export const RANKS = [
@@ -19,8 +22,8 @@ export function awardsFor(previous, record) {
// 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)
const categories = new Set(previous.map(p => p.category).filter(c => CITY_CATEGORIES.includes(c)));
if (CITY_CATEGORIES.includes(record.category) && !categories.has(record.category) && categories.size === CITY_CATEGORIES.length - 1)
awards.push({label: 'همهٔ انواع مکان', points: POINTS.allCategories});
return awards;
}
@@ -36,7 +39,11 @@ const placesVisited = purchases => new Set(purchases.map(p => p.machineId)).size
export const MISSIONS = [
{
id: 'first', title: 'اولین خرید را از دستگاه بوستان ملت انجام بده', target: 'mellat-park', xp: 150, hetoman: 30,
id: 'factory', title: 'در سالن تولید از دستگاه نمونهٔ کارخانه خرید کن', target: 'factory', xp: 100, hetoman: 20,
done: ps => ps.some(p => p.machineId === 'factory')
},
{
id: 'first', title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده', target: 'mellat-park', xp: 150, hetoman: 30,
done: ps => ps.some(p => p.machineId === 'mellat-park')
},
{
@@ -56,8 +63,8 @@ export const MISSIONS = [
done: ps => ps.some(p => p.machineId === 'mashhad-mall')
},
{
id: 'explorer', title: 'از ۵ مکان متفاوت خرید کن', target: null, xp: 400, hetoman: 80,
done: ps => placesVisited(ps) >= 5
id: 'explorer', title: 'از ۵ مکان متفاوت شهر خرید کن', target: null, xp: 400, hetoman: 80,
done: ps => placesVisited(ps.filter(p => p.category !== 'factory')) >= 5
}
];
+33
View File
@@ -8,6 +8,7 @@
--teal: #3ee0c9;
--red: #ff6b5e;
--font: 'Vazirmatn', Tahoma, sans-serif;
--safe-top: env(safe-area-inset-top, 0px);
}
* { box-sizing: border-box; }
@@ -123,6 +124,10 @@ button { font: inherit; cursor: pointer; }
background: rgba(0, 0, 0, .55); text-align: center; font-size: 16px; line-height: 1.7;
}
#subtitle b { margin-inline-end: 8px; }
#subtitle { display: flex; align-items: center; gap: 4px; }
#subtitle[hidden] { display: none; }
#subtitle-face { width: 52px; height: 52px; object-fit: cover; border-radius: 50%; margin-inline-end: 10px; background: #f7fcfc; border: 2px solid var(--teal); }
#subtitle-face[hidden] { display: none; }
#subtitle b.robot { color: var(--teal); }
#subtitle b.father { color: var(--gold); }
#subtitle b.child { color: #ff9f7a; }
@@ -275,3 +280,31 @@ dialog header { display: flex; justify-content: space-between; align-items: star
.map-body { grid-template-columns: 1fr; grid-template-rows: 1fr 40%; }
.map-side { border-inline-start: 0; border-top: 1px solid var(--line); }
}
/* ---------- PWA: install and update ---------- */
.brief-robot { float: left; width: 110px; height: auto; margin: -8px 0 0 8px; filter: drop-shadow(0 8px 18px rgba(62, 224, 201, .25)); }
.brief-actions { display: flex; gap: 8px; margin-top: 8px; }
.brief-actions button { flex: 1; padding: 10px; font-size: 13px; }
.brief-actions button[hidden] { display: none; }
#ios-install { width: min(420px, 92vw); padding: 22px; border-top: 3px solid var(--teal); }
#ios-install h2 { margin: 0 0 8px; }
#ios-install ol { line-height: 2; padding-inline-start: 20px; }
#ios-install button { width: 100%; padding: 10px; border: 0; background: var(--teal); color: #04201b; font-weight: 800; }
#update-toast {
position: fixed; z-index: 60; top: calc(12px + var(--safe-top)); left: 50%; transform: translateX(-50%);
padding: 8px 10px 8px 14px; background: var(--panel-solid); border: 1px solid var(--teal); display: flex; gap: 10px; align-items: center;
}
#update-toast[hidden] { display: none; }
#update-toast button { padding: 4px 12px; border: 0; background: var(--teal); color: #04201b; font-weight: 800; }
@media (max-width: 760px) { .brief-robot { width: 70px; } }
/* ---------- Real-world mode ---------- */
#gps-status { display: block; margin-top: 2px; color: var(--gold); font-size: 12px; }
#gps-status[hidden] { display: none; }
#gps-toggle.on { background: var(--teal); color: #04201b; border-color: var(--teal); }
#real-intro { width: min(460px, 92vw); padding: 22px; border-top: 3px solid var(--gold); }
#real-intro img { float: left; border-radius: 50%; background: #f7fcfc; }
#real-intro h2 { margin: 0 0 8px; }
#real-intro p, #real-intro li { line-height: 1.9; font-size: 14px; }
#real-intro ul { padding-inline-start: 18px; color: var(--dim); }
#real-go { flex: 1; padding: 10px; border: 0; background: var(--gold); color: #1b1406; font-weight: 800; }
+17
View File
@@ -0,0 +1,17 @@
import * as THREE from 'three';
// Canvas texture that is redrawn once web fonts are ready, so Persian text never keeps a fallback font.
export function textTexture(width, height, draw) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const paint = () => {
draw(canvas.getContext('2d'), width, height);
texture.needsUpdate = true;
};
paint();
document.fonts?.ready.then(paint);
return texture;
}
+39 -16
View File
@@ -43,33 +43,56 @@ export function createCarModel(color = '#d9d9d4') {
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 => {
// Ambient cars on the main roads. They stay within a few hundred metres of the player: a car that
// falls too far behind is moved to a road ahead or around them. Cars stop for anything in their lane.
export function createTraffic(scene, data, count = 28) {
const roads = data.roads.filter(r => r[0] <= 3).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});
}
return {pts, lengths, total: lengths.at(-1), lanes: r[0] <= 1 ? 2 : 1};
}).filter(r => r.total > 80);
const sample = (road, d) => {
let i = 1;
while (i < road.lengths.length - 1 && road.lengths[i] < d) i++;
const a = road.pts[i - 1], b = road.pts[i], t = (d - road.lengths[i - 1]) / (road.lengths[i] - road.lengths[i - 1] || 1);
return {x: a.x + (b.x - a.x) * t, z: a.y + (b.y - a.y) * t, dx: b.x - a.x, dz: b.y - a.y};
};
function update(dt, obstacles) {
let seed = 7;
const random = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
// Places a car on a random main-road point between 120 and 600 m from (x, z), if one exists.
function respawn(car, x, z) {
for (let attempt = 0; attempt < 40; attempt++) {
const road = roads[Math.floor(random() * roads.length)], d = random() * road.total, p = sample(road, d);
const dist = Math.hypot(p.x - x, p.z - z);
if (dist < 120 || dist > 600) continue;
Object.assign(car, {road, d, dir: random() < .5 ? 1 : -1, lane: 2.2 + (road.lanes > 1 && random() < .5 ? 3.2 : 0), speed: 9 + random() * 7});
car.model.group.visible = true;
return true;
}
car.model.group.visible = false;
return false;
}
const cars = [];
for (let i = 0; i < count; i++) {
const model = createCarModel(COLORS[i % COLORS.length]);
scene.add(model.group);
model.group.visible = false;
cars.push({model, road: null, d: 0, dir: 1, lane: 2.2, speed: 10, x: 1e9, z: 1e9});
}
let cursor = 0;
function update(dt, obstacles, around) {
// Re-home a couple of distant cars per frame, which keeps the cost per frame small.
for (let n = 0; n < 2; n++) {
const car = cars[cursor = (cursor + 1) % cars.length];
if (!car.road || Math.hypot(car.x - around.x, car.z - around.z) > 800) respawn(car, around.x, around.z);
}
for (const car of cars) {
if (!car.road || !car.model.group.visible) continue;
const here = sample(car.road, car.d), len = Math.hypot(here.dx, here.dz) || 1;
const fx = here.dx / len * car.dir, fz = here.dz / len * car.dir;
// 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 ahead = obstacles.concat(cars.filter(c => c !== car && c.model.group.visible)).some(o => {
const ox = o.x - car.x, oz = o.z - car.z, along = ox * fx + oz * fz;
return along > 0 && along < 9 && Math.abs(ox * fz - oz * fx) < 2.2;
});
@@ -81,8 +104,8 @@ export function createTraffic(scene, data, count = 32) {
}
// 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.x = p.x - fz * car.lane;
car.z = p.z + fx * car.lane;
car.model.group.position.set(car.x, 0, car.z);
car.model.group.rotation.y = Math.atan2(-fz, fx);
for (const wheel of car.model.wheels) wheel.rotation.z -= speed * dt / .36;
+1 -1
View File
@@ -1,5 +1,5 @@
// 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 const CAR = {maxSpeed: 33, maxReverse: 6, accel: 7, brake: 14, drag: 1.6, maxSteer: .55, wheelBase: 2.7, radius: 1.6};
export function createCar(x, z, heading = 0) {
return {x, z, heading, speed: 0, steer: 0};
+5 -10
View File
@@ -1,13 +1,11 @@
import * as THREE from 'three';
import {catalog} from './game-state.js';
import {textTexture} from './textures.js';
const PRODUCT_COLORS = {juice: '#e5ae47', water: '#9fd3e6', milk: '#8a5a3c', tea: '#c7783a', lemonade: '#f0dc5a', biscuit: '#c9935a', chocolate: '#5a3524', nuts: '#b9884f', cake: '#e58fa6'};
function label(text, sub, width = 512, height = 160) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const c = canvas.getContext('2d');
// Sign panel; the shared helper redraws it once the Persian web font has loaded.
const label = (text, sub) => textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9';
@@ -20,10 +18,7 @@ function label(text, sub, width = 512, height = 160) {
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) {
@@ -81,7 +76,7 @@ export function createVendingMachine(def) {
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 beaconMaterial = new THREE.MeshBasicMaterial({color: '#3ee0c9', transparent: true, opacity: .22, depthWrite: false, fog: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide});
const beacon = new THREE.Mesh(new THREE.CylinderGeometry(.7, .7, 90, 16, 1, true), beaconMaterial);
beacon.position.y = 45;
group.add(beacon);
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {smoothFix, arrivalRadius, insideWorld} from '../src/realworld.js';
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
assert.deepEqual([first.x, first.z], [0, 0]);
const equal = smoothFix(first, {x: 10, z: 0, accuracy: 10, time: 1000});
assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
assert.ok(equal.accuracy < 10, 'two fixes are better than one');
const poor = smoothFix(first, {x: 50, z: 0, accuracy: 60, time: 1000});
assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
const stale = smoothFix(first, {x: 99, z: 0, accuracy: 30, time: 60000});
assert.equal(stale.x, 99);
});
test('Arrival radius widens with poor GPS and bounds check the world', () => {
assert.equal(arrivalRadius(5), 20);
assert.equal(arrivalRadius(20), 30);
assert.equal(arrivalRadius(100), 40);
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
assert.ok(insideWorld(bounds, 0, 0));
assert.equal(insideWorld(bounds, 11, 0), false);
});
+6 -4
View File
@@ -16,6 +16,7 @@ test('Discovery, combo and all-category bonuses are paid once', () => {
assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
buy(machineById('factory'), 'water');
buy(machineById('school-rahil'), 'water');
buy(machineById('ferdowsi-university'), 'water');
buy(machineById('azadi-metro'), 'water');
@@ -29,11 +30,12 @@ test('Ranks and missions advance with progress', () => {
assert.equal(rankFor(RANKS[1].xp).index, 1);
assert.equal(rankFor(1e6).progress, 1);
const state = createState();
assert.equal(activeMission(state).id, 'first');
assert.equal(activeMission(state).id, 'factory');
purchase(state, machineById('factory'), 'water');
assert.deepEqual(completeMissions(state).map(m => m.id), ['factory']);
assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price');
purchase(state, machineById('mellat-park'), 'water');
const done = completeMissions(state);
assert.deepEqual(done.map(m => m.id), ['first']);
assert.equal(state.balance, 500 - 15 + 30);
assert.deepEqual(completeMissions(state).map(m => m.id), ['first']);
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
assert.equal(activeMission(state).id, 'school');
});
+6 -4
View File
@@ -5,7 +5,7 @@ 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';
import {machines, machineById, FACTORY} from '../src/locations.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
@@ -27,10 +27,12 @@ test('Circles are pushed out of footprints and segments stop at walls', () => {
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', () => {
test('Real map: the factory connects by car to the city, and every city machine is reachable', () => {
const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
const start = machines[0].anchor;
for (const m of machines) {
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
assert.ok(toCity && toCity.length > 18000 && toCity.length < 23000, `factory → Park Mellat is ~20 km (${toCity?.length})`);
const start = machineById('mellat-park').anchor;
for (const m of machines.filter(m => m.anchor)) {
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
assert.ok(onFoot, `walk route to ${m.id}`);
assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);
+43
View File
@@ -1,8 +1,51 @@
import {defineConfig} from 'vite';
import {readdirSync, statSync} from 'node:fs';
import {join, relative} from 'node:path';
import {createHash} from 'node:crypto';
const listFiles = dir => readdirSync(dir).flatMap(name => {
const path = join(dir, name);
return statSync(path).isDirectory() ? listFiles(path) : [path];
});
// Emits sw.js listing every built file, so the installed game works offline. The cache name is a
// hash of that list: a new build gets a new cache and the page offers to reload into it.
function offlineServiceWorker() {
return {
name: 'offline-service-worker',
apply: 'build',
generateBundle(_, bundle) {
const files = [...Object.keys(bundle), ...listFiles('public').map(f => relative('public', f).replaceAll('\\', '/'))]
.filter(f => f !== 'sw.js' && !f.endsWith('.map'));
const version = createHash('sha1').update(files.join('\n')).digest('hex').slice(0, 12);
this.emitFile({
type: 'asset', fileName: 'sw.js', source: `// Generated by vite.config.js — do not edit.
const CACHE = 'robot-market-${version}';
const FILES = ${JSON.stringify(['./', ...files.map(f => `./${f}`)])};
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES))));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k.startsWith('robot-market-') && k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim())));
self.addEventListener('message', event => event.data === 'skip-waiting' && self.skipWaiting());
self.addEventListener('fetch', event => {
const request = event.request;
if (request.method !== 'GET' || new URL(request.url).origin !== location.origin) return;
// Pages: network first so updates arrive, cached page when offline. Everything else: cache first.
if (request.mode === 'navigate') {
event.respondWith(fetch(request).catch(() => caches.match('./')));
return;
}
event.respondWith(caches.match(request, {ignoreSearch: true}).then(hit => hit ?? fetch(request)));
});
`
});
}
};
}
export default defineConfig({
// Relative assets also load under a GitHub Pages repository path.
base: './',
plugins: [offlineServiceWorker()],
server: {
host: true,
allowedHosts: [