refactor: centralize brand, company info, assets and Persian strings

Phase 1 of turning the game into the company landing page.

- Add src/config/company.js with name, tagline, contact, about and the
  product line, plus a brand palette built around #00a693 (contrast
  checked); placeholders are marked TODO(company).
- Add src/config/assets.js listing every loaded file with its expected
  format; placeholders are marked TODO(asset).
- Move all Persian interface text, dialogue, missions and scenery text
  into src/i18n/fa.js; components and game code read from it.
- Expose the palette as CSS variables (--color-primary and shades) and
  Tailwind colours, and use it for 3D materials and map drawings.
- Fix the arrival line that still called the manager's car a white
  pickup.
- Add tests that block hard-coded Persian text and low-contrast colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 08:13:00 +03:30
co-authored by Claude Opus 5.5
parent e02347142b
commit 8dbaf3d97e
54 changed files with 1184 additions and 486 deletions
+4 -1
View File
@@ -205,12 +205,15 @@ node scripts/fetch-imagery.mjs
## ساختار
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگ‌های برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکه‌های اجتماعی، درباره‌ما و فهرست محصولات. مقدارهای `TODO(company)` جایگزین‌شدنی‌اند.
- `src/config/assets.js`: مسیر همهٔ فایل‌ها (لوگو، تصویر محصولات، چهره‌های روبات، مدل‌های سه‌بعدی و دادهٔ نقشه) با قالب و اندازهٔ مورد انتظار هر کدام. موارد `TODO(asset)` جای فایل واقعی‌اند.
- `src/i18n/fa.js`: همهٔ متن‌های فارسی رابط کاربری، گفت‌وگوها، مأموریت‌ها و نوشته‌های روی صحنه. تست `tests/content.test.js` جلوی متن فارسی مستقیم در کد را می‌گیرد.
- `src/game/createGame.js و src/game/runtime/`: صحنه، دوربین سوم‌شخص، کنترل‌ها، رابط کاربری (HUD)، نقشه، خرید و حلقهٔ بازی
- `src/game/world/city.js`: ساخت شهر سه‌بعدی از داده‌ها (خیابان، پیاده‌رو، ساختمان، پارک، درخت، ورودی مترو، کوه‌ها) در تکه‌های ۲ کیلومتری که دورها پنهان می‌شوند
- `src/game/world/mapRender.js`: نقشهٔ گوشه، نقشهٔ کامل شهر (کاشی‌های کش‌شده و نمای کلی) و نقشهٔ ایران
- `src/game/world/routing.js`: گراف خیابان‌ها، مسیریابی A* و پیدا کردن نام خیابان
- `src/game/world/collision.js`: برخورد با ساختمان‌ها برای شخصیت‌ها، ماشین و دوربین
- `src/constants/locations.js`: دستگاه‌ها، آدرس‌ها، ساعت کاری و محصولات
- `src/constants/locations.js`: دستگاه‌های نصب‌شده در شهر، آدرس‌ها، ساعت کاری و محصولات
- `src/game/economy/gameState.js`، `src/game/economy/payment.js`، `src/game/economy/scoring.js`: پول، خرید، پرداخت، امتیاز، درجه و مأموریت‌ها
- `src/game/models/characters.js`: پدر و فرزند، و روبات راهنما که از دستگاه‌ها بیرون می‌آید
- `src/game/models/factory.js`: محوطهٔ کارخانه، شامل سالن تولید، خط مونتاژ، دفتر، حیاط و تابلوها، به‌همراه دفترها، کامیون و ورود مدیر
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="theme-color" content="#0d1412" />
<meta name="theme-color" content="#0b1614" />
<title>عملیات مشهد | روبات مارکت</title>
<meta
name="description"
+71
View File
@@ -0,0 +1,71 @@
// Every file the game loads, in one place. Most entries are placeholders until the company sends
// real assets: swap the path (and drop the file into public/) without touching any game logic.
// Paths are relative to the site root so the game also works from a sub-folder.
//
// Expected formats
// - Images: WebP (JPG fallback), sRGB, under 300 KB each.
// - Logo: SVG preferred, or a transparent PNG at least 512 px wide.
// - 3D models: glTF binary (.glb), Draco or Meshopt compressed, real-world metres, +X forward,
// under 2 MB each and around 20k triangles for a machine.
/** Robot guide portraits, one per expression. 256 × 240 WebP. */
const ROBOT_FACES = [
'front',
'happy',
'wink',
'excited',
'curious',
'laugh',
'love',
'serious',
'surprised',
];
export const ASSETS = {
brand: {
// TODO(asset): replace with the real company logo (SVG).
logo: './icons/icon-512.png',
// TODO(asset): hero image for the no-WebGL fallback screen. 1600 × 900 WebP.
hero: './icons/icon-512.png',
favicon: './icons/favicon-64.png',
},
robot: Object.fromEntries(ROBOT_FACES.map((face) => [face, `./robot/${face}.webp`])),
// Product photos for the showroom. TODO(asset): real photos, 1200 × 1600 WebP; `gif` is a short
// looping turntable (GIF or WebP animation, under 1 MB). Missing files show a placeholder card.
products: {
'rm-35': {
image: './images/machine-35-base.webp',
imageAlt: './images/machine-35-white.webp',
gif: './gifs/machine-35.gif',
},
'rm-48': {
image: './images/machine-48-base.webp',
imageAlt: './images/machine-48-white.webp',
gif: './gifs/machine-48.gif',
},
'rm-60': {
image: './images/machine-60-base.webp',
imageAlt: './images/machine-60-white.webp',
gif: './gifs/machine-60.gif',
},
},
// 3D models. Every entry is null today: the game builds low-poly stand-ins in code
// (src/game/models). TODO(asset): set a .glb path to use a real model.
models: {
vendingMachine: null,
factory: null,
familyCar: null,
},
// World data generated by the scripts in scripts/.
world: {
buildings: './data/buildings.bin',
imagery: './imagery/index.json',
},
};
/**
* Path of a robot portrait.
* @param {string} face - Expression name, such as 'happy'.
* @returns {string} Image path; the front view when the expression is unknown.
*/
export const robotFace = (face) => ASSETS.robot[face] ?? ASSETS.robot.front;
+106
View File
@@ -0,0 +1,106 @@
// Everything about the company in one place: name, colours, contact details, history and the
// product line. Replace the values marked `TODO(company)` with real content; nothing else in the
// game needs to change. Colours are plain hex so both CSS (styles/gameTheme.js) and Three.js
// materials can use them.
import { ASSETS } from './assets.js';
/**
* @typedef {object} Product
* @property {string} id - Stable id used by the showroom and missions.
* @property {string} name - Full product name.
* @property {string} shortLabel - Short label for buttons.
* @property {number} channels - Number of product channels.
* @property {string} priceLabel - Price as shown to people.
* @property {number} priceNumeric - Price in rial.
* @property {string} description - One or two sentences.
* @property {string[]} highlights - Key specs, shown as a list.
* @property {string} image - Main image path, from config/assets.js.
* @property {string} imageAlt - Alternative colour image path.
* @property {string} gif - Animated view path.
*/
// Brand palette derived from the primary colour #00a693. On the dark game surfaces the primary is
// used for accents and text (contrast 6.0:1 on `surface`); buttons filled with the primary use the
// dark `onPrimary` text (contrast 5.5:1).
const COLORS = {
primary: '#00a693',
primaryLight: '#33b8a9',
primaryLighter: '#9fe0d6',
primaryDark: '#007d6e',
primaryDarker: '#00473f',
onPrimary: '#03221e',
surface: '#0b1614',
surfaceRaised: '#13231f',
surfaceDeep: '#060c0b',
text: '#ecf2ee',
textMuted: '#a9bdb6',
accent: '#f2c94c',
danger: '#ff6b5e',
};
export const COMPANY = {
name: 'شرکت روبات مارکت',
shortName: 'روبات مارکت',
tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند',
colors: COLORS,
// TODO(company): real postal address, phone, email, website and social links.
contact: {
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
city: 'مشهد',
phone: '۰۵۱-۰۰۰۰۰۰۰۰',
email: 'info@example.com',
website: 'https://example.com',
socials: [
{ id: 'instagram', label: 'اینستاگرام', url: 'https://instagram.com/' },
{ id: 'telegram', label: 'تلگرام', url: 'https://t.me/' },
{ id: 'linkedin', label: 'لینکدین', url: 'https://linkedin.com/' },
],
},
// TODO(company): real history, values, FAQ and open positions.
about: {
history: 'متن کوتاه تاریخچهٔ شرکت اینجا قرار می‌گیرد.',
values: ['کیفیت ساخت', 'پشتیبانی شبانه‌روزی', 'فناوری بومی'],
faq: [{ question: 'پرسش نمونه؟', answer: 'پاسخ نمونه.' }],
careers: [{ title: 'عنوان شغل نمونه', note: 'توضیح کوتاه' }],
},
/** @type {Product[]} Display prices in rial; independent of the in-game snack economy. */
products: [
{
id: 'rm-35',
category: 'warm-vending',
name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال',
channels: 35,
priceLabel: '4,680,000,000 ریال',
priceNumeric: 4680000000,
description:
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
...ASSETS.products['rm-35'],
},
{
id: 'rm-48',
category: 'warm-vending',
name: 'روبات مارکت ۴۸ کانال',
shortLabel: '۴۸ کانال',
channels: 48,
priceLabel: '5,480,000,000 ریال',
priceNumeric: 5480000000,
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.',
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
...ASSETS.products['rm-48'],
},
{
id: 'rm-60',
category: 'warm-vending',
name: 'روبات مارکت ۶۰ کانال',
shortLabel: '۶۰ کانال',
channels: 60,
priceLabel: '5,980,000,000 ریال',
priceNumeric: 5980000000,
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.',
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
...ASSETS.products['rm-60'],
},
],
};
+21 -18
View File
@@ -1,40 +1,43 @@
import { COMPANY } from '../config/company.js';
import { strings } from '../i18n/fa.js';
// 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: 'کارخانه',
label: strings.categories.factory,
icon: '🏭',
},
park: {
label: 'پارک',
label: strings.categories.park,
icon: '🌳',
},
university: {
label: 'دانشگاه',
label: strings.categories.university,
icon: '🎓',
},
school: {
label: 'مدرسه',
label: strings.categories.school,
icon: '🏫',
},
metro: {
label: 'ایستگاه مترو',
label: strings.categories.metro,
icon: 'Ⓜ',
},
mall: {
label: 'مرکز خرید',
label: strings.categories.mall,
icon: '🛍',
},
test: {
label: 'نقطهٔ آزمایشی GPS',
label: strings.categories.test,
icon: '🧪',
},
};
const ALL_DAY = [7 * 60, 23 * 60];
export const FACTORY = {
name: 'شرکت روبات مارکت',
tagline: 'سازندهٔ دستگاه‌های وندینگ هوشمند',
address: 'بزرگراه مشهد–چناران، خیابان کاله، کنار پارک علم و فناوری خراسان',
name: COMPANY.name,
tagline: COMPANY.tagline,
address: COMPANY.contact.address,
// Centre of the compound and the direction of its driveway (towards the gate on Kaleh Street).
center: {
x: -10308.4,
@@ -48,14 +51,14 @@ export const FACTORY = {
export const machines = [
{
id: 'factory',
name: 'دستگاه دفتر فنی مهندسی',
name: strings.places.officeMachine,
category: 'factory',
address: `${FACTORY.address}، ساختمان اداری، دفتر فنی مهندسی`,
address: strings.places.officeAddress(FACTORY.address),
anchor: null,
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'juice', 'tea', 'biscuit', 'chocolate'],
note: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه',
note: strings.places.officeNote,
},
{
id: 'mellat-park',
@@ -168,10 +171,10 @@ export const machines = [
*/
const testPoint = (n, lat, lon) => ({
id: `test-${n}`,
name: `نقطهٔ آزمایشی ${['۱', '۲', '۳'][n - 1]}`,
name: strings.places.testPoint(n.toLocaleString('fa-IR')),
category: 'test',
test: true,
address: `کنار کارخانهٔ روبات مارکت · ${lat.toFixed(6)}, ${lon.toFixed(6)}`,
address: strings.places.testAddress(`${lat.toFixed(6)}, ${lon.toFixed(6)}`),
anchor: null,
spot: {
lat,
@@ -180,7 +183,7 @@ const testPoint = (n, lat, lon) => ({
hours: [0, 24 * 60],
priceFactor: 0.5,
products: ['water', 'biscuit'],
note: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
note: strings.places.testNote,
});
machines.push(
testPoint(1, 36.450276, 59.423431),
@@ -192,11 +195,11 @@ machines.push(
export const lockedPlaces = [
{
name: 'پارک کوهسنگی',
note: 'خارج از محدودهٔ فعلی · به‌زودی',
note: strings.places.comingSoon,
},
{
name: 'حرم و مرکز شهر',
note: 'خارج از محدودهٔ فعلی · به‌زودی',
note: strings.places.comingSoon,
},
];
export const START = {
+4 -46
View File
@@ -1,47 +1,5 @@
/** Display-only machine prices in rial, independent of the consumable economy. */
export const SHOWROOM_CATALOG = [
{
id: 'rm-35',
category: 'warm-vending',
name: 'روبات مارکت ۳۵ کانال',
shortLabel: '۳۵ کانال',
channels: 35,
priceLabel: '4,680,000,000 ریال',
priceNumeric: 4680000000,
description:
'یخچالدار، ۳۵ کانال اختصاصی، سیستم خنک‌کننده قدرتمند، تحویل نرم، مدیریت آنلاین ۲۴ ساعته.',
highlights: ['یخچالدار', '۳۵ کانال اختصاصی', 'تحویل نرم', 'مدیریت آنلاین ۲۴ ساعته'],
image: '/images/machine-35-base.webp',
imageAlt: '/images/machine-35-white.webp',
gif: '/gifs/machine-35.gif',
},
{
id: 'rm-48',
category: 'warm-vending',
name: 'روبات مارکت ۴۸ کانال',
shortLabel: '۴۸ کانال',
channels: 48,
priceLabel: '5,480,000,000 ریال',
priceNumeric: 5480000000,
description: 'یخچالدار، ۴۸ کانال اختصاصی، قابلیت‌های پیشرفته مدیریت آنلاین و تحویل کالا.',
highlights: ['یخچالدار', '۴۸ کانال اختصاصی', 'مدیریت آنلاین پیشرفته', 'تحویل هوشمند کالا'],
image: '/images/machine-48-base.webp',
imageAlt: '/images/machine-48-white.webp',
gif: '/gifs/machine-48.gif',
},
{
id: 'rm-60',
category: 'warm-vending',
name: 'روبات مارکت ۶۰ کانال',
shortLabel: '۶۰ کانال',
channels: 60,
priceLabel: '5,980,000,000 ریال',
priceNumeric: 5980000000,
description: 'یخچالدار، ۶۰ کانال اختصاصی برای کسب‌وکارهای پرتردد.',
highlights: ['یخچالدار', '۶۰ کانال اختصاصی', 'مناسب اماکن پرتردد', 'ظرفیت بالا'],
image: '/images/machine-60-base.webp',
imageAlt: '/images/machine-60-white.webp',
gif: '/gifs/machine-60.gif',
},
];
import { COMPANY } from '../config/company.js';
/** Display-only machine models; the product line itself lives in config/company.js. */
export const SHOWROOM_CATALOG = COMPANY.products;
export const SHOWROOM_REACH = 4;
+4 -10
View File
@@ -1,3 +1,5 @@
import { strings } from '../i18n/fa.js';
// Every vehicle in the factory yard is painted this gloss black.
export const VEHICLE_BLACK = '#0c0d0f';
@@ -7,16 +9,8 @@ export const ENTER_DISTANCE = 4.5;
// Above this speed (m/s) the player has to stop before getting off.
export const EXIT_MAX_SPEED = 3;
// Persian names shown in prompts. `bmw` is the family car parked beside the start point.
export const VEHICLE_LABELS = {
bmw: 'بی‌ام‌و',
bugatti: 'بوگاتی',
lamborghini: 'لامبورگینی',
mazda: 'مزدا',
nissan: 'نیسان',
apache: 'موتور آپاچی',
traffic: 'ماشین',
};
// Names shown in prompts, from the strings file. `bmw` is the family car beside the start point.
export const VEHICLE_LABELS = strings.vehicles;
// Brands parked in the staff bays, in bay order. Mr Mohammadi arrives in the Bugatti.
export const STAFF_CAR_BRANDS = ['lamborghini', 'mazda', 'nissan'];
+4 -4
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { strings } from '../../i18n/fa.js';
// Location-based AR: the phone's rear camera is the background and the 3D camera stands where the
// phone is (GPS) and points where the phone points (orientation sensors), so machines and the
@@ -119,8 +120,7 @@ export function createArView(video) {
// Opens the rear camera. Resolves to null on success or to a message explaining the failure.
async start() {
if (stream) return null;
if (!navigator.mediaDevices?.getUserMedia)
return 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).';
if (!navigator.mediaDevices?.getUserMedia) return strings.real.cameraNeedsHttps;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
@@ -139,8 +139,8 @@ export function createArView(video) {
} catch (error) {
stream = null;
return error?.name === 'NotAllowedError'
? 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.'
: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟';
? strings.real.cameraDenied
: strings.real.cameraBusy;
}
video.srcObject = stream;
video.hidden = false;
+6 -12
View File
@@ -1,4 +1,5 @@
import { toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.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.
@@ -124,11 +125,11 @@ export function startTracking({
stabilizer = createStabilizer(),
}) {
if (!('geolocation' in navigator)) {
onError('این دستگاه موقعیت‌یابی (GPS) ندارد.');
onError(strings.real.noGps);
return () => {};
}
if (!window.isSecureContext) {
onError('موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.');
onError(strings.real.needsHttps);
return () => {};
}
let fixes = 0;
@@ -160,17 +161,10 @@ export function startTracking({
* @returns {*} Operation result, when applicable.
*/
const fail = (error) => {
if (error.code === error.PERMISSION_DENIED)
onError(
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
);
else if (error.code === error.POSITION_UNAVAILABLE)
onError(
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)',
);
if (error.code === error.PERMISSION_DENIED) onError(strings.real.denied);
else if (error.code === error.POSITION_UNAVAILABLE) onError(strings.real.unavailable);
// A timeout only means no fix yet; the watch keeps trying, so it is a status, not an error.
else if (!fixes)
onStatus('هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)');
else if (!fixes) onStatus(strings.real.timeout);
};
// A quick coarse fix from Wi-Fi or the mobile network shows the player at once; GPS refines it.
navigator.geolocation.getCurrentPosition(handle, () => {}, {
+11 -10
View File
@@ -1,3 +1,4 @@
import { strings } from '../../i18n/fa.js';
// Money is counted in whole hetoman: 10 hetoman = 100,000 rial.
export const INITIAL_BALANCE = 500;
export const START_MINUTES = 8 * 60 + 30;
@@ -12,7 +13,7 @@ export const fa = (n) => n.toLocaleString('fa-IR');
* @param {*} value - value.
* @returns {*} Operation result, when applicable.
*/
export const formatHetoman = (value) => `${fa(value)} هتومان`;
export const formatHetoman = (value) => strings.units.hetoman(fa(value));
/**
* Runs the hetoman to rial operation for this subsystem.
* @param {*} value - value.
@@ -31,63 +32,63 @@ export const formatClock = (minutes) => {
export const catalog = {
juice: {
id: 'juice',
name: 'آب‌میوه',
name: strings.snacks.juice,
icon: '🧃',
cost: 35,
type: 'drink',
},
water: {
id: 'water',
name: 'آب معدنی',
name: strings.snacks.water,
icon: '💧',
cost: 15,
type: 'drink',
},
milk: {
id: 'milk',
name: 'شیر کاکائو',
name: strings.snacks.milk,
icon: '🥛',
cost: 40,
type: 'drink',
},
tea: {
id: 'tea',
name: 'چای سرد',
name: strings.snacks.tea,
icon: '🧋',
cost: 45,
type: 'drink',
},
lemonade: {
id: 'lemonade',
name: 'لیموناد',
name: strings.snacks.lemonade,
icon: '🍋',
cost: 40,
type: 'drink',
},
biscuit: {
id: 'biscuit',
name: 'بیسکویت',
name: strings.snacks.biscuit,
icon: '🍪',
cost: 25,
type: 'snack',
},
chocolate: {
id: 'chocolate',
name: 'شکلات',
name: strings.snacks.chocolate,
icon: '🍫',
cost: 30,
type: 'snack',
},
nuts: {
id: 'nuts',
name: 'آجیل',
name: strings.snacks.nuts,
icon: '🥜',
cost: 65,
type: 'snack',
},
cake: {
id: 'cake',
name: 'کیک',
name: strings.snacks.cake,
icon: '🧁',
cost: 35,
type: 'snack',
+18 -17
View File
@@ -1,4 +1,5 @@
import { CATEGORIES } from '../../constants/locations.js';
import { strings } from '../../i18n/fa.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' && c !== 'test');
@@ -10,27 +11,27 @@ export const POINTS = {
};
export const RANKS = [
{
name: 'تازه‌وارد',
name: strings.ranks[0],
xp: 0,
},
{
name: 'کاوشگر',
name: strings.ranks[1],
xp: 600,
},
{
name: 'راه‌بلد',
name: strings.ranks[2],
xp: 1400,
},
{
name: 'شکارچی دستگاه',
name: strings.ranks[3],
xp: 2600,
},
{
name: 'استاد نقشه',
name: strings.ranks[4],
xp: 4200,
},
{
name: 'افسانهٔ مشهد',
name: strings.ranks[5],
xp: 6500,
},
];
@@ -45,21 +46,21 @@ export const RANKS = [
export function awardsFor(previous, record) {
const awards = [
{
label: 'خرید موفق',
label: strings.bonuses.purchase,
points: POINTS.purchase,
},
];
const here = previous.filter((p) => p.machineId === record.machineId);
if (!here.length)
awards.push({
label: 'کشف مکان جدید',
label: strings.bonuses.newPlace,
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: 'ترکیب نوشیدنی و خوراکی',
label: strings.bonuses.combo,
points: POINTS.combo,
});
const categories = new Set(
@@ -71,7 +72,7 @@ export function awardsFor(previous, record) {
categories.size === CITY_CATEGORIES.length - 1
)
awards.push({
label: 'همهٔ انواع مکان',
label: strings.bonuses.allCategories,
points: POINTS.allCategories,
});
return awards;
@@ -102,7 +103,7 @@ const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).
export const MISSIONS = [
{
id: 'factory',
title: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
title: strings.missions.factory,
target: 'factory',
xp: 100,
hetoman: 20,
@@ -110,7 +111,7 @@ export const MISSIONS = [
},
{
id: 'first',
title: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
title: strings.missions.first,
target: 'mellat-park',
xp: 150,
hetoman: 30,
@@ -118,7 +119,7 @@ export const MISSIONS = [
},
{
id: 'school',
title: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
title: strings.missions.school,
target: 'school-rahil',
xp: 200,
hetoman: 40,
@@ -126,7 +127,7 @@ export const MISSIONS = [
},
{
id: 'campus',
title: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
title: strings.missions.campus,
target: 'ferdowsi-university',
xp: 200,
hetoman: 40,
@@ -134,7 +135,7 @@ export const MISSIONS = [
},
{
id: 'metro',
title: 'از دستگاه ایستگاه مترو آزادی خرید کن',
title: strings.missions.metro,
target: 'azadi-metro',
xp: 150,
hetoman: 30,
@@ -142,7 +143,7 @@ export const MISSIONS = [
},
{
id: 'mall',
title: 'با ماشین تا مشهد مال برو و خرید کن',
title: strings.missions.mall,
target: 'mashhad-mall',
xp: 250,
hetoman: 50,
@@ -150,7 +151,7 @@ export const MISSIONS = [
},
{
id: 'explorer',
title: 'از ۵ مکان متفاوت شهر خرید کن',
title: strings.missions.explorer,
target: null,
xp: 400,
hetoman: 80,
+5 -1
View File
@@ -1,4 +1,8 @@
import * as THREE from 'three';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// Detailed vehicles for the family and the factory, plus the shared helpers luxuryCars.js builds
// on. All face local +X and sit on y = 0, like createCarModel in traffic.js; `wheels` lists rear wheels first, then the two front (steering) ones.
@@ -337,7 +341,7 @@ export function createTruck(color = '#e9eef0') {
const black = mat('#1b1d1f', {
roughness: 0.7,
}),
teal = mat('#0b8f88', {
teal = mat(BRAND.primary, {
metalness: 0.3,
roughness: 0.4,
});
+65 -78
View File
@@ -6,6 +6,11 @@ import { MANAGER_CAR_BRAND, MOTORCYCLE_COUNT, VEHICLE_BLACK } from '../../consta
import { createPerson, createCrew, nameTag } from './staff.js';
import { FACTORY } from '../../constants/locations.js';
import { textTexture } from './textures.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the stripes operation for this subsystem.
* @param {*} c - c.
@@ -32,9 +37,9 @@ function stripes(c, w, h, base, line, every) {
*/
const sign = (text, sub = '') =>
textTexture(512, 160, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillStyle = BRAND.primaryDarker;
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(0, h - 12, w, 12);
c.textAlign = 'center';
c.direction = 'rtl';
@@ -42,7 +47,7 @@ const sign = (text, sub = '') =>
c.font = `bold ${sub ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(text, w / 2, sub ? 72 : 100);
if (sub) {
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '36px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, w / 2, 128);
}
@@ -113,25 +118,20 @@ const CODE = {
),
sheet: screen(
[
['فاکتور دستگاه مبلغ', '#1d3b2f'],
['۱۰۴۲ بوستان ملت ۴۸۰', '#333'],
['۱۰۴۳ دانشگاه فردوسی ۳۲۰', '#333'],
['۱۰۴۴ مترو آزادی ۲۹۰', '#333'],
['۱۰۴۵ مشهد مال ۶۱۰', '#333'],
['۱۰۴۶ مدرسه راحیل ۱۸۰', '#333'],
['جمع ۱۸۸۰', '#0b8f88'],
// Header, five invoice rows, then the total in the brand colour.
...strings.factory.decor.invoices.map((line, i, all) => [
line,
['#1d3b2f', ...Array(all.length - 2).fill('#333'), COMPANY.colors.primaryDark][i],
]),
],
'#f4f7f2',
),
dashboard: screen(
[
['فروش امروز: ۱۲۸ دستگاه', '#3ee0c9'],
['تولید هفته: ۴۶ دستگاه', '#f2c94c'],
['ارسال شده: ۳۸', '#eeffff'],
['', '#fff'],
['▇▇▇▇▇▇▇▇▇▇▇ خم', '#82aaff'],
['▇▇▇▇▇▇▇▇▇ برش', '#82aaff'],
['▇▇▇▇▇▇▇ رنگ', '#82aaff'],
...strings.factory.decor.dashboard.map((line, i) => [
line,
[COMPANY.colors.primary, '#f2c94c', '#eeffff', '#fff'][i] ?? '#82aaff',
]),
],
'#101820',
),
@@ -507,7 +507,7 @@ function factoryBuildFlat(build) {
build.steel = build.mat('#59646b', {
metalness: 0.5,
});
build.teal = build.mat('#0b8f88', {
build.teal = build.mat(BRAND.primary, {
metalness: 0.3,
roughness: 0.4,
});
@@ -563,16 +563,16 @@ function factoryBuildAnimated(build) {
*/
function factoryBuildNameSign(build) {
build.nameSign = textTexture(1024, 320, (c, w, h) => {
c.fillStyle = '#0d2f2c';
c.fillStyle = BRAND.primaryDarker;
c.fillRect(0, 0, w, h);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
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.fillStyle = BRAND.primaryLighter;
c.font = '44px Vazirmatn, Tahoma, sans-serif';
c.fillText(FACTORY.tagline, w / 2, 210);
c.font = 'bold 34px sans-serif';
@@ -716,12 +716,8 @@ function factoryBuildGeometry3(build) {
for (const du of [-2.2, 2.2])
build.box(u + du - 0.03, u + du + 0.03, 20.97, 21.03, 7.45, build.H, build.steel, false);
};
build.zone('خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه', -93);
build.zone('برش لیزر', 'برش ورق و قطعات', -71);
build.zone('رنگ‌کاری', 'کابین رنگ پودری', -52);
build.zone('یخچال‌سازی', 'کمپرسور و عایق‌کاری', -31);
build.zone('مونتاژ', 'خط مونتاژ نهایی', -13);
build.zone('بسته‌بندی', 'ارسال به شهر', -3);
// Work areas along the hall, from the press brakes to packing (u of each sign).
[-93, -71, -52, -31, -13, -3].forEach((u, i) => build.zone(...strings.factory.zones[i], u));
// Bending: two press brakes with a moving ram, sheet stacks behind.
}
@@ -942,7 +938,7 @@ function factoryBuildGeometry6(build) {
*/
function factoryBuildFrames(build) {
build.frames = [];
build.frameMaterial = build.mat('#0b8f88', {
build.frameMaterial = build.mat(BRAND.primary, {
wireframe: true,
});
for (let i = 0; i < 4; i++) {
@@ -990,7 +986,7 @@ function factoryBuildGeometry7(build) {
function factoryBuildGeometry8(build) {
for (let i = 0; i < 4; i++) {
const finished = createVendingMachine({
name: 'آمادهٔ نصب',
name: strings.factory.readyToInstall,
products: ['water', 'juice', 'biscuit', 'chocolate'],
});
finished.beacon.visible = finished.ring.visible = false;
@@ -1014,7 +1010,7 @@ function factoryBuildCarton(build) {
c.fillRect(0, 0, w, h);
c.fillStyle = 'rgba(255,255,255,.18)';
for (let y = 0; y < h; y += 40) c.fillRect(0, y, w, 14);
c.fillStyle = '#0b8f88';
c.fillStyle = BRAND.primary;
c.fillRect(0, h * 0.42, w, 34);
c.fillStyle = '#1d2a2a';
c.textAlign = 'center';
@@ -1294,9 +1290,9 @@ function factoryBuildGeometry9(build) {
])
build.box(u0, u1, -36, -34, build.OH - 0.08, build.OH - 0.02, build.lamp, false).castShadow =
false;
build.panel(sign('دفتر فنی مهندسی', 'Engineering'), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
build.panel(sign('واحد حسابداری', 'Accounting'), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
build.panel(sign('مدیریت', 'آقای محمدی'), 3.6, 1.1, 45, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.engineering), 3.6, 1.1, 15.5, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.accounting), 3.6, 1.1, 32.5, 3.25, build.F + 0.05);
build.panel(sign(...strings.factory.signs.management), 3.6, 1.1, 45, 3.25, build.F + 0.05);
build.wood = build.mat('#c8a477', {
roughness: 0.6,
});
@@ -1322,7 +1318,7 @@ function factoryBuildFabric(build) {
function factoryBuildDevelopers(build) {
build.developers = [
{
name: 'حامد',
name: strings.factory.staff.hamed.name,
role: 'React',
u: 11,
v: -31.6,
@@ -1333,13 +1329,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.react,
speaker: 'hamed',
lines: [
'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاه‌ها رو با React می‌سازم.',
'هر چی روی صفحهٔ دستگاه می‌بینین، از همین مانیتور اومده!',
],
lines: strings.factory.staff.hamed.lines,
},
{
name: 'رایان',
name: strings.factory.staff.ryan.name,
role: 'Go',
u: 16,
v: -31.6,
@@ -1351,10 +1344,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.go,
speaker: 'ryan',
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.'],
lines: strings.factory.staff.ryan.lines,
},
{
name: 'علیرضا',
name: strings.factory.staff.alireza.name,
role: 'C++',
u: 21,
v: -31.6,
@@ -1366,12 +1359,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.cpp,
speaker: 'alireza',
lines: [
'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم می‌کنم.',
],
lines: strings.factory.staff.alireza.lines,
},
{
name: 'رضا',
name: strings.factory.staff.reza.name,
role: 'C++',
u: 13.5,
v: -38.6,
@@ -1384,13 +1375,10 @@ function factoryBuildDevelopers(build) {
},
code: CODE.cpp,
speaker: 'reza',
lines: [
'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
],
lines: strings.factory.staff.reza.lines,
},
{
name: 'محمد',
name: strings.factory.staff.mohammad.name,
role: '',
u: 18.5,
v: -38.6,
@@ -1402,7 +1390,7 @@ function factoryBuildDevelopers(build) {
},
code: CODE.terminal,
speaker: 'mohammad',
lines: ['سلام! من محمدم. به دفتر فنی مهندسی روبات مارکت خوش اومدین!'],
lines: strings.factory.staff.mohammad.lines,
},
];
}
@@ -1444,12 +1432,10 @@ function factoryBuildBoard(build) {
c.direction = 'rtl';
c.fillStyle = '#1d4f91';
c.font = 'bold 40px Vazirmatn, Tahoma, sans-serif';
c.fillText('اسپرینت ۱۴: دستگاه نسل جدید', w / 2, 56);
c.fillText(strings.factory.decor.sprintTitle, w / 2, 56);
c.font = '28px Vazirmatn, Tahoma, sans-serif';
c.fillStyle = '#333';
['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'].forEach((t, i) =>
c.fillText(t, w / 2, 110 + i * 44),
);
strings.factory.decor.sprintItems.forEach((t, i) => c.fillText(t, w / 2, 110 + i * 44));
});
build.panel(build.board, 4, 2, 16, 1.9, build.B + 0.05);
build.officeMachine = {
@@ -1538,10 +1524,10 @@ function factoryBuildGeometry11(build) {
);
build.talks.push({
speaker: 'accountant',
label: 'واحد حسابداری',
label: strings.factory.accounting.label,
radius: 4,
pos: () => build.toWorld(33, -29),
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'],
lines: strings.factory.accounting.lines,
});
// Management: the manager's desk facing the door, guest chairs, bookshelf and flag.
@@ -1576,7 +1562,15 @@ function factoryBuildGeometry12(build) {
build.leaves = new THREE.Mesh(new THREE.SphereGeometry(0.45, 10, 8), build.mat('#3e7a3e'));
build.leaves.position.set(49.25, 1.0, -26.75);
build.group.add(build.leaves);
build.nameplate = build.panel(sign('محمدی', 'مدیر شرکت'), 0.6, 0.19, 45, 0.87, -39.2, 1);
build.nameplate = build.panel(
sign(...strings.factory.signs.nameplate),
0.6,
0.19,
45,
0.87,
-39.2,
1,
);
build.nameplate.rotation.x = -0.3;
// ---------- Staff car park ----------
@@ -1618,7 +1612,7 @@ function factoryBuildManager(build) {
glasses: true,
shoes: '#111',
});
build.managerTag = nameTag('آقای محمدی', 'مدیر شرکت');
build.managerTag = nameTag(strings.factory.manager.name, strings.factory.manager.role);
build.managerTag.position.y = 2.15;
build.manager.root.add(build.managerTag);
build.group.add(build.manager.root);
@@ -1652,15 +1646,11 @@ function factoryBuildArrival(build) {
};
build.talks.push({
speaker: 'mohammadi',
label: 'آقای محمدی',
label: strings.factory.manager.name,
radius: 3.4,
pos: () => build.toWorld(build.manager.root.position.x, build.manager.root.position.z),
ready: () => build.arrival.stage === 'seated',
lines: [
'سلام، خوش اومدین! من محمدی‌ام، مدیر روبات مارکت.',
'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.',
'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!',
],
lines: strings.factory.managerLines,
});
// Position and direction a distance `s` along a polyline of [u, v] points.
@@ -1751,7 +1741,7 @@ function factoryBuildDelivery(build) {
radius: 9,
pos: () => build.toWorld(4, 31),
once: true,
lines: ['بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!'],
lines: [strings.factory.truck],
});
// The family comments on each work area the first time they walk into it.
}
@@ -1761,23 +1751,20 @@ function factoryBuildDelivery(build) {
* @returns {*} Operation result, when applicable.
*/
function factoryBuildGeometry14(build) {
for (const [u, text, speaker] of [
[-93, 'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.', 'father'],
[-71, 'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.', 'child'],
[-52, 'این کابین رنگه؛ بدنه‌ها اینجا رنگ فیروزه‌ای روبات مارکت می‌گیرن.', 'father'],
[-31, 'یخچال‌سازی! برای همینه که نوشیدنی‌های دستگاه همیشه خنکن.', 'child'],
[
-13,
'خط مونتاژ: روبات‌ها و کارگرها قطعه‌ها رو کنار هم می‌ذارن و دستگاه کامل می‌شه.',
'father',
],
])
// u of each production area and who comments on it; the lines come from the strings file.
for (const [i, [u, speaker]] of [
[-93, 'father'],
[-71, 'child'],
[-52, 'father'],
[-31, 'child'],
[-13, 'father'],
].entries())
build.talks.push({
speaker,
radius: 8,
pos: () => build.toWorld(u, 25),
once: true,
lines: [text],
lines: [strings.factory.areaTalks[i]],
});
// ---------- Key positions ----------
+6 -2
View File
@@ -1,5 +1,9 @@
import * as THREE from 'three';
import { textTexture } from './textures.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// Factory staff. createPerson() builds one articulated office person (hoodie, suit, or manteau with
// a headscarf) that can stand, walk, sit and type. createCrew() draws the production-line workers
@@ -264,7 +268,7 @@ export function nameTag(name, role = '') {
c.beginPath();
c.roundRect(6, 6, w - 12, h - 12, 26);
c.fill();
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(6, h - 18, w - 12, 8);
c.textAlign = 'center';
c.direction = 'rtl';
@@ -272,7 +276,7 @@ export function nameTag(name, role = '') {
c.font = `bold ${role ? 58 : 66}px Vazirmatn, Tahoma, sans-serif`;
c.fillText(name, w / 2, role ? 72 : 100);
if (role) {
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '40px Vazirmatn, Tahoma, sans-serif';
c.direction = 'ltr';
c.fillText(role, w / 2, 126);
+11 -7
View File
@@ -1,6 +1,10 @@
import * as THREE from 'three';
import { catalog } from '../economy/gameState.js';
import { textTexture } from './textures.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
const PRODUCT_COLORS = {
juice: '#e5ae47',
water: '#9fd3e6',
@@ -24,14 +28,14 @@ const label = (text, sub) =>
textTexture(512, 160, (c, width, height) => {
c.fillStyle = '#0f2a2a';
c.fillRect(0, 0, width, height);
c.fillStyle = '#3ee0c9';
c.fillStyle = BRAND.primary;
c.fillRect(0, height - 10, width, 10);
c.textAlign = 'center';
c.direction = 'rtl';
c.fillStyle = '#e9fff8';
c.font = 'bold 56px Vazirmatn, Tahoma, sans-serif';
c.fillText(text, width / 2, 72);
c.fillStyle = '#9fe9da';
c.fillStyle = BRAND.primaryLighter;
c.font = '34px Vazirmatn, Tahoma, sans-serif';
c.fillText(sub, width / 2, 126);
});
@@ -75,10 +79,10 @@ export function createVendingMachine(def) {
parent.add(mesh);
return mesh;
};
const shell = mat('#0b8f88', {
const shell = mat(BRAND.primary, {
metalness: 0.45,
roughness: 0.3,
emissive: '#0b8f88',
emissive: BRAND.primary,
emissiveIntensity: 0.15,
}),
white = mat('#eef5f3', {
@@ -190,7 +194,7 @@ export function createVendingMachine(def) {
const sign = new THREE.Mesh(
new THREE.PlaneGeometry(1.86, 0.3),
new THREE.MeshBasicMaterial({
map: label('روبات مارکت', def.name),
map: label(COMPANY.shortName, def.name),
}),
);
sign.position.set(0, 2.62, 0.575);
@@ -213,7 +217,7 @@ export function createVendingMachine(def) {
// Objective beacon: a tall column of light visible across the city, like a mission marker.
const beaconMaterial = new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.22,
depthWrite: false,
@@ -230,7 +234,7 @@ export function createVendingMachine(def) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(1.6, 1.9, 40),
new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.6,
depthWrite: false,
+3 -2
View File
@@ -3,6 +3,7 @@ import { CATEGORIES, lockedPlaces } from '../../constants/locations.js';
import { catalog, priceAt, isOpen, fa } from '../economy/gameState.js';
import { drawFullMap, drawIran } from '../world/mapRender.js';
import { formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the size canvas operation for this subsystem.
* @param {*} context - context.
@@ -104,7 +105,7 @@ export function selectMachine(context, id, center = false) {
text: `📍 ${def.address}`,
});
context.ui.patch('detail-meta', {
text: `🕒 ${context.hoursText(def)} · ${isOpen(def, context.state.minutes) ? 'الان باز است' : 'الان بسته است'} · ${def.note}`,
text: strings.map.details(context.hoursText(def), isOpen(def, context.state.minutes), def.note),
});
context.ui.patch('detail-products', {
text: def.products
@@ -112,7 +113,7 @@ export function selectMachine(context, id, center = false) {
.join(' · '),
});
context.ui.patch('route-button', {
text: context.waypoint === id ? 'مسیر فعال است ✓' : 'مسیریابی تا اینجا',
text: context.waypoint === id ? strings.map.routeActive : strings.map.route,
});
context.renderMachineList();
context.renderMap();
+4 -6
View File
@@ -4,6 +4,7 @@ import { resolveCircle } from '../world/collision.js';
import { stepCar } from '../systems/vehicle.js';
import { EXIT_MAX_SPEED } from '../../constants/vehicles.js';
import { collideDriving, nearestRide, takeRide, walkAroundVehicles } from './garage.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the look operation for this subsystem.
* @param {*} context - context.
@@ -39,7 +40,7 @@ export // ---------- Car ----------
function toggleCar(context) {
if (context.payment.phase !== 'idle' || context.state.busy) return;
if (context.real.on) {
context.say('father', 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.');
context.say('father', strings.real.noVirtualCar);
return;
}
if (context.mode === 'walk') getIn(context);
@@ -58,10 +59,7 @@ function getIn(context) {
context.mode = 'drive';
context.cam.distance = Math.max(context.cam.distance, kind === 'bike' ? 6 : 8);
context.tone(320, 0.2, 0.05, 'square');
const line =
kind === 'bike'
? 'کلاه ایمنی سرت باشه! با موتور می‌شه از لای ماشین‌ها رد شد.'
: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.';
const line = kind === 'bike' ? strings.dialogue.bikeOn : strings.dialogue.carOn;
context.say('father', line);
}
@@ -72,7 +70,7 @@ function getIn(context) {
function getOut(context) {
const { state, spec } = context.vehicle;
if (Math.abs(state.speed) > EXIT_MAX_SPEED) {
context.say('father', 'اول نگه دار، بعد پیاده می‌شویم.');
context.say('father', strings.dialogue.stopFirst);
return;
}
const left = { x: -Math.sin(state.heading), z: -Math.cos(state.heading) };
+18 -18
View File
@@ -8,6 +8,11 @@ import { kmh } from '../systems/vehicle.js';
import { nearestRide } from './garage.js';
import { drawMinimap } from '../world/mapRender.js';
import { bearing, formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the rebuild route mesh operation for this subsystem.
* @param {*} context - context.
@@ -125,12 +130,7 @@ export function drawCompass(context, headingDeg, markers) {
context.compass.fillRect(0, 10, w, 24);
context.compass.textAlign = 'center';
context.compass.textBaseline = 'middle';
const labels = {
0: 'شمال',
90: 'شرق',
180: 'جنوب',
270: 'غرب',
};
const labels = strings.hud.compass;
for (let d = -100; d <= 100; d += 5) {
const deg = (((Math.round(headingDeg / 5) * 5 + d) % 360) + 360) % 360,
x = w / 2 + ((deg - headingDeg + 540) % 360) * 2.6 - 180 * 2.6;
@@ -204,9 +204,9 @@ export function refreshHUD(context) {
* @returns {string} Persian label.
*/
function modeLabel(context) {
if (context.mode !== 'drive') return 'پیاده';
if (context.vehicle.kind === 'bike') return 'روی موتور · F برای پیاده شدن';
return `در ${context.vehicle.label} · F برای پیاده شدن`;
if (context.mode !== 'drive') return strings.common.walking;
if (context.vehicle.kind === 'bike') return strings.travel.onBike;
return strings.travel.inVehicle(context.vehicle.label);
}
/**
* Updates prompt from the active simulation.
@@ -217,7 +217,7 @@ export function updatePrompt(context) {
const sample = context.nearShowroom();
if (sample && !context.state.busy && context.payment.phase === 'idle') {
context.ui.patch('prompt', {
text: `Q · معرفی ${sample.model.name} و پرسیدن قیمت`,
text: strings.prompts.showroom(sample.model.name),
hidden: false,
});
return;
@@ -228,9 +228,9 @@ export function updatePrompt(context) {
if (!context.state.busy && context.payment.phase === 'idle') {
if (station)
text = isOpen(station.def, context.state.minutes)
? `E · خرید از روبات مارکت · ${station.def.name}`
: `دستگاه بسته است · ${context.hoursText(station.def)}`;
else if (ride) text = `F · سوار ${ride.label} شو`;
? strings.prompts.buy(station.def.name)
: strings.prompts.closed(context.hoursText(station.def));
else if (ride) text = strings.prompts.getIn(ride.label);
else if (
context.mode === 'drive' &&
Math.abs(context.car.speed) < 1 &&
@@ -238,7 +238,7 @@ export function updatePrompt(context) {
(s) => Math.hypot(s.front.x - context.car.x, s.front.z - context.car.z) < 25,
)
)
text = 'F · پیاده شو؛ دستگاه نزدیک است';
text = strings.prompts.getOutNearMachine;
}
context.ui.patch('prompt', {
text,
@@ -334,7 +334,7 @@ export function updateHUD(context, dt) {
});
for (const s of context.stations) {
const isObjective = s === objective;
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : '#3ee0c9');
s.model.beacon.material.color.set(isObjective ? '#f2c94c' : BRAND.primary);
s.model.beacon.material.opacity =
(isObjective ? 0.32 : 0.14) * Math.min(1, Math.hypot(s.x - pos.x, s.z - pos.z) / 40);
}
@@ -346,10 +346,10 @@ export function updateHUD(context, dt) {
const inFactory = Math.hypot(pos.x - FACTORY.center.x, pos.z - FACTORY.center.z) < 130;
context.ui.patch('street', {
text: inFactory
? `کارخانهٔ ${FACTORY.name}`
? strings.hud.factory(FACTORY.name)
: street
? `مشهد · ${data.names[street.name]}`
: 'حومهٔ مشهد',
? strings.hud.street(data.names[street.name])
: strings.hud.outskirts,
});
}
context.updatePrompt();
+38 -28
View File
@@ -24,6 +24,7 @@ import {
activeMission,
MISSIONS,
} from '../economy/scoring.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the open showroom operation for this subsystem.
* @param {*} context - context.
@@ -40,7 +41,7 @@ export function openShowroom(context) {
modelId: sample.model.id,
});
context.ui.patch('showroom-response', {
text: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.',
text: strings.showroom.allModels,
});
context.keys.clear();
document.exitPointerLock?.();
@@ -55,13 +56,13 @@ export function interact(context) {
const station = context.nearStation();
if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return;
if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) {
context.say('father', 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.');
context.say('father', strings.dialogue.tooFastToBuy);
return;
}
if (!isOpen(station.def, context.state.minutes)) {
context.say(
context.guideOut() ? 'robot' : 'father',
`این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(station.def)}.`,
strings.dialogue.machineClosed(context.hoursText(station.def)),
'serious',
);
return;
@@ -81,16 +82,20 @@ export function interact(context) {
export function renderShop(context) {
const def = context.shopStation.def;
context.ui.patch('shop-category', {
text: `${CATEGORIES[def.category].icon} ${CATEGORIES[def.category].label} · ساعت کاری ${context.hoursText(def)}`,
text: strings.shop.category(
CATEGORIES[def.category].icon,
CATEGORIES[def.category].label,
context.hoursText(def),
),
});
context.ui.patch('shop-name', {
text: `روبات مارکت · ${def.name}`,
text: strings.shop.title(def.name),
});
context.ui.patch('shop-address', {
text: def.address,
});
context.ui.patch('shop-note', {
text: `${def.note} · موجودی شما: ${formatHetoman(context.state.balance)}`,
text: strings.shop.balance(def.note, formatHetoman(context.state.balance)),
});
context.ui.patch('products', {
items: def.products.map((id) => ({
@@ -120,7 +125,7 @@ export function startPayment(context, productId) {
text: `${p.icon} ${p.name} · ${formatHetoman(priceAt(context.shopStation.def, productId))}`,
});
context.ui.patch('payment-step', {
text: 'در حال نزدیک شدن به کارت‌خوان…',
text: strings.payment.approaching,
});
context.ui.patch('swipe', {
disabled: true,
@@ -128,7 +133,7 @@ export function startPayment(context, productId) {
context.ui.patch('cancel-payment', {
hidden: false,
});
context.say('child', 'بابا، کارت رو بده من بکشم!');
context.say('child', strings.dialogue.childSwipes);
}
/**
* Runs the swipe operation for this subsystem.
@@ -146,7 +151,7 @@ export function swipe(context) {
hidden: true,
});
context.ui.patch('payment-step', {
text: 'پرداخت موفق ✓ محصول در حال تحویل است',
text: strings.payment.success,
});
context.tone(1100, 0.3);
context.refreshHUD();
@@ -178,7 +183,7 @@ export function updatePayment(context, dt) {
disabled: false,
});
context.ui.patch('payment-step', {
text: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
text: strings.payment.swipeHint,
});
}
if (context.payment.phase === 'approved') {
@@ -243,7 +248,7 @@ export function reward(context, record, product) {
rankBefore = rankFor(context.state.xp).index;
if (context.real.on)
awards.push({
label: 'خرید در دنیای واقعی',
label: strings.payment.realPurchase,
points: 200,
});
const total = awards.reduce((s, a) => s + a.points, 0);
@@ -260,13 +265,13 @@ export function reward(context, record, product) {
}),
3200,
);
context.say('child', `${product.icon} ${product.name} گرفتم! مرسی بابا.`);
context.say('child', strings.dialogue.childGotIt(product.icon, product.name));
const done = completeMissions(context.state);
for (const m of done) {
context.showBanner(
'مأموریت انجام شد',
strings.rewards.missionDone,
m.title,
`+${fa(m.xp)} XP · +${formatHetoman(m.hetoman)} پاداش`,
strings.rewards.missionReward(fa(m.xp), formatHetoman(m.hetoman)),
);
context.tone(660, 0.5);
}
@@ -274,9 +279,9 @@ export function reward(context, record, product) {
context.setTimeout(
() =>
context.showBanner(
'ارتقای درجه',
strings.rewards.rankUp,
rankFor(context.state.xp).name,
'دستگاه‌های بیشتری پیدا کن',
strings.rewards.findMore,
),
done.length ? 3400 : 0,
);
@@ -286,7 +291,11 @@ export function reward(context, record, product) {
context.route = null;
context.setTimeout(
() =>
context.say(context.guideOut() ? 'robot' : 'father', `مأموریت بعدی: ${next.title}`, 'wink'),
context.say(
context.guideOut() ? 'robot' : 'father',
strings.dialogue.nextMission(next.title),
'wink',
),
1500,
);
}
@@ -328,25 +337,26 @@ export function checkEnd(context) {
context.ended = true;
document.exitPointerLock?.();
context.ui.patch('end-tag', {
text: allDone ? 'عملیات موفق' : 'پایان بودجه',
text: allDone ? strings.end.success : strings.end.outOfBudget,
});
context.ui.patch('end-title', {
text: allDone ? 'عملیات مشهد کامل شد!' : 'بودجهٔ امروز تمام شد',
text: allDone ? strings.end.successHeading : strings.end.budgetHeading,
});
context.ui.patch('end-text', {
text: allDone
? 'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.'
: 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:',
text: allDone ? strings.end.successText : strings.end.budgetText,
});
const places = new Set(context.state.purchases.map((p) => p.machineId)).size;
context.ui.patch('end-stats', {
items: [
['امتیاز', `${fa(context.state.xp)} XP`],
['درجه', rankFor(context.state.xp).name],
['خریدها', fa(context.state.purchases.length)],
['مکان‌های کشف‌شده', `${fa(places)} از ${fa(machines.length)}`],
['مأموریت‌ها', `${fa(context.state.missionsDone.length)} از ${fa(MISSIONS.length)}`],
['باقی‌مانده', formatHetoman(context.state.balance)],
[strings.end.score, `${fa(context.state.xp)} XP`],
[strings.end.rank, rankFor(context.state.xp).name],
[strings.end.purchases, fa(context.state.purchases.length)],
[strings.end.places, strings.end.outOf(fa(places), fa(machines.length))],
[
strings.end.missions,
strings.end.outOf(fa(context.state.missionsDone.length), fa(MISSIONS.length)),
],
[strings.end.remaining, formatHetoman(context.state.balance)],
],
});
context.ui.patch('continue', {
+11 -14
View File
@@ -9,6 +9,7 @@ import data from '../../data/mellat.json';
import { resolveCircle } from '../world/collision.js';
import { fa } from '../economy/gameState.js';
import { formatDistance } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
/**
* Runs the set real mode operation for this subsystem.
* @param {*} context - context.
@@ -59,10 +60,7 @@ export function setRealMode(context, on) {
stabilizer: (context.real.stabilizer = createStabilizer()),
});
context.real.stabilizer.stable = context.stableMode;
context.say(
'father',
'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.',
);
context.say('father', strings.real.on);
context.setArMode(true);
}
@@ -80,21 +78,17 @@ export async function setArMode(context, on) {
if (on && !context.arMode) {
const problem = await context.arView.start();
if (problem) {
context.say('father', `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`);
context.say('father', strings.real.cameraFailed(problem));
return;
}
if (!context.real.on) {
context.arView.stop();
return;
}
context.say(
'robot',
'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
'excited',
);
context.say('robot', strings.real.cameraOn, 'excited');
context.setTimeout(() => {
if (context.real.on && !context.arView.hasOrientation)
context.say('father', 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.');
context.say('father', strings.real.noOrientation);
}, 3000);
} else if (!on && context.arMode) context.arView.stop();
else return;
@@ -133,16 +127,19 @@ function askRealMode(context, thenStart) {
export function updateReal(context, dt) {
let uiStatus;
if (context.real.error) uiStatus = `⚠ ${context.real.error}`;
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? 'در حال یافتن موقعیت…'}`;
else if (!context.real.fix) uiStatus = `📍 ${context.real.waiting ?? strings.real.locating}`;
else if (!insideWorld(data.bounds, context.real.fix.x, context.real.fix.z)) {
const nearest = Math.min(
...context
.activeStations()
.map((s) => Math.hypot(s.x - context.real.fix.x, s.z - context.real.fix.z)),
);
uiStatus = `📍 خارج از محدودهٔ بازی · ${formatDistance(nearest)} تا نزدیک‌ترین دستگاه`;
uiStatus = strings.real.outOfArea(formatDistance(nearest));
} else
uiStatus = `📍 GPS ±${fa(Math.round(context.real.fix.accuracy))} متر${context.real.fix.speed > MAX_WALKING_SPEED ? ' · حرکت سریع' : ''}`;
uiStatus = strings.real.accuracy(
fa(Math.round(context.real.fix.accuracy)),
context.real.fix.speed > MAX_WALKING_SPEED,
);
context.ui.patch('gps-status', {
text: uiStatus,
});
+12 -13
View File
@@ -3,6 +3,11 @@ import { createState } from '../economy/gameState.js';
import { createPayment } from '../economy/payment.js';
import { createVendingMachine } from '../models/vending.js';
import { resetGarage } from './garage.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
/**
* Runs the place station operation for this subsystem.
* @param {*} context - context.
@@ -53,7 +58,7 @@ export function updateReachCircles(context) {
s.reach.visible = context.real.on;
if (!context.real.on) continue;
s.reach.scale.setScalar(radius);
s.reach.material.color.set(s === objective ? '#f2c94c' : '#3ee0c9');
s.reach.material.color.set(s === objective ? '#f2c94c' : BRAND.primary);
}
}
// GPS test points only take part while real-world mode is on.
@@ -130,18 +135,12 @@ export function startMission(context) {
context.ui.patch('hud', {
hidden: false,
});
context.say(
'robot',
'سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدل‌های کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.',
'wink',
);
context.say('child', 'بابا، اینجا همون کارخونه‌ایه که دستگاه‌های روبات مارکت رو می‌سازه؟');
context.say(
'father',
'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
);
context.say('child', 'اون وانت سفیده کیه داره میاد تو؟');
context.say('father', 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.');
const { dialogue } = strings;
context.say('robot', dialogue.showroomIntro, 'wink');
context.say('child', dialogue.childAsksFactory);
context.say('father', dialogue.fatherAnswersFactory);
context.say('child', dialogue.childAsksArrival);
context.say('father', dialogue.fatherAnswersArrival);
if (!context.isTouch && !context.real.on)
context.canvas.requestPointerLock?.()?.catch?.(() => {});
}
+35 -38
View File
@@ -24,6 +24,11 @@ import { createTraffic } from '../systems/traffic.js';
import { createGarage } from './garage.js';
import { createMapAssets } from '../world/mapRender.js';
import { formatDistance, toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// City map zoom limits in metres per screen pixel (closest and furthest).
const MAP_ZOOM_MIN = 0.35;
@@ -73,7 +78,7 @@ export async function initializeRenderer(context) {
});
} catch {
context.ui.patch('loading', {
text: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.',
text: strings.app.noWebgl,
});
throw new Error('WebGL unavailable');
}
@@ -281,7 +286,7 @@ export async function initializeStationById(context) {
s.reach = new THREE.Mesh(
new THREE.RingGeometry(0.96, 1, 96),
new THREE.MeshBasicMaterial({
color: '#3ee0c9',
color: BRAND.primary,
transparent: true,
opacity: 0.55,
depthWrite: false,
@@ -326,7 +331,7 @@ export async function initializeClosestStation(context) {
});
if (context.imagery.available)
context.ui.patch('attribution', {
text: `نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${context.imageryIndex.attribution}`,
text: strings.app.osmImageryCredit(context.imageryIndex.attribution),
});
context.traffic = createTraffic(context.scene, data, 28);
context.mapAssets = createMapAssets(data, [context.factory.label]);
@@ -357,7 +362,7 @@ export async function buildParkGate(context) {
c.fillStyle = '#f4efd9';
c.textAlign = 'center';
c.font = 'bold 64px Vazirmatn, Tahoma, sans-serif';
c.fillText('بوستان ملت', 256, 86);
c.fillText(strings.places.parkSign, 256, 86);
const texture = new THREE.CanvasTexture(sign);
texture.colorSpace = THREE.SRGBColorSpace;
const gate = context.parkGate,
@@ -598,11 +603,7 @@ export async function initializeAxis(context) {
context.axis = (pos, neg) =>
Number(pos.some((k) => context.keys.has(k))) - Number(neg.some((k) => context.keys.has(k)));
// ---------- Subtitles ----------
context.SPEAKERS = {
robot: 'روبات',
father: 'پدر',
child: 'بچه',
};
context.SPEAKERS = { ...strings.speakers };
// Factory staff speak under their own names.
for (const t of context.factory.talks) if (t.label) context.SPEAKERS[t.speaker] = t.label;
context.lines = [];
@@ -637,7 +638,7 @@ export async function registerSoundControl(context) {
};
}
context.ui.patch('sound', {
text: context.soundOn ? 'صدا: روشن' : 'صدا: خاموش',
text: context.soundOn ? strings.toolbar.soundOn : strings.toolbar.soundOff,
});
context.ui.patch('sound', {
pressed: String(context.soundOn),
@@ -645,7 +646,7 @@ export async function registerSoundControl(context) {
context.tone(700);
} catch {
context.ui.patch('sound', {
text: 'صدا در دسترس نیست',
text: strings.toolbar.soundUnavailable,
});
}
});
@@ -668,8 +669,8 @@ export async function initializeReachRadius(context) {
: null;
context.hoursText = (m) =>
m.hours[1] - m.hours[0] >= 24 * 60
? 'شبانه‌روزی'
: `${formatClock(m.hours[0])} تا ${formatClock(m.hours[1])}`;
? strings.common.allDay
: strings.common.hours(formatClock(m.hours[0]), formatClock(m.hours[1]));
context.shopStation = null;
context.nearShowroom = () =>
!context.real.on && context.mode === 'walk' ? context.showroom.nearest(context.player) : null;
@@ -679,7 +680,7 @@ export async function initializeReachRadius(context) {
modelId: id,
});
context.ui.patch('showroom-response', {
text: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟',
text: strings.showroom.whatToKnow,
});
});
}
@@ -699,7 +700,7 @@ export async function registerPurchaseControls(context) {
const answer = showroomAnswer(question);
if (!answer) return;
context.ui.patch('showroom-response', {
text: `روبات: ${answer}`,
text: strings.showroom.robotSays(answer),
});
context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink');
});
@@ -717,7 +718,7 @@ export async function registerPurchaseControls(context) {
context.ui.on('cancel-payment', context.cancel);
context.ui.on('shop-close', () => context.ui.close('shop'));
context.ui.patch('quality', {
text: context.quality === 'low' ? 'گرافیک: سبک' : 'گرافیک: کامل',
text: context.quality === 'low' ? strings.toolbar.graphicsLow : strings.toolbar.graphicsHigh,
});
}
/**
@@ -825,7 +826,10 @@ export async function registerRouteSelection(context) {
const s = context.stationById(context.selected);
context.say(
'father',
`مسیر تا ${s.def.name} رو گذاشتم؛ ${context.route ? formatDistance(context.route.length) : ''}. دنبال خط طلایی بریم.`,
strings.dialogue.routeSet(
s.def.name,
context.route ? formatDistance(context.route.length) : '',
),
);
context.selectMachine(context.selected);
});
@@ -978,63 +982,63 @@ export async function registerMapSelectionAndZoom(context) {
export async function initializeCities(context) {
context.CITIES = [
{
name: 'مشهد',
name: strings.cities.mashhad,
lat: 36.3,
lon: 59.6,
active: true,
},
{
name: 'تهران',
name: strings.cities.tehran,
lat: 35.69,
lon: 51.39,
},
{
name: 'اصفهان',
name: strings.cities.isfahan,
lat: 32.65,
lon: 51.67,
},
{
name: 'شیراز',
name: strings.cities.shiraz,
lat: 29.59,
lon: 52.58,
},
{
name: 'تبریز',
name: strings.cities.tabriz,
lat: 38.08,
lon: 46.29,
},
{
name: 'اهواز',
name: strings.cities.ahvaz,
lat: 31.32,
lon: 48.67,
},
{
name: 'کرمان',
name: strings.cities.kerman,
lat: 30.28,
lon: 57.08,
},
{
name: 'رشت',
name: strings.cities.rasht,
lat: 37.28,
lon: 49.58,
},
{
name: 'یزد',
name: strings.cities.yazd,
lat: 31.9,
lon: 54.37,
},
{
name: 'زاهدان',
name: strings.cities.zahedan,
lat: 29.5,
lon: 60.86,
},
{
name: 'کرمانشاه',
name: strings.cities.kermanshah,
lat: 34.31,
lon: 47.07,
},
{
name: 'بندرعباس',
name: strings.cities.bandarAbbas,
lat: 27.18,
lon: 56.27,
},
@@ -1046,14 +1050,7 @@ export async function initializeCities(context) {
* @returns {*} Operation result, when applicable.
*/
export async function initializeBriefingAndTracking(context) {
context.BRIEF = [
`نقطهٔ شروع: ${FACTORY.name}، بزرگراه مشهد–چناران`,
'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست',
'هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های روبات مارکت را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.',
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.',
'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.',
].join('\n');
context.BRIEF = strings.briefing.lines.join('\n');
context.briefChars = 0;
context.ui.on('start-mission', context.startMission);
@@ -1099,7 +1096,7 @@ export async function initializeArMode(context) {
context.real.stabilizer.stable = context.stableMode;
context.ui.classState('stable-toggle', 'on', context.stableMode);
context.ui.patch('stable-toggle', {
text: context.stableMode ? '🎯 پایدار: روشن' : '🎯 پایدار: خاموش',
text: context.stableMode ? strings.toolbar.stableOn : strings.toolbar.stableOff,
});
});
+12 -27
View File
@@ -1,6 +1,7 @@
import * as THREE from 'three';
import { FACTORY } from '../../constants/locations.js';
import { isOpen } from '../economy/gameState.js';
import { strings } from '../../i18n/fa.js';
/**
* Updates talks from the active simulation.
* @param {*} context - context.
@@ -33,36 +34,20 @@ export function greet(context, station) {
first = !context.guideState.greeted.has(def.id);
context.guideState.greeted.add(def.id);
if (def.id === 'factory' && first) {
context.say(
'robot',
'سلام! من روبات راهنمای روبات مارکتم؛ تو دستگاه دفتر فنی زندگی می‌کنم!',
'excited',
);
context.say(
'robot',
`به ${FACTORY.name} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`,
'happy',
);
context.say('robot', 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', 'wink');
context.say(
'robot',
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!',
'love',
);
context.say('robot', strings.dialogue.robotHello, 'excited');
context.say('robot', strings.dialogue.robotWelcome(FACTORY.name), 'happy');
context.say('robot', strings.dialogue.robotFirstBuy, 'wink');
context.say('robot', strings.dialogue.robotGoToPark, 'love');
return;
}
if (!isOpen(def, context.state.minutes)) {
context.say(
'robot',
`سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${context.hoursText(def)}.`,
'serious',
);
context.say('robot', strings.dialogue.robotClosed(context.hoursText(def)), 'serious');
return;
}
if (first) {
context.say('robot', `سلام دوباره! به دستگاه ${def.name} خوش اومدین.`, 'excited');
context.say('robot', `${def.note}. برای خرید E رو بزنین.`, 'happy');
} else context.say('robot', 'باز هم خوش اومدین! برای خرید E رو بزنین.', 'wink');
context.say('robot', strings.dialogue.robotWelcomeBack(def.name), 'excited');
context.say('robot', strings.dialogue.robotNote(def.note), 'happy');
} else context.say('robot', strings.dialogue.robotAgain, 'wink');
}
/**
* Updates guide from the active simulation.
@@ -95,7 +80,7 @@ export function updateGuide(context, dt) {
context.tone(990, 0.15);
context.tone(1320, 0.2);
}
if (wasOut && near !== s) context.say('robot', 'خداحافظ! مسیر طلایی رو دنبال کنین.', 'happy');
if (wasOut && near !== s) context.say('robot', strings.dialogue.robotBye, 'happy');
if (!context.guideState.t) {
context.guideState.station = null;
context.guide.group.visible = false;
@@ -149,7 +134,7 @@ export function updateHints(context) {
context.hints.closed = true;
context.say(
'robot',
`${target.def.name} الان بسته است (${context.hoursText(target.def)}). از نقشه مقصد دیگری انتخاب کن.`,
strings.dialogue.targetClosed(target.def.name, context.hoursText(target.def)),
);
}
if (
@@ -159,6 +144,6 @@ export function updateHints(context) {
!context.hints.car
) {
context.hints.car = true;
context.say('father', 'راه طولانیه؛ بهتره با ماشین بریم.');
context.say('father', strings.dialogue.longWay);
}
}
+11 -6
View File
@@ -1,3 +1,8 @@
import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js';
// Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors;
// 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 = 0.75; // tile pixels per metre
@@ -271,7 +276,7 @@ function machineIcon(c, x, y, { objective, visited, open }) {
c.fillRect(-7, -7, 14, 14);
c.strokeRect(-7, -7, 14, 14);
} else {
c.fillStyle = open ? (visited ? '#7fb8ad' : '#3ee0c9') : '#7b817f';
c.fillStyle = open ? (visited ? '#7fb8ad' : BRAND.primary) : '#7b817f';
c.strokeStyle = '#08201c';
c.lineWidth = 2;
c.beginPath();
@@ -367,7 +372,7 @@ export function drawMinimap(ctx, assets, view) {
ctx.font = 'bold 13px Vazirmatn, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('ش', (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.fillText(strings.hud.minimapNorth, (nx / nl) * (r - 12), (ny / nl) * (r - 12));
ctx.restore();
ctx.strokeStyle = 'rgba(230,240,235,.55)';
ctx.lineWidth = 2;
@@ -461,11 +466,11 @@ export function drawFullMap(ctx, assets, view, state) {
metro: '#ff8a80',
mall: '#f5b8d9',
industrial: '#a9b2ad',
factory: '#3ee0c9',
factory: BRAND.primary,
}[l.kind];
if (l.kind === 'factory') ctx.font = 'bold 14px Vazirmatn, sans-serif';
ctx.fillText(
l.kind === 'metro' ? `Ⓜ ${l.name.replace('ایستگاه مترو ', '')}` : l.name,
l.kind === 'metro' ? `Ⓜ ${l.name.replace(strings.hud.metroPrefix, '')}` : l.name,
sx,
sy + 14,
);
@@ -546,9 +551,9 @@ export function drawIran(
for (const province of iran.provinces) {
const active = province.id === highlight;
ctx.fillStyle = active
? `rgba(62,224,201,${0.35 + Math.sin(t * 3) * 0.12})`
? `rgba(0,166,147,${0.35 + Math.sin(t * 3) * 0.12})`
: 'rgba(40,56,50,.85)';
ctx.strokeStyle = active ? '#3ee0c9' : 'rgba(150,190,175,.35)';
ctx.strokeStyle = active ? BRAND.primary : 'rgba(150,190,175,.35)';
ctx.lineWidth = active ? 2 : 1;
for (const ring of province.rings) {
ctx.beginPath();
+457
View File
@@ -0,0 +1,457 @@
// Every piece of Persian text the game shows: menus, HUD, prompts, dialogue, missions and the
// scenery text painted onto 3D props. Company and product content lives in config/company.js,
// and place names come from the map data. To add a language, copy this file with the same keys.
// Entries that need values are functions; everything else is a plain string.
import { COMPANY } from '../config/company.js';
export const strings = {
common: {
close: 'بستن',
cancel: 'انصراف',
gotIt: 'متوجه شدم',
mashhad: 'مشهد',
iran: 'ایران',
walking: 'پیاده',
open: 'باز',
closed: 'بسته',
allDay: 'شبانه‌روزی',
/** @type {(from: string, to: string) => string} */
hours: (from, to) => `${from} تا ${to}`,
},
units: {
/** @type {(n: string) => string} */
metres: (n) => `${n} متر`,
/** @type {(n: string) => string} */
kilometres: (n) => `${n} کیلومتر`,
/** @type {(n: string) => string} */
hetoman: (n) => `${n} هتومان`,
/** @type {(n: string) => string} */
rial: (n) => `${n} ریال`,
hetomanName: 'هتومان',
},
app: {
preparing: 'در حال آماده‌سازی بازی…',
buildingCity: 'در حال ساختن شهر مشهد…',
/** @type {(error: string) => string} */
failed: (error) => `بازی اجرا نشد: ${error}`,
retry: 'تلاش دوباره',
noWebgl: 'مرورگر شما گرافیک سه‌بعدی (WebGL) را اجرا نمی‌کند. شتاب‌دهی گرافیکی را فعال کنید.',
dataFailed: 'بارگذاری داده ناموفق بود',
updateReady: 'نسخهٔ جدید بازی آماده است ',
update: 'به‌روزرسانی',
osmCredit: 'نقشه: © مشارکت‌کنندگان OpenStreetMap',
/** @type {(imagery: string) => string} */
osmImageryCredit: (imagery) =>
`نقشه: © مشارکت‌کنندگان OpenStreetMap · تصویر ماهواره‌ای: ${imagery}`,
},
briefing: {
robotAlt: `روبات راهنمای ${COMPANY.shortName}`,
tag: 'عملیات ۰۱ · خراسان رضوی',
title: 'عملیات مشهد',
subtitle: `${COMPANY.shortName} · شکار دستگاه‌های وندینگ`,
start: 'شروع عملیات',
realWorld: '📍 بازی در دنیای واقعی (GPS)',
install: '⬇ نصب بازی روی گوشی',
keys: 'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M',
lines: [
`نقطهٔ شروع: ${COMPANY.name}، بزرگراه مشهد–چناران`,
'تیم: پدر و فرزند · روبات راهنما در دستگاه‌ها منتظر شماست',
`هدف: از کارخانه تا بوستان ملت و دانشگاه فردوسی (حدود ۲۰ کیلومتر) بروید و دستگاه‌های ${COMPANY.shortName} را در پارک، مدرسه، دانشگاه، ایستگاه مترو و مرکز خرید پیدا کنید.`,
'هر خرید امتیاز دارد؛ کشف مکان تازه و ترکیب نوشیدنی و خوراکی امتیاز بیشتری دارد.',
'بودجه: ۵۰۰ هتومان · مأموریت‌ها پاداش نقدی دارند.',
'رفت‌وآمد: پیاده یا با ماشین. نقشهٔ گوشهٔ صفحه را بزنید تا آدرس دستگاه‌ها را ببینید.',
],
},
toolbar: {
showroom: 'مدل‌های کارخانه · Q',
realWorld: '📍 دنیای واقعی',
camera: '📷 دوربین',
stableOn: '🎯 پایدار: روشن',
stableOff: '🎯 پایدار: خاموش',
soundOn: 'صدا: روشن',
soundOff: 'صدا: خاموش',
soundUnavailable: 'صدا در دسترس نیست',
graphics: 'گرافیک',
graphicsLow: 'گرافیک: سبک',
graphicsHigh: 'گرافیک: کامل',
restart: 'شروع دوباره',
},
status: {
budget: 'بودجه',
budgetStart: '۵۰۰',
clock: 'ساعت',
clockStart: '۰۸:۳۰',
speed: 'سرعت',
speedStart: '۰',
rankStart: 'تازه‌وارد',
xpStart: '۰ XP',
missions: 'مأموریت‌ها',
run: 'دویدن',
},
hud: {
openMap: 'باز کردن نقشهٔ شهر (M)',
mapHint: 'نقشه · M',
interact: 'تعامل با بازی',
compass: { 0: 'شمال', 90: 'شرق', 180: 'جنوب', 270: 'غرب' },
// Single-letter north marker on the minimap.
minimapNorth: 'ش',
/** @type {(name: string) => string} */
factory: (name) => `کارخانهٔ ${name}`,
/** @type {(street: string) => string} */
street: (street) => `مشهد · ${street}`,
outskirts: 'حومهٔ مشهد',
metroPrefix: 'ایستگاه مترو ',
},
travel: {
onBike: 'روی موتور · F برای پیاده شدن',
/** @type {(vehicle: string) => string} */
inVehicle: (vehicle) => `در ${vehicle} · F برای پیاده شدن`,
},
prompts: {
/** @type {(model: string) => string} */
showroom: (model) => `Q · معرفی ${model} و پرسیدن قیمت`,
/** @type {(machine: string) => string} */
buy: (machine) => `E · خرید از ${COMPANY.shortName} · ${machine}`,
/** @type {(hours: string) => string} */
closed: (hours) => `دستگاه بسته است · ${hours}`,
/** @type {(vehicle: string) => string} */
getIn: (vehicle) => `F · سوار ${vehicle} شو`,
getOutNearMachine: 'F · پیاده شو؛ دستگاه نزدیک است',
},
vehicles: {
bmw: 'بی‌ام‌و',
bugatti: 'بوگاتی',
lamborghini: 'لامبورگینی',
mazda: 'مزدا',
nissan: 'نیسان',
apache: 'موتور آپاچی',
traffic: 'ماشین',
},
map: {
close: 'بستن',
help: 'کشیدن: جابه‌جایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب',
machines: `دستگاه‌های ${COMPANY.shortName}`,
route: 'مسیریابی تا اینجا',
routeActive: 'مسیر فعال است ✓',
openNow: 'الان باز است',
closedNow: 'الان بسته است',
/** @type {(hours: string, open: boolean, note: string) => string} */
details: (hours, open, note) =>
`🕒 ${hours} · ${open ? 'الان باز است' : 'الان بسته است'} · ${note}`,
},
lists: {
drink: 'نوشیدنی',
snack: 'خوراکی',
noFunds: ' · موجودی کافی نیست',
/** @type {(category: string, open: boolean) => string} */
machineState: (category, open) => `${category} · ${open ? 'باز' : 'بسته'}`,
},
install: {
title: 'نصب روی آیفون',
shareBefore: 'در Safari دکمهٔ ',
shareAfter: ' (مربع با فلش رو به بالا) را بزنید.',
addBefore: 'گزینهٔ ',
addAfter: ' را انتخاب کنید.',
note: `${COMPANY.shortName} مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.`,
},
realIntro: {
title: 'بازی در دنیای واقعی',
body: `با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه ${COMPANY.shortName} برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.`,
safety: [
'هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.',
'بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.',
'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.',
'موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.',
],
accept: 'قبول دارم، شروع کن',
},
real: {
on: 'حالت دنیای واقعی روشن شد؛ از چشم خودمون می‌بینیم و هر جا راه بریم، توی بازی هم همون‌جاییم.',
/** @type {(problem: string) => string} */
cameraFailed: (problem) => `${problem} فعلاً شهر سه‌بعدی رو از چشم خودمون می‌بینیم.`,
cameraOn: 'دوربین روشن شد! گوشی رو صاف جلوی خودت بگیر؛ ستون نورانی طلایی جای دستگاهه.',
noOrientation: 'حسگر جهت گوشی جواب نمی‌ده؛ برای چرخیدن، صفحه رو با انگشت بکش.',
noVirtualCar: 'توی حالت دنیای واقعی پیاده راه می‌ریم؛ ماشین مجازی لازم نیست.',
locating: 'در حال یافتن موقعیت…',
/** @type {(distance: string) => string} */
outOfArea: (distance) => `📍 خارج از محدودهٔ بازی · ${distance} تا نزدیک‌ترین دستگاه`,
/** @type {(metres: string, fast: boolean) => string} */
accuracy: (metres, fast) => `📍 GPS ±${metres} متر${fast ? ' · حرکت سریع' : ''}`,
noGps: 'این دستگاه موقعیت‌یابی (GPS) ندارد.',
needsHttps: 'موقعیت‌یابی فقط روی HTTPS کار می‌کند؛ بازی را از نشانی https:// باز کنید.',
denied:
'اجازهٔ دسترسی به موقعیت داده نشد. در تنظیمات مرورگر و گوشی، Location را برای این سایت مجاز کنید. (کد ۱)',
unavailable:
'موقعیت در دسترس نیست. Location (GPS) گوشی را روشن کنید؛ روی کامپیوتر معمولاً موقعیت‌یابی کار نمی‌کند. (کد ۲)',
timeout: 'هنوز سیگنال GPS نرسیده… در فضای باز و کنار پنجره سریع‌تر پیدا می‌شود. (کد ۳)',
cameraNeedsHttps: 'این مرورگر به دوربین دسترسی نمی‌دهد (HTTPS لازم است).',
cameraDenied: 'اجازهٔ دوربین داده نشد؛ از تنظیمات مرورگر اجازه بدهید.',
cameraBusy: 'دوربین باز نشد؛ برنامهٔ دیگری از آن استفاده نمی‌کند؟',
},
shop: {
close: 'بستن',
note: '۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.',
/** @type {(icon: string, category: string, hours: string) => string} */
category: (icon, category, hours) => `${icon} ${category} · ساعت کاری ${hours}`,
/** @type {(machine: string) => string} */
title: (machine) => `${COMPANY.shortName} · ${machine}`,
/** @type {(note: string, balance: string) => string} */
balance: (note, balance) => `${note} · موجودی شما: ${balance}`,
},
payment: {
reader: `کارت‌خوان ${COMPANY.shortName}`,
swipe: 'کشیدن کارت · C',
approaching: 'در حال نزدیک شدن به کارت‌خوان…',
success: 'پرداخت موفق ✓ محصول در حال تحویل است',
swipeHint: 'کارت را بکشید: دکمهٔ «کشیدن کارت» یا کلید C',
realPurchase: 'خرید در دنیای واقعی',
},
rewards: {
missionDone: 'مأموریت انجام شد',
/** @type {(xp: string, cash: string) => string} */
missionReward: (xp, cash) => `+${xp} XP · +${cash} پاداش`,
rankUp: 'ارتقای درجه',
findMore: 'دستگاه‌های بیشتری پیدا کن',
},
end: {
title: 'پایان عملیات',
again: 'عملیات دوباره',
keepExploring: 'ادامهٔ گشت در شهر',
success: 'عملیات موفق',
outOfBudget: 'پایان بودجه',
successHeading: 'عملیات مشهد کامل شد!',
budgetHeading: 'بودجهٔ امروز تمام شد',
successText:
'همهٔ مأموریت‌های محدودهٔ بوستان ملت انجام شد. مناطق دیگر مشهد به‌زودی باز می‌شوند.',
budgetText: 'دیگر نمی‌توانید از دستگاه‌ها خرید کنید. امتیاز امروز:',
score: 'امتیاز',
rank: 'درجه',
purchases: 'خریدها',
places: 'مکان‌های کشف‌شده',
missions: 'مأموریت‌ها',
remaining: 'باقی‌مانده',
/** @type {(done: string, total: string) => string} */
outOf: (done, total) => `${done} از ${total}`,
},
showroom: {
/** @type {(model: string) => string} */
heading: (model) => `نمایشگاه کارخانه · ${model}`,
close: 'بستن معرفی دستگاه',
models: 'مدل دستگاه',
/** @type {(price: string) => string} */
price: (price) => `قیمت خود دستگاه: ${price}`,
displayOnly: 'این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند.',
askPrice: 'روبات، قیمتش چنده؟',
askFeatures: 'روبات، امکاناتش چیه؟',
robotAlt: 'روبات راهنمای نمایشگاه',
greeting: 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.',
allModels: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.',
whatToKnow: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟',
/** @type {(answer: string) => string} */
robotSays: (answer) => `روبات: ${answer}`,
noImage: 'نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.',
views: { image: 'نمای اصلی', imageAlt: 'رنگ سفید', gif: 'نمای متحرک' },
/** @type {(model: string, price: string) => string} */
priceAnswer: (model, price) =>
`قیمت ${model}، ${price} است. این قیمت خود دستگاه به ریال است و از بودجهٔ بازی کم نمی‌شود.`,
/** @type {(model: string, description: string, features: string) => string} */
featuresAnswer: (model, description, features) =>
`${model}: ${description} امکانات: ${features}.`,
featureSeparator: '، ',
},
speakers: { robot: 'روبات', father: 'پدر', child: 'بچه' },
dialogue: {
showroomIntro: `سه نمونهٔ ۳۵، ۴۸ و ۶۰ کانال توی حیاط کنار ماشین هستند. دکمهٔ مدل‌های کارخانه یا Q را بزن تا قیمت و امکاناتشان را بگویم.`,
childAsksFactory: `بابا، اینجا همون کارخونه‌ایه که دستگاه‌های ${COMPANY.shortName} رو می‌سازه؟`,
fatherAnswersFactory:
'آره! سالن تولید اون‌وره؛ دستگاه وندینگ هم توی دفتر فنی مهندسیه. بریم ببینیم!',
childAsksArrival: 'اون ماشین مشکیه کیه داره میاد تو؟',
fatherAnswersArrival: 'آقای محمدیه، مدیر شرکت. بعداً بریم اتاقش سلام کنیم.',
robotHello: `سلام! من روبات راهنمای ${COMPANY.shortName}م؛ تو دستگاه دفتر فنی زندگی می‌کنم!`,
/** @type {(company: string) => string} */
robotWelcome: (company) =>
`به ${company} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`,
robotFirstBuy: 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.',
robotGoToPark:
'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!',
/** @type {(hours: string) => string} */
robotClosed: (hours) => `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hours}.`,
/** @type {(machine: string) => string} */
robotWelcomeBack: (machine) => `سلام دوباره! به دستگاه ${machine} خوش اومدین.`,
/** @type {(note: string) => string} */
robotNote: (note) => `${note}. برای خرید E رو بزنین.`,
robotAgain: 'باز هم خوش اومدین! برای خرید E رو بزنین.',
robotBye: 'خداحافظ! مسیر طلایی رو دنبال کنین.',
/** @type {(machine: string, hours: string) => string} */
targetClosed: (machine, hours) =>
`${machine} الان بسته است (${hours}). از نقشه مقصد دیگری انتخاب کن.`,
longWay: 'راه طولانیه؛ بهتره با ماشین بریم.',
bikeOn: 'کلاه ایمنی سرت باشه! با موتور می‌شه از لای ماشین‌ها رد شد.',
carOn: 'کمربندها رو ببندیم! مسیر طلایی روی زمین راه رو نشون می‌ده.',
stopFirst: 'اول نگه دار، بعد پیاده می‌شویم.',
tooFastToBuy: 'داریم سریع حرکت می‌کنیم؛ اول بایستیم، بعد خرید کنیم.',
/** @type {(hours: string) => string} */
machineClosed: (hours) => `این دستگاه الان بسته است. ساعت کاری: ${hours}.`,
childSwipes: 'بابا، کارت رو بده من بکشم!',
/** @type {(icon: string, product: string) => string} */
childGotIt: (icon, product) => `${icon} ${product} گرفتم! مرسی بابا.`,
/** @type {(mission: string) => string} */
nextMission: (mission) => `مأموریت بعدی: ${mission}`,
/** @type {(machine: string, distance: string) => string} */
routeSet: (machine, distance) =>
`مسیر تا ${machine} رو گذاشتم؛ ${distance}. دنبال خط طلایی بریم.`,
},
ranks: ['تازه‌وارد', 'کاوشگر', 'راه‌بلد', 'شکارچی دستگاه', 'استاد نقشه', 'افسانهٔ مشهد'],
bonuses: {
purchase: 'خرید موفق',
newPlace: 'کشف مکان جدید',
combo: 'ترکیب نوشیدنی و خوراکی',
allCategories: 'همهٔ انواع مکان',
},
missions: {
factory: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن',
first: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده',
school: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن',
campus: 'به دانشگاه فردوسی برو و یک خوراکی بخر',
metro: 'از دستگاه ایستگاه مترو آزادی خرید کن',
mall: 'با ماشین تا مشهد مال برو و خرید کن',
explorer: 'از ۵ مکان متفاوت شهر خرید کن',
},
snacks: {
juice: 'آب‌میوه',
water: 'آب معدنی',
milk: 'شیر کاکائو',
tea: 'چای سرد',
lemonade: 'لیموناد',
biscuit: 'بیسکویت',
chocolate: 'شکلات',
nuts: 'آجیل',
cake: 'کیک',
},
categories: {
factory: 'کارخانه',
park: 'پارک',
university: 'دانشگاه',
school: 'مدرسه',
metro: 'ایستگاه مترو',
mall: 'مرکز خرید',
test: 'نقطهٔ آزمایشی GPS',
},
places: {
officeMachine: 'دستگاه دفتر فنی مهندسی',
/** @type {(address: string) => string} */
officeAddress: (address) => `${address}، ساختمان اداری، دفتر فنی مهندسی`,
officeNote: 'دستگاه دفتر برنامه‌نویس‌ها؛ نصف قیمت برای مهمان‌های کارخانه',
/** @type {(n: string) => string} */
testPoint: (n) => `نقطهٔ آزمایشی ${n}`,
/** @type {(coords: string) => string} */
testAddress: (coords) => `کنار کارخانهٔ ${COMPANY.shortName} · ${coords}`,
testNote: 'برای آزمایش حالت دنیای واقعی: با گوشی تا اینجا راه بروید',
comingSoon: 'خارج از محدودهٔ فعلی · به‌زودی',
parkSign: 'بوستان ملت',
},
cities: {
mashhad: 'مشهد',
tehran: 'تهران',
isfahan: 'اصفهان',
shiraz: 'شیراز',
tabriz: 'تبریز',
ahvaz: 'اهواز',
kerman: 'کرمان',
rasht: 'رشت',
yazd: 'یزد',
zahedan: 'زاهدان',
kermanshah: 'کرمانشاه',
bandarAbbas: 'بندرعباس',
},
factory: {
readyToInstall: 'آمادهٔ نصب',
zones: [
['خم‌کاری', 'ورق‌کاری بدنهٔ دستگاه'],
['برش لیزر', 'برش ورق و قطعات'],
['رنگ‌کاری', 'کابین رنگ پودری'],
['یخچال‌سازی', 'کمپرسور و عایق‌کاری'],
['مونتاژ', 'خط مونتاژ نهایی'],
['بسته‌بندی', 'ارسال به شهر'],
],
signs: {
engineering: ['دفتر فنی مهندسی', 'Engineering'],
accounting: ['واحد حسابداری', 'Accounting'],
management: ['مدیریت', 'آقای محمدی'],
nameplate: ['محمدی', 'مدیر شرکت'],
},
manager: { name: 'آقای محمدی', role: 'مدیر شرکت' },
staff: {
hamed: {
name: 'حامد',
lines: [
'سلام! من حامدم. رابط کاربری صفحهٔ لمسی دستگاه‌ها رو با React می‌سازم.',
'هر چی روی صفحهٔ دستگاه می‌بینین، از همین مانیتور اومده!',
],
},
ryan: {
name: 'رایان',
lines: ['سلام! من رایانم. سرور پرداخت و گزارش فروش دستگاه‌ها رو با Go می‌نویسم.'],
},
alireza: {
name: 'علیرضا',
lines: [
'سلام! علیرضام. فریمور بُرد کنترل دستگاه با C++ ـه؛ الان دارم موتورهای تحویل کالا رو تنظیم می‌کنم.',
],
},
reza: {
name: 'رضا',
lines: [
'سلام، خوش اومدین. من رضام؛ کد C++ سنسورها و دمای یخچال دستگاه با منه.',
'دستگاه وندینگ همین دفتر کنار دره؛ از اون خرید کنین!',
],
},
mohammad: {
name: 'محمد',
lines: [`سلام! من محمدم. به دفتر فنی مهندسی ${COMPANY.shortName} خوش اومدین!`],
},
},
accounting: {
label: 'واحد حسابداری',
lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'],
},
managerLines: [
`سلام، خوش اومدین! من محمدی‌ام، مدیر ${COMPANY.shortName}.`,
'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.',
'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!',
],
truck: 'بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!',
// One comment per production area, spoken the first time the family walks in.
areaTalks: [
'اینجا خم‌کاریه؛ ورق‌های فلزی بدنهٔ دستگاه رو با پرس خم می‌کنن.',
'دستگاه برش لیزر رو ببین! ورق رو مثل کاغذ می‌بُره.',
`این کابین رنگه؛ بدنه‌ها اینجا رنگ برند ${COMPANY.shortName} رو می‌گیرن.`,
'یخچال‌سازی! برای همینه که نوشیدنی‌های دستگاه همیشه خنکن.',
'خط مونتاژ: روبات‌ها و کارگرها قطعه‌ها رو کنار هم می‌ذارن و دستگاه کامل می‌شه.',
],
// Scenery text painted on office screens and boards.
decor: {
invoices: [
'فاکتور دستگاه مبلغ',
'۱۰۴۲ بوستان ملت ۴۸۰',
'۱۰۴۳ دانشگاه فردوسی ۳۲۰',
'۱۰۴۴ مترو آزادی ۲۹۰',
'۱۰۴۵ مشهد مال ۶۱۰',
'۱۰۴۶ مدرسه راحیل ۱۸۰',
'جمع ۱۸۸۰',
],
dashboard: [
'فروش امروز: ۱۲۸ دستگاه',
'تولید هفته: ۴۶ دستگاه',
'ارسال شده: ۳۸',
'',
'▇▇▇▇▇▇▇▇▇▇▇ خم',
'▇▇▇▇▇▇▇▇▇ برش',
'▇▇▇▇▇▇▇ رنگ',
],
sprintTitle: 'اسپرینت ۱۴: دستگاه نسل جدید',
sprintItems: ['صفحهٔ لمسی جدید ← React', 'سرویس پرداخت ← Go', 'فریمور موتور و سنسور ← C++'],
},
},
};
+11 -13
View File
@@ -1,4 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
import { robotFace } from '@/config/assets.js';
/** @typedef {{nodes:object,actions:object}} BriefingPanelProps */
/** Presents the BriefingPanel view using plain models and semantic native elements.
* @param {BriefingPanelProps} props - Interface snapshots and callbacks.
@@ -14,14 +16,14 @@ export function BriefingPanel({ nodes, actions }) {
<div className="brief-panel">
<img
className="brief-robot"
src="./robot/front.webp"
alt="روبات راهنمای روبات مارکت"
src={robotFace('front')}
alt={strings.briefing.robotAlt}
width="136"
height="223"
/>
<small className="tag">{'عملیات ۰۱ · خراسان رضوی'}</small>
<h1>{'عملیات مشهد'}</h1>
<p className="sub">{'روبات مارکت · شکار دستگاه‌های وندینگ'}</p>
<small className="tag">{strings.briefing.tag}</small>
<h1>{strings.briefing.title}</h1>
<p className="sub">{strings.briefing.subtitle}</p>
<GameNode
as="pre"
id="brief-text"
@@ -35,7 +37,7 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['start-mission']}
onAction={() => actions.emit('start-mission')}
>
{'شروع عملیات'}
{strings.briefing.start}
</GameNode>
<div className="brief-actions">
<GameNode
@@ -46,7 +48,7 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['real-mode']}
onAction={() => actions.emit('real-mode')}
>
{'📍 بازی در دنیای واقعی (GPS)'}
{strings.briefing.realWorld}
</GameNode>
<GameNode
as="button"
@@ -57,14 +59,10 @@ export function BriefingPanel({ nodes, actions }) {
model={nodes['install']}
onAction={() => actions.emit('install')}
>
{'⬇ نصب بازی روی گوشی'}
{strings.briefing.install}
</GameNode>
</div>
<p className="keys">
{
'حرکت: W A S D · دویدن: Shift · دوربین: ماوس · سوار و پیاده شدن: F · خرید: E · نقشه: M'
}
</p>
<p className="keys">{strings.briefing.keys}</p>
</div>
</GameNode>
</>
+4 -3
View File
@@ -1,5 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} EndDialogProps */
/** Presents the EndDialog view using plain models and semantic native elements.
* @param {EndDialogProps} props - Interface snapshots and callbacks.
@@ -10,7 +11,7 @@ export function EndDialog({ nodes, actions }) {
<>
<GameNode as="dialog" id="end" model={nodes['end']} onClose={() => actions.close('end')}>
<GameNode as="small" id="end-tag" model={nodes['end-tag']}>
{'پایان عملیات'}
{strings.end.title}
</GameNode>
<GameNode as="h2" id="end-title" model={nodes['end-title']}></GameNode>
<GameNode as="p" id="end-text" model={nodes['end-text']}></GameNode>
@@ -29,7 +30,7 @@ export function EndDialog({ nodes, actions }) {
model={nodes['again']}
onAction={() => actions.emit('again')}
>
{'عملیات دوباره'}
{strings.end.again}
</GameNode>
<GameNode
as="button"
@@ -39,7 +40,7 @@ export function EndDialog({ nodes, actions }) {
model={nodes['continue']}
onAction={() => actions.emit('continue')}
>
{'ادامهٔ گشت در شهر'}
{strings.end.keepExploring}
</GameNode>
</div>
</GameNode>
+5 -4
View File
@@ -6,6 +6,7 @@ import { GameToolbar } from './GameToolbar.jsx';
import { TouchControls } from './TouchControls.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} GameHudProps */
/** Presents the GameHud view using plain models and semantic native elements.
* @param {GameHudProps} props - Interface snapshots and callbacks.
@@ -20,12 +21,12 @@ export function GameHud({ nodes, actions }) {
as="button"
id="minimap-button"
type="button"
aria-label="باز کردن نقشهٔ شهر (M)"
aria-label={strings.hud.openMap}
model={nodes['minimap-button']}
onAction={() => actions.emit('minimap-button')}
>
<GameNode as="canvas" id="minimap" width="220" height="220" model={nodes['minimap']} />
<span className="map-hint">{'نقشه · M'}</span>
<span className="map-hint">{strings.hud.mapHint}</span>
</GameNode>
<LocationStatus nodes={nodes} actions={actions} />
<MissionList nodes={nodes} actions={actions} />
@@ -39,7 +40,7 @@ export function GameHud({ nodes, actions }) {
id="prompt"
hidden
type="button"
aria-label="تعامل با بازی"
aria-label={strings.hud.interact}
model={nodes['prompt']}
onAction={() => actions.emit('prompt')}
></GameNode>
@@ -68,7 +69,7 @@ export function GameHud({ nodes, actions }) {
<TouchControls nodes={nodes} actions={actions} />
<GameNode as="div" id="crosshair" model={nodes['crosshair']}></GameNode>
<GameNode as="small" id="attribution" model={nodes['attribution']}>
{'نقشه: © مشارکت‌کنندگان OpenStreetMap'}
{strings.app.osmCredit}
</GameNode>
</GameNode>
</>
+3 -2
View File
@@ -9,6 +9,7 @@ import { GameSurfaces } from './GameSurfaces.jsx';
import { BriefingPanel } from './BriefingPanel.jsx';
import { GameHud } from './GameHud.jsx';
import { GameDialogs } from './GameDialogs.jsx';
import { strings } from '@/i18n/fa.js';
/** Composes React views around the isolated Three.js runtime. */
export function GamePage() {
@@ -49,9 +50,9 @@ export function GamePage() {
role="alert"
className="fixed inset-0 z-[100] grid place-content-center bg-surface p-8 text-hud"
>
<p>بازی اجرا نشد: {error}</p>
<p>{strings.app.failed(error)}</p>
<button type="button" onClick={() => location.reload()}>
تلاش دوباره
{strings.app.retry}
</button>
</div>
)}
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
/** Presents the GameSurfaces view using plain models and semantic native elements.
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
@@ -18,7 +19,7 @@ export function GameSurfaces({ nodes }) {
></GameNode>
<GameNode as="canvas" id="world" model={nodes['world']} />
<GameNode as="div" id="loading" model={nodes['loading']}>
{'در حال ساختن شهر مشهد…'}
{strings.app.buildingCity}
</GameNode>
</>
);
+8 -7
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
/** Presents the GameToolbar using the current interface snapshot. */
export function GameToolbar({ nodes, actions }) {
@@ -12,7 +13,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['showroom-open']}
onAction={() => actions.emit('showroom-open')}
>
مدل‌های کارخانه · Q
{strings.toolbar.showroom}
</GameNode>
<GameNode
as="button"
@@ -21,7 +22,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['gps-toggle']}
onAction={() => actions.emit('gps-toggle')}
>
{'📍 دنیای واقعی'}
{strings.toolbar.realWorld}
</GameNode>
<GameNode
as="button"
@@ -31,7 +32,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['ar-toggle']}
onAction={() => actions.emit('ar-toggle')}
>
{'📷 دوربین'}
{strings.toolbar.camera}
</GameNode>
<GameNode
as="button"
@@ -42,7 +43,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['stable-toggle']}
onAction={() => actions.emit('stable-toggle')}
>
{'🎯 پایدار: روشن'}
{strings.toolbar.stableOn}
</GameNode>
<GameNode
as="button"
@@ -52,7 +53,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['sound']}
onAction={() => actions.emit('sound')}
>
{'صدا: خاموش'}
{strings.toolbar.soundOff}
</GameNode>
<GameNode
as="button"
@@ -61,7 +62,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['quality']}
onAction={() => actions.emit('quality')}
>
{'گرافیک'}
{strings.toolbar.graphics}
</GameNode>
<GameNode
as="button"
@@ -70,7 +71,7 @@ export function GameToolbar({ nodes, actions }) {
model={nodes['restart']}
onAction={() => actions.emit('restart')}
>
{'شروع دوباره'}
{strings.toolbar.restart}
</GameNode>
</GameNode>
);
+8 -9
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} InstallDialogProps */
/** Presents the InstallDialog view using plain models and semantic native elements.
* @param {InstallDialogProps} props - Interface snapshots and callbacks.
@@ -13,21 +14,19 @@ export function InstallDialog({ nodes, actions }) {
model={nodes['ios-install']}
onClose={() => actions.close('ios-install')}
>
<h2>{'نصب روی آیفون'}</h2>
<h2>{strings.install.title}</h2>
<ol>
<li>
{'در Safari دکمهٔ '}
{strings.install.shareBefore}
<b>{'Share'}</b>
{' (مربع با فلش رو به بالا) را بزنید.'}
{strings.install.shareAfter}
</li>
<li>
{'گزینهٔ '}
{strings.install.addBefore}
<b>{'Add to Home Screen'}</b>
{' را انتخاب کنید.'}
</li>
<li>
{'روبات مارکت مثل یک اپ روی صفحهٔ اصلی گوشی شما قرار می‌گیرد و آفلاین هم اجرا می‌شود.'}
{strings.install.addAfter}
</li>
<li>{strings.install.note}</li>
</ol>
<GameNode
as="button"
@@ -36,7 +35,7 @@ export function InstallDialog({ nodes, actions }) {
model={nodes['ios-close']}
onAction={() => actions.emit('ios-close')}
>
{'متوجه شدم'}
{strings.common.gotIt}
</GameNode>
</GameNode>
</>
+3 -2
View File
@@ -1,14 +1,15 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
/** Presents the LocationStatus using the current interface snapshot. */
export function LocationStatus({ nodes }) {
return (
<GameNode as="div" id="location" model={nodes['location']}>
<GameNode as="span" id="street" model={nodes['street']}>
{'مشهد'}
{strings.common.mashhad}
</GameNode>
<GameNode as="small" id="mode-label" model={nodes['mode-label']}>
{'پیاده'}
{strings.common.walking}
</GameNode>
<GameNode as="small" id="gps-status" hidden model={nodes['gps-status']}></GameNode>
</GameNode>
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
/** Presents the MachineDetails using the current interface snapshot. */
export function MachineDetails({ nodes, actions }) {
@@ -15,7 +16,7 @@ export function MachineDetails({ nodes, actions }) {
model={nodes['route-button']}
onAction={() => actions.emit('route-button')}
>
{'مسیریابی تا اینجا'}
{strings.map.route}
</GameNode>
</GameNode>
);
+6 -7
View File
@@ -1,6 +1,7 @@
import { MachineDetails } from './MachineDetails.jsx';
import { MachineList } from './MachineList.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
/** Presents the MapDialog view using plain models and semantic native elements.
* @param {MapDialogProps} props - Interface snapshots and callbacks.
@@ -27,7 +28,7 @@ export function MapDialog({ nodes, actions }) {
model={nodes['tab-city']}
onAction={() => actions.emit('tab-city')}
>
{'مشهد'}
{strings.common.mashhad}
</GameNode>
<GameNode
as="button"
@@ -39,7 +40,7 @@ export function MapDialog({ nodes, actions }) {
model={nodes['tab-iran']}
onAction={() => actions.emit('tab-iran')}
>
{'ایران'}
{strings.common.iran}
</GameNode>
</div>
<GameNode
@@ -47,7 +48,7 @@ export function MapDialog({ nodes, actions }) {
type="button"
className="close"
id="map-close"
aria-label="بستن"
aria-label={strings.map.close}
model={nodes['map-close']}
onAction={() => actions.emit('map-close')}
>
@@ -58,12 +59,10 @@ export function MapDialog({ nodes, actions }) {
<div className="map-view">
<GameNode as="canvas" id="fullmap" model={nodes['fullmap']} />
<GameNode as="canvas" id="iran-map" hidden model={nodes['iran-map']} />
<p className="map-help">
{'کشیدن: جابه‌جایی · دو انگشت یا چرخ ماوس: زوم · لمس یا کلیک روی دستگاه: انتخاب'}
</p>
<p className="map-help">{strings.map.help}</p>
</div>
<aside className="map-side">
<h3>{'دستگاه‌های روبات مارکت'}</h3>
<h3>{strings.map.machines}</h3>
<MachineDetails nodes={nodes} actions={actions} />
<MachineList nodes={nodes} actions={actions} />
</aside>
+2 -1
View File
@@ -1,11 +1,12 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} MissionListProps */
/** Presents the MissionList using the current interface snapshot. */
export function MissionList({ nodes, actions }) {
return (
<GameNode as="aside" id="objectives" model={nodes['objectives']}>
<h2>{'مأموریت‌ها'}</h2>
<h2>{strings.status.missions}</h2>
<GameNode as="ol" id="mission-list" model={nodes['mission-list']}>
<ViewLists
id="mission-list"
+4 -3
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} PaymentPanelProps */
/** Presents the PaymentPanel view using plain models and semantic native elements.
* @param {PaymentPanelProps} props - Interface snapshots and callbacks.
@@ -8,7 +9,7 @@ export function PaymentPanel({ nodes, actions }) {
return (
<>
<GameNode as="section" id="payment" hidden model={nodes['payment']}>
<small>{'کارت‌خوان روبات مارکت'}</small>
<small>{strings.payment.reader}</small>
<GameNode as="h2" id="payment-title" model={nodes['payment-title']}></GameNode>
<GameNode as="p" id="payment-step" model={nodes['payment-step']}></GameNode>
<div className="actions">
@@ -20,7 +21,7 @@ export function PaymentPanel({ nodes, actions }) {
model={nodes['swipe']}
onAction={() => actions.emit('swipe')}
>
{'کشیدن کارت · C'}
{strings.payment.swipe}
</GameNode>
<GameNode
as="button"
@@ -30,7 +31,7 @@ export function PaymentPanel({ nodes, actions }) {
model={nodes['cancel-payment']}
onAction={() => actions.emit('cancel-payment')}
>
{'انصراف'}
{strings.common.cancel}
</GameNode>
</div>
</GameNode>
+3 -2
View File
@@ -1,14 +1,15 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
/** Presents the RankProgress using the current interface snapshot. */
export function RankProgress({ nodes }) {
return (
<GameNode as="div" id="rank" model={nodes['rank']}>
<GameNode as="b" id="rank-name" model={nodes['rank-name']}>
{'تازه‌وارد'}
{strings.status.rankStart}
</GameNode>
<GameNode as="span" id="xp" model={nodes['xp']}>
{'۰ XP'}
{strings.status.xpStart}
</GameNode>
<div className="bar">
<GameNode as="i" id="xp-bar" model={nodes['xp-bar']}></GameNode>
+10 -13
View File
@@ -1,4 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
import { robotFace } from '@/config/assets.js';
/** @typedef {{nodes:object,actions:object}} RealIntroDialogProps */
/** Presents the RealIntroDialog view using plain models and semantic native elements.
* @param {RealIntroDialogProps} props - Interface snapshots and callbacks.
@@ -13,18 +15,13 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-intro']}
onClose={() => actions.close('real-intro')}
>
<img src="./robot/serious.webp" alt="" width="72" height="68" />
<h2>{'بازی در دنیای واقعی'}</h2>
<p>
{
'با روشن کردن این حالت، موقعیت گوشی شما (GPS) روی نقشهٔ بازی نمایش داده می‌شود. وقتی واقعاً به محل یک دستگاه روبات مارکت برسید، می‌توانید خرید کنید و ۲۰۰ امتیاز اضافه بگیرید.'
}
</p>
<img src={robotFace('serious')} alt="" width="72" height="68" />
<h2>{strings.realIntro.title}</h2>
<p>{strings.realIntro.body}</p>
<ul>
<li>{'هنگام راه رفتن به خیابان و ماشین‌ها نگاه کنید، نه به صفحه.'}</li>
<li>{'بچه‌ها فقط همراه یک بزرگ‌تر بازی کنند.'}</li>
<li>{'هنگام رانندگی از این حالت استفاده نکنید؛ در سرعت بالا خرید غیرفعال می‌شود.'}</li>
<li>{'موقعیت شما فقط روی همین گوشی می‌ماند و جایی ارسال نمی‌شود.'}</li>
{strings.realIntro.safety.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<div className="actions">
<GameNode
@@ -34,7 +31,7 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-go']}
onAction={() => actions.emit('real-go')}
>
{'قبول دارم، شروع کن'}
{strings.realIntro.accept}
</GameNode>
<GameNode
as="button"
@@ -44,7 +41,7 @@ export function RealIntroDialog({ nodes, actions }) {
model={nodes['real-cancel']}
onAction={() => actions.emit('real-cancel')}
>
{'انصراف'}
{strings.common.cancel}
</GameNode>
</div>
</GameNode>
+3 -4
View File
@@ -1,5 +1,6 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} ShopDialogProps */
/** Presents the ShopDialog view using plain models and semantic native elements.
* @param {ShopDialogProps} props - Interface snapshots and callbacks.
@@ -20,7 +21,7 @@ export function ShopDialog({ nodes, actions }) {
type="button"
className="close"
id="shop-close"
aria-label="بستن"
aria-label={strings.shop.close}
model={nodes['shop-close']}
onAction={() => actions.emit('shop-close')}
>
@@ -35,9 +36,7 @@ export function ShopDialog({ nodes, actions }) {
onAction={(name, value) => actions.emit(name, value)}
/>
</GameNode>
<p className="rate">
{'۱۰ هتومان = ۱۰۰٬۰۰۰ ریال · مبالغ نمایشی‌اند و پرداخت واقعی انجام نمی‌شود.'}
</p>
<p className="rate">{strings.shop.note}</p>
</GameNode>
</>
);
+12 -15
View File
@@ -1,7 +1,9 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
import { robotFace } from '@/config/assets.js';
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
import { ShowroomImage } from './ShowroomImage.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
/** Presents the factory sample catalog and questions answered by the robot host.
@@ -22,17 +24,17 @@ export function ShowroomDialog({ nodes, actions }) {
>
<header className="mb-5 flex items-center justify-between gap-4">
<h2 id="showroom-title" className="text-xl font-bold">
نمایشگاه کارخانه · {model.name}
{strings.showroom.heading(model.name)}
</h2>
<button
type="button"
aria-label="بستن معرفی دستگاه"
aria-label={strings.showroom.close}
onClick={() => actions.close('showroom')}
>
✕
</button>
</header>
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
<nav aria-label={strings.showroom.models} className="mb-5 flex flex-wrap gap-3">
{SHOWROOM_CATALOG.map((item) => (
<button
key={item.id}
@@ -49,16 +51,14 @@ export function ShowroomDialog({ nodes, actions }) {
<ShowroomImage key={model.id} model={model} />
<section>
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
<p className="mb-3 text-gold">{strings.showroom.price(formatRial(model.priceNumeric))}</p>
<p>{model.description}</p>
<ul className="my-4 list-inside list-disc">
{model.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="text-sm opacity-75">
این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند.
</p>
<p className="text-sm opacity-75">{strings.showroom.displayOnly}</p>
</section>
</div>
<div className="mt-5 flex flex-wrap gap-3">
@@ -72,7 +72,7 @@ export function ShowroomDialog({ nodes, actions }) {
})
}
>
روبات، قیمتش چنده؟
{strings.showroom.askPrice}
</button>
<button
className="rounded border border-teal px-4 py-3"
@@ -84,19 +84,16 @@ export function ShowroomDialog({ nodes, actions }) {
})
}
>
روبات، امکاناتش چیه؟
{strings.showroom.askFeatures}
</button>
</div>
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
<img
className="h-16 w-16 shrink-0 object-contain"
src="./robot/happy.webp"
alt="روبات راهنمای نمایشگاه"
src={robotFace('happy')}
alt={strings.showroom.robotAlt}
/>
<p role="status">
{nodes['showroom-response']?.text ??
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
</p>
<p role="status">{nodes['showroom-response']?.text ?? strings.showroom.greeting}</p>
</div>
</GameNode>
);
+5 -8
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { strings } from '@/i18n/fa.js';
/** @typedef {{model:object}} ShowroomImageProps */
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
@@ -10,7 +11,7 @@ export function ShowroomImage({ model }) {
const [failed, setFailed] = useState({});
const path = model[variant];
const unavailable = failed[path];
const src = new URL(`.${path}`, document.baseURI).href;
const src = new URL(path, document.baseURI).href;
return (
<div className="grid gap-3">
{unavailable ? (
@@ -18,13 +19,13 @@ export function ShowroomImage({ model }) {
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
role="status"
>
نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
{strings.showroom.noImage}
</div>
) : (
<img
className="mx-auto max-h-64 max-w-full object-contain"
src={src}
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
alt={`${model.name} · ${strings.showroom.views[variant]}`}
onError={() =>
setFailed((previous) => ({
...previous,
@@ -34,11 +35,7 @@ export function ShowroomImage({ model }) {
/>
)}
<div className="flex flex-wrap gap-2">
{[
['image', 'نمای اصلی'],
['imageAlt', 'رنگ سفید'],
['gif', 'نمای متحرک'],
].map(([key, label]) => (
{Object.entries(strings.showroom.views).map(([key, label]) => (
<button
className="rounded border border-white/20 px-3 py-2"
key={key}
+8 -7
View File
@@ -1,26 +1,27 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
/** Presents the StatusBar using the current interface snapshot. */
export function StatusBar({ nodes }) {
return (
<GameNode as="div" id="status" model={nodes['status']}>
<div className="stat">
<small>{'بودجه'}</small>
<small>{strings.status.budget}</small>
<GameNode as="strong" id="balance" model={nodes['balance']}>
{'۵۰۰'}
{strings.status.budgetStart}
</GameNode>
<span>{'هتومان'}</span>
<span>{strings.units.hetomanName}</span>
</div>
<div className="stat">
<small>{'ساعت'}</small>
<small>{strings.status.clock}</small>
<GameNode as="strong" id="clock" model={nodes['clock']}>
{'۰۸:۳۰'}
{strings.status.clockStart}
</GameNode>
</div>
<GameNode as="div" className="stat" id="speed-stat" hidden model={nodes['speed-stat']}>
<small>{'سرعت'}</small>
<small>{strings.status.speed}</small>
<GameNode as="strong" id="speed" model={nodes['speed']}>
{'۰'}
{strings.status.speedStart}
</GameNode>
<span>{'km/h'}</span>
</GameNode>
+2 -1
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
/** Presents the TouchControls using the current interface snapshot. */
export function TouchControls({ nodes, actions }) {
@@ -36,7 +37,7 @@ export function TouchControls({ nodes, actions }) {
model={nodes['touch-shift']}
onAction={() => actions.emit('touch-shift')}
>
{'دویدن'}
{strings.status.run}
</GameNode>
</div>
</GameNode>
+3 -2
View File
@@ -1,4 +1,5 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { strings } from '@/i18n/fa.js';
/** @typedef {{nodes:object,actions:object}} UpdateNoticeProps */
/** Presents the UpdateNotice view using plain models and semantic native elements.
* @param {UpdateNoticeProps} props - Interface snapshots and callbacks.
@@ -8,7 +9,7 @@ export function UpdateNotice({ nodes, actions }) {
return (
<>
<GameNode as="div" id="update-toast" hidden model={nodes['update-toast']}>
{'نسخهٔ جدید بازی آماده است '}
{strings.app.updateReady}
<GameNode
as="button"
type="button"
@@ -16,7 +17,7 @@ export function UpdateNotice({ nodes, actions }) {
model={nodes['update-reload']}
onAction={() => actions.emit('update-reload')}
>
{'به‌روزرسانی'}
{strings.app.update}
</GameNode>
</GameNode>
</>
+4 -3
View File
@@ -1,4 +1,5 @@
import { formatHetoman } from '@/game/economy/gameState.js';
import { strings } from '@/i18n/fa.js';
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
/** Renders structured game lists as JSX; strings are never interpreted as HTML.
@@ -20,8 +21,8 @@ export function ViewLists({ id, model = {}, onAction }) {
<span className="price">{formatHetoman(item.price)}</span>
{item.price !== item.cost && <s>{formatHetoman(item.cost)}</s>}
<small>
{item.type === 'drink' ? 'نوشیدنی' : 'خوراکی'}
{item.blocked === 'funds' ? ' · موجودی کافی نیست' : ''}
{item.type === 'drink' ? strings.lists.drink : strings.lists.snack}
{item.blocked === 'funds' ? strings.lists.noFunds : ''}
</small>
</button>
));
@@ -52,7 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) {
{item.name}
{item.visited ? ' ✓' : ''}
<span className="meta">
{item.locked ? item.note : `${item.category} · ${item.open ? 'باز' : 'بسته'}`}
{item.locked ? item.note : strings.lists.machineState(item.category, item.open)}
</span>
</span>
<span className="dist">{item.distance}</span>
+2 -1
View File
@@ -1,4 +1,5 @@
import { Suspense, lazy } from 'react';
import { strings } from '@/i18n/fa.js';
const GamePage = lazy(() =>
import('@/pages/game/GamePage.jsx').then((module) => ({
default: module.GamePage,
@@ -7,7 +8,7 @@ const GamePage = lazy(() =>
/** Loads the one game page on demand while keeping a visible startup status. */
export function AppRoutes() {
return (
<Suspense fallback={<p role="status">در حال آماده‌سازی بازی…</p>}>
<Suspense fallback={<p role="status">{strings.app.preparing}</p>}>
<GamePage />
</Suspense>
);
+2 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios';
import { strings } from '../i18n/fa.js';
/** Shared HTTP client; data paths resolve below the deployment directory. */
export const apiClient = axios.create({
@@ -10,7 +11,7 @@ apiClient.interceptors.response.use(
(response) => response,
(error) =>
Promise.reject(
Object.assign(new Error(error.message || 'بارگذاری داده ناموفق بود'), {
Object.assign(new Error(error.message || strings.app.dataFailed), {
status: error.response?.status ?? null,
code: error.code,
}),
+3 -4
View File
@@ -1,9 +1,8 @@
import { apiClient } from './apiClient.js';
import { SATELLITE_GROUND } from '../constants/world.js';
const WORLD_FILES = {
buildings: './data/buildings.bin',
imagery: './imagery/index.json',
};
import { ASSETS } from '../config/assets.js';
const WORLD_FILES = ASSETS.world;
/** Loads optional world assets without preventing the OSM fallback on network failure.
* @param {AbortSignal} signal - Cancels startup when the game unmounts.
+34 -7
View File
@@ -1,20 +1,47 @@
import { COMPANY } from '../config/company.js';
// Tailwind base layer preserves the established game presentation during the React migration.
const { colors } = COMPANY;
/**
* Converts a #rrggbb colour to an rgba() string.
* @param {string} hex - Colour such as '#00a693'.
* @param {number} alpha - Opacity from 0 to 1.
* @returns {string} CSS rgba() value.
*/
const withAlpha = (hex, alpha) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export const gameTheme = {
':root': {
// Brand palette from config/company.js; every accent below refers to these.
'--color-primary': colors.primary,
'--color-primary-light': colors.primaryLight,
'--color-primary-lighter': colors.primaryLighter,
'--color-primary-dark': colors.primaryDark,
'--color-primary-darker': colors.primaryDarker,
'--color-on-primary': colors.onPrimary,
'--color-surface': colors.surface,
'--color-surface-raised': colors.surfaceRaised,
'--color-surface-deep': colors.surfaceDeep,
'--color-text': colors.text,
'--color-text-muted': colors.textMuted,
'--game-token-1': 'rgba(236, 242, 238, 0.92)',
'--game-token-2': 'rgba(200, 214, 207, 0.65)',
'--game-token-3': 'rgba(10, 16, 14, 0.62)',
'--game-token-4': 'rgba(10, 16, 14, 0.9)',
'--game-token-5': 'rgba(220, 235, 228, 0.2)',
'--game-token-6': '#f2c94c',
'--game-token-7': '#3ee0c9',
'--game-token-7': 'var(--color-primary)',
'--game-token-8': '#ff6b5e',
'--game-token-9': '0px',
'--game-token-10': '#0d1412',
'--game-token-10': 'var(--color-surface)',
'--game-token-11': '18px',
'--game-token-12': '0.5px',
'--game-token-13': '#16231f',
'--game-token-14': '#070b0a',
'--game-token-13': 'var(--color-surface-raised)',
'--game-token-14': 'var(--color-surface-deep)',
'--game-token-15': 'rgba(255, 255, 255, 0.025)',
'--game-token-16': '1px',
'--game-token-17': '4px',
@@ -59,7 +86,7 @@ export const gameTheme = {
'--game-token-56': '96px',
'--game-token-57': '380px',
'--game-token-58': 'rgba(255, 255, 255, 0.15)',
'--game-token-59': '#04201b',
'--game-token-59': 'var(--color-on-primary)',
'--game-token-60': 'rgba(0, 0, 0, 0.45)',
'--game-token-61': '1180px',
'--game-token-62': '760px',
@@ -68,7 +95,7 @@ export const gameTheme = {
'--game-token-65': 'rgba(255, 255, 255, 0.05)',
'--game-token-66': '620px',
'--game-token-67': '140px',
'--game-token-68': 'rgba(62, 224, 201, 0.1)',
'--game-token-68': withAlpha(colors.primary, 0.1),
'--game-token-69': '30px',
'--game-token-70': '480px',
'--game-token-71': '90px',
@@ -83,7 +110,7 @@ export const gameTheme = {
'--game-token-80': '198px',
'--game-token-81': '70px',
'--game-token-82': '110px',
'--game-token-83': 'rgba(62, 224, 201, 0.25)',
'--game-token-83': withAlpha(colors.primary, 0.25),
'--game-token-84': '420px',
'--game-token-85': '460px',
'--game-token-86': 'rgba(0, 0, 0, 0.7)',
+3 -2
View File
@@ -1,3 +1,4 @@
import { strings } from '../i18n/fa.js';
// Local tangent-plane projection around the playable Park Mellat area.
// World axes: +x east, -z north, 1 unit = 1 metre.
export const ORIGIN = {
@@ -48,6 +49,6 @@ export function bearing(dx, dz) {
*/
export function formatDistance(metres) {
return metres < 1000
? `${Math.round(metres).toLocaleString('fa-IR')} متر`
: `${(Math.round(metres / 100) / 10).toLocaleString('fa-IR')} کیلومتر`;
? strings.units.metres(Math.round(metres).toLocaleString('fa-IR'))
: strings.units.kilometres((Math.round(metres / 100) / 10).toLocaleString('fa-IR'));
}
+8 -3
View File
@@ -1,4 +1,5 @@
import { SHOWROOM_CATALOG } from '../constants/showroomCatalog.js';
import { strings } from '../i18n/fa.js';
/** Looks up a display model by its stable identifier.
* @param {string} id - Model identifier.
@@ -13,7 +14,7 @@ export function showroomModelById(id) {
* @returns {string} Persian rial price.
*/
export function formatRial(price) {
return `${price.toLocaleString('fa-IR')} ریال`;
return strings.units.rial(price.toLocaleString('fa-IR'));
}
/** Builds the robot's deterministic answer from the shared showroom catalog.
@@ -24,8 +25,12 @@ export function showroomAnswer({ modelId, topic }) {
const model = showroomModelById(modelId);
if (!model) return null;
if (topic === 'price')
return `قیمت ${model.name}، ${formatRial(model.priceNumeric)} است. این قیمت خود دستگاه به ریال است و از بودجهٔ بازی کم نمی‌شود.`;
return strings.showroom.priceAnswer(model.name, formatRial(model.priceNumeric));
if (topic === 'features')
return `${model.name}: ${model.description} امکانات: ${model.highlights.join('، ')}.`;
return strings.showroom.featuresAnswer(
model.name,
model.description,
model.highlights.join(strings.showroom.featureSeparator),
);
return null;
}
+12 -1
View File
@@ -4,7 +4,18 @@ export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: { surface: '#0d1412', hud: 'var(--hud)', gold: 'var(--gold)', teal: 'var(--teal)' },
colors: {
surface: 'var(--color-surface)',
hud: 'var(--hud)',
gold: 'var(--gold)',
teal: 'var(--teal)',
primary: {
DEFAULT: 'var(--color-primary)',
light: 'var(--color-primary-light)',
dark: 'var(--color-primary-dark)',
on: 'var(--color-on-primary)',
},
},
},
},
plugins: [plugin(({ addBase }) => addBase(gameTheme))],
+74
View File
@@ -0,0 +1,74 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { COMPANY } from '../src/config/company.js';
import { strings } from '../src/i18n/fa.js';
const SRC = new URL('../src/', import.meta.url).pathname;
// Persian text may only live in the strings file, the company config, map data and the list of
// machine sites (place names and addresses are map content, not interface text).
const ALLOWED = ['i18n/', 'config/', 'data/', 'constants/locations.js'];
const PERSIAN = /[؀-ۿ]/;
// Persian digits used for number formatting, not text.
const DIGITS_ONLY = /^[۰-۹]$/;
/**
* Lists source files below a folder.
* @param {string} dir - Folder to walk.
* @returns {string[]} File paths.
*/
function sourceFiles(dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.(js|jsx)$/.test(name) && !/\.test\./.test(name) ? [path] : [];
});
}
/**
* WCAG contrast ratio between two #rrggbb colours.
* @param {string} a - First colour.
* @param {string} b - Second colour.
* @returns {number} Ratio from 1 to 21.
*/
function contrast(a, b) {
const luminance = (hex) => {
const [r, g, bl] = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
};
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (high + 0.05) / (low + 0.05);
}
test('Interface text lives in the strings file, not in components or game code', () => {
const offenders = sourceFiles(SRC)
.map((path) => relative(SRC, path))
.filter((path) => !ALLOWED.some((allowed) => path.startsWith(allowed)))
.flatMap((path) =>
readFileSync(join(SRC, path), 'utf8')
.split('\n')
.map((line, i) => [line, i + 1])
.filter(([line]) => [...line].some((ch) => PERSIAN.test(ch) && !DIGITS_ONLY.test(ch)))
.map(([, n]) => `${path}:${n}`),
);
assert.deepEqual(offenders, []);
});
test('Brand palette keeps readable contrast', () => {
const c = COMPANY.colors;
assert.equal(c.primary, '#00a693');
assert.ok(contrast(c.primary, c.surface) >= 4.5, 'primary accents on dark surfaces');
assert.ok(contrast(c.onPrimary, c.primary) >= 4.5, 'text on primary buttons');
assert.ok(contrast(c.text, c.surface) >= 7, 'body text');
assert.ok(contrast(c.textMuted, c.surface) >= 4.5, 'secondary text');
});
test('Strings that take values build complete sentences', () => {
assert.equal(strings.prompts.getIn('بی‌ام‌و'), 'F · سوار بی‌ام‌و شو');
assert.equal(strings.units.hetoman('۵۰۰'), '۵۰۰ هتومان');
assert.ok(strings.briefing.subtitle.includes(COMPANY.shortName));
assert.equal(strings.ranks.length, 6);
});