Turn the game into a third-person Mashhad city game

Replace the linear forest walk with an open area of real western Mashhad
built from OpenStreetMap data, starting at Park Mellat. A father and child,
guided by the Robot Market robot, find vending machines at a park, school,
universities, a metro station and a mall, on foot or by car.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 11:27:48 +03:30
co-authored by Claude Opus 5.5
parent 8a49dc07fa
commit 95a2ae851d
31 changed files with 3131 additions and 2770 deletions
+75
View File
@@ -0,0 +1,75 @@
import {CATEGORIES} from './locations.js';
export const POINTS = {purchase: 100, discovery: 250, combo: 150, allCategories: 500};
export const RANKS = [
{name: 'تازه‌وارد', xp: 0},
{name: 'کاوشگر', xp: 600},
{name: 'راه‌بلد', xp: 1400},
{name: 'شکارچی دستگاه', xp: 2600},
{name: 'استاد نقشه', xp: 4200},
{name: 'افسانهٔ مشهد', xp: 6500}
];
// Awards for `record`, given the purchases that happened before it.
export function awardsFor(previous, record) {
const awards = [{label: 'خرید موفق', points: POINTS.purchase}];
const here = previous.filter(p => p.machineId === record.machineId);
if (!here.length) awards.push({label: 'کشف مکان جدید', points: POINTS.discovery});
// Paid once per machine: the first time it has sold both a drink and a snack.
const typesHere = new Set(here.map(p => p.type));
if (typesHere.size === 1 && !typesHere.has(record.type)) awards.push({label: 'ترکیب نوشیدنی و خوراکی', points: POINTS.combo});
const categories = new Set(previous.map(p => p.category));
if (!categories.has(record.category) && categories.size === Object.keys(CATEGORIES).length - 1)
awards.push({label: 'همهٔ انواع مکان', points: POINTS.allCategories});
return awards;
}
export function rankFor(xp) {
let index = 0;
while (index + 1 < RANKS.length && xp >= RANKS[index + 1].xp) index++;
const next = RANKS[index + 1] ?? null;
return {index, name: RANKS[index].name, min: RANKS[index].xp, next: next?.xp ?? null, progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1};
}
const placesVisited = purchases => new Set(purchases.map(p => p.machineId)).size;
export const MISSIONS = [
{
id: 'first', title: 'اولین خرید را از دستگاه بوستان ملت انجام بده', target: 'mellat-park', xp: 150, hetoman: 30,
done: ps => ps.some(p => p.machineId === 'mellat-park')
},
{
id: 'school', title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن', target: 'school-rahil', xp: 200, hetoman: 40,
done: ps => ps.some(p => p.machineId === 'school-rahil')
},
{
id: 'campus', title: 'به دانشگاه فردوسی برو و یک خوراکی بخر', target: 'ferdowsi-university', xp: 200, hetoman: 40,
done: ps => ps.some(p => p.machineId === 'ferdowsi-university' && p.type === 'snack')
},
{
id: 'metro', title: 'از دستگاه ایستگاه مترو آزادی خرید کن', target: 'azadi-metro', xp: 150, hetoman: 30,
done: ps => ps.some(p => p.machineId === 'azadi-metro')
},
{
id: 'mall', title: 'با ماشین تا مشهد مال برو و خرید کن', target: 'mashhad-mall', xp: 250, hetoman: 50,
done: ps => ps.some(p => p.machineId === 'mashhad-mall')
},
{
id: 'explorer', title: 'از ۵ مکان متفاوت خرید کن', target: null, xp: 400, hetoman: 80,
done: ps => placesVisited(ps) >= 5
}
];
// Marks and returns missions that the current purchases complete for the first time.
export function completeMissions(state) {
const fresh = MISSIONS.filter(m => !state.missionsDone.includes(m.id) && m.done(state.purchases));
for (const m of fresh) {
state.missionsDone.push(m.id);
state.xp += m.xp;
state.balance += m.hetoman;
}
return fresh;
}
export const activeMission = state => MISSIONS.find(m => !state.missionsDone.includes(m.id)) ?? null;